Files
motovaultpro/frontend/src/features/maintenance/components/MaintenanceScheduleReviewScreen.tsx
Eric Gullickson 11f52258db feat: add 410 error handling, progress messages, touch targets, and tests (refs #145)
- Handle poll errors including 410 Gone in useManualExtraction hook
- Add specific progress stage messages (Preparing/Processing/Mapping/Complete)
- Enforce 44px minimum touch targets on all interactive elements
- Add tests for inline editing, mobile fullscreen, and desktop modal layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:12:29 -06:00

397 lines
13 KiB
TypeScript

/**
* @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 (
<Box
sx={{ display: 'flex', gap: 0.25, ml: 1 }}
aria-label={`Confidence: ${Math.round(confidence * 100)}%`}
>
{[0, 1, 2, 3].map((i) => (
<Box
key={i}
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: i < filledDots
? (isLow ? 'warning.main' : 'success.main')
: 'grey.300',
}}
/>
))}
</Box>
);
};
interface InlineFieldProps {
label: string;
value: string | number | null;
type?: 'text' | 'number';
onSave: (value: string | number | null) => void;
suffix?: string;
}
const InlineField: React.FC<InlineFieldProps> = ({ 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 (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 50, flexShrink: 0 }}>
{label}:
</Typography>
<TextField
size="small"
value={editValue}
onChange={(e) => 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();
}}
/>
<IconButton size="small" onClick={handleSave} color="primary" sx={{ minWidth: 44, minHeight: 44 }}>
<CheckIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={handleCancel} sx={{ minWidth: 44, minHeight: 44 }}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
minHeight: 44,
'&:hover .edit-icon': { opacity: 1 },
}}
onClick={() => setIsEditing(true)}
role="button"
tabIndex={0}
aria-label={`Edit ${label}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsEditing(true);
}
}}
>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 50, flexShrink: 0 }}>
{label}:
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: value !== null ? 500 : 400,
color: value !== null ? 'text.primary' : 'text.disabled',
}}
>
{displayValue}
</Typography>
<EditIcon className="edit-icon" sx={{ fontSize: 14, opacity: 0, transition: 'opacity 0.2s', color: 'text.secondary' }} />
</Box>
);
};
export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReviewScreenProps> = ({
open,
items,
vehicleId,
onClose,
onCreated,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const createMutation = useCreateSchedulesFromExtraction();
const [editableItems, setEditableItems] = useState<EditableItem[]>(() =>
items.map((item) => ({ ...item, selected: true }))
);
const [createError, setCreateError] = useState<string | null>(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 (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: { maxHeight: isMobile ? '100vh' : '90vh' },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" component="span">
Extracted Maintenance Schedules
</Typography>
<IconButton onClick={onClose} size="small" aria-label="Close">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{isEmpty ? (
<Box sx={{ textAlign: 'center', py: 6 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
No maintenance items found
</Typography>
<Typography variant="body2" color="text.secondary">
The manual did not contain any recognizable routine maintenance schedules.
</Typography>
</Box>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{selectedCount} of {editableItems.length} items selected
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<SelectAllIcon />}
onClick={handleSelectAll}
disabled={selectedCount === editableItems.length}
>
Select All
</Button>
<Button
size="small"
startIcon={<DeselectIcon />}
onClick={handleDeselectAll}
disabled={selectedCount === 0}
>
Deselect All
</Button>
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{editableItems.map((item, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'flex-start',
p: 1.5,
borderRadius: 1,
border: '1px solid',
borderColor: item.selected ? 'primary.light' : 'divider',
backgroundColor: item.selected ? 'primary.50' : 'transparent',
opacity: item.selected ? 1 : 0.6,
transition: 'all 0.15s ease',
'&:hover': { borderColor: 'primary.main' },
}}
>
<Checkbox
checked={item.selected}
onChange={() => handleToggle(index)}
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 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<InlineField
label="Service"
value={item.service}
onSave={(v) => handleFieldUpdate(index, 'service', v)}
/>
<ConfidenceIndicator confidence={item.confidence} />
</Box>
<Box sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 0.5 : 2,
}}>
<InlineField
label="Miles"
value={item.intervalMiles}
type="number"
onSave={(v) => handleFieldUpdate(index, 'intervalMiles', v)}
suffix="mi"
/>
<InlineField
label="Months"
value={item.intervalMonths}
type="number"
onSave={(v) => handleFieldUpdate(index, 'intervalMonths', v)}
suffix="mo"
/>
</Box>
{item.details && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{item.details}
</Typography>
)}
{item.subtypes.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{item.subtypes.map((subtype) => (
<Chip key={subtype} label={subtype} size="small" variant="outlined" />
))}
</Box>
)}
</Box>
</Box>
))}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}>
Tap any field to edit before creating schedules.
</Typography>
</>
)}
{createError && (
<Alert severity="error" sx={{ mt: 2 }}>
{createError}
</Alert>
)}
</DialogContent>
<DialogActions
sx={{
flexDirection: isMobile ? 'column' : 'row',
gap: 1,
p: 2,
}}
>
<Button
onClick={onClose}
sx={{ order: isMobile ? 2 : 1, width: isMobile ? '100%' : 'auto' }}
>
{isEmpty ? 'Close' : 'Skip'}
</Button>
{!isEmpty && (
<>
<Box sx={{ flex: 1, display: isMobile ? 'none' : 'block' }} />
<Button
variant="contained"
onClick={handleCreate}
disabled={selectedCount === 0 || createMutation.isPending}
startIcon={createMutation.isPending ? <CircularProgress size={16} /> : <CheckIcon />}
sx={{ minHeight: 44, order: isMobile ? 1 : 2, width: isMobile ? '100%' : 'auto' }}
>
{createMutation.isPending
? 'Creating...'
: `Create ${selectedCount} Schedule${selectedCount !== 1 ? 's' : ''}`}
</Button>
</>
)}
</DialogActions>
</Dialog>
);
};
export default MaintenanceScheduleReviewScreen;