diff --git a/frontend/src/features/settings/README-IMPORT.md b/frontend/src/features/settings/README-IMPORT.md new file mode 100644 index 0000000..31c44c6 --- /dev/null +++ b/frontend/src/features/settings/README-IMPORT.md @@ -0,0 +1,91 @@ +# User Data Import Feature + +## Overview + +Frontend implementation of user data import feature (issue #26, Milestone 4). + +## Components + +### ImportButton +**File**: `components/ImportButton.tsx` +- Opens file selector for .tar.gz files +- Client-side validation (file extension, size limit 500MB) +- Triggers ImportDialog on file selection + +### ImportDialog +**File**: `components/ImportDialog.tsx` +- Multi-step wizard: Upload → Preview → Confirm → Progress → Results +- Step 1 (Upload): Shows selected file details +- Step 2 (Preview): Loading state while generating preview +- Step 3 (Confirm): Displays manifest, conflicts, mode selection (merge/replace) +- Step 4 (Progress): Shows import in progress +- Step 5 (Results): Displays summary with counts and any errors/warnings +- Responsive design for mobile (320px, 768px) and desktop (1920px) +- Touch targets >= 44px per CLAUDE.md requirement + +### API Client +**File**: `api/import.api.ts` +- `getPreview(file)`: POST /api/user/import/preview (multipart) +- `executeImport(file, mode)`: POST /api/user/import (multipart) +- 2-minute timeout for large files + +### React Query Hooks +**File**: `hooks/useImportUserData.ts` +- `useImportPreview()`: Mutation for preview generation +- `useImportUserData()`: Mutation for import execution +- Toast notifications for success/error states + +### Types +**File**: `types/import.types.ts` +- `ImportManifest`: Archive contents and metadata +- `ImportPreview`: Preview data with conflicts +- `ImportResult`: Import execution results +- Mirrors backend types from `backend/src/features/user-import/domain/user-import.types.ts` + +## Integration + +The import button is placed in the Data Management section of the mobile settings screen, directly above the existing export button. + +**File**: `mobile/MobileSettingsScreen.tsx` +- Added ImportButton component +- Added ImportDialog component +- Manages file selection and dialog state + +## Usage Flow + +1. User clicks "Import My Data" button +2. File selector opens (.tar.gz filter) +3. User selects export archive +4. Dialog opens and automatically generates preview +5. Preview shows counts, conflicts, warnings +6. User selects mode (merge or replace) +7. User confirms import +8. Progress indicator shows during import +9. Results screen displays summary with counts +10. User clicks "Done" to close dialog + +## Validation + +- Client-side: File extension (.tar.gz), size (500MB max) +- Server-side: MIME type, magic bytes, archive structure, manifest validation +- User-facing error messages for all failure scenarios + +## Responsive Design + +- Mobile (320px): Full-width dialog, stacked layout, 44px touch targets +- Tablet (768px): Centered dialog, readable text +- Desktop (1920px): Max-width constrained dialog (2xl = 672px) + +## Quality Checklist + +- [x] Type-check passes +- [x] Linting passes +- [x] Mobile viewport support (320px, 768px) +- [x] Desktop viewport support (1920px) +- [x] Touch targets >= 44px +- [x] Error handling with user-friendly messages +- [x] Loading states for async operations +- [x] Success/error toast notifications +- [x] Follows existing export button pattern +- [x] Material-UI component consistency +- [x] Dark mode support diff --git a/frontend/src/features/settings/api/import.api.ts b/frontend/src/features/settings/api/import.api.ts new file mode 100644 index 0000000..7067704 --- /dev/null +++ b/frontend/src/features/settings/api/import.api.ts @@ -0,0 +1,45 @@ +/** + * @ai-summary API client for user data import + * @ai-context Uploads import archive, generates preview, executes import + */ + +import { apiClient } from '../../../core/api/client'; +import { ImportPreview, ImportResult } from '../types/import.types'; + +export const importApi = { + /** + * Generate preview of import data + */ + getPreview: async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('/user/import/preview', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 120000, // 2 minute timeout for large files + }); + return response.data; + }, + + /** + * Execute import with specified mode + */ + executeImport: async ( + file: File, + mode: 'merge' | 'replace' + ): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('mode', mode); + + const response = await apiClient.post('/user/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 120000, // 2 minute timeout for large imports + }); + return response.data; + }, +}; diff --git a/frontend/src/features/settings/components/ImportButton.tsx b/frontend/src/features/settings/components/ImportButton.tsx new file mode 100644 index 0000000..c1b20e3 --- /dev/null +++ b/frontend/src/features/settings/components/ImportButton.tsx @@ -0,0 +1,67 @@ +/** + * @ai-summary Import button component + * @ai-context Opens file selector and triggers import dialog + */ + +import React, { useRef } from 'react'; +import toast from 'react-hot-toast'; + +interface ImportButtonProps { + onFileSelected: (file: File) => void; + disabled?: boolean; +} + +export const ImportButton: React.FC = ({ + onFileSelected, + disabled = false, +}) => { + const fileInputRef = useRef(null); + + const handleButtonClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file extension + if (!file.name.endsWith('.tar.gz')) { + toast.error('Please select a .tar.gz file'); + return; + } + + // Validate file size (max 500MB) + const maxSize = 500 * 1024 * 1024; + if (file.size > maxSize) { + toast.error('File size exceeds 500MB limit'); + return; + } + + onFileSelected(file); + + // Reset input so same file can be selected again + event.target.value = ''; + }; + + return ( + <> + + + + ); +}; diff --git a/frontend/src/features/settings/components/ImportDialog.tsx b/frontend/src/features/settings/components/ImportDialog.tsx new file mode 100644 index 0000000..52a825f --- /dev/null +++ b/frontend/src/features/settings/components/ImportDialog.tsx @@ -0,0 +1,374 @@ +/** + * @ai-summary Import dialog component + * @ai-context Multi-step dialog: upload -> preview -> confirm -> progress -> results + */ + +import React, { useState, useEffect } from 'react'; +import { useImportPreview, useImportUserData } from '../hooks/useImportUserData'; +import { ImportPreview, ImportResult } from '../types/import.types'; + +interface ImportDialogProps { + isOpen: boolean; + onClose: () => void; + file: File | null; +} + +type ImportStep = 'upload' | 'preview' | 'confirm' | 'progress' | 'results'; + +export const ImportDialog: React.FC = ({ + isOpen, + onClose, + file, +}) => { + const [step, setStep] = useState('upload'); + const [preview, setPreview] = useState(null); + const [mode, setMode] = useState<'merge' | 'replace'>('merge'); + const [result, setResult] = useState(null); + + const previewMutation = useImportPreview(); + const importMutation = useImportUserData(); + + const handleGeneratePreview = async () => { + if (!file) return; + + setStep('preview'); + try { + const previewData = await previewMutation.mutateAsync(file); + setPreview(previewData); + setStep('confirm'); + } catch { + // Error handled by mutation hook + setStep('upload'); + } + }; + + const handleConfirmImport = async () => { + if (!file) return; + + setStep('progress'); + try { + const importResult = await importMutation.mutateAsync({ file, mode }); + setResult(importResult); + setStep('results'); + } catch { + // Error handled by mutation hook + setStep('confirm'); + } + }; + + // Reset state when dialog opens + useEffect(() => { + if (isOpen && file) { + setStep('upload'); + setPreview(null); + setMode('merge'); + setResult(null); + // Automatically start preview generation + handleGeneratePreview(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, file]); + + const handleClose = () => { + setStep('upload'); + setPreview(null); + setMode('merge'); + setResult(null); + onClose(); + }; + + if (!isOpen) return null; + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + }; + + return ( +
+
+

