feat: Add customer appointment details modal and ATM-style currency input
- Add appointment detail modal to CustomerDashboard with payment info display - Shows service, date/time, duration, status, and notes - Displays payment summary: service price, deposit paid, payment made, amount due - Print receipt functionality with secure DOM manipulation - Cancel appointment button for upcoming appointments - Add CurrencyInput component for ATM-style price entry - Digits entered as cents, shift left as more digits added (e.g., "1234" → $12.34) - Robust input validation: handles keyboard, mobile, paste, drop, IME - Only allows integer digits (0-9) - Update useAppointments hook to map payment fields from backend - Converts amounts from cents to dollars for display - Update Services page to use CurrencyInput for price and deposit fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useOutletContext, Link } from 'react-router-dom';
|
||||
import { User, Business, Appointment } from '../../types';
|
||||
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
|
||||
import { useServices } from '../../hooks/useServices';
|
||||
import { Calendar, Clock, MapPin, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Calendar, Clock, X, FileText, Tag, Loader2, DollarSign, CreditCard, Printer, Receipt } from 'lucide-react';
|
||||
import Portal from '../../components/Portal';
|
||||
|
||||
const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, business }) => {
|
||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
// Fetch appointments from API - backend filters for current customer
|
||||
const { data: appointments = [], isLoading, error } = useAppointments();
|
||||
@@ -26,7 +28,7 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
||||
const hoursBefore = (new Date(appointment.startTime).getTime() - new Date().getTime()) / 3600000;
|
||||
if (hoursBefore < business.cancellationWindowHours) {
|
||||
const service = services.find(s => s.id === appointment.serviceId);
|
||||
const fee = service ? (service.price * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
|
||||
const fee = service ? (parseFloat(service.price) * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
|
||||
if (!window.confirm(`Cancelling within the ${business.cancellationWindowHours}-hour window may incur a fee of $${fee}. Are you sure?`)) return;
|
||||
} else {
|
||||
if (!window.confirm("Are you sure you want to cancel this appointment?")) return;
|
||||
@@ -39,6 +41,120 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to create receipt row elements
|
||||
const createReceiptRow = (doc: Document, label: string, value: string, className?: string): HTMLDivElement => {
|
||||
const row = doc.createElement('div');
|
||||
row.className = 'receipt-row' + (className ? ' ' + className : '');
|
||||
const labelSpan = doc.createElement('span');
|
||||
labelSpan.className = 'label';
|
||||
labelSpan.textContent = label;
|
||||
const valueSpan = doc.createElement('span');
|
||||
valueSpan.className = 'value' + (className ? ' ' + className : '');
|
||||
valueSpan.textContent = value;
|
||||
row.appendChild(labelSpan);
|
||||
row.appendChild(valueSpan);
|
||||
return row;
|
||||
};
|
||||
|
||||
const handlePrintReceipt = (appointment: Appointment) => {
|
||||
const service = services.find(s => s.id === appointment.serviceId);
|
||||
|
||||
// Create an iframe for printing
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'absolute';
|
||||
iframe.style.width = '0';
|
||||
iframe.style.height = '0';
|
||||
iframe.style.border = 'none';
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (!iframeDoc) {
|
||||
alert('Unable to generate receipt. Please try again.');
|
||||
document.body.removeChild(iframe);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build HTML structure using DOM methods
|
||||
const style = iframeDoc.createElement('style');
|
||||
style.textContent = `
|
||||
body { font-family: system-ui, -apple-system, sans-serif; padding: 40px; max-width: 600px; margin: 0 auto; }
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
.business-name { color: #666; margin-bottom: 24px; }
|
||||
.receipt-section { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee; }
|
||||
.receipt-row { display: flex; justify-content: space-between; margin-bottom: 8px; }
|
||||
.label { color: #666; }
|
||||
.value { font-weight: 600; }
|
||||
.total { font-size: 18px; font-weight: bold; }
|
||||
.paid { color: #16a34a; }
|
||||
.due { color: #ea580c; }
|
||||
.footer { margin-top: 32px; text-align: center; color: #999; font-size: 12px; }
|
||||
`;
|
||||
iframeDoc.head.appendChild(style);
|
||||
|
||||
const title = iframeDoc.createElement('title');
|
||||
title.textContent = `Receipt - ${service?.name || 'Appointment'}`;
|
||||
iframeDoc.head.appendChild(title);
|
||||
|
||||
// Header
|
||||
const h1 = iframeDoc.createElement('h1');
|
||||
h1.textContent = 'Receipt';
|
||||
iframeDoc.body.appendChild(h1);
|
||||
|
||||
const businessName = iframeDoc.createElement('div');
|
||||
businessName.className = 'business-name';
|
||||
businessName.textContent = business.name;
|
||||
iframeDoc.body.appendChild(businessName);
|
||||
|
||||
// Appointment Details Section
|
||||
const detailsSection = iframeDoc.createElement('div');
|
||||
detailsSection.className = 'receipt-section';
|
||||
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Service', service?.name || 'Appointment'));
|
||||
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Date', new Date(appointment.startTime).toLocaleDateString()));
|
||||
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Time', new Date(appointment.startTime).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })));
|
||||
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Duration', `${appointment.durationMinutes} minutes`));
|
||||
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Status', appointment.status.replace(/_/g, ' ')));
|
||||
iframeDoc.body.appendChild(detailsSection);
|
||||
|
||||
// Payment Section
|
||||
const paymentSection = iframeDoc.createElement('div');
|
||||
paymentSection.className = 'receipt-section';
|
||||
|
||||
if (service?.price) {
|
||||
const priceText = appointment.isVariablePricing ? 'Variable' : `$${service.price}`;
|
||||
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Service Price', priceText));
|
||||
}
|
||||
if (appointment.depositAmount && appointment.depositAmount > 0) {
|
||||
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Deposit Paid', `-$${appointment.depositAmount.toFixed(2)}`, 'paid'));
|
||||
}
|
||||
if (appointment.finalPrice && appointment.isVariablePricing) {
|
||||
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Final Price', `$${appointment.finalPrice.toFixed(2)}`));
|
||||
}
|
||||
if (appointment.remainingBalance && appointment.remainingBalance > 0 && !['COMPLETED', 'PAID'].includes(appointment.status)) {
|
||||
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Amount Due', `$${appointment.remainingBalance.toFixed(2)}`, 'total due'));
|
||||
}
|
||||
if (['COMPLETED', 'PAID'].includes(appointment.status) && (!appointment.remainingBalance || appointment.remainingBalance <= 0)) {
|
||||
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Status', 'Paid in Full', 'total paid'));
|
||||
}
|
||||
iframeDoc.body.appendChild(paymentSection);
|
||||
|
||||
// Footer
|
||||
const footer = iframeDoc.createElement('div');
|
||||
footer.className = 'footer';
|
||||
const thankYou = iframeDoc.createElement('p');
|
||||
thankYou.textContent = 'Thank you for your business!';
|
||||
const appointmentId = iframeDoc.createElement('p');
|
||||
appointmentId.textContent = `Appointment ID: ${appointment.id}`;
|
||||
footer.appendChild(thankYou);
|
||||
footer.appendChild(appointmentId);
|
||||
iframeDoc.body.appendChild(footer);
|
||||
|
||||
// Print and cleanup
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow?.print();
|
||||
setTimeout(() => document.body.removeChild(iframe), 1000);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8 flex items-center justify-center py-12">
|
||||
@@ -66,20 +182,26 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
||||
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
|
||||
const service = services.find(s => s.id === apt.serviceId);
|
||||
return (
|
||||
<div key={apt.id} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div
|
||||
key={apt.id}
|
||||
onClick={() => setSelectedAppointment(apt)}
|
||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between cursor-pointer hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-md transition-all"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold">{service?.name || 'Appointment'}</h3>
|
||||
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
|
||||
</div>
|
||||
{activeTab === 'upcoming' && (
|
||||
<button
|
||||
onClick={() => handleCancel(apt)}
|
||||
disabled={updateAppointment.isPending}
|
||||
className="text-sm font-medium text-red-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{updateAppointment.isPending ? 'Cancelling...' : 'Cancel'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||
apt.status === 'SCHEDULED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||
apt.status === 'PENDING_DEPOSIT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
|
||||
apt.status === 'CANCELLED' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||
apt.status === 'COMPLETED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{apt.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -89,6 +211,249 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appointment Detail Modal */}
|
||||
{selectedAppointment && (
|
||||
<Portal>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setSelectedAppointment(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Appointment Details
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedAppointment(null)}
|
||||
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Service */}
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
|
||||
<Tag size={20} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Service</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{services.find(s => s.id === selectedAppointment.serviceId)?.name || 'Appointment'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date, Time & Duration */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar size={14} className="text-gray-400" />
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Date</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{new Date(selectedAppointment.startTime).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock size={14} className="text-gray-400" />
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Time</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{new Date(selectedAppointment.startTime).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Duration</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{selectedAppointment.durationMinutes} minutes
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Status</p>
|
||||
<span className={`inline-flex text-xs font-medium px-2 py-1 rounded-full ${
|
||||
selectedAppointment.status === 'SCHEDULED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||
selectedAppointment.status === 'PENDING_DEPOSIT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
|
||||
selectedAppointment.status === 'CANCELLED' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||
selectedAppointment.status === 'COMPLETED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{selectedAppointment.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppointment.notes && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText size={14} className="text-gray-400" />
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Notes</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">{selectedAppointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Information */}
|
||||
{(() => {
|
||||
const service = services.find(s => s.id === selectedAppointment.serviceId);
|
||||
const hasPaymentData = selectedAppointment.depositAmount || selectedAppointment.finalPrice ||
|
||||
selectedAppointment.finalChargeTransactionId || service?.price;
|
||||
|
||||
if (!hasPaymentData) return null;
|
||||
|
||||
// Calculate payment made (final charge minus deposit that was already applied)
|
||||
const paymentMade = selectedAppointment.finalChargeTransactionId && selectedAppointment.finalPrice
|
||||
? (selectedAppointment.finalPrice - (selectedAppointment.depositAmount || 0))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<DollarSign size={16} className="text-green-600 dark:text-green-400" />
|
||||
<p className="text-sm font-semibold text-green-800 dark:text-green-300">Payment Summary</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/* Service Price */}
|
||||
{service?.price && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Service Price</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{selectedAppointment.isVariablePricing ? 'Variable' : `$${service.price}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Price (for variable pricing after completion) */}
|
||||
{selectedAppointment.finalPrice && selectedAppointment.isVariablePricing && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Final Price</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
${selectedAppointment.finalPrice.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deposit Paid */}
|
||||
{selectedAppointment.depositAmount && selectedAppointment.depositAmount > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||
<CreditCard size={12} />
|
||||
Deposit Paid
|
||||
</span>
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
${selectedAppointment.depositAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Made (final charge) */}
|
||||
{paymentMade && paymentMade > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||
<CreditCard size={12} />
|
||||
Payment Made
|
||||
</span>
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
${paymentMade.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Amount Due */}
|
||||
{selectedAppointment.remainingBalance && selectedAppointment.remainingBalance > 0 && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-green-200 dark:border-green-700">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{['COMPLETED', 'PAID'].includes(selectedAppointment.status) ? 'Balance Due' : 'Amount Due at Service'}
|
||||
</span>
|
||||
<span className="font-bold text-orange-600 dark:text-orange-400">
|
||||
${selectedAppointment.remainingBalance.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overpaid / Refund Due */}
|
||||
{selectedAppointment.overpaidAmount && selectedAppointment.overpaidAmount > 0 && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-green-200 dark:border-green-700">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Refund Due</span>
|
||||
<span className="font-bold text-blue-600 dark:text-blue-400">
|
||||
${selectedAppointment.overpaidAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fully Paid indicator */}
|
||||
{['COMPLETED', 'PAID'].includes(selectedAppointment.status) &&
|
||||
(!selectedAppointment.remainingBalance || selectedAppointment.remainingBalance <= 0) && (
|
||||
<div className="flex justify-between text-sm pt-2 border-t border-green-200 dark:border-green-700">
|
||||
<span className="font-medium text-green-700 dark:text-green-300">Status</span>
|
||||
<span className="font-bold text-green-600 dark:text-green-400">
|
||||
Paid in Full
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* TODO: Add View Contracts button when contracts feature is linked to appointments */}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 flex justify-between border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedAppointment(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePrintReceipt(selectedAppointment)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Printer size={16} />
|
||||
Print Receipt
|
||||
</button>
|
||||
</div>
|
||||
{new Date(selectedAppointment.startTime) >= new Date() && selectedAppointment.status !== 'CANCELLED' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCancel(selectedAppointment);
|
||||
setSelectedAppointment(null);
|
||||
}}
|
||||
disabled={updateAppointment.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{updateAppointment.isPending ? 'Cancelling...' : 'Cancel Appointment'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user