- 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>
188 lines
4.6 KiB
TypeScript
188 lines
4.6 KiB
TypeScript
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;
|