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:
poduck
2025-12-09 12:46:10 -05:00
parent 7f389830f8
commit 90fa628cb5
4 changed files with 954 additions and 62 deletions

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef } from 'react';
interface CurrencyInputProps {
value: number; // Value in cents (integer)
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
className?: string;
min?: number;
max?: number;
}
/**
* ATM-style currency input where digits are entered as cents.
* As more digits are entered, they shift from cents to dollars.
* Only accepts integer values (digits 0-9).
*
* Example: typing "1234" displays "$12.34"
* - Type "1" → $0.01
* - Type "2" → $0.12
* - Type "3" → $1.23
* - Type "4" → $12.34
*/
const CurrencyInput: React.FC<CurrencyInputProps> = ({
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
className = '',
min,
max,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
// Ensure value is always an integer
const safeValue = Math.floor(Math.abs(value)) || 0;
// Format cents as dollars string (e.g., 1234 → "$12.34")
const formatCentsAsDollars = (cents: number): string => {
if (cents === 0 && !isFocused) return '';
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
};
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
// Process a new digit being added
const addDigit = (digit: number) => {
let newValue = safeValue * 10 + digit;
// Enforce max if specified
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
};
// Remove the last digit
const removeDigit = () => {
const newValue = Math.floor(safeValue / 10);
onChange(newValue);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Allow navigation keys without preventing default
if (
e.key === 'Tab' ||
e.key === 'Escape' ||
e.key === 'Enter' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'Home' ||
e.key === 'End'
) {
return;
}
// Handle backspace/delete
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
removeDigit();
return;
}
// Only allow digits 0-9
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
addDigit(parseInt(e.key, 10));
return;
}
// Block everything else
e.preventDefault();
};
// Catch input from mobile keyboards, IME, voice input, etc.
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
const inputEvent = e.nativeEvent as InputEvent;
const data = inputEvent.data;
// Always prevent default - we handle all input ourselves
e.preventDefault();
if (!data) return;
// Extract only digits from the input
const digits = data.replace(/\D/g, '');
// Add each digit one at a time
for (const char of digits) {
addDigit(parseInt(char, 10));
}
};
const handleFocus = () => {
setIsFocused(true);
};
const handleBlur = () => {
setIsFocused(false);
// Enforce min on blur if specified
if (min !== undefined && safeValue < min && safeValue > 0) {
onChange(min);
}
};
// Handle paste - extract digits only
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const digits = pastedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
// Handle drop - extract digits only
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault();
const droppedText = e.dataTransfer.getData('text');
const digits = droppedText.replace(/\D/g, '');
if (digits) {
let newValue = parseInt(digits, 10);
if (max !== undefined && newValue > max) {
newValue = max;
}
onChange(newValue);
}
};
return (
<input
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={displayValue}
onKeyDown={handleKeyDown}
onBeforeInput={handleBeforeInput}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onDrop={handleDrop}
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
disabled={disabled}
required={required}
placeholder={placeholder}
className={className}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
);
};
export default CurrencyInput;

View File

@@ -52,6 +52,14 @@ export const useAppointments = (filters?: AppointmentFilters) => {
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
// Payment fields (amounts stored in cents, convert to dollars for display)
depositAmount: a.deposit_amount ? parseFloat(a.deposit_amount) / 100 : null,
depositTransactionId: a.deposit_transaction_id || '',
finalPrice: a.final_price ? parseFloat(a.final_price) / 100 : null,
finalChargeTransactionId: a.final_charge_transaction_id || '',
isVariablePricing: a.is_variable_pricing || false,
remainingBalance: a.remaining_balance ? parseFloat(a.remaining_balance) / 100 : null,
overpaidAmount: a.overpaid_amount ? parseFloat(a.overpaid_amount) / 100 : null,
}));
},
});
@@ -85,6 +93,14 @@ export const useAppointment = (id: string) => {
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
status: data.status as AppointmentStatus,
notes: data.notes || '',
// Payment fields (amounts stored in cents, convert to dollars for display)
depositAmount: data.deposit_amount ? parseFloat(data.deposit_amount) / 100 : null,
depositTransactionId: data.deposit_transaction_id || '',
finalPrice: data.final_price ? parseFloat(data.final_price) / 100 : null,
finalChargeTransactionId: data.final_charge_transaction_id || '',
isVariablePricing: data.is_variable_pricing || false,
remainingBalance: data.remaining_balance ? parseFloat(data.remaining_balance) / 100 : null,
overpaidAmount: data.overpaid_amount ? parseFloat(data.overpaid_amount) / 100 : null,
};
},
enabled: !!id,

