- 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>
476 lines
31 KiB
TypeScript
476 lines
31 KiB
TypeScript
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, 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();
|
|
const { data: services = [] } = useServices();
|
|
const updateAppointment = useUpdateAppointment();
|
|
|
|
// Sort appointments by start time (newest first)
|
|
const sortedAppointments = useMemo(() =>
|
|
[...appointments].sort((a, b) => b.startTime.getTime() - a.startTime.getTime()),
|
|
[appointments]
|
|
);
|
|
|
|
const upcomingAppointments = sortedAppointments.filter(apt => new Date(apt.startTime) >= new Date() && apt.status !== 'CANCELLED');
|
|
const pastAppointments = sortedAppointments.filter(apt => new Date(apt.startTime) < new Date() || apt.status === 'CANCELLED');
|
|
|
|
const handleCancel = async (appointment: Appointment) => {
|
|
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 ? (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;
|
|
}
|
|
try {
|
|
await updateAppointment.mutateAsync({ id: appointment.id, updates: { status: 'CANCELLED' } });
|
|
} catch (err) {
|
|
console.error('Failed to cancel appointment:', err);
|
|
alert('Failed to cancel appointment. Please try again.');
|
|
}
|
|
};
|
|
|
|
// 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">
|
|
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="mt-8 text-center py-8 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
|
<p className="text-red-600 dark:text-red-400">Failed to load appointments. Please try again later.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mt-8">
|
|
<h2 className="text-xl font-bold mb-4">Your Appointments</h2>
|
|
<div className="flex items-center gap-2 mb-6 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 self-start">
|
|
<button onClick={() => setActiveTab('upcoming')} className={`px-4 py-2 text-sm font-medium rounded-md ${activeTab === 'upcoming' ? 'bg-brand-500 text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}>Upcoming</button>
|
|
<button onClick={() => setActiveTab('past')} className={`px-4 py-2 text-sm font-medium rounded-md ${activeTab === 'past' ? 'bg-brand-500 text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'}`}>Past</button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
|
|
const service = services.find(s => s.id === apt.serviceId);
|
|
return (
|
|
<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>
|
|
<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>
|
|
);
|
|
})}
|
|
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).length === 0 && (
|
|
<div className="text-center py-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
<p className="text-gray-500 dark:text-gray-400">No {activeTab} appointments found.</p>
|
|
</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>
|
|
);
|
|
};
|
|
|
|
const CustomerDashboard: React.FC = () => {
|
|
const { user, business } = useOutletContext<{ user: User, business: Business }>();
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Welcome, {user.name.split(' ')[0]}!</h1>
|
|
<p className="text-gray-500 dark:text-gray-400">View your upcoming appointments and manage your account.</p>
|
|
</div>
|
|
|
|
<AppointmentList user={user} business={business} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CustomerDashboard; |