feat: Expand OCR with fuel receipt scanning and maintenance extraction (#129) #147

Merged
egullickson merged 26 commits from issue-129-expand-ocr-fuel-receipt-maintenance into main 2026-02-13 02:25:55 +00:00
5 changed files with 143 additions and 865 deletions
Showing only changes of commit 11f52258db - Show all commits

File diff suppressed because one or more lines are too long

View File

@@ -622,7 +622,12 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
sx={{ borderRadius: 1 }}
/>
<div className="text-xs text-slate-500 dark:text-titanio mt-1">
{extraction.progress > 0 ? `${extraction.progress}% complete` : 'Starting extraction...'}
{extraction.progress >= 100 ? '100% - Complete' :
extraction.progress >= 95 ? `${extraction.progress}% - Mapping maintenance schedules...` :
extraction.progress >= 50 ? `${extraction.progress}% - Processing maintenance data...` :
extraction.progress >= 10 ? `${extraction.progress}% - Preparing document...` :
extraction.progress > 0 ? `${extraction.progress}% complete` :
'Starting extraction...'}
</div>
</div>
</div>

View File

@@ -98,13 +98,28 @@ export function useManualExtraction() {
}, [submitMutation]);
const jobData = pollQuery.data;
const hasPollError = !!pollQuery.error;
const status: JobStatus | 'idle' = !jobId
? 'idle'
: hasPollError
? 'failed'
: jobData?.status ?? 'pending';
const progress = jobData?.progress ?? 0;
const result = jobData?.result ?? null;
const error = jobData?.error
?? (submitMutation.error ? String((submitMutation.error as Error).message || submitMutation.error) : null);
let error: string | null = null;
if (jobData?.error) {
error = jobData.error;
} else if (pollQuery.error) {
const err = pollQuery.error as any;
if (err.response?.status === 410) {
error = 'Job expired. Please resubmit the document.';
} else {
error = String((err as Error).message || err);
}
} else if (submitMutation.error) {
error = String((submitMutation.error as Error).message || submitMutation.error);
}
return {
submit,

View File

@@ -7,6 +7,23 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { MaintenanceScheduleReviewScreen } from './MaintenanceScheduleReviewScreen';
import type { MaintenanceScheduleItem } from '../../documents/hooks/useManualExtraction';
// Mock matchMedia for responsive tests
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
// Mock the create hook
const mockMutateAsync = jest.fn();
jest.mock('../hooks/useCreateSchedulesFromExtraction', () => ({
@@ -222,4 +239,70 @@ describe('MaintenanceScheduleReviewScreen', () => {
await screen.findByText('Network error');
});
});
describe('Editing', () => {
it('should update item data via inline editing', async () => {
mockMutateAsync.mockResolvedValue([{ id: '1' }]);
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
// Click on the Months field of the third item (unique value: 12 mo)
const monthsField = screen.getByText('12 mo');
fireEvent.click(monthsField);
// Find the input that appeared and change its value
const monthsInput = screen.getByDisplayValue('12');
fireEvent.change(monthsInput, { target: { value: '24' } });
fireEvent.keyDown(monthsInput, { key: 'Enter' });
// Deselect items 1 and 2 to only create the edited item
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
// Create the schedule and verify updated value is used
fireEvent.click(screen.getByRole('button', { name: /create 1 schedule$/i }));
expect(mockMutateAsync).toHaveBeenCalledWith({
vehicleId: 'vehicle-123',
items: [expect.objectContaining({
service: 'Cabin Air Filter Replacement',
intervalMonths: 24,
})],
});
});
});
describe('Responsive layout', () => {
afterEach(() => {
// Reset matchMedia after each test
mockMatchMedia(false);
});
it('should render in fullscreen mode on mobile viewports', () => {
// Simulate mobile: breakpoints.down('sm') returns true
mockMatchMedia(true);
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
// On mobile, dialog renders with fullScreen prop - check that the MuiDialog-paperFullScreen
// class is applied. MUI renders the dialog in a portal, so query from document.
const paper = document.querySelector('.MuiDialog-paperFullScreen');
expect(paper).not.toBeNull();
});
it('should render as modal dialog on desktop viewports', () => {
// Simulate desktop: breakpoints.down('sm') returns false
mockMatchMedia(false);
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
// On desktop, dialog should NOT have fullScreen class
const fullScreenPaper = document.querySelector('.MuiDialog-paperFullScreen');
expect(fullScreenPaper).toBeNull();
// But the dialog should still render
expect(screen.getByText('Extracted Maintenance Schedules')).toBeInTheDocument();
});
});
});

View File

@@ -117,10 +117,10 @@ const InlineField: React.FC<InlineFieldProps> = ({ label, value, type = 'text',
if (e.key === 'Escape') handleCancel();
}}
/>
<IconButton size="small" onClick={handleSave} color="primary">
<IconButton size="small" onClick={handleSave} color="primary" sx={{ minWidth: 44, minHeight: 44 }}>
<CheckIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={handleCancel}>
<IconButton size="small" onClick={handleCancel} sx={{ minWidth: 44, minHeight: 44 }}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
@@ -134,6 +134,7 @@ const InlineField: React.FC<InlineFieldProps> = ({ label, value, type = 'text',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
minHeight: 44,
'&:hover .edit-icon': { opacity: 1 },
}}
onClick={() => setIsEditing(true)}
@@ -293,7 +294,7 @@ export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReview
<Checkbox
checked={item.selected}
onChange={() => handleToggle(index)}
sx={{ mt: -0.5, mr: 1 }}
sx={{ mt: -0.5, mr: 1, '& .MuiSvgIcon-root': { fontSize: 24 }, minWidth: 44, minHeight: 44 }}
inputProps={{ 'aria-label': `Select ${item.service}` }}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
@@ -379,7 +380,7 @@ export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReview
onClick={handleCreate}
disabled={selectedCount === 0 || createMutation.isPending}
startIcon={createMutation.isPending ? <CircularProgress size={16} /> : <CheckIcon />}
sx={{ order: isMobile ? 1 : 2, width: isMobile ? '100%' : 'auto' }}
sx={{ minHeight: 44, order: isMobile ? 1 : 2, width: isMobile ? '100%' : 'auto' }}
>
{createMutation.isPending
? 'Creating...'