Vehicle ETL Process fixed. Admin settings fixed.

This commit is contained in:
Eric Gullickson
2025-12-15 20:51:52 -06:00
parent 1a9ead9d9d
commit b84d4c7fef
23 changed files with 4553 additions and 2450 deletions

View File

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

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';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Stations' | 'Documents' | 'Settings' | 'AdminUsers' | 'AdminCatalog' | 'AdminStations';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {

View File

@@ -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;
},
};

View File

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

View File

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

View File

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