Files
smoothschedule/frontend/src/pages/customer/CustomerDashboard.tsx
poduck 90fa628cb5 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>
2025-12-09 12:46:10 -05:00

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;