Some checks failed
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
Deploy to Staging / Verify Staging (pull_request) Failing after 6s
Deploy to Staging / Notify Staging Ready (pull_request) Has been skipped
Deploy to Staging / Notify Staging Failure (pull_request) Successful in 6s
- Add audit_logs table with categories, severities, and indexes - Create AuditLogService and AuditLogRepository - Add REST API endpoints for viewing and exporting logs - Wire audit logging into auth, vehicles, admin, and backup features - Add desktop AdminLogsPage with filters and CSV export - Add mobile AdminLogsMobileScreen with card layout - Implement 90-day retention cleanup job - Remove old AuditLogPanel from AdminCatalogPage Security fixes: - Escape LIKE special characters to prevent pattern injection - Limit CSV export to 5000 records to prevent memory exhaustion - Add truncation warning headers for large exports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
671 lines
21 KiB
TypeScript
671 lines
21 KiB
TypeScript
/**
|
|
* @ai-summary Admin Vehicle Catalog page with search-first UI
|
|
* @ai-context Uses server-side search and pagination instead of loading all data
|
|
*/
|
|
|
|
import React, { useState, useCallback, useRef } from 'react';
|
|
import { Navigate } from 'react-router-dom';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
CircularProgress,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
IconButton,
|
|
InputAdornment,
|
|
Paper,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TablePagination,
|
|
TableRow,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
Alert,
|
|
Collapse,
|
|
} from '@mui/material';
|
|
import {
|
|
Search,
|
|
Delete,
|
|
FileDownload,
|
|
FileUpload,
|
|
Clear,
|
|
ExpandMore,
|
|
ExpandLess,
|
|
} from '@mui/icons-material';
|
|
import toast from 'react-hot-toast';
|
|
import { useAdminAccess } from '../../core/auth/useAdminAccess';
|
|
import {
|
|
useCatalogSearch,
|
|
useExportCatalog,
|
|
useImportPreview,
|
|
useImportApply,
|
|
} from '../../features/admin/hooks/useCatalog';
|
|
import { adminApi } from '../../features/admin/api/admin.api';
|
|
import {
|
|
AdminSectionHeader,
|
|
} from '../../features/admin/components';
|
|
import {
|
|
CatalogSearchResult,
|
|
ImportPreviewResult,
|
|
ImportApplyResult,
|
|
} from '../../features/admin/types/admin.types';
|
|
|
|
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
|
|
|
export const AdminCatalogPage: React.FC = () => {
|
|
const { loading: authLoading, isAdmin } = useAdminAccess();
|
|
|
|
// Search state
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
const [pageSize, setPageSize] = useState(50);
|
|
|
|
// Selection state
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
|
|
// Dialog state
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<CatalogSearchResult | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
// Import state
|
|
const [importDialogOpen, setImportDialogOpen] = 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 + 1, // API uses 1-based pages
|
|
pageSize
|
|
);
|
|
const exportMutation = useExportCatalog();
|
|
const importPreviewMutation = useImportPreview();
|
|
const importApplyMutation = useImportApply();
|
|
|
|
// Search handlers
|
|
const handleSearch = useCallback(() => {
|
|
setPage(0);
|
|
setSelectedIds(new Set());
|
|
setSearchQuery(searchInput);
|
|
}, [searchInput]);
|
|
|
|
const handleSearchKeyPress = useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter') {
|
|
handleSearch();
|
|
}
|
|
},
|
|
[handleSearch]
|
|
);
|
|
|
|
const handleClearSearch = useCallback(() => {
|
|
setSearchInput('');
|
|
setSearchQuery('');
|
|
setPage(0);
|
|
setSelectedIds(new Set());
|
|
}, []);
|
|
|
|
// Pagination handlers
|
|
const handlePageChange = useCallback((_: unknown, newPage: number) => {
|
|
setPage(newPage);
|
|
setSelectedIds(new Set());
|
|
}, []);
|
|
|
|
const handlePageSizeChange = useCallback(
|
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setPageSize(parseInt(event.target.value, 10));
|
|
setPage(0);
|
|
setSelectedIds(new Set());
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Selection handlers
|
|
const handleSelectAll = useCallback(
|
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (event.target.checked && searchData?.items) {
|
|
setSelectedIds(new Set(searchData.items.map((item) => item.id)));
|
|
} else {
|
|
setSelectedIds(new Set());
|
|
}
|
|
},
|
|
[searchData]
|
|
);
|
|
|
|
const handleSelectRow = useCallback((id: number) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Delete handlers
|
|
const handleDeleteClick = useCallback((row: CatalogSearchResult) => {
|
|
setDeleteTarget(row);
|
|
setDeleteDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleBulkDeleteClick = useCallback(() => {
|
|
setDeleteTarget(null);
|
|
setDeleteDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleDeleteConfirm = useCallback(async () => {
|
|
setDeleting(true);
|
|
try {
|
|
if (deleteTarget) {
|
|
// Single delete - delete from vehicle_options
|
|
await adminApi.deleteEngine(deleteTarget.id.toString());
|
|
toast.success('Configuration deleted');
|
|
} else {
|
|
// Bulk delete
|
|
const idsToDelete = Array.from(selectedIds);
|
|
await Promise.all(
|
|
idsToDelete.map((id) => adminApi.deleteEngine(id.toString()))
|
|
);
|
|
toast.success(`${idsToDelete.length} configurations deleted`);
|
|
setSelectedIds(new Set());
|
|
}
|
|
setDeleteDialogOpen(false);
|
|
setDeleteTarget(null);
|
|
refetch();
|
|
} catch (error) {
|
|
toast.error('Failed to delete configuration(s)');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}, [deleteTarget, selectedIds, refetch]);
|
|
|
|
// Import handlers
|
|
const handleImportClick = useCallback(() => {
|
|
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);
|
|
setImportDialogOpen(true);
|
|
} catch (error) {
|
|
// Error is handled by mutation
|
|
}
|
|
|
|
// Reset file input
|
|
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 dialog open for error review
|
|
} else {
|
|
toast.success(
|
|
`Import completed successfully: ${result.created} created, ${result.updated} updated`
|
|
);
|
|
// Auto-close on complete success
|
|
setImportDialogOpen(false);
|
|
setImportPreview(null);
|
|
setImportResult(null);
|
|
}
|
|
|
|
refetch();
|
|
} catch (error) {
|
|
// Error is handled by mutation's onError
|
|
}
|
|
}, [importPreview, importApplyMutation, refetch]);
|
|
|
|
const handleImportDialogClose = useCallback(() => {
|
|
if (importApplyMutation.isPending) return;
|
|
setImportDialogOpen(false);
|
|
setImportPreview(null);
|
|
setImportResult(null);
|
|
setErrorsExpanded(false);
|
|
}, [importApplyMutation.isPending]);
|
|
|
|
// Export handler
|
|
const handleExport = useCallback(() => {
|
|
exportMutation.mutate();
|
|
}, [exportMutation]);
|
|
|
|
// Auth loading
|
|
if (authLoading) {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
minHeight: '60vh',
|
|
}}
|
|
>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Not admin
|
|
if (!isAdmin) {
|
|
return <Navigate to="/garage/settings" replace />;
|
|
}
|
|
|
|
const items = searchData?.items || [];
|
|
const total = searchData?.total || 0;
|
|
const hasResults = searchQuery.length > 0 && items.length > 0;
|
|
const noResults = searchQuery.length > 0 && items.length === 0 && !searchLoading;
|
|
|
|
return (
|
|
<Box sx={{ py: 2, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<AdminSectionHeader
|
|
title="Vehicle Catalog"
|
|
stats={[{ label: 'Total Results', value: total }]}
|
|
/>
|
|
|
|
{/* Search and Actions Bar */}
|
|
<Paper elevation={1} sx={{ p: 2, borderRadius: 1.5 }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: { xs: 'column', md: 'row' },
|
|
gap: 2,
|
|
alignItems: { xs: 'stretch', md: 'center' },
|
|
}}
|
|
>
|
|
{/* Search Input */}
|
|
<TextField
|
|
placeholder="Search vehicles (e.g., 2024 Toyota Camry)"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
onKeyPress={handleSearchKeyPress}
|
|
fullWidth
|
|
sx={{ flex: 1 }}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search color="action" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: searchInput && (
|
|
<InputAdornment position="end">
|
|
<IconButton size="small" onClick={handleClearSearch}>
|
|
<Clear />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
/>
|
|
|
|
{/* Search Button */}
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSearch}
|
|
disabled={searchLoading || !searchInput.trim()}
|
|
sx={{ textTransform: 'none', minWidth: 100 }}
|
|
>
|
|
{searchLoading ? <CircularProgress size={20} /> : 'Search'}
|
|
</Button>
|
|
|
|
{/* Action Buttons */}
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Tooltip title="Import CSV">
|
|
<Button
|
|
variant="outlined"
|
|
onClick={handleImportClick}
|
|
startIcon={<FileUpload />}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Import
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title="Export CSV">
|
|
<Button
|
|
variant="outlined"
|
|
onClick={handleExport}
|
|
disabled={exportMutation.isPending}
|
|
startIcon={<FileDownload />}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Export
|
|
</Button>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Hidden file input for import */}
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleFileSelect}
|
|
accept=".csv"
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</Paper>
|
|
|
|
{/* Instructions when no search */}
|
|
{!searchQuery && (
|
|
<Paper elevation={1} sx={{ p: 4, borderRadius: 1.5, textAlign: 'center' }}>
|
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
|
Search for Vehicles
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Enter a search term like "2024 Toyota Camry" or "Honda Civic" to find vehicles in the
|
|
catalog.
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
Use Import/Export buttons to manage catalog data in bulk via CSV files.
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* No Results */}
|
|
{noResults && (
|
|
<Paper elevation={1} sx={{ p: 4, borderRadius: 1.5, textAlign: 'center' }}>
|
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
|
No Results Found
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
No vehicles match "{searchQuery}". Try a different search term.
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Results Table */}
|
|
{hasResults && (
|
|
<Paper elevation={1} sx={{ borderRadius: 1.5 }}>
|
|
{/* Bulk Actions */}
|
|
{selectedIds.size > 0 && (
|
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
|
<Button
|
|
variant="outlined"
|
|
color="error"
|
|
startIcon={<Delete />}
|
|
onClick={handleBulkDeleteClick}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Delete Selected ({selectedIds.size})
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
<TableContainer>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
indeterminate={selectedIds.size > 0 && selectedIds.size < items.length}
|
|
checked={selectedIds.size === items.length && items.length > 0}
|
|
onChange={handleSelectAll}
|
|
size="small"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>Year</TableCell>
|
|
<TableCell>Make</TableCell>
|
|
<TableCell>Model</TableCell>
|
|
<TableCell>Trim</TableCell>
|
|
<TableCell>Engine</TableCell>
|
|
<TableCell>Transmission</TableCell>
|
|
<TableCell align="right">Actions</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{items.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
hover
|
|
selected={selectedIds.has(row.id)}
|
|
>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
checked={selectedIds.has(row.id)}
|
|
onChange={() => handleSelectRow(row.id)}
|
|
size="small"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{row.year}</TableCell>
|
|
<TableCell>{row.make}</TableCell>
|
|
<TableCell>{row.model}</TableCell>
|
|
<TableCell>{row.trim}</TableCell>
|
|
<TableCell>{row.engineName || '-'}</TableCell>
|
|
<TableCell>{row.transmissionType || '-'}</TableCell>
|
|
<TableCell align="right">
|
|
<Tooltip title="Delete">
|
|
<IconButton
|
|
size="small"
|
|
color="error"
|
|
onClick={() => handleDeleteClick(row)}
|
|
>
|
|
<Delete fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
|
|
<TablePagination
|
|
component="div"
|
|
count={total}
|
|
page={page}
|
|
onPageChange={handlePageChange}
|
|
rowsPerPage={pageSize}
|
|
onRowsPerPageChange={handlePageSizeChange}
|
|
rowsPerPageOptions={PAGE_SIZE_OPTIONS}
|
|
/>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog open={deleteDialogOpen} onClose={() => !deleting && setDeleteDialogOpen(false)}>
|
|
<DialogTitle>
|
|
{deleteTarget
|
|
? 'Delete Configuration'
|
|
: `Delete ${selectedIds.size} Configuration${selectedIds.size > 1 ? 's' : ''}`}
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
{deleteTarget ? (
|
|
<Typography>
|
|
Are you sure you want to delete the configuration for{' '}
|
|
<strong>
|
|
{deleteTarget.year} {deleteTarget.make} {deleteTarget.model} {deleteTarget.trim}
|
|
</strong>
|
|
?
|
|
</Typography>
|
|
) : (
|
|
<Typography>
|
|
Are you sure you want to delete {selectedIds.size} selected configuration
|
|
{selectedIds.size > 1 ? 's' : ''}?
|
|
</Typography>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={() => setDeleteDialogOpen(false)}
|
|
disabled={deleting}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleDeleteConfirm}
|
|
disabled={deleting}
|
|
color="error"
|
|
variant="contained"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{deleting ? <CircularProgress size={20} /> : 'Delete'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Import Preview/Results Dialog */}
|
|
<Dialog
|
|
open={importDialogOpen}
|
|
onClose={handleImportDialogClose}
|
|
maxWidth="md"
|
|
fullWidth
|
|
>
|
|
<DialogTitle>
|
|
{importResult ? 'Import Results' : 'Import Preview'}
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
{/* Preview Mode */}
|
|
{importPreview && !importResult && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
|
<Typography>
|
|
<strong>To Create:</strong> {importPreview.toCreate.length}
|
|
</Typography>
|
|
<Typography>
|
|
<strong>To Update:</strong> {importPreview.toUpdate.length}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{importPreview.errors.length > 0 && (
|
|
<Alert severity="error">
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
{importPreview.errors.length} Error(s) Found:
|
|
</Typography>
|
|
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
|
{importPreview.errors.slice(0, 10).map((err, idx) => (
|
|
<li key={idx}>
|
|
Row {err.row}: {err.error}
|
|
</li>
|
|
))}
|
|
{importPreview.errors.length > 10 && (
|
|
<li>...and {importPreview.errors.length - 10} more errors</li>
|
|
)}
|
|
</Box>
|
|
</Alert>
|
|
)}
|
|
|
|
{importPreview.valid ? (
|
|
<Alert severity="success">
|
|
The import file is valid and ready to be applied.
|
|
</Alert>
|
|
) : (
|
|
<Alert severity="warning">
|
|
Please fix the errors above before importing.
|
|
</Alert>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Results Mode */}
|
|
{importResult && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
|
<Typography>
|
|
<strong>Created:</strong> {importResult.created}
|
|
</Typography>
|
|
<Typography>
|
|
<strong>Updated:</strong> {importResult.updated}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{importResult.errors.length > 0 && (
|
|
<Box sx={{ border: 1, borderColor: 'error.main', borderRadius: 1 }}>
|
|
<Box
|
|
onClick={() => setErrorsExpanded(!errorsExpanded)}
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
p: 2,
|
|
bgcolor: 'error.light',
|
|
cursor: 'pointer',
|
|
'&:hover': { bgcolor: 'error.main', color: 'white' },
|
|
}}
|
|
>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
{importResult.errors.length} Error(s) Occurred
|
|
</Typography>
|
|
<IconButton size="small" sx={{ color: 'inherit' }}>
|
|
{errorsExpanded ? <ExpandLess /> : <ExpandMore />}
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Collapse in={errorsExpanded}>
|
|
<Box sx={{ maxHeight: 400, overflow: 'auto', p: 2, bgcolor: 'background.paper' }}>
|
|
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
|
{importResult.errors.map((err, idx) => (
|
|
<Typography
|
|
component="li"
|
|
key={idx}
|
|
variant="body2"
|
|
sx={{ mb: 1, fontFamily: 'monospace', fontSize: '0.875rem' }}
|
|
>
|
|
<strong>Row {err.row}:</strong> {err.error}
|
|
</Typography>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Collapse>
|
|
</Box>
|
|
)}
|
|
|
|
{importResult.errors.length === 0 && (
|
|
<Alert severity="success">
|
|
Import completed successfully with no errors.
|
|
</Alert>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={handleImportDialogClose}
|
|
disabled={importApplyMutation.isPending}
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{importResult ? 'Close' : 'Cancel'}
|
|
</Button>
|
|
{!importResult && (
|
|
<Button
|
|
onClick={handleImportConfirm}
|
|
disabled={!importPreview?.valid || importApplyMutation.isPending}
|
|
variant="contained"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
|
|
</Button>
|
|
)}
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|