feat: add form integration, tier gating, and receipt display (refs #153)
- Add tier-gated "Scan Receipt" button to MaintenanceRecordForm - Wire useMaintenanceReceiptOcr hook with CameraCapture and ReviewModal - Auto-populate form fields from accepted OCR results via setValue - Upload receipt as document and pass receiptDocumentId on record create - Show receipt thumbnail + "View Receipt" button in edit dialog - Add receipt indicator chip on records list rows - Add receiptDocumentId and receiptDocument to frontend types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Edit dialog for maintenance records
|
||||
* @ai-context Mobile-friendly dialog with proper form handling
|
||||
* @ai-summary Edit dialog for maintenance records with linked receipt display
|
||||
* @ai-context Mobile-friendly dialog with proper form handling and receipt thumbnail/view
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
MenuItem,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import ReceiptIcon from '@mui/icons-material/Receipt';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
} from '../types/maintenance.types';
|
||||
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import { documentsApi } from '../../documents/api/documents.api';
|
||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface MaintenanceRecordEditDialogProps {
|
||||
@@ -53,7 +56,10 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
|
||||
const vehiclesQuery = useVehicles();
|
||||
const vehicles = vehiclesQuery.data;
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||
const [receiptThumbnailUrl, setReceiptThumbnailUrl] = useState<string | null>(null);
|
||||
|
||||
// Reset form when record changes
|
||||
useEffect(() => {
|
||||
@@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
// Load receipt thumbnail when record has a linked receipt document
|
||||
useEffect(() => {
|
||||
if (!record?.receiptDocument?.documentId) {
|
||||
setReceiptThumbnailUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let revoked = false;
|
||||
documentsApi.download(record.receiptDocument.documentId).then((blob) => {
|
||||
if (!revoked) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setReceiptThumbnailUrl(url);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[MaintenanceRecordEditDialog] Failed to load receipt thumbnail:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
revoked = true;
|
||||
if (receiptThumbnailUrl) {
|
||||
URL.revokeObjectURL(receiptThumbnailUrl);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [record?.receiptDocument?.documentId]);
|
||||
|
||||
const handleViewReceipt = async () => {
|
||||
if (!record?.receiptDocument?.documentId) return;
|
||||
try {
|
||||
const blob = await documentsApi.download(record.receiptDocument.documentId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
// Revoke after a delay to allow the new tab to load
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
} catch (err) {
|
||||
console.error('[MaintenanceRecordEditDialog] Failed to open receipt:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof UpdateMaintenanceRecordRequest, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@@ -182,6 +227,76 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Linked Receipt Display */}
|
||||
{record.receiptDocument && (
|
||||
<Grid item xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'center' : 'center',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
{receiptThumbnailUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={receiptThumbnailUrl}
|
||||
alt="Receipt"
|
||||
sx={{
|
||||
width: isMobile ? 64 : 80,
|
||||
height: isMobile ? 64 : 80,
|
||||
borderRadius: 1,
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: isMobile ? 64 : 80,
|
||||
height: isMobile ? 64 : 80,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ReceiptIcon color="action" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0, textAlign: isMobile ? 'center' : 'left' }}>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
Linked Receipt
|
||||
</Typography>
|
||||
{record.receiptDocument.fileName && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{record.receiptDocument.fileName}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ReceiptIcon />}
|
||||
onClick={handleViewReceipt}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
View Receipt
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @ai-summary Form component for creating maintenance records
|
||||
* @ai-context Mobile-first responsive design with proper validation
|
||||
* @ai-summary Form component for creating maintenance records with receipt OCR integration
|
||||
* @ai-context Mobile-first responsive design with tier-gated receipt scanning, mirrors FuelLogForm OCR pattern
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
CircularProgress,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
Dialog,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
@@ -36,6 +38,13 @@ import {
|
||||
CreateMaintenanceRecordRequest,
|
||||
getCategoryDisplayName,
|
||||
} from '../types/maintenance.types';
|
||||
import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr';
|
||||
import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal';
|
||||
import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton';
|
||||
import { CameraCapture } from '../../../shared/components/CameraCapture';
|
||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
|
||||
import { documentsApi } from '../../documents/api/documents.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const schema = z.object({
|
||||
@@ -58,6 +67,29 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
const { createRecord, isRecordMutating } = useMaintenanceRecords();
|
||||
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
|
||||
|
||||
// Tier access check for receipt scan feature
|
||||
const { hasAccess } = useTierAccess();
|
||||
const hasReceiptScanAccess = hasAccess('maintenance.receiptScan');
|
||||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||
|
||||
// Receipt OCR integration
|
||||
const {
|
||||
isCapturing,
|
||||
isProcessing,
|
||||
result: ocrResult,
|
||||
receiptImageUrl,
|
||||
error: ocrError,
|
||||
startCapture,
|
||||
cancelCapture,
|
||||
processImage,
|
||||
acceptResult,
|
||||
reset: resetOcr,
|
||||
updateField,
|
||||
} = useMaintenanceReceiptOcr();
|
||||
|
||||
// Store captured file for document upload on submit
|
||||
const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -89,8 +121,69 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
}
|
||||
}, [watchedCategory, setValue]);
|
||||
|
||||
// Wrap processImage to also save file reference
|
||||
const handleCaptureImage = async (file: File, croppedFile?: File) => {
|
||||
setCapturedReceiptFile(croppedFile || file);
|
||||
await processImage(file, croppedFile);
|
||||
};
|
||||
|
||||
// Handle accepting OCR results and populating the form
|
||||
const handleAcceptOcrResult = () => {
|
||||
const mappedFields = acceptResult();
|
||||
if (!mappedFields) return;
|
||||
|
||||
// Populate form fields from OCR result
|
||||
if (mappedFields.category) {
|
||||
setValue('category', mappedFields.category);
|
||||
setSelectedCategory(mappedFields.category);
|
||||
}
|
||||
if (mappedFields.subtypes && mappedFields.subtypes.length > 0) {
|
||||
setValue('subtypes', mappedFields.subtypes);
|
||||
}
|
||||
if (mappedFields.date) {
|
||||
setValue('date', mappedFields.date);
|
||||
}
|
||||
if (mappedFields.cost !== undefined) {
|
||||
setValue('cost', mappedFields.cost as any);
|
||||
}
|
||||
if (mappedFields.shopName) {
|
||||
setValue('shop_name', mappedFields.shopName);
|
||||
}
|
||||
if (mappedFields.odometerReading !== undefined) {
|
||||
setValue('odometer_reading', mappedFields.odometerReading as any);
|
||||
}
|
||||
if (mappedFields.notes) {
|
||||
setValue('notes', mappedFields.notes);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle retaking photo
|
||||
const handleRetakePhoto = () => {
|
||||
resetOcr();
|
||||
setCapturedReceiptFile(null);
|
||||
startCapture();
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
let receiptDocumentId: string | undefined;
|
||||
|
||||
// Upload receipt as document if we have a captured file
|
||||
if (capturedReceiptFile) {
|
||||
try {
|
||||
const doc = await documentsApi.create({
|
||||
vehicleId: data.vehicle_id,
|
||||
documentType: 'manual',
|
||||
title: `Maintenance Receipt - ${new Date(data.date).toLocaleDateString()}`,
|
||||
});
|
||||
await documentsApi.upload(doc.id, capturedReceiptFile);
|
||||
receiptDocumentId = doc.id;
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload receipt document:', uploadError);
|
||||
toast.error('Receipt upload failed, but the record will be saved without the receipt.');
|
||||
}
|
||||
}
|
||||
|
||||
const payload: CreateMaintenanceRecordRequest = {
|
||||
vehicleId: data.vehicle_id,
|
||||
category: data.category as MaintenanceCategory,
|
||||
@@ -100,6 +193,7 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
cost: data.cost ? Number(data.cost) : undefined,
|
||||
shopName: data.shop_name || undefined,
|
||||
notes: data.notes || undefined,
|
||||
receiptDocumentId,
|
||||
};
|
||||
|
||||
await createRecord(payload);
|
||||
@@ -117,6 +211,7 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
notes: '',
|
||||
});
|
||||
setSelectedCategory(null);
|
||||
setCapturedReceiptFile(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to create maintenance record:', error);
|
||||
toast.error('Failed to add maintenance record');
|
||||
@@ -140,6 +235,31 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
<Card>
|
||||
<CardHeader title="Add Maintenance Record" />
|
||||
<CardContent>
|
||||
{/* Receipt Scan Button */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mb: 3,
|
||||
pb: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<ReceiptCameraButton
|
||||
onClick={() => {
|
||||
if (!hasReceiptScanAccess) {
|
||||
setShowUpgradeDialog(true);
|
||||
return;
|
||||
}
|
||||
startCapture();
|
||||
}}
|
||||
disabled={isProcessing || isRecordMutating}
|
||||
variant="button"
|
||||
locked={!hasReceiptScanAccess}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Vehicle Selection */}
|
||||
@@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Camera Capture Modal */}
|
||||
<Dialog
|
||||
open={isCapturing}
|
||||
onClose={cancelCapture}
|
||||
fullScreen
|
||||
PaperProps={{
|
||||
sx: { backgroundColor: 'black' },
|
||||
}}
|
||||
>
|
||||
<CameraCapture
|
||||
onCapture={handleCaptureImage}
|
||||
onCancel={cancelCapture}
|
||||
guidanceType="receipt"
|
||||
allowCrop={true}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{/* OCR Processing Overlay */}
|
||||
<Backdrop
|
||||
open={isProcessing}
|
||||
sx={{
|
||||
color: '#fff',
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
<Typography variant="body1">Extracting receipt data...</Typography>
|
||||
</Backdrop>
|
||||
|
||||
{/* OCR Review Modal */}
|
||||
{ocrResult && (
|
||||
<MaintenanceReceiptReviewModal
|
||||
open={!!ocrResult}
|
||||
extractedFields={ocrResult.extractedFields}
|
||||
receiptImageUrl={receiptImageUrl}
|
||||
categorySuggestion={ocrResult.categorySuggestion}
|
||||
onAccept={handleAcceptOcrResult}
|
||||
onRetake={handleRetakePhoto}
|
||||
onCancel={() => {
|
||||
resetOcr();
|
||||
setCapturedReceiptFile(null);
|
||||
}}
|
||||
onFieldEdit={updateField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upgrade Required Dialog for Receipt Scan */}
|
||||
<UpgradeRequiredDialog
|
||||
featureKey="maintenance.receiptScan"
|
||||
open={showUpgradeDialog}
|
||||
onClose={() => setShowUpgradeDialog(false)}
|
||||
/>
|
||||
|
||||
{/* OCR Error Display */}
|
||||
{ocrError && (
|
||||
<Dialog open={!!ocrError} onClose={resetOcr} maxWidth="xs">
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
OCR Error
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{ocrError}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button onClick={startCapture} variant="contained">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetOcr();
|
||||
setCapturedReceiptFile(null);
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)}
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import { Edit, Delete } from '@mui/icons-material';
|
||||
import { Edit, Delete, Receipt } from '@mui/icons-material';
|
||||
import {
|
||||
MaintenanceRecordResponse,
|
||||
getCategoryDisplayName,
|
||||
@@ -136,6 +136,15 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{record.receiptDocument && (
|
||||
<Chip
|
||||
icon={<Receipt fontSize="small" />}
|
||||
label="Receipt"
|
||||
size="small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{record.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
|
||||
cost?: number;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
receiptDocumentId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -105,6 +106,15 @@ export interface CreateMaintenanceRecordRequest {
|
||||
cost?: number;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
receiptDocumentId?: string;
|
||||
}
|
||||
|
||||
// Receipt document metadata returned on GET
|
||||
export interface ReceiptDocumentMeta {
|
||||
documentId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceRecordRequest {
|
||||
@@ -148,6 +158,7 @@ export interface UpdateScheduleRequest {
|
||||
// Response types (camelCase)
|
||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||
subtypeCount: number;
|
||||
receiptDocument?: ReceiptDocumentMeta | null;
|
||||
}
|
||||
|
||||
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {
|
||||
|
||||
Reference in New Issue
Block a user