- 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>
397 lines
13 KiB
TypeScript
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;
|