From 90fa628cb528fa68b774448b8035cae3a57bc49e Mon Sep 17 00:00:00 2001 From: poduck Date: Tue, 9 Dec 2025 12:46:10 -0500 Subject: [PATCH] feat: Add customer appointment details modal and ATM-style currency input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/CurrencyInput.tsx | 187 ++++++++ frontend/src/hooks/useAppointments.ts | 16 + frontend/src/pages/Services.tsx | 422 ++++++++++++++++-- .../src/pages/customer/CustomerDashboard.tsx | 391 +++++++++++++++- 4 files changed, 954 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/CurrencyInput.tsx diff --git a/frontend/src/components/CurrencyInput.tsx b/frontend/src/components/CurrencyInput.tsx new file mode 100644 index 0000000..d0cc0e9 --- /dev/null +++ b/frontend/src/components/CurrencyInput.tsx @@ -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 = ({ + value, + onChange, + disabled = false, + required = false, + placeholder = '$0.00', + className = '', + min, + max, +}) => { + const inputRef = useRef(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) => { + // 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) => { + 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) => { + 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) => { + 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 ( + {}} // Controlled via onKeyDown/onBeforeInput + disabled={disabled} + required={required} + placeholder={placeholder} + className={className} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> + ); +}; + +export default CurrencyInput; diff --git a/frontend/src/hooks/useAppointments.ts b/frontend/src/hooks/useAppointments.ts index e8273ad..12a9d84 100644 --- a/frontend/src/hooks/useAppointments.ts +++ b/frontend/src/hooks/useAppointments.ts @@ -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, diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index 7eff623..eb31815 100644 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -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({ 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 = () => { {service.variable_pricing ? ( <> - {t('services.fromPrice', 'From')} ${service.price.toFixed(2)} + {t('services.fromPrice', 'From')} ${service.price} ) : ( - `$${service.price.toFixed(2)}` + `$${service.price}` )} {service.variable_pricing && ( @@ -446,6 +510,19 @@ const Services: React.FC = () => { {service.photos.length} )} + {/* Resource assignment indicator */} + 0 + ? service.resource_names.map(r => r.name).join(', ') + : t('services.noResourcesAssigned', 'No resources assigned') + }> + + {service.all_resources + ? t('services.allResourcesBadge', 'All') + : service.resource_names?.length || 0} + @@ -497,9 +574,9 @@ const Services: React.FC = () => { {service.variable_pricing ? ( - <>From ${service.price.toFixed(2)} + <>From ${service.price} ) : ( - `$${service.price.toFixed(2)}` + `$${service.price}` )} {service.variable_pricing && service.deposit_display && ( @@ -530,7 +607,7 @@ const Services: React.FC = () => { {/* Modal */} {isModalOpen && (
-
+

{editingService @@ -599,7 +676,7 @@ const Services: React.FC = () => {
{
- 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 ? ( + + ) : ( + 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 && (

{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') && (

- setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || null })} + 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" />
)} @@ -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 && ( -

- {t('services.depositCalculated', 'Deposit: ${amount}', { - amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2) - })} + {formData.deposit_percent && formData.price_cents > 0 && ( +

+ = ${formatCentsAsDollars(Math.round(formData.price_cents * formData.deposit_percent / 100))}

)}
@@ -770,6 +845,255 @@ const Services: React.FC = () => { )}
+ {/* Resource Assignment */} +
+
+
+ + +
+
+ + {/* All Resources Toggle */} +
+
+ + {t('services.allResources', 'All Resources')} + +

+ {t('services.allResourcesDescription', 'Any resource can be booked for this service')} +

+
+ +
+ + {/* Specific Resource Selection */} + {!formData.all_resources && ( +
+

+ {t('services.selectSpecificResources', 'Select specific resources that can provide this service:')} +

+ {resources && resources.length > 0 ? ( +
+ {resources.map((resource) => ( + + ))} +
+ ) : ( +

+ {t('services.noStaffResources', 'No resources available. Add resources first.')} +

+ )} + {!formData.all_resources && formData.resource_ids.length === 0 && resources && resources.length > 0 && ( +

+ {t('services.selectAtLeastOne', 'Select at least one resource, or enable "All Resources"')} +

+ )} +
+ )} +
+ + {/* Prep Time and Takedown Time */} +
+
+ + +
+
+
+ + 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" + /> +

+ {t('services.prepTimeHint', 'Time needed before the appointment')} +

+
+
+ + 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" + /> +

+ {t('services.takedownTimeHint', 'Time needed after the appointment')} +

+
+
+
+ + {/* Reminder Notifications */} +
+
+
+ + +
+ +
+ + {formData.reminder_enabled && ( +
+ {/* Reminder timing */} +
+ +
+ 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" + /> + + {t('services.hoursBefore', 'hours before appointment')} + +
+
+ + {/* Reminder methods */} +
+ +
+ + +
+
+
+ )} +
+ + {/* Thank You Email */} +
+
+
+ +
+ +

+ {t('services.thankYouEmailDescription', 'Send a follow-up email after the appointment')} +

+
+
+ +
+
+ {/* Description */}
); };