Files
smoothschedule/frontend/src/pos/components/BarcodeScannerStatus.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

463 lines
11 KiB
TypeScript

/**
* BarcodeScannerStatus Component
*
* Displays barcode scanner status and provides manual barcode entry fallback.
* Shows visual feedback when scanner is active and receiving input.
*/
import React, { useState, KeyboardEvent } from 'react';
import { useBarcodeScanner } from '../hooks/useBarcodeScanner';
import { useBarcodeScanner as useProductLookup } from '../hooks/usePOSProducts';
import { useCart } from '../hooks/useCart';
interface BarcodeScannerStatusProps {
/**
* Enable/disable scanner
*/
enabled: boolean;
/**
* Callback when barcode is scanned (manually or via scanner)
*/
onScan: (barcode: string) => void;
/**
* Show manual entry input
* @default true
*/
showManualEntry?: boolean;
/**
* Compact mode - icon only
* @default false
*/
compact?: boolean;
/**
* Custom keystroke threshold (ms)
*/
keystrokeThreshold?: number;
/**
* Custom timeout (ms)
*/
timeout?: number;
/**
* Minimum barcode length
*/
minLength?: number;
/**
* Auto-add products to cart when scanned
* @default false
*/
autoAddToCart?: boolean;
}
export const BarcodeScannerStatus: React.FC<BarcodeScannerStatusProps> = ({
enabled,
onScan,
showManualEntry = true,
compact = false,
keystrokeThreshold,
timeout,
minLength,
autoAddToCart = false,
}) => {
const [manualBarcode, setManualBarcode] = useState('');
const [showTooltip, setShowTooltip] = useState(false);
const [lastScanSuccess, setLastScanSuccess] = useState(false);
const { lookupBarcode } = useProductLookup();
const { addProduct } = useCart();
/**
* Handle barcode scan from hardware scanner or manual entry
*/
const handleScan = async (barcode: string) => {
// Call the callback
onScan(barcode);
// Auto-add to cart if enabled
if (autoAddToCart) {
try {
const product = await lookupBarcode(barcode);
if (product) {
addProduct(product);
setLastScanSuccess(true);
setTimeout(() => setLastScanSuccess(false), 1000);
} else {
setLastScanSuccess(false);
}
} catch (error) {
console.error('Barcode lookup failed:', error);
setLastScanSuccess(false);
}
}
};
// Hook into hardware scanner
const { buffer, isScanning } = useBarcodeScanner({
onScan: handleScan,
enabled,
keystrokeThreshold,
timeout,
minLength,
});
/**
* Handle manual barcode entry
*/
const handleManualSubmit = () => {
const trimmed = manualBarcode.trim();
if (trimmed) {
handleScan(trimmed);
setManualBarcode('');
}
};
/**
* Handle Enter key in manual input
*/
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleManualSubmit();
}
};
/**
* Get status text
*/
const getStatusText = () => {
if (isScanning) return 'Scanning...';
if (!enabled) return 'Scanner Inactive';
return 'Scanner Active';
};
/**
* Get status color class
*/
const getStatusClass = () => {
if (isScanning) return 'scanning';
if (lastScanSuccess) return 'success';
if (!enabled) return 'inactive';
return 'active';
};
if (compact) {
return (
<div
className={`barcode-scanner-compact ${getStatusClass()} compact`}
aria-label="Barcode scanner status"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<div className="scanner-icon">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<path d="M8 7v10" />
<path d="M12 7v10" />
<path d="M16 7v10" />
</svg>
</div>
{showTooltip && (
<div className="scanner-tooltip" role="tooltip">
{getStatusText()}
{buffer && <div className="buffer-display">Code: {buffer}</div>}
</div>
)}
<style>{`
.barcode-scanner-compact {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
transition: all 0.2s;
}
.barcode-scanner-compact.inactive {
color: #9CA3AF;
background: #F3F4F6;
}
.barcode-scanner-compact.active {
color: #10B981;
background: #D1FAE5;
}
.barcode-scanner-compact.scanning {
color: #3B82F6;
background: #DBEAFE;
animation: pulse 1s ease-in-out infinite;
}
.barcode-scanner-compact.success {
color: #10B981;
background: #D1FAE5;
animation: flash 0.5s ease-in-out;
}
.scanner-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1F2937;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.scanner-tooltip::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: #1F2937;
}
.buffer-display {
margin-top: 4px;
font-family: 'Courier New', monospace;
color: #60A5FA;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</div>
);
}
return (
<div className={`barcode-scanner-status ${getStatusClass()}`} aria-label="Barcode scanner status">
<div className="scanner-indicator">
<div className="status-icon">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<path d="M8 7v10" />
<path d="M12 7v10" />
<path d="M16 7v10" />
</svg>
</div>
<div className="status-text">
<div className="status-label">{getStatusText()}</div>
{buffer && <div className="buffer-display">Reading: {buffer}</div>}
</div>
</div>
{showManualEntry && (
<div className="manual-entry">
<input
type="text"
value={manualBarcode}
onChange={(e) => setManualBarcode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter barcode manually"
className="manual-input"
disabled={!enabled}
/>
<button
onClick={handleManualSubmit}
className="manual-submit"
disabled={!enabled || !manualBarcode.trim()}
>
Add
</button>
</div>
)}
<style>{`
.barcode-scanner-status {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: white;
border: 2px solid #E5E7EB;
border-radius: 8px;
transition: all 0.2s;
}
.barcode-scanner-status.inactive {
border-color: #E5E7EB;
opacity: 0.6;
}
.barcode-scanner-status.active {
border-color: #10B981;
background: #F0FDF4;
}
.barcode-scanner-status.scanning {
border-color: #3B82F6;
background: #EFF6FF;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
animation: pulse 1s ease-in-out infinite;
}
.barcode-scanner-status.success {
border-color: #10B981;
background: #D1FAE5;
animation: flash 0.5s ease-in-out;
}
.scanner-indicator {
display: flex;
align-items: center;
gap: 12px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: white;
}
.barcode-scanner-status.inactive .status-icon {
color: #9CA3AF;
}
.barcode-scanner-status.active .status-icon {
color: #10B981;
}
.barcode-scanner-status.scanning .status-icon {
color: #3B82F6;
}
.barcode-scanner-status.success .status-icon {
color: #10B981;
}
.status-text {
flex: 1;
}
.status-label {
font-weight: 600;
font-size: 14px;
color: #1F2937;
}
.buffer-display {
font-size: 12px;
color: #6B7280;
font-family: 'Courier New', monospace;
margin-top: 2px;
}
.manual-entry {
display: flex;
gap: 8px;
}
.manual-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #D1D5DB;
border-radius: 6px;
font-size: 14px;
font-family: 'Courier New', monospace;
transition: border-color 0.2s;
}
.manual-input:focus {
outline: none;
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.manual-input:disabled {
background: #F3F4F6;
color: #9CA3AF;
cursor: not-allowed;
}
.manual-submit {
padding: 8px 16px;
background: #3B82F6;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.manual-submit:hover:not(:disabled) {
background: #2563EB;
}
.manual-submit:disabled {
background: #D1D5DB;
cursor: not-allowed;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
@keyframes flash {
0%, 100% { opacity: 1; }
25%, 75% { opacity: 0.7; }
50% { opacity: 0.9; }
}
`}</style>
</div>
);
};
export default BarcodeScannerStatus;