feat: Backup & Restore - Manual backup tested complete.

This commit is contained in:
Eric Gullickson
2025-12-25 10:50:09 -06:00
parent 8ef6b3d853
commit 0357ce391f
38 changed files with 5734 additions and 1415447 deletions

View File

@@ -31,14 +31,14 @@ const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/Doc
// Admin pages (lazy-loaded)
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage').then(m => ({ default: m.AdminUsersPage })));
const AdminCatalogPage = lazy(() => import('./pages/admin/AdminCatalogPage').then(m => ({ default: m.AdminCatalogPage })));
const AdminStationsPage = lazy(() => import('./pages/admin/AdminStationsPage').then(m => ({ default: m.AdminStationsPage })));
const AdminEmailTemplatesPage = lazy(() => import('./pages/admin/AdminEmailTemplatesPage').then(m => ({ default: m.AdminEmailTemplatesPage })));
const AdminBackupPage = lazy(() => import('./pages/admin/AdminBackupPage').then(m => ({ default: m.AdminBackupPage })));
// Admin mobile screens (lazy-loaded)
const AdminUsersMobileScreen = lazy(() => import('./features/admin/mobile/AdminUsersMobileScreen').then(m => ({ default: m.AdminUsersMobileScreen })));
const AdminCatalogMobileScreen = lazy(() => import('./features/admin/mobile/AdminCatalogMobileScreen').then(m => ({ default: m.AdminCatalogMobileScreen })));
const AdminStationsMobileScreen = lazy(() => import('./features/admin/mobile/AdminStationsMobileScreen').then(m => ({ default: m.AdminStationsMobileScreen })));
const AdminEmailTemplatesMobileScreen = lazy(() => import('./features/admin/mobile/AdminEmailTemplatesMobileScreen'));
const AdminBackupMobileScreen = lazy(() => import('./features/admin/mobile/AdminBackupMobileScreen'));
// Admin Community Stations (lazy-loaded)
const AdminCommunityStationsPage = lazy(() => import('./features/admin/pages/AdminCommunityStationsPage').then(m => ({ default: m.AdminCommunityStationsPage })));
@@ -756,31 +756,6 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "AdminStations" && (
<motion.div
key="admin-stations"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="AdminStations">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
Loading station management...
</div>
</div>
</GlassCard>
</div>
}>
<AdminStationsMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "AdminCommunityStations" && (
<motion.div
key="admin-community-stations"
@@ -831,6 +806,31 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "AdminBackup" && (
<motion.div
key="admin-backup"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="AdminBackup">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
Loading backup management...
</div>
</div>
</GlassCard>
</div>
}>
<AdminBackupMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
@@ -898,9 +898,9 @@ function App() {
<Route path="/garage/settings" element={<SettingsPage />} />
<Route path="/garage/settings/admin/users" element={<AdminUsersPage />} />
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
<Route path="/garage/settings/admin/stations" element={<AdminStationsPage />} />
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
<Route path="/garage/settings/admin/email-templates" element={<AdminEmailTemplatesPage />} />
<Route path="/garage/settings/admin/backup" element={<AdminBackupPage />} />
<Route path="*" element={<Navigate to="/garage/vehicles" replace />} />
</Routes>
</RouteSuspense>

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { safeStorage } from '../utils/safe-storage';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations' | 'AdminCommunityStations' | 'AdminEmailTemplates';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {

View File

@@ -24,9 +24,6 @@ import {
UpdateCatalogTrimRequest,
CreateCatalogEngineRequest,
UpdateCatalogEngineRequest,
StationOverview,
CreateStationRequest,
UpdateStationRequest,
CatalogSearchResponse,
ImportPreviewResult,
ImportApplyResult,
@@ -41,6 +38,15 @@ import {
DeactivateUserRequest,
UpdateUserProfileRequest,
PromoteToAdminRequest,
// Backup types
BackupHistory,
BackupSchedule,
BackupSettings,
ListBackupsResponse,
CreateBackupRequest,
CreateScheduleRequest,
UpdateScheduleRequest,
RestorePreviewResponse,
} from '../types/admin.types';
export interface AuditLogsResponse {
@@ -189,26 +195,6 @@ export const adminApi = {
await apiClient.delete(`/admin/catalog/engines/${id}`);
},
// Stations
listStations: async (): Promise<StationOverview[]> => {
const response = await apiClient.get<StationOverview[]>('/admin/stations');
return response.data;
},
createStation: async (data: CreateStationRequest): Promise<StationOverview> => {
const response = await apiClient.post<StationOverview>('/admin/stations', data);
return response.data;
},
updateStation: async (id: string, data: UpdateStationRequest): Promise<StationOverview> => {
const response = await apiClient.put<StationOverview>(`/admin/stations/${id}`, data);
return response.data;
},
deleteStation: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/stations/${id}`);
},
// Catalog Search
searchCatalog: async (
query: string,
@@ -368,4 +354,114 @@ export const adminApi = {
return response.data;
},
},
// Backup & Restore
backups: {
// List backups with pagination
list: async (params: { page?: number; pageSize?: number } = {}): Promise<ListBackupsResponse> => {
const response = await apiClient.get<ListBackupsResponse>('/admin/backups', { params });
return response.data;
},
// Get backup details
get: async (id: string): Promise<BackupHistory> => {
const response = await apiClient.get<BackupHistory>(`/admin/backups/${id}`);
return response.data;
},
// Create manual backup
create: async (data: CreateBackupRequest = {}): Promise<BackupHistory> => {
const response = await apiClient.post<BackupHistory>('/admin/backups', data);
return response.data;
},
// Download backup file
download: async (id: string): Promise<Blob> => {
const response = await apiClient.get(`/admin/backups/${id}/download`, {
responseType: 'blob',
});
return response.data;
},
// Delete backup
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/backups/${id}`);
},
// Upload backup file
upload: async (file: File): Promise<BackupHistory> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<BackupHistory>('/admin/backups/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
// Preview restore
previewRestore: async (id: string): Promise<RestorePreviewResponse> => {
const response = await apiClient.post<RestorePreviewResponse>(
`/admin/backups/${id}/restore/preview`
);
return response.data;
},
// Execute restore
restore: async (id: string): Promise<{ message: string }> => {
const response = await apiClient.post<{ message: string }>(
`/admin/backups/${id}/restore`
);
return response.data;
},
// Schedules
schedules: {
list: async (): Promise<BackupSchedule[]> => {
const response = await apiClient.get<BackupSchedule[]>('/admin/backups/schedules');
return response.data;
},
get: async (id: string): Promise<BackupSchedule> => {
const response = await apiClient.get<BackupSchedule>(`/admin/backups/schedules/${id}`);
return response.data;
},
create: async (data: CreateScheduleRequest): Promise<BackupSchedule> => {
const response = await apiClient.post<BackupSchedule>('/admin/backups/schedules', data);
return response.data;
},
update: async (id: string, data: UpdateScheduleRequest): Promise<BackupSchedule> => {
const response = await apiClient.put<BackupSchedule>(
`/admin/backups/schedules/${id}`,
data
);
return response.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/admin/backups/schedules/${id}`);
},
toggle: async (id: string): Promise<BackupSchedule> => {
const response = await apiClient.patch<BackupSchedule>(
`/admin/backups/schedules/${id}/toggle`
);
return response.data;
},
},
// Settings
settings: {
get: async (): Promise<BackupSettings> => {
const response = await apiClient.get<BackupSettings>('/admin/backups/settings');
return response.data;
},
update: async (data: Partial<BackupSettings>): Promise<BackupSettings> => {
const response = await apiClient.put<BackupSettings>('/admin/backups/settings', data);
return response.data;
},
},
},
};

View File

@@ -0,0 +1,239 @@
/**
* @ai-summary React Query hooks for Backup & Restore admin feature
* @ai-context Provides hooks for managing backups, schedules, and settings
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../api/admin.api';
import {
BackupSettings,
CreateBackupRequest,
CreateScheduleRequest,
UpdateScheduleRequest,
} from '../types/admin.types';
import toast from 'react-hot-toast';
// Query keys
export const backupKeys = {
all: ['admin', 'backups'] as const,
lists: () => [...backupKeys.all, 'list'] as const,
list: (params: { page?: number; pageSize?: number }) =>
[...backupKeys.lists(), params] as const,
details: () => [...backupKeys.all, 'detail'] as const,
detail: (id: string) => [...backupKeys.details(), id] as const,
schedules: () => [...backupKeys.all, 'schedules'] as const,
settings: () => [...backupKeys.all, 'settings'] as const,
};
// List backups with pagination
export const useBackups = (params: { page?: number; pageSize?: number } = {}) => {
return useQuery({
queryKey: backupKeys.list(params),
queryFn: () => adminApi.backups.list(params),
});
};
// Get backup details
export const useBackupDetails = (id: string) => {
return useQuery({
queryKey: backupKeys.detail(id),
queryFn: () => adminApi.backups.get(id),
enabled: !!id,
});
};
// List backup schedules
export const useBackupSchedules = () => {
return useQuery({
queryKey: backupKeys.schedules(),
queryFn: () => adminApi.backups.schedules.list(),
});
};
// Get backup settings
export const useBackupSettings = () => {
return useQuery({
queryKey: backupKeys.settings(),
queryFn: () => adminApi.backups.settings.get(),
});
};
// Create manual backup
export const useCreateBackup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateBackupRequest) => adminApi.backups.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.lists() });
toast.success('Backup created successfully');
},
onError: () => {
toast.error('Failed to create backup');
},
});
};
// Delete backup
export const useDeleteBackup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.lists() });
toast.success('Backup deleted successfully');
},
onError: () => {
toast.error('Failed to delete backup');
},
});
};
// Download backup
export const useDownloadBackup = () => {
return useMutation({
mutationFn: async ({ id, filename }: { id: string; filename: string }) => {
const blob = await adminApi.backups.download(id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
onSuccess: () => {
toast.success('Backup download started');
},
onError: () => {
toast.error('Failed to download backup');
},
});
};
// Upload backup
export const useUploadBackup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => adminApi.backups.upload(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.lists() });
toast.success('Backup uploaded successfully');
},
onError: () => {
toast.error('Failed to upload backup');
},
});
};
// Preview restore
export const useRestorePreview = () => {
return useMutation({
mutationFn: (id: string) => adminApi.backups.previewRestore(id),
onError: () => {
toast.error('Failed to generate restore preview');
},
});
};
// Execute restore
export const useExecuteRestore = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.restore(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: backupKeys.all });
toast.success(data.message || 'Restore completed successfully');
},
onError: () => {
toast.error('Failed to restore backup');
},
});
};
// Create schedule
export const useCreateSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateScheduleRequest) => adminApi.backups.schedules.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success('Schedule created successfully');
},
onError: () => {
toast.error('Failed to create schedule');
},
});
};
// Update schedule
export const useUpdateSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateScheduleRequest }) =>
adminApi.backups.schedules.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success('Schedule updated successfully');
},
onError: () => {
toast.error('Failed to update schedule');
},
});
};
// Delete schedule
export const useDeleteSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.schedules.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success('Schedule deleted successfully');
},
onError: () => {
toast.error('Failed to delete schedule');
},
});
};
// Toggle schedule enabled/disabled
export const useToggleSchedule = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.backups.schedules.toggle(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: backupKeys.schedules() });
toast.success(
data.isEnabled ? 'Schedule enabled successfully' : 'Schedule disabled successfully'
);
},
onError: () => {
toast.error('Failed to toggle schedule');
},
});
};
// Update settings
export const useUpdateSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<BackupSettings>) => adminApi.backups.settings.update(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: backupKeys.settings() });
toast.success('Settings updated successfully');
},
onError: () => {
toast.error('Failed to update settings');
},
});
};

View File

@@ -1,79 +0,0 @@
/**
* @ai-summary React Query hooks for station overview (admin)
* @ai-context CRUD operations for global station management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { adminApi } from '../api/admin.api';
import { CreateStationRequest, UpdateStationRequest } from '../types/admin.types';
import toast from 'react-hot-toast';
interface ApiError {
response?: {
data?: {
error?: string;
};
};
message?: string;
}
export const useStationOverview = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['adminStations'],
queryFn: () => adminApi.listStations(),
enabled: isAuthenticated && !isLoading,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
});
};
export const useCreateStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateStationRequest) => adminApi.createStation(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
toast.success('Station created successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to create station');
},
});
};
export const useUpdateStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateStationRequest }) =>
adminApi.updateStation(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
toast.success('Station updated successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to update station');
},
});
};
export const useDeleteStation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminApi.deleteStation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminStations'] });
toast.success('Station deleted successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to delete station');
},
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +0,0 @@
/**
* @ai-summary Mobile admin screen for gas station management
* @ai-context CRUD operations for global station data with mobile UI
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
export const AdminStationsMobileScreen: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
if (loading) {
return (
<MobileContainer>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="text-slate-500 mb-2">Loading admin access...</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
</MobileContainer>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Station Management</h1>
<p className="text-slate-500 mt-2">Manage gas station data</p>
</div>
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Gas Stations</h2>
<p className="text-sm text-slate-600 mb-4">
Station management interface coming soon.
</p>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-semibold">Features:</p>
<ul className="list-disc pl-5 space-y-1">
<li>View all gas stations</li>
<li>Create new stations</li>
<li>Update station information</li>
<li>Delete stations</li>
<li>View station usage statistics</li>
</ul>
</div>
</div>
</GlassCard>
</div>
</MobileContainer>
);
};

View File

@@ -125,34 +125,6 @@ export interface UpdateCatalogEngineRequest {
fuelType?: string;
}
// Station types for admin
export interface StationOverview {
id: string;
name: string;
placeId: string;
address: string;
latitude: number;
longitude: number;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface CreateStationRequest {
name: string;
placeId: string;
address: string;
latitude: number;
longitude: number;
}
export interface UpdateStationRequest {
name?: string;
address?: string;
latitude?: number;
longitude?: number;
}
// Admin access verification
export interface AdminAccessResponse {
isAdmin: boolean;
@@ -300,3 +272,92 @@ export interface UpdateUserProfileRequest {
export interface PromoteToAdminRequest {
role?: 'admin' | 'super_admin';
}
// ============================================
// Backup & Restore types
// ============================================
// Backup types
export type BackupFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly';
export type BackupType = 'scheduled' | 'manual';
export type BackupStatus = 'in_progress' | 'completed' | 'failed';
export interface BackupHistory {
id: string;
scheduleId: string | null;
backupType: BackupType;
filename: string;
filePath: string;
fileSizeBytes: number;
databaseTablesCount: number | null;
documentsCount: number | null;
status: BackupStatus;
errorMessage: string | null;
startedAt: string;
completedAt: string | null;
createdBy: string | null;
metadata: Record<string, unknown>;
}
export interface BackupSchedule {
id: string;
name: string;
frequency: BackupFrequency;
cronExpression: string;
retentionCount: number;
isEnabled: boolean;
lastRunAt: string | null;
nextRunAt: string | null;
createdAt: string;
updatedAt: string;
lastBackup?: BackupHistory | null;
}
export interface BackupSettings {
emailOnSuccess: boolean;
emailOnFailure: boolean;
adminEmail: string;
maxBackupSizeMb: number;
compressionEnabled: boolean;
}
export interface ListBackupsResponse {
items: BackupHistory[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface CreateBackupRequest {
name?: string;
includeDocuments?: boolean;
}
export interface CreateScheduleRequest {
name: string;
frequency: BackupFrequency;
retentionCount?: number;
isEnabled?: boolean;
}
export interface UpdateScheduleRequest {
name?: string;
frequency?: BackupFrequency;
retentionCount?: number;
isEnabled?: boolean;
}
export interface RestorePreviewResponse {
backupId: string;
manifest: {
version: string;
createdAt: string;
contents: {
database: { tablesCount: number; sizeBytes: number };
documents: { totalFiles: number; totalSizeBytes: number };
};
};
warnings: string[];
estimatedDuration: string;
}

View File

@@ -421,14 +421,6 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="font-semibold">Vehicle Catalog</div>
<div className="text-sm text-blue-600 mt-1">Manage makes, models, and engines</div>
</button>
<button
onClick={() => navigateToScreen('AdminStations')}
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
style={{ minHeight: '44px' }}
>
<div className="font-semibold">Station Management</div>
<div className="text-sm text-blue-600 mt-1">Manage gas station data and locations</div>
</button>
<button
onClick={() => navigateToScreen('AdminEmailTemplates')}
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
@@ -437,6 +429,14 @@ export const MobileSettingsScreen: React.FC = () => {
<div className="font-semibold">Email Templates</div>
<div className="text-sm text-blue-600 mt-1">Manage notification email templates</div>
</button>
<button
onClick={() => navigateToScreen('AdminBackup')}
className="w-full text-left p-4 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors active:bg-blue-200"
style={{ minHeight: '44px' }}
>
<div className="font-semibold">Backup & Restore</div>
<div className="text-sm text-blue-600 mt-1">Create backups and restore data</div>
</button>
</div>
</div>
</GlassCard>

View File

@@ -430,23 +430,6 @@ export const SettingsPage: React.FC = () => {
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Station Management"
secondary="Manage gas station data and locations"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<MuiButton
variant="outlined"
size="small"
onClick={() => navigate('/garage/settings/admin/stations')}
>
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Email Templates"
@@ -463,6 +446,23 @@ export const SettingsPage: React.FC = () => {
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Backup & Restore"
secondary="Create backups and restore data"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<MuiButton
variant="outlined"
size="small"
onClick={() => navigate('/garage/settings/admin/backup')}
>
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
)}

View File

@@ -0,0 +1,921 @@
/**
* @ai-summary Admin Backup & Restore page for managing backups, schedules, and settings
* @ai-context Desktop version with tabbed interface for backup management
*/
import React, { useState, useCallback, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import dayjs from 'dayjs';
import {
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Switch,
Tab,
Tabs,
TextField,
Typography,
Alert,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
MenuItem,
Select,
FormControl,
InputLabel,
Paper,
} from '@mui/material';
import {
Backup,
Download,
Delete,
Schedule,
Settings,
Add,
Upload,
RestorePage,
Edit,
} from '@mui/icons-material';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
import {
useBackups,
useBackupSchedules,
useBackupSettings,
useCreateBackup,
useDeleteBackup,
useDownloadBackup,
useUploadBackup,
useRestorePreview,
useExecuteRestore,
useCreateSchedule,
useUpdateSchedule,
useDeleteSchedule,
useToggleSchedule,
useUpdateSettings,
} from '../../features/admin/hooks/useBackups';
import {
BackupSchedule,
BackupSettings,
CreateScheduleRequest,
UpdateScheduleRequest,
BackupFrequency,
BackupHistory,
} from '../../features/admin/types/admin.types';
// Helper to format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', '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];
};
// Helper to format date
const formatDate = (dateString: string): string => {
return dayjs(dateString).format('MMM DD, YYYY HH:mm');
};
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
return (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
</div>
);
};
export const AdminBackupPage: React.FC = () => {
const { loading: authLoading, isAdmin } = useAdminAccess();
const [tabValue, setTabValue] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// State for dialogs
const [createBackupDialogOpen, setCreateBackupDialogOpen] = useState(false);
const [createScheduleDialogOpen, setCreateScheduleDialogOpen] = useState(false);
const [editScheduleDialogOpen, setEditScheduleDialogOpen] = useState(false);
const [deleteScheduleDialogOpen, setDeleteScheduleDialogOpen] = useState(false);
const [deleteBackupDialogOpen, setDeleteBackupDialogOpen] = useState(false);
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [restorePreviewDialogOpen, setRestorePreviewDialogOpen] = useState(false);
// State for forms
const [selectedSchedule, setSelectedSchedule] = useState<BackupSchedule | null>(null);
const [selectedBackup, setSelectedBackup] = useState<BackupHistory | null>(null);
const [backupName, setBackupName] = useState('');
const [includeDocuments, setIncludeDocuments] = useState(true);
const [scheduleName, setScheduleName] = useState('');
const [scheduleFrequency, setScheduleFrequency] = useState<BackupFrequency>('daily');
const [scheduleRetention, setScheduleRetention] = useState(7);
const [scheduleEnabled, setScheduleEnabled] = useState(true);
// Queries
const { data: backupsData, isLoading: backupsLoading } = useBackups();
const { data: schedules, isLoading: schedulesLoading } = useBackupSchedules();
const { data: settings, isLoading: settingsLoading } = useBackupSettings();
// Mutations
const createBackupMutation = useCreateBackup();
const deleteBackupMutation = useDeleteBackup();
const downloadBackupMutation = useDownloadBackup();
const uploadBackupMutation = useUploadBackup();
const restorePreviewMutation = useRestorePreview();
const executeRestoreMutation = useExecuteRestore();
const createScheduleMutation = useCreateSchedule();
const updateScheduleMutation = useUpdateSchedule();
const deleteScheduleMutation = useDeleteSchedule();
const toggleScheduleMutation = useToggleSchedule();
const updateSettingsMutation = useUpdateSettings();
// Handlers for backups
const handleCreateBackup = useCallback(() => {
createBackupMutation.mutate(
{
name: backupName || undefined,
includeDocuments,
},
{
onSuccess: () => {
setCreateBackupDialogOpen(false);
setBackupName('');
setIncludeDocuments(true);
},
}
);
}, [backupName, includeDocuments, createBackupMutation]);
const handleDownloadBackup = useCallback(
(backup: BackupHistory) => {
downloadBackupMutation.mutate({
id: backup.id,
filename: backup.filename,
});
},
[downloadBackupMutation]
);
const handleDeleteBackup = useCallback(() => {
if (!selectedBackup) return;
deleteBackupMutation.mutate(selectedBackup.id, {
onSuccess: () => {
setDeleteBackupDialogOpen(false);
setSelectedBackup(null);
},
});
}, [selectedBackup, deleteBackupMutation]);
const handleUploadBackup = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadBackupMutation.mutate(file);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[uploadBackupMutation]
);
const handleRestorePreview = useCallback(
(backup: BackupHistory) => {
setSelectedBackup(backup);
restorePreviewMutation.mutate(backup.id, {
onSuccess: () => {
setRestorePreviewDialogOpen(true);
},
});
},
[restorePreviewMutation]
);
const handleExecuteRestore = useCallback(() => {
if (!selectedBackup) return;
executeRestoreMutation.mutate(selectedBackup.id, {
onSuccess: () => {
setRestoreDialogOpen(false);
setRestorePreviewDialogOpen(false);
setSelectedBackup(null);
},
});
}, [selectedBackup, executeRestoreMutation]);
// Handlers for schedules
const handleCreateSchedule = useCallback(() => {
const data: CreateScheduleRequest = {
name: scheduleName,
frequency: scheduleFrequency,
retentionCount: scheduleRetention,
isEnabled: scheduleEnabled,
};
createScheduleMutation.mutate(data, {
onSuccess: () => {
setCreateScheduleDialogOpen(false);
setScheduleName('');
setScheduleFrequency('daily');
setScheduleRetention(7);
setScheduleEnabled(true);
},
});
}, [scheduleName, scheduleFrequency, scheduleRetention, scheduleEnabled, createScheduleMutation]);
const handleEditSchedule = useCallback(
(schedule: BackupSchedule) => {
setSelectedSchedule(schedule);
setScheduleName(schedule.name);
setScheduleFrequency(schedule.frequency);
setScheduleRetention(schedule.retentionCount);
setScheduleEnabled(schedule.isEnabled);
setEditScheduleDialogOpen(true);
},
[]
);
const handleUpdateSchedule = useCallback(() => {
if (!selectedSchedule) return;
const data: UpdateScheduleRequest = {
name: scheduleName !== selectedSchedule.name ? scheduleName : undefined,
frequency: scheduleFrequency !== selectedSchedule.frequency ? scheduleFrequency : undefined,
retentionCount:
scheduleRetention !== selectedSchedule.retentionCount ? scheduleRetention : undefined,
isEnabled: scheduleEnabled !== selectedSchedule.isEnabled ? scheduleEnabled : undefined,
};
updateScheduleMutation.mutate(
{ id: selectedSchedule.id, data },
{
onSuccess: () => {
setEditScheduleDialogOpen(false);
setSelectedSchedule(null);
},
}
);
}, [
selectedSchedule,
scheduleName,
scheduleFrequency,
scheduleRetention,
scheduleEnabled,
updateScheduleMutation,
]);
const handleDeleteSchedule = useCallback(() => {
if (!selectedSchedule) return;
deleteScheduleMutation.mutate(selectedSchedule.id, {
onSuccess: () => {
setDeleteScheduleDialogOpen(false);
setSelectedSchedule(null);
},
});
}, [selectedSchedule, deleteScheduleMutation]);
const handleToggleSchedule = useCallback(
(schedule: BackupSchedule) => {
toggleScheduleMutation.mutate(schedule.id);
},
[toggleScheduleMutation]
);
// Handlers for settings
const handleUpdateSettings = useCallback(
(field: keyof BackupSettings, value: boolean | string | number) => {
if (!settings) return;
updateSettingsMutation.mutate({ [field]: value });
},
[settings, updateSettingsMutation]
);
// Auth loading
if (authLoading) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
</Container>
);
}
// Not admin
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" sx={{ mb: 1, fontWeight: 600 }}>
<Box display="flex" alignItems="center" gap={2}>
<Backup />
Backup & Restore
</Box>
</Typography>
<Typography variant="body1" color="text.secondary">
Manage database backups, schedules, and restore operations
</Typography>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Backups" icon={<Backup />} iconPosition="start" />
<Tab label="Schedules" icon={<Schedule />} iconPosition="start" />
<Tab label="Settings" icon={<Settings />} iconPosition="start" />
</Tabs>
</Box>
{/* Backups Tab */}
<TabPanel value={tabValue} index={0}>
<Box sx={{ mb: 3, display: 'flex', gap: 2 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateBackupDialogOpen(true)}
>
Create Backup
</Button>
<Button
variant="outlined"
startIcon={<Upload />}
onClick={() => fileInputRef.current?.click()}
>
Upload Backup
</Button>
<input
ref={fileInputRef}
type="file"
accept=".tar.gz,.tar"
style={{ display: 'none' }}
onChange={handleUploadBackup}
/>
</Box>
{backupsLoading ? (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Filename</TableCell>
<TableCell>Type</TableCell>
<TableCell>Size</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{backupsData?.items.map((backup) => (
<TableRow key={backup.id}>
<TableCell>{backup.filename}</TableCell>
<TableCell>
<Chip
label={backup.backupType}
size="small"
color={backup.backupType === 'scheduled' ? 'primary' : 'default'}
/>
</TableCell>
<TableCell>{formatFileSize(backup.fileSizeBytes)}</TableCell>
<TableCell>
<Chip
label={backup.status}
size="small"
color={
backup.status === 'completed'
? 'success'
: backup.status === 'failed'
? 'error'
: 'warning'
}
/>
</TableCell>
<TableCell>{formatDate(backup.startedAt)}</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => handleDownloadBackup(backup)}
title="Download"
>
<Download />
</IconButton>
<IconButton
size="small"
onClick={() => handleRestorePreview(backup)}
title="Restore"
disabled={backup.status !== 'completed'}
>
<RestorePage />
</IconButton>
<IconButton
size="small"
onClick={() => {
setSelectedBackup(backup);
setDeleteBackupDialogOpen(true);
}}
title="Delete"
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</TabPanel>
{/* Schedules Tab */}
<TabPanel value={tabValue} index={1}>
<Box sx={{ mb: 3 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateScheduleDialogOpen(true)}
>
Create Schedule
</Button>
</Box>
{schedulesLoading ? (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Frequency</TableCell>
<TableCell>Retention</TableCell>
<TableCell>Last Run</TableCell>
<TableCell>Next Run</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{schedules?.map((schedule) => (
<TableRow key={schedule.id}>
<TableCell>{schedule.name}</TableCell>
<TableCell>
<Chip label={schedule.frequency} size="small" />
</TableCell>
<TableCell>{schedule.retentionCount} backups</TableCell>
<TableCell>
{schedule.lastRunAt ? formatDate(schedule.lastRunAt) : 'Never'}
</TableCell>
<TableCell>
{schedule.nextRunAt ? formatDate(schedule.nextRunAt) : 'N/A'}
</TableCell>
<TableCell>
<Switch
checked={schedule.isEnabled}
onChange={() => handleToggleSchedule(schedule)}
color="primary"
/>
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => handleEditSchedule(schedule)}
title="Edit"
>
<Edit />
</IconButton>
<IconButton
size="small"
onClick={() => {
setSelectedSchedule(schedule);
setDeleteScheduleDialogOpen(true);
}}
title="Delete"
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</TabPanel>
{/* Settings Tab */}
<TabPanel value={tabValue} index={2}>
{settingsLoading ? (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress />
</Box>
) : settings ? (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Backup Settings
</Typography>
<Box sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={settings.emailOnSuccess}
onChange={(e) => handleUpdateSettings('emailOnSuccess', e.target.checked)}
/>
}
label="Email on backup success"
/>
</Box>
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.emailOnFailure}
onChange={(e) => handleUpdateSettings('emailOnFailure', e.target.checked)}
/>
}
label="Email on backup failure"
/>
</Box>
<Box sx={{ mt: 3 }}>
<TextField
label="Admin Email"
value={settings.adminEmail}
onChange={(e) => handleUpdateSettings('adminEmail', e.target.value)}
fullWidth
helperText="Email address to receive backup notifications"
/>
</Box>
<Box sx={{ mt: 3 }}>
<TextField
label="Max Backup Size (MB)"
type="number"
value={settings.maxBackupSizeMb}
onChange={(e) =>
handleUpdateSettings('maxBackupSizeMb', parseInt(e.target.value, 10))
}
fullWidth
helperText="Maximum backup file size in megabytes"
/>
</Box>
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={
<Switch
checked={settings.compressionEnabled}
onChange={(e) => handleUpdateSettings('compressionEnabled', e.target.checked)}
/>
}
label="Enable compression"
/>
</Box>
</CardContent>
</Card>
) : null}
</TabPanel>
{/* Create Backup Dialog */}
<Dialog
open={createBackupDialogOpen}
onClose={() => setCreateBackupDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Create Manual Backup</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
label="Backup Name (optional)"
fullWidth
value={backupName}
onChange={(e) => setBackupName(e.target.value)}
placeholder="e.g., Pre-upgrade backup"
/>
<FormControlLabel
control={
<Switch
checked={includeDocuments}
onChange={(e) => setIncludeDocuments(e.target.checked)}
/>
}
label="Include document files"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateBackupDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleCreateBackup}
variant="contained"
disabled={createBackupMutation.isPending}
>
{createBackupMutation.isPending ? 'Creating...' : 'Create Backup'}
</Button>
</DialogActions>
</Dialog>
{/* Create Schedule Dialog */}
<Dialog
open={createScheduleDialogOpen}
onClose={() => setCreateScheduleDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Create Backup Schedule</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
label="Schedule Name"
fullWidth
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
required
/>
<FormControl fullWidth>
<InputLabel>Frequency</InputLabel>
<Select
value={scheduleFrequency}
onChange={(e) => setScheduleFrequency(e.target.value as BackupFrequency)}
label="Frequency"
>
<MenuItem value="hourly">Hourly</MenuItem>
<MenuItem value="daily">Daily</MenuItem>
<MenuItem value="weekly">Weekly</MenuItem>
<MenuItem value="monthly">Monthly</MenuItem>
</Select>
</FormControl>
<TextField
label="Retention Count"
type="number"
fullWidth
value={scheduleRetention}
onChange={(e) => setScheduleRetention(parseInt(e.target.value, 10))}
helperText="Number of backups to keep"
/>
<FormControlLabel
control={
<Switch
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
/>
}
label="Enabled"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateScheduleDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleCreateSchedule}
variant="contained"
disabled={!scheduleName || createScheduleMutation.isPending}
>
{createScheduleMutation.isPending ? 'Creating...' : 'Create Schedule'}
</Button>
</DialogActions>
</Dialog>
{/* Edit Schedule Dialog */}
<Dialog
open={editScheduleDialogOpen}
onClose={() => setEditScheduleDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Edit Schedule</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
label="Schedule Name"
fullWidth
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
required
/>
<FormControl fullWidth>
<InputLabel>Frequency</InputLabel>
<Select
value={scheduleFrequency}
onChange={(e) => setScheduleFrequency(e.target.value as BackupFrequency)}
label="Frequency"
>
<MenuItem value="hourly">Hourly</MenuItem>
<MenuItem value="daily">Daily</MenuItem>
<MenuItem value="weekly">Weekly</MenuItem>
<MenuItem value="monthly">Monthly</MenuItem>
</Select>
</FormControl>
<TextField
label="Retention Count"
type="number"
fullWidth
value={scheduleRetention}
onChange={(e) => setScheduleRetention(parseInt(e.target.value, 10))}
helperText="Number of backups to keep"
/>
<FormControlLabel
control={
<Switch
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
/>
}
label="Enabled"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditScheduleDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleUpdateSchedule}
variant="contained"
disabled={!scheduleName || updateScheduleMutation.isPending}
>
{updateScheduleMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Schedule Dialog */}
<Dialog open={deleteScheduleDialogOpen} onClose={() => setDeleteScheduleDialogOpen(false)}>
<DialogTitle>Delete Schedule</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the schedule "{selectedSchedule?.name}"? This action
cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteScheduleDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleDeleteSchedule}
variant="contained"
color="error"
disabled={deleteScheduleMutation.isPending}
>
{deleteScheduleMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Backup Dialog */}
<Dialog open={deleteBackupDialogOpen} onClose={() => setDeleteBackupDialogOpen(false)}>
<DialogTitle>Delete Backup</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the backup "{selectedBackup?.filename}"? This action
cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteBackupDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleDeleteBackup}
variant="contained"
color="error"
disabled={deleteBackupMutation.isPending}
>
{deleteBackupMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
{/* Restore Preview Dialog */}
<Dialog
open={restorePreviewDialogOpen}
onClose={() => setRestorePreviewDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Restore Preview</DialogTitle>
<DialogContent>
{restorePreviewMutation.isPending ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress />
</Box>
) : restorePreviewMutation.data ? (
<Box sx={{ mt: 2 }}>
<Alert severity="warning" sx={{ mb: 3 }}>
A safety backup will be created automatically before restoring.
</Alert>
<Typography variant="h6" gutterBottom>
Backup Information
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Version: {restorePreviewMutation.data.manifest.version}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Created: {formatDate(restorePreviewMutation.data.manifest.createdAt)}
</Typography>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom>
Contents
</Typography>
<Typography variant="body2">
Database Tables: {restorePreviewMutation.data.manifest.contents.database.tablesCount}
</Typography>
<Typography variant="body2">
Database Size:{' '}
{formatFileSize(restorePreviewMutation.data.manifest.contents.database.sizeBytes)}
</Typography>
<Typography variant="body2">
Documents: {restorePreviewMutation.data.manifest.contents.documents.totalFiles}
</Typography>
<Typography variant="body2">
Documents Size:{' '}
{formatFileSize(
restorePreviewMutation.data.manifest.contents.documents.totalSizeBytes
)}
</Typography>
{restorePreviewMutation.data.warnings.length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom color="warning.main">
Warnings
</Typography>
{restorePreviewMutation.data.warnings.map((warning, index) => (
<Alert key={index} severity="warning" sx={{ mb: 1 }}>
{warning}
</Alert>
))}
</>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="body2" color="text.secondary">
Estimated Duration: {restorePreviewMutation.data.estimatedDuration}
</Typography>
</Box>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={() => setRestorePreviewDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => {
setRestorePreviewDialogOpen(false);
setRestoreDialogOpen(true);
}}
variant="contained"
color="warning"
>
Proceed to Restore
</Button>
</DialogActions>
</Dialog>
{/* Restore Confirmation Dialog */}
<Dialog open={restoreDialogOpen} onClose={() => setRestoreDialogOpen(false)}>
<DialogTitle>Confirm Restore</DialogTitle>
<DialogContent>
<Alert severity="error" sx={{ mb: 2 }}>
This action will replace all current data with the backup data. A safety backup will be
created first.
</Alert>
<Typography>
Are you sure you want to restore from "{selectedBackup?.filename}"?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setRestoreDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleExecuteRestore}
variant="contained"
color="error"
disabled={executeRestoreMutation.isPending}
>
{executeRestoreMutation.isPending ? 'Restoring...' : 'Restore'}
</Button>
</DialogActions>
</Dialog>
</Container>
);
};

View File

@@ -1,53 +0,0 @@
/**
* @ai-summary Desktop admin page for gas station management
* @ai-context CRUD operations for global station data
*/
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Box, Typography, CircularProgress } from '@mui/material';
import { Card } from '../../shared-minimal/components/Card';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
export const AdminStationsPage: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
</Box>
);
}
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
Station Management
</Typography>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Gas Stations
</Typography>
<Typography variant="body2" color="text.secondary">
Station management interface coming soon.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Features:
</Typography>
<ul>
<li>View all gas stations</li>
<li>Create new stations</li>
<li>Update station information</li>
<li>Delete stations</li>
<li>View station usage statistics</li>
</ul>
</Card>
</Box>
);
};