- Add comprehensive analytics components with export functionality - Implement territory management with manager performance tracking - Add seatmap components for venue layout management - Create customer management features with modal interface - Add advanced hooks for dashboard flags and territory data - Implement seat selection and venue management utilities - Add type definitions for ticketing and seatmap systems 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
/**
|
|
* Manual Entry Modal for Ticket Scanner
|
|
* Optimized for gate staff with gloves and stylus input
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
X,
|
|
Delete,
|
|
Check,
|
|
Loader2,
|
|
AlertCircle,
|
|
Hash,
|
|
Eye,
|
|
EyeOff
|
|
} from 'lucide-react';
|
|
|
|
import { Button } from '../../components/ui/Button';
|
|
import { Card } from '../../components/ui/Card';
|
|
import { formatBackupCode, QRValidator } from '../../lib/qr-validator';
|
|
|
|
export interface ManualEntryProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (code: string) => Promise<void>;
|
|
isLoading: boolean;
|
|
lastError?: string;
|
|
}
|
|
|
|
const KEYPAD_BUTTONS = [
|
|
['1', '2', '3'],
|
|
['4', '5', '6'],
|
|
['7', '8', '9'],
|
|
['clear', '0', 'delete']
|
|
] as const;
|
|
|
|
const LETTER_BUTTONS = [
|
|
['A', 'B', 'C'],
|
|
['D', 'E', 'F']
|
|
] as const;
|
|
|
|
export function ManualEntryModal({
|
|
isOpen,
|
|
onClose,
|
|
onSubmit,
|
|
isLoading,
|
|
lastError
|
|
}: ManualEntryProps): JSX.Element {
|
|
const [code, setCode] = useState('');
|
|
const [showLetters, setShowLetters] = useState(false);
|
|
const [validator] = useState(() => new QRValidator());
|
|
|
|
// Reset state when modal opens/closes
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setCode('');
|
|
setShowLetters(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleKeyPress = useCallback((key: string) => {
|
|
if (isLoading) {return;}
|
|
|
|
switch (key) {
|
|
case 'clear':
|
|
setCode('');
|
|
break;
|
|
|
|
case 'delete':
|
|
setCode(prev => prev.slice(0, -1));
|
|
break;
|
|
|
|
default:
|
|
if (code.length < 8) {
|
|
setCode(prev => prev + key);
|
|
}
|
|
break;
|
|
}
|
|
}, [code.length, isLoading]);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (code.length === 0 || isLoading) {return;}
|
|
|
|
const validation = validator.validateBackupCode(code);
|
|
if (!validation.valid) {
|
|
// Don't submit invalid codes - let user fix them
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await onSubmit(validation.normalizedCode!);
|
|
} catch (error) {
|
|
// Error handling is done in parent component
|
|
console.error('Manual entry submission error:', error);
|
|
}
|
|
}, [code, isLoading, onSubmit, validator]);
|
|
|
|
const isCodeValid = validator.validateBackupCode(code).valid;
|
|
const formattedCode = formatBackupCode(code.padEnd(8, '_'));
|
|
|
|
// Keyboard event handling
|
|
useEffect(() => {
|
|
if (!isOpen) {return;}
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (isLoading) {return;}
|
|
|
|
const {key} = event;
|
|
|
|
// Numbers
|
|
if (/^[0-9]$/.test(key)) {
|
|
event.preventDefault();
|
|
handleKeyPress(key);
|
|
}
|
|
|
|
// Letters (A-F for hex codes)
|
|
if (/^[A-Fa-f]$/.test(key)) {
|
|
event.preventDefault();
|
|
handleKeyPress(key.toUpperCase());
|
|
}
|
|
|
|
// Backspace
|
|
if (key === 'Backspace') {
|
|
event.preventDefault();
|
|
handleKeyPress('delete');
|
|
}
|
|
|
|
// Escape
|
|
if (key === 'Escape') {
|
|
event.preventDefault();
|
|
onClose();
|
|
}
|
|
|
|
// Enter
|
|
if (key === 'Enter' && isCodeValid) {
|
|
event.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, handleKeyPress, handleSubmit, isCodeValid, isLoading, onClose]);
|
|
|
|
if (!isOpen) {return <></>;}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 bg-black bg-opacity-75 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.95, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.95, opacity: 0 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full max-w-md mx-auto"
|
|
>
|
|
<Card className="bg-glass-bg backdrop-blur-lg border-glass-border p-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center space-x-3">
|
|
<Hash className="h-6 w-6 text-primary-500" />
|
|
<h2 className="text-lg font-semibold text-primary-text">
|
|
Manual Entry
|
|
</h2>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={onClose}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="p-2"
|
|
disabled={isLoading}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Instructions */}
|
|
<div className="mb-6">
|
|
<p className="text-sm text-secondary-text mb-2">
|
|
Enter the last 8 characters from the ticket code
|
|
</p>
|
|
<div className="text-xs text-tertiary-text space-y-1">
|
|
<p>• Found at bottom of physical tickets</p>
|
|
<p>• Use when QR code is damaged or unreadable</p>
|
|
<p>• Numbers and letters A-F only</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Code Display */}
|
|
<div className="mb-6">
|
|
<div className="relative">
|
|
<div className={`
|
|
w-full p-4 text-center font-mono text-2xl tracking-wider
|
|
bg-input-bg border border-input-border rounded-lg
|
|
${isCodeValid ? 'border-success-500 bg-success-50' : ''}
|
|
${code.length > 0 && !isCodeValid ? 'border-warning-500 bg-warning-50' : ''}
|
|
`}>
|
|
{formattedCode}
|
|
</div>
|
|
|
|
{/* Validation indicator */}
|
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
{code.length === 8 && isCodeValid && (
|
|
<Check className="h-5 w-5 text-success-500" />
|
|
)}
|
|
{code.length > 0 && code.length < 8 && (
|
|
<span className="text-xs text-tertiary-text">
|
|
{8 - code.length} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{lastError && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
className="mt-2 p-3 bg-error-50 border border-error-200 rounded-lg flex items-center space-x-2"
|
|
>
|
|
<AlertCircle className="h-4 w-4 text-error-500 flex-shrink-0" />
|
|
<p className="text-sm text-error-600">{lastError}</p>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Letter/Number Toggle */}
|
|
<div className="mb-4 flex items-center justify-center">
|
|
<Button
|
|
onClick={() => setShowLetters(!showLetters)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex items-center space-x-2"
|
|
disabled={isLoading}
|
|
>
|
|
{showLetters ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
<span>{showLetters ? 'Hide' : 'Show'} Letters A-F</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Keypad */}
|
|
<div className="space-y-3">
|
|
{/* Number Keys */}
|
|
{KEYPAD_BUTTONS.map((row, rowIndex) => (
|
|
<div key={rowIndex} className="grid grid-cols-3 gap-3">
|
|
{row.map((key) => {
|
|
let buttonContent: React.ReactNode;
|
|
let variant: 'primary' | 'outline' | 'secondary' = 'outline';
|
|
let disabled = isLoading;
|
|
|
|
switch (key) {
|
|
case 'clear':
|
|
buttonContent = 'Clear';
|
|
variant = 'secondary';
|
|
disabled = disabled || code.length === 0;
|
|
break;
|
|
case 'delete':
|
|
buttonContent = <Delete className="h-5 w-5" />;
|
|
variant = 'secondary';
|
|
disabled = disabled || code.length === 0;
|
|
break;
|
|
default:
|
|
buttonContent = key;
|
|
disabled = disabled || code.length >= 8;
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
key={key}
|
|
onClick={() => handleKeyPress(key)}
|
|
variant={variant}
|
|
disabled={disabled}
|
|
className="h-14 text-lg font-semibold touch-manipulation"
|
|
>
|
|
{buttonContent}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
|
|
{/* Letter Keys (A-F) */}
|
|
{showLetters && (
|
|
<div className="space-y-3 border-t border-glass-border pt-3">
|
|
{LETTER_BUTTONS.map((row, rowIndex) => (
|
|
<div key={`letters-${rowIndex}`} className="grid grid-cols-3 gap-3">
|
|
{row.map((key) => (
|
|
<Button
|
|
key={key}
|
|
onClick={() => handleKeyPress(key)}
|
|
variant="outline"
|
|
disabled={isLoading || code.length >= 8}
|
|
className="h-14 text-lg font-semibold touch-manipulation"
|
|
>
|
|
{key}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div className="mt-6 flex space-x-3">
|
|
<Button
|
|
onClick={onClose}
|
|
variant="outline"
|
|
disabled={isLoading}
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleSubmit}
|
|
variant="primary"
|
|
disabled={!isCodeValid || isLoading}
|
|
className="flex-1 flex items-center justify-center space-x-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span>Checking...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check className="h-4 w-4" />
|
|
<span>Submit</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Footer Help */}
|
|
<div className="mt-4 text-center">
|
|
<p className="text-xs text-tertiary-text">
|
|
Press Enter to submit • Esc to cancel
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
} |