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:
187
frontend/src/components/CurrencyInput.tsx
Normal file
187
frontend/src/components/CurrencyInput.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user