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(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 (
); } if (error) { return (

Failed to load appointments. Please try again later.

); } return (

Your Appointments

{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => { const service = services.find(s => s.id === apt.serviceId); return (
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" >

{service?.name || 'Appointment'}

{new Date(apt.startTime).toLocaleString()}

{apt.status.replace(/_/g, ' ')}
); })} {(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).length === 0 && (

No {activeTab} appointments found.

)}
{/* Appointment Detail Modal */} {selectedAppointment && (
setSelectedAppointment(null)} >
e.stopPropagation()} > {/* Header */}

Appointment Details

{/* Content */}
{/* Service */}

Service

{services.find(s => s.id === selectedAppointment.serviceId)?.name || 'Appointment'}

{/* Date, Time & Duration */}

Date

{new Date(selectedAppointment.startTime).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}

Time

{new Date(selectedAppointment.startTime).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}

{/* Duration & Status */}

Duration

{selectedAppointment.durationMinutes} minutes

Status

{selectedAppointment.status.replace(/_/g, ' ')}
{/* Notes */} {selectedAppointment.notes && (

Notes

{selectedAppointment.notes}

)} {/* 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 (

Payment Summary

{/* Service Price */} {service?.price && (
Service Price {selectedAppointment.isVariablePricing ? 'Variable' : `$${service.price}`}
)} {/* Final Price (for variable pricing after completion) */} {selectedAppointment.finalPrice && selectedAppointment.isVariablePricing && (
Final Price ${selectedAppointment.finalPrice.toFixed(2)}
)} {/* Deposit Paid */} {selectedAppointment.depositAmount && selectedAppointment.depositAmount > 0 && (
Deposit Paid ${selectedAppointment.depositAmount.toFixed(2)}
)} {/* Payment Made (final charge) */} {paymentMade && paymentMade > 0 && (
Payment Made ${paymentMade.toFixed(2)}
)} {/* Amount Due */} {selectedAppointment.remainingBalance && selectedAppointment.remainingBalance > 0 && (
{['COMPLETED', 'PAID'].includes(selectedAppointment.status) ? 'Balance Due' : 'Amount Due at Service'} ${selectedAppointment.remainingBalance.toFixed(2)}
)} {/* Overpaid / Refund Due */} {selectedAppointment.overpaidAmount && selectedAppointment.overpaidAmount > 0 && (
Refund Due ${selectedAppointment.overpaidAmount.toFixed(2)}
)} {/* Fully Paid indicator */} {['COMPLETED', 'PAID'].includes(selectedAppointment.status) && (!selectedAppointment.remainingBalance || selectedAppointment.remainingBalance <= 0) && (
Status Paid in Full
)}
); })()} {/* TODO: Add View Contracts button when contracts feature is linked to appointments */} {/* Action Buttons */}
{new Date(selectedAppointment.startTime) >= new Date() && selectedAppointment.status !== 'CANCELLED' && ( )}
)}
); }; const CustomerDashboard: React.FC = () => { const { user, business } = useOutletContext<{ user: User, business: Business }>(); return (

Welcome, {user.name.split(' ')[0]}!

View your upcoming appointments and manage your account.

); }; export default CustomerDashboard;