feat: add frontend import UI (refs #26)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 19:58:17 -06:00
parent 068db991a4
commit 7a5579df7b
7 changed files with 712 additions and 0 deletions

View File

@@ -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

View File

@@ -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<ImportPreview> => {
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<ImportResult> => {
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;
},
};

View File

@@ -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<ImportButtonProps> = ({
onFileSelected,
disabled = false,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleButtonClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<input
ref={fileInputRef}
type="file"
accept=".tar.gz"
onChange={handleFileChange}
style={{ display: 'none' }}
aria-label="Select import file"
/>
<button
onClick={handleButtonClick}
disabled={disabled}
className="w-full text-left p-3 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors disabled:opacity-50 dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
style={{ minHeight: '44px' }}
>
Import My Data
</button>
</>
);
};

View File

@@ -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<ImportDialogProps> = ({
isOpen,
onClose,
file,
}) => {
const [step, setStep] = useState<ImportStep>('upload');
const [preview, setPreview] = useState<ImportPreview | null>(null);
const [mode, setMode] = useState<'merge' | 'replace'>('merge');
const [result, setResult] = useState<ImportResult | null>(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 (
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-scuro rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">
Import Data
</h3>
{/* Step 1: Upload */}
{step === 'upload' && file && (
<div>
<p className="text-slate-600 dark:text-titanio mb-4">
File selected: {file.name} ({formatBytes(file.size)})
</p>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={handleClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium"
style={{ minHeight: '44px', minWidth: '44px' }}
>
Cancel
</button>
</div>
</div>
)}
{/* Step 2: Preview (Loading) */}
{step === 'preview' && (
<div>
<div className="flex flex-col items-center justify-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mb-4"></div>
<p className="text-slate-600 dark:text-titanio">
Analyzing import file...
</p>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 'confirm' && preview && (
<div>
<div className="mb-6">
<h4 className="font-semibold text-slate-800 dark:text-avus mb-3">
Import Summary
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">Vehicles:</span>
<span className="font-medium text-slate-800 dark:text-avus">
{preview.manifest.contents.vehicles.count}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">Fuel Logs:</span>
<span className="font-medium text-slate-800 dark:text-avus">
{preview.manifest.contents.fuelLogs.count}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">
Maintenance Records:
</span>
<span className="font-medium text-slate-800 dark:text-avus">
{preview.manifest.contents.maintenanceRecords.count}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">
Maintenance Schedules:
</span>
<span className="font-medium text-slate-800 dark:text-avus">
{preview.manifest.contents.maintenanceSchedules.count}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">Documents:</span>
<span className="font-medium text-slate-800 dark:text-avus">
{preview.manifest.contents.documents.count}
</span>
</div>
</div>
{preview.conflicts.vehicles > 0 && (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-300">
<strong>Conflicts detected:</strong> {preview.conflicts.vehicles}{' '}
vehicle(s) with matching VINs already exist.
</p>
</div>
)}
{preview.manifest.warnings.length > 0 && (
<div className="mt-4 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-sm font-medium text-orange-800 dark:text-orange-300 mb-2">
Warnings:
</p>
<ul className="text-sm text-orange-700 dark:text-orange-400 list-disc list-inside">
{preview.manifest.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</div>
)}
</div>
<div className="mb-6">
<h4 className="font-semibold text-slate-800 dark:text-avus mb-3">
Import Mode
</h4>
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="radio"
name="mode"
value="merge"
checked={mode === 'merge'}
onChange={() => setMode('merge')}
className="mt-1"
style={{ minWidth: '20px', minHeight: '20px' }}
/>
<div className="flex-1">
<p className="font-medium text-slate-800 dark:text-avus">
Merge (Recommended)
</p>
<p className="text-sm text-slate-600 dark:text-titanio">
Keep existing data and add new items. Update matching VINs.
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="radio"
name="mode"
value="replace"
checked={mode === 'replace'}
onChange={() => setMode('replace')}
className="mt-1"
style={{ minWidth: '20px', minHeight: '20px' }}
/>
<div className="flex-1">
<p className="font-medium text-slate-800 dark:text-avus">
Replace All
</p>
<p className="text-sm text-slate-600 dark:text-titanio">
Delete all existing data and replace with imported data.
</p>
</div>
</label>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={handleClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg font-medium"
style={{ minHeight: '44px', minWidth: '44px' }}
>
Cancel
</button>
<button
onClick={handleConfirmImport}
className="px-4 py-2 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors dark:bg-primary-600 dark:hover:bg-primary-700"
style={{ minHeight: '44px', minWidth: '44px' }}
>
{mode === 'replace' ? 'Replace All Data' : 'Import'}
</button>
</div>
</div>
)}
{/* Step 4: Progress */}
{step === 'progress' && (
<div>
<div className="flex flex-col items-center justify-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mb-4"></div>
<p className="text-slate-600 dark:text-titanio">
Importing data... This may take a few minutes.
</p>
</div>
</div>
)}
{/* Step 5: Results */}
{step === 'results' && result && (
<div>
<div className="mb-6">
<div
className={`p-4 rounded-lg mb-4 ${
result.success
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-red-50 dark:bg-red-900/20'
}`}
>
<p
className={`font-semibold ${
result.success
? 'text-green-800 dark:text-green-300'
: 'text-red-800 dark:text-red-300'
}`}
>
{result.success
? 'Import completed successfully!'
: 'Import completed with errors'}
</p>
</div>
<h4 className="font-semibold text-slate-800 dark:text-avus mb-3">
Import Summary
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">
Mode:
</span>
<span className="font-medium text-slate-800 dark:text-avus capitalize">
{result.mode}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">
Imported:
</span>
<span className="font-medium text-green-600 dark:text-green-400">
{result.summary.imported}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">
Updated:
</span>
<span className="font-medium text-blue-600 dark:text-blue-400">
{result.summary.updated}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600 dark:text-titanio">
Skipped:
</span>
<span className="font-medium text-gray-600 dark:text-gray-400">
{result.summary.skipped}
</span>
</div>
</div>
{result.summary.errors.length > 0 && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p className="text-sm font-medium text-red-800 dark:text-red-300 mb-2">
Errors:
</p>
<ul className="text-sm text-red-700 dark:text-red-400 list-disc list-inside max-h-40 overflow-y-auto">
{result.summary.errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</div>
)}
{result.warnings.length > 0 && (
<div className="mt-4 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-sm font-medium text-orange-800 dark:text-orange-300 mb-2">
Warnings:
</p>
<ul className="text-sm text-orange-700 dark:text-orange-400 list-disc list-inside">
{result.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</div>
)}
</div>
<div className="flex justify-end mt-6">
<button
onClick={handleClose}
className="px-4 py-2 bg-primary-500 text-white rounded-lg font-medium hover:bg-primary-600 transition-colors dark:bg-primary-600 dark:hover:bg-primary-700"
style={{ minHeight: '44px', minWidth: '44px' }}
>
Done
</button>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -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<ImportPreview, ApiError, File>({
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'
);
},
});
};

View File

@@ -11,6 +11,8 @@ import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { useNavigationStore } from '../../../core/store'; import { useNavigationStore } from '../../../core/store';
import { DeleteAccountModal } from './DeleteAccountModal'; import { DeleteAccountModal } from './DeleteAccountModal';
import { PendingDeletionBanner } from './PendingDeletionBanner'; import { PendingDeletionBanner } from './PendingDeletionBanner';
import { ImportButton } from '../components/ImportButton';
import { ImportDialog } from '../components/ImportDialog';
interface ToggleSwitchProps { interface ToggleSwitchProps {
enabled: boolean; enabled: boolean;
@@ -90,6 +92,8 @@ export const MobileSettingsScreen: React.FC = () => {
const [isEditingProfile, setIsEditingProfile] = useState(false); const [isEditingProfile, setIsEditingProfile] = useState(false);
const [editedDisplayName, setEditedDisplayName] = useState(''); const [editedDisplayName, setEditedDisplayName] = useState('');
const [editedNotificationEmail, setEditedNotificationEmail] = useState(''); const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
const [showImportDialog, setShowImportDialog] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
// Initialize edit form when profile loads or edit mode starts // Initialize edit form when profile loads or edit mode starts
React.useEffect(() => { React.useEffect(() => {
@@ -108,6 +112,16 @@ export const MobileSettingsScreen: React.FC = () => {
exportMutation.mutate(); exportMutation.mutate();
}; };
const handleImportFileSelected = (file: File) => {
setImportFile(file);
setShowImportDialog(true);
};
const handleImportDialogClose = () => {
setShowImportDialog(false);
setImportFile(null);
};
const handleEditProfile = () => { const handleEditProfile = () => {
setIsEditingProfile(true); setIsEditingProfile(true);
@@ -439,9 +453,14 @@ export const MobileSettingsScreen: React.FC = () => {
<div> <div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Data Management</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">Data Management</h2>
<div className="space-y-3"> <div className="space-y-3">
<ImportButton onFileSelected={handleImportFileSelected} />
<p className="text-sm text-slate-500 dark:text-titanio">
Restore your data from a previous export
</p>
<button <button
onClick={() => setShowDataExport(true)} onClick={() => setShowDataExport(true)}
className="w-full text-left p-3 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30" className="w-full text-left p-3 bg-primary-50 text-primary-700 rounded-lg font-medium hover:bg-primary-100 transition-colors dark:bg-primary-900/20 dark:text-primary-300 dark:hover:bg-primary-900/30"
style={{ minHeight: '44px' }}
> >
Export My Data Export My Data
</button> </button>
@@ -572,6 +591,13 @@ export const MobileSettingsScreen: React.FC = () => {
isOpen={showDeleteConfirm} isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)} onClose={() => setShowDeleteConfirm(false)}
/> />
{/* Import Dialog */}
<ImportDialog
isOpen={showImportDialog}
onClose={handleImportDialogClose}
file={importFile}
/>
</div> </div>
</MobileContainer> </MobileContainer>
); );

View File

@@ -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[];
}