Files
smoothschedule/frontend/src/pos/components/NumPad.tsx
poduck 1aa5b76e3b Add Point of Sale system and tax rate lookup integration
POS System:
- Full POS interface with product grid, cart panel, and payment flow
- Product and category management with barcode scanning support
- Cash drawer operations and shift management
- Order history and receipt generation
- Thermal printer integration (ESC/POS protocol)
- Gift card support with purchase and redemption
- Inventory tracking with low stock alerts
- Customer selection and walk-in support

Tax Rate Integration:
- ZIP-to-state mapping for automatic state detection
- SST boundary data import for 24 member states
- Static rates for uniform-rate states (IN, MA, CT, etc.)
- Statewide jurisdiction fallback for simple lookups
- Tax rate suggestion in location editor with auto-apply
- Multiple data sources: SST, CDTFA, TX Comptroller, Avalara

UI Improvements:
- POS renders full-screen outside BusinessLayout
- Clear cart button prominently in cart header
- Tax rate limited to 2 decimal places
- Location tax rate field with suggestion UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:31:19 -05:00

347 lines
9.5 KiB
TypeScript

/**
* NumPad Component
*
* On-screen number pad for tablets with large, touch-friendly buttons.
* Ideal for cash tender entry and custom tip amounts.
*
* Features:
* - Large 60x60px buttons minimum
* - Visual feedback on press
* - Decimal point support
* - Backspace and clear
* - Keyboard input support
* - Format as currency ($)
*/
import React, { useState, useEffect, useRef } from 'react';
import { Delete, X } from 'lucide-react';
interface NumPadProps {
/** Current value in cents */
value: number;
/** Callback when value changes (in cents) */
onChange: (cents: number) => void;
/** Optional label/title above the display */
label?: string;
/** Show currency symbol ($) */
showCurrency?: boolean;
/** Maximum value allowed in cents */
maxCents?: number;
/** Callback when Enter/OK is pressed */
onSubmit?: () => void;
/** Show submit button */
showSubmit?: boolean;
/** Submit button text */
submitText?: string;
/** Additional CSS classes */
className?: string;
}
export const NumPad: React.FC<NumPadProps> = ({
value,
onChange,
label,
showCurrency = true,
maxCents,
onSubmit,
showSubmit = false,
submitText = 'OK',
className = '',
}) => {
const [displayValue, setDisplayValue] = useState<string>('');
// Track if user is actively inputting (to prevent value prop from overwriting)
const isInputtingRef = useRef(false);
// Convert cents to display value (dollars) only when:
// 1. Value prop changes externally (not from our own onChange)
// 2. Not currently inputting
useEffect(() => {
if (!isInputtingRef.current) {
const dollars = (value / 100).toFixed(2);
setDisplayValue(dollars);
}
}, [value]);
/**
* Handle number button click
*/
const handleNumber = (num: string) => {
isInputtingRef.current = true;
// Remove all non-digit characters except decimal
const cleanValue = displayValue.replace(/[^\d.]/g, '');
// Special case: if value is "0.00" (initial zero state), start fresh with the new number
if (cleanValue === '0.00') {
setDisplayValue(num);
updateValue(num);
return;
}
// Split into dollars and cents
const parts = cleanValue.split('.');
if (parts.length === 1) {
// No decimal yet
const newValue = cleanValue + num;
setDisplayValue(newValue);
updateValue(newValue);
} else if (parts.length === 2) {
// Already has decimal
if (parts[1].length < 2) {
// Can still add cents
const newValue = parts[0] + '.' + parts[1] + num;
setDisplayValue(newValue);
updateValue(newValue);
}
}
};
/**
* Handle decimal point
*/
const handleDecimal = () => {
isInputtingRef.current = true;
const cleanValue = displayValue.replace(/[^\d.]/g, '');
if (!cleanValue.includes('.')) {
const newValue = (cleanValue || '0') + '.';
setDisplayValue(newValue);
updateValue(newValue);
}
};
/**
* Handle backspace
*/
const handleBackspace = () => {
isInputtingRef.current = true;
const cleanValue = displayValue.replace(/[^\d.]/g, '');
if (cleanValue.length > 0) {
const newValue = cleanValue.slice(0, -1) || '0';
setDisplayValue(newValue);
updateValue(newValue);
}
};
/**
* Handle clear
*/
const handleClear = () => {
isInputtingRef.current = false; // Reset to allow external value sync
setDisplayValue('0.00');
updateValue('0');
};
/**
* Update the value and call onChange with cents
* Returns the actual cents value used (may be capped)
*/
const updateValue = (newValue: string): number => {
let cents = Math.round(parseFloat(newValue || '0') * 100);
// Cap at max value if specified
if (maxCents !== undefined && cents > maxCents) {
cents = maxCents;
// Update display to show capped value
const cappedDisplay = (cents / 100).toFixed(2);
setDisplayValue(cappedDisplay);
}
onChange(cents);
return cents;
};
/**
* Keyboard support
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
handleNumber(e.key);
} else if (e.key === '.' || e.key === ',') {
e.preventDefault();
handleDecimal();
} else if (e.key === 'Backspace') {
e.preventDefault();
handleBackspace();
} else if (e.key === 'Escape') {
e.preventDefault();
handleClear();
} else if (e.key === 'Enter' && onSubmit) {
e.preventDefault();
onSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [displayValue]);
/**
* Format display value with currency symbol
*/
const formattedDisplay = showCurrency
? `$${displayValue}`
: displayValue;
return (
<div className={`flex flex-col ${className}`}>
{/* Label */}
{label && (
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{label}
</div>
)}
{/* Display */}
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-4">
<div className="text-4xl font-bold text-gray-900 dark:text-white text-right font-mono">
{formattedDisplay}
</div>
</div>
{/* Number pad grid */}
<div className="grid grid-cols-3 gap-2">
{/* Numbers 1-9 */}
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<button
key={num}
onClick={() => handleNumber(num.toString())}
className="
h-16 w-full
bg-white dark:bg-gray-700
hover:bg-gray-50 dark:hover:bg-gray-600
active:bg-gray-100 dark:active:bg-gray-500
border border-gray-300 dark:border-gray-600
rounded-lg
text-2xl font-semibold
text-gray-900 dark:text-white
transition-colors
touch-manipulation
select-none
"
>
{num}
</button>
))}
{/* Bottom row: Clear, 0, Decimal */}
<button
onClick={handleClear}
className="
h-16 w-full
bg-red-100 dark:bg-red-900/30
hover:bg-red-200 dark:hover:bg-red-900/50
active:bg-red-300 dark:active:bg-red-900/70
border border-red-300 dark:border-red-700
rounded-lg
text-xl font-semibold
text-red-700 dark:text-red-400
transition-colors
touch-manipulation
select-none
flex items-center justify-center
"
title="Clear (Esc)"
>
<X className="h-6 w-6" />
</button>
<button
onClick={() => handleNumber('0')}
className="
h-16 w-full
bg-white dark:bg-gray-700
hover:bg-gray-50 dark:hover:bg-gray-600
active:bg-gray-100 dark:active:bg-gray-500
border border-gray-300 dark:border-gray-600
rounded-lg
text-2xl font-semibold
text-gray-900 dark:text-white
transition-colors
touch-manipulation
select-none
"
>
0
</button>
<button
onClick={handleDecimal}
className="
h-16 w-full
bg-white dark:bg-gray-700
hover:bg-gray-50 dark:hover:bg-gray-600
active:bg-gray-100 dark:active:bg-gray-500
border border-gray-300 dark:border-gray-600
rounded-lg
text-2xl font-semibold
text-gray-900 dark:text-white
transition-colors
touch-manipulation
select-none
"
>
.
</button>
{/* Backspace button - spans column */}
<button
onClick={handleBackspace}
className="
col-span-3
h-16 w-full
bg-gray-200 dark:bg-gray-600
hover:bg-gray-300 dark:hover:bg-gray-500
active:bg-gray-400 dark:active:bg-gray-400
border border-gray-300 dark:border-gray-500
rounded-lg
text-xl font-semibold
text-gray-700 dark:text-gray-200
transition-colors
touch-manipulation
select-none
flex items-center justify-center gap-2
"
title="Backspace"
>
<Delete className="h-5 w-5" />
Backspace
</button>
{/* Optional submit button */}
{showSubmit && onSubmit && (
<button
onClick={onSubmit}
className="
col-span-3
h-16 w-full
bg-brand-600 dark:bg-brand-600
hover:bg-brand-700 dark:hover:bg-brand-700
active:bg-brand-800 dark:active:bg-brand-800
border border-brand-700 dark:border-brand-700
rounded-lg
text-xl font-semibold
text-white
transition-colors
touch-manipulation
select-none
"
>
{submitText}
</button>
)}
</div>
{/* Keyboard hint */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
Keyboard: 0-9, . (decimal), Backspace, Esc (clear)
{onSubmit && ', Enter (submit)'}
</div>
</div>
);
};
export default NumPad;