fix: Database schema fixes. CI/CD improvements.

This commit is contained in:
Eric Gullickson
2025-12-27 16:23:22 -06:00
parent 344df5184c
commit dc2c731119
26 changed files with 242360 additions and 481192 deletions

View File

@@ -347,11 +347,10 @@ export const useImportApply = () => {
return useMutation({
mutationFn: (previewId: string) => adminApi.importApply(previewId),
onSuccess: (result) => {
onSuccess: () => {
// Invalidate cache to refresh catalog data
queryClient.invalidateQueries({ queryKey: ['catalogSearch'] });
toast.success(
`Import completed: ${result.created} created, ${result.updated} updated`
);
// Note: Toast and dialog behavior now handled by parent components
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to apply import');

View File

@@ -13,6 +13,8 @@ import {
MoreVert,
Close,
History,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
@@ -29,6 +31,7 @@ import { adminApi } from '../api/admin.api';
import {
CatalogSearchResult,
ImportPreviewResult,
ImportApplyResult,
} from '../types/admin.types';
export const AdminCatalogMobileScreen: React.FC = () => {
@@ -54,6 +57,8 @@ export const AdminCatalogMobileScreen: React.FC = () => {
// 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
@@ -144,15 +149,38 @@ export const AdminCatalogMobileScreen: React.FC = () => {
if (!importPreview?.previewId) return;
try {
await importApplyMutation.mutateAsync(importPreview.previewId);
setImportSheet(false);
setImportPreview(null);
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
// 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);
@@ -435,17 +463,16 @@ export const AdminCatalogMobileScreen: React.FC = () => {
</div>
)}
{/* Import Preview Sheet */}
{importSheet && importPreview && (
{/* 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">Import Preview</h2>
<h2 className="text-xl font-bold text-slate-800">
{importResult ? 'Import Results' : 'Import Preview'}
</h2>
<button
onClick={() => {
setImportSheet(false);
setImportPreview(null);
}}
onClick={handleImportSheetClose}
disabled={importApplyMutation.isPending}
className="p-2 text-slate-500 hover:text-slate-700"
style={{ minHeight: '44px', minWidth: '44px' }}
@@ -454,74 +481,127 @@ export const AdminCatalogMobileScreen: React.FC = () => {
</button>
</div>
{/* Summary */}
<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>
{/* 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>
{/* Errors */}
{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.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>
)}
</>
)}
{/* Status */}
{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={() => {
setImportSheet(false);
setImportPreview(null);
}}
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' }}
>
Cancel
</button>
<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'
)}
{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>

View File

@@ -28,6 +28,7 @@ import {
Tooltip,
Typography,
Alert,
Collapse,
} from '@mui/material';
import {
Search,
@@ -35,6 +36,8 @@ import {
FileDownload,
FileUpload,
Clear,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import toast from 'react-hot-toast';
import { useAdminAccess } from '../../core/auth/useAdminAccess';
@@ -52,6 +55,7 @@ import {
import {
CatalogSearchResult,
ImportPreviewResult,
ImportApplyResult,
} from '../../features/admin/types/admin.types';
const PAGE_SIZE_OPTIONS = [25, 50, 100];
@@ -76,6 +80,8 @@ export const AdminCatalogPage: React.FC = () => {
// 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
@@ -217,15 +223,38 @@ export const AdminCatalogPage: React.FC = () => {
if (!importPreview?.previewId) return;
try {
await importApplyMutation.mutateAsync(importPreview.previewId);
setImportDialogOpen(false);
setImportPreview(null);
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
// 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();
@@ -506,18 +535,20 @@ export const AdminCatalogPage: React.FC = () => {
</DialogActions>
</Dialog>
{/* Import Preview Dialog */}
{/* Import Preview/Results Dialog */}
<Dialog
open={importDialogOpen}
onClose={() => !importApplyMutation.isPending && setImportDialogOpen(false)}
onClose={handleImportDialogClose}
maxWidth="md"
fullWidth
>
<DialogTitle>Import Preview</DialogTitle>
<DialogTitle>
{importResult ? 'Import Results' : 'Import Preview'}
</DialogTitle>
<DialogContent>
{importPreview && (
{/* Preview Mode */}
{importPreview && !importResult && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{/* Summary */}
<Box sx={{ display: 'flex', gap: 3 }}>
<Typography>
<strong>To Create:</strong> {importPreview.toCreate.length}
@@ -527,7 +558,6 @@ export const AdminCatalogPage: React.FC = () => {
</Typography>
</Box>
{/* Errors */}
{importPreview.errors.length > 0 && (
<Alert severity="error">
<Typography variant="subtitle2" gutterBottom>
@@ -546,7 +576,6 @@ export const AdminCatalogPage: React.FC = () => {
</Alert>
)}
{/* Valid status */}
{importPreview.valid ? (
<Alert severity="success">
The import file is valid and ready to be applied.
@@ -558,23 +587,86 @@ export const AdminCatalogPage: React.FC = () => {
)}
</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={() => setImportDialogOpen(false)}
onClick={handleImportDialogClose}
disabled={importApplyMutation.isPending}
sx={{ textTransform: 'none' }}
>
Cancel
</Button>
<Button
onClick={handleImportConfirm}
disabled={!importPreview?.valid || importApplyMutation.isPending}
variant="contained"
sx={{ textTransform: 'none' }}
>
{importApplyMutation.isPending ? <CircularProgress size={20} /> : 'Apply Import'}
{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>