View File

@@ -1,29 +1,50 @@
import React, { useState, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle } from 'lucide-react';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle, Users, Bell, Mail, MessageSquare, Heart } from 'lucide-react';
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { Service, User, Business } from '../types';
import { getOverQuotaServiceIds } from '../utils/quotaUtils';
import CurrencyInput from '../components/CurrencyInput';
interface ServiceFormData {
name: string;
durationMinutes: number;
price: number;
price_cents: number; // Price in cents (e.g., 5000 = $50.00)
description: string;
photos: string[];
// Pricing fields
variable_pricing: boolean;
deposit_enabled: boolean;
deposit_type: 'amount' | 'percent';
deposit_amount: number | null;
deposit_amount_cents: number | null; // Deposit in cents (e.g., 2500 = $25.00)
deposit_percent: number | null;
// Resource assignment fields
all_resources: boolean;
resource_ids: string[];
// Timing fields
prep_time: number;
takedown_time: number;
// Reminder notification fields
reminder_enabled: boolean;
reminder_hours_before: number;
reminder_email: boolean;
reminder_sms: boolean;
// Thank you email
thank_you_email_enabled: boolean;
}
// Helper to format cents as dollars for display
const formatCentsAsDollars = (cents: number): string => {
return (cents / 100).toFixed(2);
};
const Services: React.FC = () => {
const { t } = useTranslation();
const { user } = useOutletContext<{ user: User, business: Business }>();
const { data: services, isLoading, error } = useServices();
const { data: resources } = useResources({ type: 'STAFF' }); // Only STAFF resources for services
const createService = useCreateService();
const updateService = useUpdateService();
const deleteService = useDeleteService();
@@ -40,14 +61,26 @@ const Services: React.FC = () => {
const [formData, setFormData] = useState<ServiceFormData>({
name: '',
durationMinutes: 60,
price: 0,
price_cents: 0,
description: '',
photos: [],
variable_pricing: false,
deposit_enabled: false,
deposit_type: 'amount',
deposit_amount: null,
deposit_amount_cents: null,
deposit_percent: null,
all_resources: true,
resource_ids: [],
// Timing fields
prep_time: 0,
takedown_time: 0,
// Reminder notification fields
reminder_enabled: false,
reminder_hours_before: 24,
reminder_email: true,
reminder_sms: false,
// Thank you email
thank_you_email_enabled: false,
});
// Photo gallery state
@@ -211,14 +244,23 @@ const Services: React.FC = () => {
setFormData({
name: '',
durationMinutes: 60,
price: 0,
price_cents: 0,
description: '',
photos: [],
variable_pricing: false,
deposit_enabled: false,
deposit_type: 'amount',
deposit_amount: null,
deposit_amount_cents: null,
deposit_percent: null,
all_resources: true,
resource_ids: [],
prep_time: 0,
takedown_time: 0,
reminder_enabled: false,
reminder_hours_before: 24,
reminder_email: true,
reminder_sms: false,
thank_you_email_enabled: false,
});
setIsModalOpen(true);
};
@@ -226,21 +268,30 @@ const Services: React.FC = () => {
const openEditModal = (service: Service) => {
setEditingService(service);
// Determine deposit configuration from existing data
const hasDeposit = (service.deposit_amount && service.deposit_amount > 0) ||
const hasDeposit = (service.deposit_amount_cents && service.deposit_amount_cents > 0) ||
(service.deposit_percent && service.deposit_percent > 0);
const depositType = service.deposit_percent && service.deposit_percent > 0 ? 'percent' : 'amount';
setFormData({
name: service.name,
durationMinutes: service.durationMinutes,
price: service.price,
price_cents: service.price_cents || 0,
description: service.description || '',
photos: service.photos || [],
variable_pricing: service.variable_pricing || false,
deposit_enabled: hasDeposit,
deposit_type: depositType,
deposit_amount: service.deposit_amount || null,
deposit_amount_cents: service.deposit_amount_cents || null,
deposit_percent: service.deposit_percent || null,
all_resources: service.all_resources ?? true,
resource_ids: service.resource_ids || [],
prep_time: service.prep_time || 0,
takedown_time: service.takedown_time || 0,
reminder_enabled: service.reminder_enabled || false,
reminder_hours_before: service.reminder_hours_before || 24,
reminder_email: service.reminder_email ?? true,
reminder_sms: service.reminder_sms || false,
thank_you_email_enabled: service.thank_you_email_enabled || false,
});
setIsModalOpen(true);
};
@@ -257,17 +308,30 @@ const Services: React.FC = () => {
const apiData = {
name: formData.name,
durationMinutes: formData.durationMinutes,
price: formData.variable_pricing ? 0 : formData.price, // Price is 0 for variable pricing
price_cents: formData.variable_pricing ? 0 : formData.price_cents, // Price is 0 for variable pricing
description: formData.description,
photos: formData.photos,
variable_pricing: formData.variable_pricing,
// Only send deposit values if deposit is enabled
deposit_amount: formData.deposit_enabled && formData.deposit_type === 'amount'
? formData.deposit_amount
deposit_amount_cents: formData.deposit_enabled && formData.deposit_type === 'amount'
? formData.deposit_amount_cents
: null,
deposit_percent: formData.deposit_enabled && formData.deposit_type === 'percent'
? formData.deposit_percent
: null,
// Resource assignment
all_resources: formData.all_resources,
resource_ids: formData.all_resources ? [] : formData.resource_ids,
// Timing fields
prep_time: formData.prep_time,
takedown_time: formData.takedown_time,
// Reminder fields - only send if enabled
reminder_enabled: formData.reminder_enabled,
reminder_hours_before: formData.reminder_enabled ? formData.reminder_hours_before : 24,
reminder_email: formData.reminder_enabled ? formData.reminder_email : true,
reminder_sms: formData.reminder_enabled ? formData.reminder_sms : false,
// Thank you email
thank_you_email_enabled: formData.thank_you_email_enabled,
};
try {
@@ -424,10 +488,10 @@ const Services: React.FC = () => {
<DollarSign className="h-3.5 w-3.5" />
{service.variable_pricing ? (
<>
{t('services.fromPrice', 'From')} ${service.price.toFixed(2)}
{t('services.fromPrice', 'From')} ${service.price}
</>
) : (
`$${service.price.toFixed(2)}`
`$${service.price}`
)}
</span>
{service.variable_pricing && (
@@ -446,6 +510,19 @@ const Services: React.FC = () => {
{service.photos.length}
</span>
)}
{/* Resource assignment indicator */}
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1" title={
service.all_resources
? t('services.allResourcesAssigned', 'All resources can provide this service')
: service.resource_names && service.resource_names.length > 0
? service.resource_names.map(r => r.name).join(', ')
: t('services.noResourcesAssigned', 'No resources assigned')
}>
<Users className="h-3.5 w-3.5" />
{service.all_resources
? t('services.allResourcesBadge', 'All')
: service.resource_names?.length || 0}
</span>
</div>
</div>
</div>
@@ -497,9 +574,9 @@ const Services: React.FC = () => {
</span>
<span className="font-semibold text-brand-600 dark:text-brand-400">
{service.variable_pricing ? (
<>From ${service.price.toFixed(2)}</>
<>From ${service.price}</>
) : (
`$${service.price.toFixed(2)}`
`$${service.price}`
)}
</span>
{service.variable_pricing && service.deposit_display && (
@@ -530,7 +607,7 @@ const Services: React.FC = () => {
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingService
@@ -599,7 +676,7 @@ const Services: React.FC = () => {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.duration', 'Duration (min)')} *
{t('services.duration', 'Duration (Minutes)')} *
</label>
<input
type="number"
@@ -613,23 +690,25 @@ const Services: React.FC = () => {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} {!formData.variable_pricing && '*'}
{t('services.price', 'Price')} {!formData.variable_pricing && '*'}
</label>
<input
type="number"
value={formData.variable_pricing ? '' : formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
required={!formData.variable_pricing}
disabled={formData.variable_pricing}
min={0}
step={0.01}
placeholder={formData.variable_pricing ? t('services.priceNA', 'N/A') : '0.00'}
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 ${
formData.variable_pricing
? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed'
: 'bg-white dark:bg-gray-700'
}`}
/>
{formData.variable_pricing ? (
<input
type="text"
value=""
disabled
placeholder={t('services.priceNA', 'N/A')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white cursor-not-allowed"
/>
) : (
<CurrencyInput
value={formData.price_cents}
onChange={(cents) => setFormData({ ...formData, price_cents: cents })}
required={!formData.variable_pricing}
placeholder="$0.00"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
)}
{formData.variable_pricing && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.variablePriceNote', 'Price determined after service')}
@@ -688,7 +767,7 @@ const Services: React.FC = () => {
...formData,
deposit_type: 'amount',
deposit_percent: null,
deposit_amount: formData.deposit_amount || 50,
deposit_amount_cents: formData.deposit_amount_cents || 5000,
})}
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
/>
@@ -704,7 +783,7 @@ const Services: React.FC = () => {
onChange={() => setFormData({
...formData,
deposit_type: 'percent',
deposit_amount: null,
deposit_amount_cents: null,
deposit_percent: formData.deposit_percent || 25,
})}
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
@@ -721,17 +800,15 @@ const Services: React.FC = () => {
{(formData.variable_pricing || formData.deposit_type === 'amount') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.depositAmount', 'Deposit Amount ($)')} *
{t('services.depositAmount', 'Deposit Amount')} *
</label>
<input
type="number"
value={formData.deposit_amount || ''}
onChange={(e) => setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || null })}
<CurrencyInput
value={formData.deposit_amount_cents || 0}
onChange={(cents) => setFormData({ ...formData, deposit_amount_cents: cents || null })}
required
min={1}
step={0.01}
placeholder="$0.00"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="50.00"
/>
</div>
)}
@@ -753,11 +830,9 @@ const Services: React.FC = () => {
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="25"
/>
{formData.deposit_percent && formData.price > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.depositCalculated', 'Deposit: ${amount}', {
amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2)
})}
{formData.deposit_percent && formData.price_cents > 0 && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
= ${formatCentsAsDollars(Math.round(formData.price_cents * formData.deposit_percent / 100))}
</p>
)}
</div>
@@ -770,6 +845,255 @@ const Services: React.FC = () => {
)}
</div>
{/* Resource Assignment */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<label className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t('services.resourceAssignment', 'Who Can Provide This Service?')}
</label>
</div>
</div>
{/* All Resources Toggle */}
<div className="flex items-center justify-between mb-3">
<div>
<span className="text-sm text-blue-800 dark:text-blue-200">
{t('services.allResources', 'All Resources')}
</span>
<p className="text-xs text-blue-600 dark:text-blue-300">
{t('services.allResourcesDescription', 'Any resource can be booked for this service')}
</p>
</div>
<button
type="button"
onClick={() => setFormData({
...formData,
all_resources: !formData.all_resources,
resource_ids: !formData.all_resources ? [] : formData.resource_ids,
})}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.all_resources ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.all_resources ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Specific Resource Selection */}
{!formData.all_resources && (
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-600">
<p className="text-xs text-blue-600 dark:text-blue-300 mb-2">
{t('services.selectSpecificResources', 'Select specific resources that can provide this service:')}
</p>
{resources && resources.length > 0 ? (
<div className="space-y-2 max-h-40 overflow-y-auto">
{resources.map((resource) => (
<label
key={resource.id}
className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-blue-100 dark:hover:bg-blue-800/30 transition-colors"
>
<input
type="checkbox"
checked={formData.resource_ids.includes(resource.id)}
onChange={(e) => {
if (e.target.checked) {
setFormData({
...formData,
resource_ids: [...formData.resource_ids, resource.id],
});
} else {
setFormData({
...formData,
resource_ids: formData.resource_ids.filter(id => id !== resource.id),
});
}
}}
className="w-4 h-4 text-blue-600 border-blue-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-blue-900 dark:text-blue-100">
{resource.name}
</span>
</label>
))}
</div>
) : (
<p className="text-sm text-blue-600 dark:text-blue-300 italic">
{t('services.noStaffResources', 'No resources available. Add resources first.')}
</p>
)}
{!formData.all_resources && formData.resource_ids.length === 0 && resources && resources.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
{t('services.selectAtLeastOne', 'Select at least one resource, or enable "All Resources"')}
</p>
)}
</div>
)}
</div>
{/* Prep Time and Takedown Time */}
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-3">
<Clock className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<label className="text-sm font-medium text-gray-900 dark:text-white">
{t('services.bufferTime', 'Buffer Time')}
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.prepTime', 'Prep Time (Minutes)')}
</label>
<input
type="number"
value={formData.prep_time}
onChange={(e) => setFormData({ ...formData, prep_time: parseInt(e.target.value) || 0 })}
min={0}
step={5}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="0"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.prepTimeHint', 'Time needed before the appointment')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.takedownTime', 'Takedown Time (Minutes)')}
</label>
<input
type="number"
value={formData.takedown_time}
onChange={(e) => setFormData({ ...formData, takedown_time: parseInt(e.target.value) || 0 })}
min={0}
step={5}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="0"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.takedownTimeHint', 'Time needed after the appointment')}
</p>
</div>
</div>
</div>
{/* Reminder Notifications */}
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<label className="text-sm font-medium text-amber-900 dark:text-amber-100">
{t('services.reminderNotifications', 'Reminder Notifications')}
</label>
</div>
<button
type="button"
onClick={() => setFormData({ ...formData, reminder_enabled: !formData.reminder_enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.reminder_enabled ? 'bg-amber-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.reminder_enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{formData.reminder_enabled && (
<div className="space-y-4 pt-3 border-t border-amber-200 dark:border-amber-600">
{/* Reminder timing */}
<div>
<label className="block text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">
{t('services.reminderTiming', 'Send reminder')}
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={formData.reminder_hours_before}
onChange={(e) => setFormData({ ...formData, reminder_hours_before: parseInt(e.target.value) || 24 })}
min={1}
max={168}
className="w-20 px-3 py-2 border border-amber-300 dark:border-amber-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
/>
<span className="text-sm text-amber-800 dark:text-amber-200">
{t('services.hoursBefore', 'hours before appointment')}
</span>
</div>
</div>
{/* Reminder methods */}
<div>
<label className="block text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
{t('services.reminderMethod', 'Send via')}
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.reminder_email}
onChange={(e) => setFormData({ ...formData, reminder_email: e.target.checked })}
className="w-4 h-4 text-amber-600 border-amber-300 rounded focus:ring-amber-500"
/>
<Mail className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-800 dark:text-amber-200">
{t('services.email', 'Email')}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.reminder_sms}
onChange={(e) => setFormData({ ...formData, reminder_sms: e.target.checked })}
className="w-4 h-4 text-amber-600 border-amber-300 rounded focus:ring-amber-500"
/>
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-800 dark:text-amber-200">
{t('services.sms', 'Text Message')}
</span>
</label>
</div>
</div>
</div>
)}
</div>
{/* Thank You Email */}
<div className="p-4 bg-pink-50 dark:bg-pink-900/20 rounded-lg border border-pink-200 dark:border-pink-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" />
<div>
<label className="text-sm font-medium text-pink-900 dark:text-pink-100">
{t('services.thankYouEmail', 'Thank You Email')}
</label>
<p className="text-xs text-pink-600 dark:text-pink-300">
{t('services.thankYouEmailDescription', 'Send a follow-up email after the appointment')}
</p>
</div>
</div>
<button
type="button"
onClick={() => setFormData({ ...formData, thank_you_email_enabled: !formData.thank_you_email_enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.thank_you_email_enabled ? 'bg-pink-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.thank_you_email_enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">

View File

@@ -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>
);
};