chore: UX design audit cleanup and receipt flow improvements #186
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @ai-summary Full-screen dialog with upload and camera options for receipt input
|
||||
* @ai-context Replaces direct camera launch with upload-first pattern; both paths feed OCR pipeline
|
||||
*/
|
||||
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import {
|
||||
DEFAULT_ACCEPTED_FORMATS,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
} from '../../../shared/components/CameraCapture/types';
|
||||
|
||||
interface AddReceiptDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onStartCamera: () => void;
|
||||
}
|
||||
|
||||
export const AddReceiptDialog: React.FC<AddReceiptDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onFileSelect,
|
||||
onStartCamera,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
const isValidType = DEFAULT_ACCEPTED_FORMATS.some((format) => {
|
||||
if (format === 'image/heic' || format === 'image/heif') {
|
||||
return (
|
||||
file.type === 'image/heic' ||
|
||||
file.type === 'image/heif' ||
|
||||
file.name.toLowerCase().endsWith('.heic') ||
|
||||
file.name.toLowerCase().endsWith('.heif')
|
||||
);
|
||||
}
|
||||
return file.type === format;
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
return 'Invalid file type. Accepted formats: JPEG, PNG, HEIC';
|
||||
}
|
||||
|
||||
if (file.size > DEFAULT_MAX_FILE_SIZE) {
|
||||
return `File too large. Maximum size: ${(DEFAULT_MAX_FILE_SIZE / (1024 * 1024)).toFixed(0)}MB`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
setError(null);
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
onFileSelect(file);
|
||||
},
|
||||
[validateFile, onFileSelect]
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleFile(file);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleClickUpload = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Reset error state when dialog closes
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
setIsDragging(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
fullScreen
|
||||
PaperProps={{
|
||||
sx: { backgroundColor: 'background.default' },
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
backgroundColor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Add Receipt</Typography>
|
||||
<IconButton
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
sx={{ minWidth: 44, minHeight: 44 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{/* Drag-and-drop upload zone */}
|
||||
<Box
|
||||
onClick={handleClickUpload}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
py: 5,
|
||||
px: 3,
|
||||
border: 2,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: isDragging ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDragging ? 'action.hover' : 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloudUploadIcon
|
||||
sx={{
|
||||
fontSize: 56,
|
||||
color: isDragging ? 'primary.main' : 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color={isDragging ? 'primary.main' : 'text.primary'}
|
||||
textAlign="center"
|
||||
fontWeight={500}
|
||||
>
|
||||
{isDragging ? 'Drop image here' : 'Drag and drop an image, or tap to browse'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" textAlign="center">
|
||||
JPEG, PNG, HEIC -- up to 10MB
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ maxWidth: 480, width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Divider with "or" */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
or
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
|
||||
</Box>
|
||||
|
||||
{/* Take Photo button */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CameraAltIcon />}
|
||||
onClick={onStartCamera}
|
||||
sx={{
|
||||
minHeight: 56,
|
||||
minWidth: 200,
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
borderRadius: 2,
|
||||
borderWidth: 2,
|
||||
'&:hover': {
|
||||
borderWidth: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Take Photo of Receipt
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={DEFAULT_ACCEPTED_FORMATS.join(',')}
|
||||
onChange={handleInputChange}
|
||||
style={{ display: 'none' }}
|
||||
aria-label="Select receipt image"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user