diff --git a/frontend/src/features/documents/components/DocumentForm.tsx b/frontend/src/features/documents/components/DocumentForm.tsx index 55947b3..e1ba7bc 100644 --- a/frontend/src/features/documents/components/DocumentForm.tsx +++ b/frontend/src/features/documents/components/DocumentForm.tsx @@ -4,7 +4,7 @@ import { UpgradeRequiredDialog } from '../../../shared-minimal/components/Upgrad import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; -import { Checkbox, FormControlLabel } from '@mui/material'; +import { Checkbox, FormControlLabel, LinearProgress } from '@mui/material'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import dayjs from 'dayjs'; import { useCreateDocument, useUpdateDocument, useAddSharedVehicle, useRemoveVehicleFromDocument } from '../hooks/useDocuments'; @@ -13,6 +13,8 @@ import type { DocumentType, DocumentRecord } from '../types/documents.types'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; import type { Vehicle } from '../../vehicles/types/vehicles.types'; import { useTierAccess } from '../../../core/hooks/useTierAccess'; +import { useManualExtraction } from '../hooks/useManualExtraction'; +import { MaintenanceScheduleReviewScreen } from '../../maintenance/components/MaintenanceScheduleReviewScreen'; interface DocumentFormProps { mode?: 'create' | 'edit'; @@ -95,6 +97,31 @@ export const DocumentForm: React.FC = ({ const removeSharedVehicle = useRemoveVehicleFromDocument(); const { hasAccess } = useTierAccess(); const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule'); + const extraction = useManualExtraction(); + const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false); + + // Open review dialog when extraction completes + React.useEffect(() => { + if (extraction.status === 'completed' && extraction.result) { + setReviewDialogOpen(true); + } + }, [extraction.status, extraction.result]); + + const isExtracting = extraction.status === 'pending' || extraction.status === 'processing'; + + const handleReviewClose = () => { + setReviewDialogOpen(false); + extraction.reset(); + resetForm(); + onSuccess?.(); + }; + + const handleSchedulesCreated = (_count: number) => { + setReviewDialogOpen(false); + extraction.reset(); + resetForm(); + onSuccess?.(); + }; const resetForm = () => { setTitle(''); @@ -234,6 +261,18 @@ export const DocumentForm: React.FC = ({ setError(uploadErr?.message || 'Failed to upload file'); return; } + + // Trigger manual extraction if scan checkbox was checked + if (scanForMaintenance && documentType === 'manual' && file.type === 'application/pdf') { + try { + await extraction.submit(file, vehicleID); + // Don't call onSuccess yet - wait for extraction and review + return; + } catch (extractionErr: any) { + setError(extractionErr?.message || 'Failed to start maintenance extraction'); + return; + } + } } resetForm(); @@ -538,8 +577,8 @@ export const DocumentForm: React.FC = ({ )} - {canScanMaintenance && ( - (Coming soon) + {canScanMaintenance && scanForMaintenance && ( + PDF will be scanned after upload )} )} @@ -569,6 +608,34 @@ export const DocumentForm: React.FC = ({
Uploading... {uploadProgress}%
)} + + {isExtracting && ( +
+
+
+
+ Scanning manual for maintenance schedules... +
+ 0 ? 'determinate' : 'indeterminate'} + value={extraction.progress} + sx={{ borderRadius: 1 }} + /> +
+ {extraction.progress > 0 ? `${extraction.progress}% complete` : 'Starting extraction...'} +
+
+
+
+ )} + + {extraction.status === 'failed' && extraction.error && ( +
+
+ Extraction failed: {extraction.error} +
+
+ )} {error && ( @@ -576,10 +643,10 @@ export const DocumentForm: React.FC = ({ )}
- - +
= ({ open={upgradeDialogOpen} onClose={() => setUpgradeDialogOpen(false)} /> + + {extraction.result && ( + + )} ); diff --git a/frontend/src/features/documents/hooks/useManualExtraction.ts b/frontend/src/features/documents/hooks/useManualExtraction.ts new file mode 100644 index 0000000..847eb18 --- /dev/null +++ b/frontend/src/features/documents/hooks/useManualExtraction.ts @@ -0,0 +1,119 @@ +/** + * @ai-summary Hook for submitting and polling manual maintenance extraction jobs + * @ai-context Submits PDF to OCR endpoint, polls for status, returns extraction results + */ + +import { useState, useCallback } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { apiClient } from '../../../core/api/client'; + +// Types matching backend ManualJobResponse / ManualExtractionResult +export interface ManualVehicleInfo { + make: string | null; + model: string | null; + year: number | null; +} + +export interface MaintenanceScheduleItem { + service: string; + intervalMiles: number | null; + intervalMonths: number | null; + details: string | null; + confidence: number; + subtypes: string[]; +} + +export interface ManualExtractionResult { + success: boolean; + vehicleInfo: ManualVehicleInfo; + maintenanceSchedules: MaintenanceScheduleItem[]; + rawTables: unknown[]; + processingTimeMs: number; + totalPages: number; + pagesProcessed: number; + error: string | null; +} + +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export interface ManualJobResponse { + jobId: string; + status: JobStatus; + progress?: number; + estimatedSeconds?: number; + result?: ManualExtractionResult; + error?: string; +} + +async function submitManualExtraction(file: File, vehicleId: string): Promise { + const form = new FormData(); + form.append('file', file); + form.append('vehicle_id', vehicleId); + const res = await apiClient.post('/ocr/extract/manual', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, + }); + return res.data; +} + +async function getJobStatus(jobId: string): Promise { + const res = await apiClient.get(`/ocr/jobs/${jobId}`); + return res.data; +} + +export function useManualExtraction() { + const [jobId, setJobId] = useState(null); + + const submitMutation = useMutation({ + mutationFn: ({ file, vehicleId }: { file: File; vehicleId: string }) => + submitManualExtraction(file, vehicleId), + onSuccess: (data) => { + setJobId(data.jobId); + }, + }); + + const pollQuery = useQuery({ + queryKey: ['manualExtractionJob', jobId], + queryFn: () => getJobStatus(jobId!), + enabled: !!jobId, + refetchInterval: (query) => { + const data = query.state.data; + if (data?.status === 'completed' || data?.status === 'failed') { + return false; + } + return 3000; + }, + refetchIntervalInBackground: false, + retry: 2, + }); + + const submit = useCallback( + (file: File, vehicleId: string) => submitMutation.mutateAsync({ file, vehicleId }), + [submitMutation] + ); + + const reset = useCallback(() => { + setJobId(null); + submitMutation.reset(); + }, [submitMutation]); + + const jobData = pollQuery.data; + const status: JobStatus | 'idle' = !jobId + ? 'idle' + : 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); + + return { + submit, + isSubmitting: submitMutation.isPending, + jobId, + status, + progress, + result, + error, + reset, + }; +} diff --git a/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.test.tsx b/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.test.tsx new file mode 100644 index 0000000..e67fa08 --- /dev/null +++ b/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.test.tsx @@ -0,0 +1,225 @@ +/** + * @ai-summary Unit tests for MaintenanceScheduleReviewScreen component + * @ai-context Tests rendering, selection, editing, empty state, and error handling + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { MaintenanceScheduleReviewScreen } from './MaintenanceScheduleReviewScreen'; +import type { MaintenanceScheduleItem } from '../../documents/hooks/useManualExtraction'; + +// Mock the create hook +const mockMutateAsync = jest.fn(); +jest.mock('../hooks/useCreateSchedulesFromExtraction', () => ({ + useCreateSchedulesFromExtraction: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})); + +const sampleItems: MaintenanceScheduleItem[] = [ + { + service: 'Engine Oil Change', + intervalMiles: 5000, + intervalMonths: 6, + details: 'Use 0W-20 full synthetic oil', + confidence: 0.95, + subtypes: ['Engine Oil'], + }, + { + service: 'Tire Rotation', + intervalMiles: 5000, + intervalMonths: 6, + details: null, + confidence: 0.88, + subtypes: ['Tires'], + }, + { + service: 'Cabin Air Filter Replacement', + intervalMiles: 15000, + intervalMonths: 12, + details: null, + confidence: 0.72, + subtypes: ['Cabin Air Filter / Purifier'], + }, +]; + +describe('MaintenanceScheduleReviewScreen', () => { + const defaultProps = { + open: true, + items: sampleItems, + vehicleId: 'vehicle-123', + onClose: jest.fn(), + onCreated: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockMutateAsync.mockResolvedValue([]); + }); + + describe('Rendering', () => { + it('should render extracted items with checkboxes', () => { + render(); + + expect(screen.getByText('Extracted Maintenance Schedules')).toBeInTheDocument(); + expect(screen.getByText('3 of 3 items selected')).toBeInTheDocument(); + + // All items should be visible + expect(screen.getByText('Engine Oil Change')).toBeInTheDocument(); + expect(screen.getByText('Tire Rotation')).toBeInTheDocument(); + expect(screen.getByText('Cabin Air Filter Replacement')).toBeInTheDocument(); + + // All checkboxes should be checked by default + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach((cb) => { + expect(cb).toBeChecked(); + }); + }); + + it('should display interval information', () => { + render(); + + expect(screen.getAllByText('5000 mi')).toHaveLength(2); + expect(screen.getAllByText('6 mo')).toHaveLength(2); + expect(screen.getByText('15000 mi')).toBeInTheDocument(); + expect(screen.getByText('12 mo')).toBeInTheDocument(); + }); + + it('should display details text when present', () => { + render(); + + expect(screen.getByText('Use 0W-20 full synthetic oil')).toBeInTheDocument(); + }); + + it('should display subtype chips', () => { + render(); + + expect(screen.getByText('Engine Oil')).toBeInTheDocument(); + expect(screen.getByText('Tires')).toBeInTheDocument(); + expect(screen.getByText('Cabin Air Filter / Purifier')).toBeInTheDocument(); + }); + }); + + describe('Selection', () => { + it('should toggle item selection on checkbox click', () => { + render(); + + const checkboxes = screen.getAllByRole('checkbox'); + + // Uncheck first item + fireEvent.click(checkboxes[0]); + expect(checkboxes[0]).not.toBeChecked(); + expect(screen.getByText('2 of 3 items selected')).toBeInTheDocument(); + + // Re-check it + fireEvent.click(checkboxes[0]); + expect(checkboxes[0]).toBeChecked(); + expect(screen.getByText('3 of 3 items selected')).toBeInTheDocument(); + }); + + it('should deselect all items', () => { + render(); + + fireEvent.click(screen.getByText('Deselect All')); + + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach((cb) => { + expect(cb).not.toBeChecked(); + }); + expect(screen.getByText('0 of 3 items selected')).toBeInTheDocument(); + }); + + it('should select all items after deselecting', () => { + render(); + + // Deselect all first + fireEvent.click(screen.getByText('Deselect All')); + expect(screen.getByText('0 of 3 items selected')).toBeInTheDocument(); + + // Select all + fireEvent.click(screen.getByText('Select All')); + + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach((cb) => { + expect(cb).toBeChecked(); + }); + expect(screen.getByText('3 of 3 items selected')).toBeInTheDocument(); + }); + + it('should disable create button when no items selected', () => { + render(); + + fireEvent.click(screen.getByText('Deselect All')); + + const createButton = screen.getByRole('button', { name: /create/i }); + expect(createButton).toBeDisabled(); + }); + }); + + describe('Empty state', () => { + it('should show no items found message for empty extraction', () => { + render(); + + expect(screen.getByText('No maintenance items found')).toBeInTheDocument(); + expect(screen.getByText(/did not contain any recognizable/)).toBeInTheDocument(); + + // Should show Close button instead of Create + expect(screen.getByText('Close')).toBeInTheDocument(); + expect(screen.queryByText(/Create/)).not.toBeInTheDocument(); + }); + }); + + describe('Schedule creation', () => { + it('should create selected schedules on button click', async () => { + mockMutateAsync.mockResolvedValue([{ id: '1' }, { id: '2' }, { id: '3' }]); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /create 3 schedules/i })); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + vehicleId: 'vehicle-123', + items: expect.arrayContaining([ + expect.objectContaining({ service: 'Engine Oil Change', selected: true }), + expect.objectContaining({ service: 'Tire Rotation', selected: true }), + expect.objectContaining({ service: 'Cabin Air Filter Replacement', selected: true }), + ]), + }); + }); + + it('should only create selected items', async () => { + mockMutateAsync.mockResolvedValue([{ id: '1' }]); + + render(); + + // Deselect last two items + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[1]); + fireEvent.click(checkboxes[2]); + + fireEvent.click(screen.getByRole('button', { name: /create 1 schedule$/i })); + + expect(mockMutateAsync).toHaveBeenCalledWith({ + vehicleId: 'vehicle-123', + items: expect.arrayContaining([ + expect.objectContaining({ service: 'Engine Oil Change', selected: true }), + ]), + }); + // Should not include unselected items + const callArgs = mockMutateAsync.mock.calls[0][0]; + expect(callArgs.items).toHaveLength(1); + }); + + it('should show error on creation failure', async () => { + mockMutateAsync.mockRejectedValue(new Error('Network error')); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /create 3 schedules/i })); + + // Wait for error to appear (async mutation) + await screen.findByText('Network error'); + }); + }); +}); diff --git a/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.tsx b/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.tsx new file mode 100644 index 0000000..f71f4e9 --- /dev/null +++ b/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.tsx @@ -0,0 +1,395 @@ +/** + * @ai-summary Review screen for extracted maintenance schedules from manual OCR + * @ai-context Dialog showing extracted items with checkboxes, inline editing, batch create + */ + +import React, { useState, useCallback } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + TextField, + Checkbox, + IconButton, + Alert, + CircularProgress, + Chip, + useTheme, + useMediaQuery, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import SelectAllIcon from '@mui/icons-material/SelectAll'; +import DeselectIcon from '@mui/icons-material/Deselect'; +import type { MaintenanceScheduleItem } from '../../documents/hooks/useManualExtraction'; +import { useCreateSchedulesFromExtraction } from '../hooks/useCreateSchedulesFromExtraction'; + +export interface MaintenanceScheduleReviewScreenProps { + open: boolean; + items: MaintenanceScheduleItem[]; + vehicleId: string; + onClose: () => void; + onCreated: (count: number) => void; +} + +interface EditableItem extends MaintenanceScheduleItem { + selected: boolean; +} + +const ConfidenceIndicator: React.FC<{ confidence: number }> = ({ confidence }) => { + const filledDots = Math.round(confidence * 4); + const isLow = confidence < 0.6; + + return ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ); +}; + +interface InlineFieldProps { + label: string; + value: string | number | null; + type?: 'text' | 'number'; + onSave: (value: string | number | null) => void; + suffix?: string; +} + +const InlineField: React.FC = ({ label, value, type = 'text', onSave, suffix }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(value !== null ? String(value) : ''); + + const displayValue = value !== null + ? (suffix ? `${value} ${suffix}` : String(value)) + : '-'; + + const handleSave = () => { + let parsed: string | number | null = editValue || null; + if (type === 'number' && editValue) { + const num = parseFloat(editValue); + parsed = isNaN(num) ? null : num; + } + onSave(parsed); + setIsEditing(false); + }; + + const handleCancel = () => { + setEditValue(value !== null ? String(value) : ''); + setIsEditing(false); + }; + + if (isEditing) { + return ( + + + {label}: + + setEditValue(e.target.value)} + type={type === 'number' ? 'number' : 'text'} + inputProps={{ step: type === 'number' ? 1 : undefined }} + autoFocus + sx={{ flex: 1, '& .MuiInputBase-input': { py: 0.5, px: 1, fontSize: '0.875rem' } }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') handleCancel(); + }} + /> + + + + + + + + ); + } + + return ( + setIsEditing(true)} + role="button" + tabIndex={0} + aria-label={`Edit ${label}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsEditing(true); + } + }} + > + + {label}: + + + {displayValue} + + + + ); +}; + +export const MaintenanceScheduleReviewScreen: React.FC = ({ + open, + items, + vehicleId, + onClose, + onCreated, +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const createMutation = useCreateSchedulesFromExtraction(); + + const [editableItems, setEditableItems] = useState(() => + items.map((item) => ({ ...item, selected: true })) + ); + const [createError, setCreateError] = useState(null); + + const selectedCount = editableItems.filter((i) => i.selected).length; + + const handleToggle = useCallback((index: number) => { + setEditableItems((prev) => + prev.map((item, i) => (i === index ? { ...item, selected: !item.selected } : item)) + ); + }, []); + + const handleSelectAll = useCallback(() => { + setEditableItems((prev) => prev.map((item) => ({ ...item, selected: true }))); + }, []); + + const handleDeselectAll = useCallback(() => { + setEditableItems((prev) => prev.map((item) => ({ ...item, selected: false }))); + }, []); + + const handleFieldUpdate = useCallback((index: number, field: keyof MaintenanceScheduleItem, value: string | number | null) => { + setEditableItems((prev) => + prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)) + ); + }, []); + + const handleCreate = async () => { + setCreateError(null); + const selectedItems = editableItems.filter((i) => i.selected); + if (selectedItems.length === 0) return; + + try { + await createMutation.mutateAsync({ vehicleId, items: selectedItems }); + onCreated(selectedItems.length); + } catch (err: any) { + setCreateError(err?.message || 'Failed to create maintenance schedules'); + } + }; + + const isEmpty = items.length === 0; + + return ( + + + + Extracted Maintenance Schedules + + + + + + + + {isEmpty ? ( + + + No maintenance items found + + + The manual did not contain any recognizable routine maintenance schedules. + + + ) : ( + <> + + + {selectedCount} of {editableItems.length} items selected + + + + + + + + + {editableItems.map((item, index) => ( + + handleToggle(index)} + sx={{ mt: -0.5, mr: 1 }} + inputProps={{ 'aria-label': `Select ${item.service}` }} + /> + + + handleFieldUpdate(index, 'service', v)} + /> + + + + + handleFieldUpdate(index, 'intervalMiles', v)} + suffix="mi" + /> + handleFieldUpdate(index, 'intervalMonths', v)} + suffix="mo" + /> + + + {item.details && ( + + {item.details} + + )} + + {item.subtypes.length > 0 && ( + + {item.subtypes.map((subtype) => ( + + ))} + + )} + + + ))} + + + + Tap any field to edit before creating schedules. + + + )} + + {createError && ( + + {createError} + + )} + + + + + {!isEmpty && ( + <> + + + + )} + + + ); +}; + +export default MaintenanceScheduleReviewScreen; diff --git a/frontend/src/features/maintenance/hooks/useCreateSchedulesFromExtraction.ts b/frontend/src/features/maintenance/hooks/useCreateSchedulesFromExtraction.ts new file mode 100644 index 0000000..cb3da87 --- /dev/null +++ b/frontend/src/features/maintenance/hooks/useCreateSchedulesFromExtraction.ts @@ -0,0 +1,41 @@ +/** + * @ai-summary Hook for batch-creating maintenance schedules from manual extraction results + * @ai-context Maps extracted MaintenanceScheduleItem[] to CreateScheduleRequest[] and creates via API + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { maintenanceApi } from '../api/maintenance.api'; +import type { CreateScheduleRequest, MaintenanceScheduleResponse } from '../types/maintenance.types'; +import type { MaintenanceScheduleItem } from '../../documents/hooks/useManualExtraction'; + +interface CreateSchedulesParams { + vehicleId: string; + items: MaintenanceScheduleItem[]; +} + +export function useCreateSchedulesFromExtraction() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ vehicleId, items }) => { + const results: MaintenanceScheduleResponse[] = []; + for (const item of items) { + const request: CreateScheduleRequest = { + vehicleId, + category: 'routine_maintenance', + subtypes: item.subtypes.length > 0 ? item.subtypes : [], + scheduleType: 'interval', + intervalMiles: item.intervalMiles ?? undefined, + intervalMonths: item.intervalMonths ?? undefined, + }; + const created = await maintenanceApi.createSchedule(request); + results.push(created); + } + return results; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['maintenanceSchedules', variables.vehicleId] }); + queryClient.invalidateQueries({ queryKey: ['maintenanceUpcoming', variables.vehicleId] }); + }, + }); +}