Vehicle ETL Process fixed. Admin settings fixed.
This commit is contained in:
@@ -37,6 +37,11 @@ const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/Doc
|
||||
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 })));
|
||||
|
||||
// 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 })));
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
|
||||
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
|
||||
@@ -604,6 +609,81 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "AdminUsers" && (
|
||||
<motion.div
|
||||
key="admin-users"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="AdminUsers">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading admin users...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<AdminUsersMobileScreen />
|
||||
</React.Suspense>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "AdminCatalog" && (
|
||||
<motion.div
|
||||
key="admin-catalog"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="AdminCatalog">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading vehicle catalog...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<AdminCatalogMobileScreen />
|
||||
</React.Suspense>
|
||||
</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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DebugInfo />
|
||||
</Layout>
|
||||
|
||||
@@ -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';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
StationOverview,
|
||||
CreateStationRequest,
|
||||
UpdateStationRequest,
|
||||
CatalogSearchResponse,
|
||||
ImportPreviewResult,
|
||||
ImportApplyResult,
|
||||
CascadeDeleteResult,
|
||||
} from '../types/admin.types';
|
||||
|
||||
export interface AuditLogsResponse {
|
||||
@@ -194,4 +198,73 @@ export const adminApi = {
|
||||
deleteStation: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/admin/stations/${id}`);
|
||||
},
|
||||
|
||||
// Catalog Search
|
||||
searchCatalog: async (
|
||||
query: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 50
|
||||
): Promise<CatalogSearchResponse> => {
|
||||
const response = await apiClient.get<CatalogSearchResponse>('/admin/catalog/search', {
|
||||
params: { q: query, page, pageSize },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Catalog Import/Export
|
||||
importPreview: async (file: File): Promise<ImportPreviewResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post<ImportPreviewResult>(
|
||||
'/admin/catalog/import/preview',
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
importApply: async (previewId: string): Promise<ImportApplyResult> => {
|
||||
const response = await apiClient.post<ImportApplyResult>('/admin/catalog/import/apply', {
|
||||
previewId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportCatalog: async (): Promise<Blob> => {
|
||||
const response = await apiClient.get('/admin/catalog/export', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cascade Delete
|
||||
deleteMakeCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
||||
const response = await apiClient.delete<CascadeDeleteResult>(
|
||||
`/admin/catalog/makes/${id}/cascade`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteModelCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
||||
const response = await apiClient.delete<CascadeDeleteResult>(
|
||||
`/admin/catalog/models/${id}/cascade`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteYearCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
||||
const response = await apiClient.delete<CascadeDeleteResult>(
|
||||
`/admin/catalog/years/${id}/cascade`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteTrimCascade: async (id: string): Promise<CascadeDeleteResult> => {
|
||||
const response = await apiClient.delete<CascadeDeleteResult>(
|
||||
`/admin/catalog/trims/${id}/cascade`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -316,3 +316,130 @@ export const useDeleteEngine = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Catalog Search
|
||||
export const useCatalogSearch = (query: string, page: number = 1, pageSize: number = 50) => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['catalogSearch', query, page, pageSize],
|
||||
queryFn: () => adminApi.searchCatalog(query, page, pageSize),
|
||||
enabled: isAuthenticated && !isLoading && query.length > 0,
|
||||
staleTime: 30 * 1000, // 30 seconds - search results can change
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Import/Export
|
||||
export const useImportPreview = () => {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => adminApi.importPreview(file),
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to preview import');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportApply = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (previewId: string) => adminApi.importApply(previewId),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
|
||||
toast.success(
|
||||
`Import completed: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted`
|
||||
);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to apply import');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useExportCatalog = () => {
|
||||
return useMutation({
|
||||
mutationFn: () => adminApi.exportCatalog(),
|
||||
onSuccess: (blob) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'vehicle-catalog.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('Catalog exported successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to export catalog');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Cascade Delete
|
||||
export const useDeleteMakeCascade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteMakeCascade(id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogMakes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
|
||||
toast.success(`Deleted ${result.totalDeleted} items (cascade)`);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to cascade delete make');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteModelCascade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteModelCascade(id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogModels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
|
||||
toast.success(`Deleted ${result.totalDeleted} items (cascade)`);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to cascade delete model');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteYearCascade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteYearCascade(id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogYears'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
|
||||
toast.success(`Deleted ${result.totalDeleted} items (cascade)`);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to cascade delete year');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTrimCascade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => adminApi.deleteTrimCascade(id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogTrims'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
|
||||
toast.success(`Deleted ${result.totalDeleted} items (cascade)`);
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error(error.response?.data?.error || 'Failed to cascade delete trim');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,3 +158,65 @@ export interface AdminAccessResponse {
|
||||
isAdmin: boolean;
|
||||
adminRecord: AdminUser | null;
|
||||
}
|
||||
|
||||
// Catalog search types
|
||||
export interface CatalogSearchResult {
|
||||
id: number;
|
||||
year: number;
|
||||
make: string;
|
||||
model: string;
|
||||
trim: string;
|
||||
engineId: number | null;
|
||||
engineName: string | null;
|
||||
transmissionId: number | null;
|
||||
transmissionType: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchResponse {
|
||||
items: CatalogSearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Catalog import types
|
||||
export interface ImportRow {
|
||||
action: 'add' | 'update' | 'delete';
|
||||
year: number;
|
||||
make: string;
|
||||
model: string;
|
||||
trim: string;
|
||||
engineName: string | null;
|
||||
transmissionType: string | null;
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
row: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ImportPreviewResult {
|
||||
previewId: string;
|
||||
toCreate: ImportRow[];
|
||||
toUpdate: ImportRow[];
|
||||
toDelete: ImportRow[];
|
||||
errors: ImportError[];
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface ImportApplyResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
errors: ImportError[];
|
||||
}
|
||||
|
||||
// Cascade delete result
|
||||
export interface CascadeDeleteResult {
|
||||
deletedMakes: number;
|
||||
deletedModels: number;
|
||||
deletedYears: number;
|
||||
deletedTrims: number;
|
||||
deletedEngines: number;
|
||||
totalDeleted: number;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
||||
import { useNavigationStore } from '../../../core/store';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
@@ -71,7 +71,7 @@ const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
|
||||
export const MobileSettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const navigate = useNavigate();
|
||||
const { navigateToScreen } = useNavigationStore();
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
@@ -258,7 +258,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-blue-600 mb-4">Admin Console</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => navigate('/garage/settings/admin/users')}
|
||||
onClick={() => navigateToScreen('AdminUsers')}
|
||||
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' }}
|
||||
>
|
||||
@@ -266,7 +266,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="text-sm text-blue-600 mt-1">Manage admin users and permissions</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/garage/settings/admin/catalog')}
|
||||
onClick={() => navigateToScreen('AdminCatalog')}
|
||||
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' }}
|
||||
>
|
||||
@@ -274,7 +274,7 @@ export const MobileSettingsScreen: React.FC = () => {
|
||||
<div className="text-sm text-blue-600 mt-1">Manage makes, models, and engines</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/garage/settings/admin/stations')}
|
||||
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' }}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user