feat: add frontend manual extraction flow with review screen (refs #136)
- Create useManualExtraction hook: submit PDF to OCR, poll job status, track progress - Create useCreateSchedulesFromExtraction hook: batch create maintenance schedules from extraction - Create MaintenanceScheduleReviewScreen: dialog with checkboxes, inline editing, batch create - Update DocumentForm: remove "(Coming soon)", trigger extraction after upload, show progress - Add 12 unit tests for review screen (rendering, selection, empty state, errors) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { UpgradeRequiredDialog } from '../../../shared-minimal/components/Upgrad
|
|||||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
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 LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCreateDocument, useUpdateDocument, useAddSharedVehicle, useRemoveVehicleFromDocument } from '../hooks/useDocuments';
|
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 { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||||
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||||
|
import { useManualExtraction } from '../hooks/useManualExtraction';
|
||||||
|
import { MaintenanceScheduleReviewScreen } from '../../maintenance/components/MaintenanceScheduleReviewScreen';
|
||||||
|
|
||||||
interface DocumentFormProps {
|
interface DocumentFormProps {
|
||||||
mode?: 'create' | 'edit';
|
mode?: 'create' | 'edit';
|
||||||
@@ -95,6 +97,31 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
|||||||
const removeSharedVehicle = useRemoveVehicleFromDocument();
|
const removeSharedVehicle = useRemoveVehicleFromDocument();
|
||||||
const { hasAccess } = useTierAccess();
|
const { hasAccess } = useTierAccess();
|
||||||
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');
|
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 = () => {
|
const resetForm = () => {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -234,6 +261,18 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
|||||||
setError(uploadErr?.message || 'Failed to upload file');
|
setError(uploadErr?.message || 'Failed to upload file');
|
||||||
return;
|
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();
|
resetForm();
|
||||||
@@ -538,8 +577,8 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
|||||||
<LockOutlinedIcon fontSize="small" />
|
<LockOutlinedIcon fontSize="small" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canScanMaintenance && (
|
{canScanMaintenance && scanForMaintenance && (
|
||||||
<span className="ml-1 text-xs text-slate-500 dark:text-titanio">(Coming soon)</span>
|
<span className="ml-1 text-xs text-slate-500 dark:text-titanio">PDF will be scanned after upload</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -569,6 +608,34 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
|||||||
<div className="text-sm text-slate-600 dark:text-titanio mt-1">Uploading... {uploadProgress}%</div>
|
<div className="text-sm text-slate-600 dark:text-titanio mt-1">Uploading... {uploadProgress}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isExtracting && (
|
||||||
|
<div className="md:col-span-2 mt-2">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg border border-primary-200 bg-primary-50 dark:border-abudhabi/30 dark:bg-scuro">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-700 dark:text-avus mb-1">
|
||||||
|
Scanning manual for maintenance schedules...
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant={extraction.progress > 0 ? 'determinate' : 'indeterminate'}
|
||||||
|
value={extraction.progress}
|
||||||
|
sx={{ borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-titanio mt-1">
|
||||||
|
{extraction.progress > 0 ? `${extraction.progress}% complete` : 'Starting extraction...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{extraction.status === 'failed' && extraction.error && (
|
||||||
|
<div className="md:col-span-2 mt-2">
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-sm p-3 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20">
|
||||||
|
Extraction failed: {extraction.error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -576,10 +643,10 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||||
<Button type="submit" className="min-h-[44px]">
|
<Button type="submit" className="min-h-[44px]" disabled={isExtracting}>
|
||||||
{mode === 'edit' ? 'Save Changes' : 'Create Document'}
|
{isExtracting ? 'Scanning...' : mode === 'edit' ? 'Save Changes' : 'Create Document'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
|
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]" disabled={isExtracting}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UpgradeRequiredDialog
|
<UpgradeRequiredDialog
|
||||||
@@ -587,6 +654,16 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
|||||||
open={upgradeDialogOpen}
|
open={upgradeDialogOpen}
|
||||||
onClose={() => setUpgradeDialogOpen(false)}
|
onClose={() => setUpgradeDialogOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{extraction.result && (
|
||||||
|
<MaintenanceScheduleReviewScreen
|
||||||
|
open={reviewDialogOpen}
|
||||||
|
items={extraction.result.maintenanceSchedules}
|
||||||
|
vehicleId={vehicleID}
|
||||||
|
onClose={handleReviewClose}
|
||||||
|
onCreated={handleSchedulesCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
119
frontend/src/features/documents/hooks/useManualExtraction.ts
Normal file
119
frontend/src/features/documents/hooks/useManualExtraction.ts
Normal file
@@ -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<ManualJobResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('vehicle_id', vehicleId);
|
||||||
|
const res = await apiClient.post<ManualJobResponse>('/ocr/extract/manual', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJobStatus(jobId: string): Promise<ManualJobResponse> {
|
||||||
|
const res = await apiClient.get<ManualJobResponse>(`/ocr/jobs/${jobId}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useManualExtraction() {
|
||||||
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: ({ file, vehicleId }: { file: File; vehicleId: string }) =>
|
||||||
|
submitManualExtraction(file, vehicleId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setJobId(data.jobId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pollQuery = useQuery<ManualJobResponse>({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Use 0W-20 full synthetic oil')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display subtype chips', () => {
|
||||||
|
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
// 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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} items={[]} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
// 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(<MaintenanceScheduleReviewScreen {...defaultProps} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create 3 schedules/i }));
|
||||||
|
|
||||||
|
// Wait for error to appear (async mutation)
|
||||||
|
await screen.findByText('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<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">
|
||||||
|
<CheckIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={handleCancel}>
|
||||||
|
<CloseIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&: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 }}
|
||||||
|
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={{ order: isMobile ? 1 : 2, width: isMobile ? '100%' : 'auto' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending
|
||||||
|
? 'Creating...'
|
||||||
|
: `Create ${selectedCount} Schedule${selectedCount !== 1 ? 's' : ''}`}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaintenanceScheduleReviewScreen;
|
||||||
@@ -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<MaintenanceScheduleResponse[], Error, CreateSchedulesParams>({
|
||||||
|
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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user