619 lines
22 KiB
TypeScript
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>
|
|
);
|
|
};
|