+ Import Data +

+ + {/* Step 1: Upload */} + {step === 'upload' && file && ( +
+

+ File selected: {file.name} ({formatBytes(file.size)}) +

+
+ +
+
+ )} + + {/* Step 2: Preview (Loading) */} + {step === 'preview' && ( +
+
+
+

+ Analyzing import file... +

+
+
+ )} + + {/* Step 3: Confirm */} + {step === 'confirm' && preview && ( +
+
+

+ Import Summary +

+
+
+ Vehicles: + + {preview.manifest.contents.vehicles.count} + +
+
+ Fuel Logs: + + {preview.manifest.contents.fuelLogs.count} + +
+
+ + Maintenance Records: + + + {preview.manifest.contents.maintenanceRecords.count} + +
+
+ + Maintenance Schedules: + + + {preview.manifest.contents.maintenanceSchedules.count} + +
+
+ Documents: + + {preview.manifest.contents.documents.count} + +
+
+ + {preview.conflicts.vehicles > 0 && ( +
+

+ Conflicts detected: {preview.conflicts.vehicles}{' '} + vehicle(s) with matching VINs already exist. +

+
+ )} + + {preview.manifest.warnings.length > 0 && ( +
+

+ Warnings: +

+
    + {preview.manifest.warnings.map((warning, idx) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+ +
+

+ Import Mode +

+
+ + +
+
+ +
+ + +
+
+ )} + + {/* Step 4: Progress */} + {step === 'progress' && ( +
+
+
+

+ Importing data... This may take a few minutes. +

+
+
+ )} + + {/* Step 5: Results */} + {step === 'results' && result && ( +
+
+
+

+ {result.success + ? 'Import completed successfully!' + : 'Import completed with errors'} +

+
+ +

+ Import Summary +

+
+
+ + Mode: + + + {result.mode} + +
+
+ + Imported: + + + {result.summary.imported} + +
+
+ + Updated: + + + {result.summary.updated} + +
+
+ + Skipped: + + + {result.summary.skipped} + +
+
+ + {result.summary.errors.length > 0 && ( +
+

+ Errors: +

+
    + {result.summary.errors.map((error, idx) => ( +
  • {error}
  • + ))} +
+
+ )} + + {result.warnings.length > 0 && ( +
+

+ Warnings: +

+
    + {result.warnings.map((warning, idx) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+ +
+ +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/features/settings/hooks/useImportUserData.ts b/frontend/src/features/settings/hooks/useImportUserData.ts new file mode 100644 index 0000000..bc975e2 --- /dev/null +++ b/frontend/src/features/settings/hooks/useImportUserData.ts @@ -0,0 +1,59 @@ +/** + * @ai-summary React Query hook for user data import + * @ai-context Manages import flow: preview -> execute with mode selection + */ + +import { useMutation } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { importApi } from '../api/import.api'; +import { ImportPreview, ImportResult } from '../types/import.types'; + +interface ApiError { + response?: { + data?: { + error?: string; + message?: string; + }; + }; + message?: string; +} + +export const useImportPreview = () => { + return useMutation({ + mutationFn: (file: File) => importApi.getPreview(file), + onError: (error: ApiError) => { + toast.error( + error.response?.data?.message || + error.response?.data?.error || + 'Failed to generate preview' + ); + }, + }); +}; + +export const useImportUserData = () => { + return useMutation< + ImportResult, + ApiError, + { file: File; mode: 'merge' | 'replace' } + >({ + mutationFn: ({ file, mode }) => importApi.executeImport(file, mode), + onSuccess: (result) => { + if (result.success) { + const { imported, updated, skipped } = result.summary; + toast.success( + `Import complete: ${imported} imported, ${updated} updated, ${skipped} skipped` + ); + } else { + toast.error('Import completed with errors. Check results for details.'); + } + }, + onError: (error: ApiError) => { + toast.error( + error.response?.data?.message || + error.response?.data?.error || + 'Failed to import data' + ); + }, + }); +}; diff --git a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx index 78e3f1a..17a0384 100644 --- a/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx +++ b/frontend/src/features/settings/mobile/MobileSettingsScreen.tsx @@ -11,6 +11,8 @@ import { useAdminAccess } from '../../../core/auth/useAdminAccess'; import { useNavigationStore } from '../../../core/store'; import { DeleteAccountModal } from './DeleteAccountModal'; import { PendingDeletionBanner } from './PendingDeletionBanner'; +import { ImportButton } from '../components/ImportButton'; +import { ImportDialog } from '../components/ImportDialog'; interface ToggleSwitchProps { enabled: boolean; @@ -90,6 +92,8 @@ export const MobileSettingsScreen: React.FC = () => { const [isEditingProfile, setIsEditingProfile] = useState(false); const [editedDisplayName, setEditedDisplayName] = useState(''); const [editedNotificationEmail, setEditedNotificationEmail] = useState(''); + const [showImportDialog, setShowImportDialog] = useState(false); + const [importFile, setImportFile] = useState(null); // Initialize edit form when profile loads or edit mode starts React.useEffect(() => { @@ -108,6 +112,16 @@ export const MobileSettingsScreen: React.FC = () => { exportMutation.mutate(); }; + const handleImportFileSelected = (file: File) => { + setImportFile(file); + setShowImportDialog(true); + }; + + const handleImportDialogClose = () => { + setShowImportDialog(false); + setImportFile(null); + }; + const handleEditProfile = () => { setIsEditingProfile(true); @@ -439,9 +453,14 @@ export const MobileSettingsScreen: React.FC = () => {

Data Management

+ +

+ Restore your data from a previous export +

@@ -572,6 +591,13 @@ export const MobileSettingsScreen: React.FC = () => { isOpen={showDeleteConfirm} onClose={() => setShowDeleteConfirm(false)} /> + + {/* Import Dialog */} +
); diff --git a/frontend/src/features/settings/types/import.types.ts b/frontend/src/features/settings/types/import.types.ts new file mode 100644 index 0000000..3931b24 --- /dev/null +++ b/frontend/src/features/settings/types/import.types.ts @@ -0,0 +1,50 @@ +/** + * @ai-summary Import types + * @ai-context Types for user data import feature (mirrors backend types) + */ + +export interface ImportManifest { + version: string; + createdAt: string; + applicationVersion?: string; + userId: string; + contents: { + vehicles: { count: number; withImages: number }; + fuelLogs: { count: number }; + documents: { count: number; withFiles: number }; + maintenanceRecords: { count: number }; + maintenanceSchedules: { count: number }; + }; + files: { + vehicleImages: number; + documentFiles: number; + totalSizeBytes: number; + }; + warnings: string[]; +} + +export interface ImportPreview { + manifest: ImportManifest; + conflicts: { + vehicles: number; // Count of VINs that already exist + }; + sampleRecords: { + vehicles?: any[]; + fuelLogs?: any[]; + documents?: any[]; + maintenanceRecords?: any[]; + maintenanceSchedules?: any[]; + }; +} + +export interface ImportResult { + success: boolean; + mode: 'merge' | 'replace'; + summary: { + imported: number; + updated: number; + skipped: number; + errors: string[]; + }; + warnings: string[]; +}