chore: create AddReceiptDialog component with upload and camera options (refs #183)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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