Files
motovaultpro/frontend/src/features/admin/mobile/AdminCatalogMobileScreen.tsx
2025-12-27 16:23:22 -06:00

619 lines
22 KiB
TypeScript

/**
* @ai-summary Admin Vehicle Catalog mobile screen with search-first UI
* @ai-context Uses server-side search and pagination, optimized for touch
*/
import React, { useState, useCallback, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import {
Search,
FileDownload,
FileUpload,
Delete,
MoreVert,
Close,
History,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { AuditLogDrawer } from '../components/AuditLogDrawer';
import {
useCatalogSearch,
useExportCatalog,
useImportPreview,
useImportApply,
} from '../hooks/useCatalog';
import { adminApi } from '../api/admin.api';
import {
CatalogSearchResult,
ImportPreviewResult,
ImportApplyResult,
} from '../types/admin.types';
export const AdminCatalogMobileScreen: React.FC = () => {
const { loading: authLoading, isAdmin } = useAdminAccess();
// Search state
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(1);
const pageSize = 25;
// UI state
const [menuOpen, setMenuOpen] = useState(false);
const [auditDrawerOpen, setAuditDrawerOpen] = useState(false);
// Delete state
const [deleteSheet, setDeleteSheet] = useState<{
open: boolean;
item: CatalogSearchResult | null;
}>({ open: false, item: null });
const [deleting, setDeleting] = useState(false);
// Import state
const [importSheet, setImportSheet] = useState(false);
const [importPreview, setImportPreview] = useState<ImportPreviewResult | null>(null);
const [importResult, setImportResult] = useState<ImportApplyResult | null>(null);
const [errorsExpanded, setErrorsExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Hooks
const { data: searchData, isLoading: searchLoading, refetch } = useCatalogSearch(
searchQuery,
page,
pageSize
);
const exportMutation = useExportCatalog();
const importPreviewMutation = useImportPreview();
const importApplyMutation = useImportApply();
// Search handlers
const handleSearch = useCallback(() => {
setPage(1);
setSearchQuery(searchInput);
}, [searchInput]);
const handleSearchKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
const handleClearSearch = useCallback(() => {
setSearchInput('');
setSearchQuery('');
setPage(1);
}, []);
// Pagination
const handleLoadMore = useCallback(() => {
setPage((prev) => prev + 1);
}, []);
// Delete handlers
const handleDeleteClick = useCallback((item: CatalogSearchResult) => {
setDeleteSheet({ open: true, item });
setMenuOpen(false);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteSheet.item) return;
setDeleting(true);
try {
await adminApi.deleteEngine(deleteSheet.item.id.toString());
toast.success('Configuration deleted');
setDeleteSheet({ open: false, item: null });
refetch();
} catch {
toast.error('Failed to delete configuration');
} finally {
setDeleting(false);
}
}, [deleteSheet.item, refetch]);
// Import handlers
const handleImportClick = useCallback(() => {
setMenuOpen(false);
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const result = await importPreviewMutation.mutateAsync(file);
setImportPreview(result);
setImportSheet(true);
} catch {
// Error handled by mutation
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[importPreviewMutation]
);
const handleImportConfirm = useCallback(async () => {
if (!importPreview?.previewId) return;
try {
const result = await importApplyMutation.mutateAsync(importPreview.previewId);
setImportResult(result);
if (result.errors.length > 0) {
toast.error(
`Import completed with ${result.errors.length} error(s): ${result.created} created, ${result.updated} updated`
);
// Keep sheet open for error review
} else {
toast.success(
`Import completed successfully: ${result.created} created, ${result.updated} updated`
);
// Auto-close on complete success
setImportSheet(false);
setImportPreview(null);
setImportResult(null);
}
refetch();
} catch {
// Error handled by mutation's onError
}
}, [importPreview, importApplyMutation, refetch]);
const handleImportSheetClose = useCallback(() => {
if (importApplyMutation.isPending) return;
setImportSheet(false);
setImportPreview(null);
setImportResult(null);
setErrorsExpanded(false);
}, [importApplyMutation.isPending]);
// Export handler
const handleExport = useCallback(() => {
setMenuOpen(false);
exportMutation.mutate();
}, [exportMutation]);
// Auth loading
if (authLoading) {
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>
</MobileContainer>
);
}
// Not admin
if (!isAdmin) {
return <Navigate to="/garage/settings" replace />;
}
const items = searchData?.items || [];
const total = searchData?.total || 0;
const hasMore = items.length < total;
const hasResults = searchQuery.length > 0 && items.length > 0;
const noResults = searchQuery.length > 0 && items.length === 0 && !searchLoading;
return (
<MobileContainer>
<div className="space-y-4 pb-24 p-4">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-800">Vehicle Catalog</h1>
<p className="text-sm text-slate-500">
{searchQuery ? `${total} results` : 'Search to view vehicles'}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setAuditDrawerOpen(true)}
className="flex items-center justify-center bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition"
style={{ minHeight: '44px', minWidth: '44px' }}
aria-label="View logs"
>
<History />
</button>
<button
onClick={() => setMenuOpen(true)}
className="flex items-center justify-center bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition"
style={{ minHeight: '44px', minWidth: '44px' }}
aria-label="More options"
>
<MoreVert />
</button>
</div>
</div>
{/* Search Bar */}
<div className="flex gap-2">
<div className="flex-1 relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
fontSize="small"
/>
<input
type="text"
placeholder="Search vehicles..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="w-full pl-10 pr-10 py-3 border border-slate-300 rounded-lg text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
style={{ minHeight: '44px' }}
/>
{searchInput && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
style={{ minHeight: '44px', minWidth: '44px' }}
>
<Close fontSize="small" />
</button>
)}
</div>
<button
onClick={handleSearch}
disabled={searchLoading || !searchInput.trim()}
className="bg-blue-600 text-white px-4 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
style={{ minHeight: '44px' }}
>
{searchLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white" />
) : (
'Search'
)}
</button>
</div>
{/* Instructions when no search */}
{!searchQuery && (
<GlassCard padding="lg">
<div className="text-center py-6">
<Search className="text-slate-400 mb-4" style={{ fontSize: 48 }} />
<h3 className="text-lg font-semibold text-slate-700 mb-2">Search for Vehicles</h3>
<p className="text-slate-500 text-sm">
Enter a search term like "2024 Toyota Camry" to find vehicles in the catalog.
</p>
<p className="text-slate-500 text-sm mt-3">
Use the menu for import/export options.
</p>
</div>
</GlassCard>
)}
{/* No Results */}
{noResults && (
<GlassCard padding="lg">
<div className="text-center py-6">
<h3 className="text-lg font-semibold text-slate-700 mb-2">No Results Found</h3>
<p className="text-slate-500 text-sm">
No vehicles match "{searchQuery}". Try a different search term.
</p>
</div>
</GlassCard>
)}
{/* Search Results */}
{hasResults && (
<div className="space-y-3">
{items.map((item) => (
<GlassCard key={item.id} padding="md">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-slate-800">
{item.year} {item.make} {item.model}
</h3>
<p className="text-slate-600 text-sm">{item.trim}</p>
<div className="flex flex-wrap gap-2 mt-2 text-xs text-slate-500">
{item.engineName && (
<span className="bg-slate-100 px-2 py-1 rounded">
{item.engineName}
</span>
)}
{item.transmissionType && (
<span className="bg-slate-100 px-2 py-1 rounded">
{item.transmissionType}
</span>
)}
</div>
</div>
<button
onClick={() => handleDeleteClick(item)}
className="text-red-600 p-2 rounded hover:bg-red-50 transition"
style={{ minHeight: '44px', minWidth: '44px' }}
aria-label="Delete"
>
<Delete fontSize="small" />
</button>
</div>
</GlassCard>
))}
{/* Load More */}
{hasMore && (
<button
onClick={handleLoadMore}
disabled={searchLoading}
className="w-full bg-slate-100 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-200 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
{searchLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-slate-600 mx-auto" />
) : (
`Load More (${items.length} of ${total})`
)}
</button>
)}
</div>
)}
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept=".csv"
style={{ display: 'none' }}
/>
</div>
{/* Menu Sheet */}
{menuOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
<div
className="bg-white rounded-t-2xl w-full max-w-lg p-4 space-y-2 animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold text-slate-800">Options</h2>
<button
onClick={() => setMenuOpen(false)}
className="p-2 text-slate-500 hover:text-slate-700"
style={{ minHeight: '44px', minWidth: '44px' }}
>
<Close />
</button>
</div>
<button
onClick={handleImportClick}
disabled={importPreviewMutation.isPending}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 hover:bg-slate-50 rounded-lg transition"
style={{ minHeight: '44px' }}
>
<FileUpload />
<span>Import CSV</span>
</button>
<button
onClick={handleExport}
disabled={exportMutation.isPending}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-slate-700 hover:bg-slate-50 rounded-lg transition"
style={{ minHeight: '44px' }}
>
<FileDownload />
<span>{exportMutation.isPending ? 'Exporting...' : 'Export CSV'}</span>
</button>
<button
onClick={() => setMenuOpen(false)}
className="w-full bg-slate-100 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-200 transition mt-4"
style={{ minHeight: '44px' }}
>
Cancel
</button>
</div>
</div>
)}
{/* Delete Confirmation Sheet */}
{deleteSheet.open && deleteSheet.item && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
<div className="bg-white rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up">
<h2 className="text-xl font-bold text-slate-800">Delete Configuration?</h2>
<p className="text-slate-600">
Are you sure you want to delete{' '}
<strong>
{deleteSheet.item.year} {deleteSheet.item.make} {deleteSheet.item.model}{' '}
{deleteSheet.item.trim}
</strong>
?
</p>
<div className="flex gap-2 pt-2">
<button
onClick={() => setDeleteSheet({ open: false, item: null })}
disabled={deleting}
className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
disabled={deleting}
className="flex-1 bg-red-600 text-white py-3 rounded-lg font-medium hover:bg-red-700 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
{deleting ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mx-auto" />
) : (
'Delete'
)}
</button>
</div>
</div>
</div>
)}
{/* Import Preview/Results Sheet */}
{importSheet && (importPreview || importResult) && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-center">
<div className="bg-white rounded-t-2xl w-full max-w-lg p-6 space-y-4 animate-slide-up max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-800">
{importResult ? 'Import Results' : 'Import Preview'}
</h2>
<button
onClick={handleImportSheetClose}
disabled={importApplyMutation.isPending}
className="p-2 text-slate-500 hover:text-slate-700"
style={{ minHeight: '44px', minWidth: '44px' }}
>
<Close />
</button>
</div>
{/* Preview Mode */}
{importPreview && !importResult && (
<>
<div className="flex gap-4 text-sm">
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
<strong>{importPreview.toCreate.length}</strong> to create
</div>
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
<strong>{importPreview.toUpdate.length}</strong> to update
</div>
</div>
{importPreview.errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-red-800 font-semibold mb-2">
{importPreview.errors.length} Error(s) Found:
</p>
<ul className="text-red-700 text-sm space-y-1">
{importPreview.errors.slice(0, 5).map((err, idx) => (
<li key={idx}>
Row {err.row}: {err.error}
</li>
))}
{importPreview.errors.length > 5 && (
<li>...and {importPreview.errors.length - 5} more errors</li>
)}
</ul>
</div>
)}
{importPreview.valid ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-green-800">
The import file is valid and ready to be applied.
</p>
</div>
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-amber-800">
Please fix the errors above before importing.
</p>
</div>
)}
</>
)}
{/* Results Mode */}
{importResult && (
<>
<div className="flex gap-4 text-sm">
<div className="bg-green-100 text-green-800 px-3 py-2 rounded-lg">
<strong>{importResult.created}</strong> created
</div>
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-lg">
<strong>{importResult.updated}</strong> updated
</div>
</div>
{importResult.errors.length > 0 && (
<div className="border border-red-500 rounded-lg overflow-hidden">
<button
onClick={() => setErrorsExpanded(!errorsExpanded)}
className="w-full flex items-center justify-between p-4 bg-red-100 hover:bg-red-200 transition"
style={{ minHeight: '44px' }}
>
<span className="text-red-900 font-semibold">
{importResult.errors.length} Error(s) Occurred
</span>
<span className="text-red-900">
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
</span>
</button>
{errorsExpanded && (
<div className="max-h-96 overflow-y-auto p-4 bg-white">
<ul className="space-y-2">
{importResult.errors.map((err, idx) => (
<li key={idx} className="text-sm font-mono text-slate-700">
<strong>Row {err.row}:</strong> {err.error}
</li>
))}
</ul>
</div>
)}
</div>
)}
{importResult.errors.length === 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-green-800">
Import completed successfully with no errors.
</p>
</div>
)}
</>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<button
onClick={handleImportSheetClose}
disabled={importApplyMutation.isPending}
className="flex-1 bg-slate-200 text-slate-700 py-3 rounded-lg font-medium hover:bg-slate-300 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
{importResult ? 'Close' : 'Cancel'}
</button>
{!importResult && (
<button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:opacity-50"
style={{ minHeight: '44px' }}
>
{importApplyMutation.isPending ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mx-auto" />
) : (
'Apply Import'
)}
</button>
)}
</div>
</div>
</div>
)}
{/* Audit Log Drawer */}
<AuditLogDrawer
open={auditDrawerOpen}
onClose={() => setAuditDrawerOpen(false)}
resourceType="catalog"
/>
</MobileContainer>
);
};