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>
347 lines
9.5 KiB
TypeScript
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;
|