Publish Docker image / build-and-push (push) Successful in 2m24s
- Added new dependencies including @base-ui/react, @fontsource-variable/geist, and tailwindcss for improved UI and styling. - Updated Vite configuration to include path aliasing for easier imports. - Refactored App.jsx to implement a theme context and improved routing structure. - Enhanced various components (e.g., ConfirmDialog, DataTable) to utilize new UI components and improve user experience. - Cleaned up CSS imports and ensured proper styling integration. Made-with: Cursor
229 lines
10 KiB
React
229 lines
10 KiB
React
import { IconPlus, IconMinus, IconEdit } from '@tabler/icons-react';
|
|
|
|
/**
|
|
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением детального diff
|
|
* Показывает ЧТО именно изменилось, не только цифры
|
|
* Использует modal-backdrop show как HistoryModal
|
|
*/
|
|
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
|
const added = diff?.added || [];
|
|
const removed = diff?.removed || [];
|
|
const changed = diff?.changed || [];
|
|
|
|
const total = added.length + removed.length + changed.length;
|
|
|
|
if (!show) return null;
|
|
|
|
const handleBackdropClick = (e) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose?.();
|
|
}
|
|
};
|
|
|
|
// Показываем только первые 5 элементов каждого типа для удобства
|
|
const maxPreview = 5;
|
|
const addedPreview = added.slice(0, maxPreview);
|
|
const removedPreview = removed.slice(0, maxPreview);
|
|
const changedPreview = changed.slice(0, maxPreview);
|
|
|
|
return (
|
|
<>
|
|
{/* Modal Backdrop */}
|
|
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
|
|
|
{/* Modal */}
|
|
<div
|
|
className="modal show d-block"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
|
>
|
|
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
|
<div className="modal-content">
|
|
<div className="modal-header">
|
|
<h5 className="modal-title">Подтвердить массовое изменение</h5>
|
|
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
|
|
</div>
|
|
|
|
<div className="modal-body">
|
|
{/* Статистика */}
|
|
<div className="alert alert-warning mb-3">
|
|
<div className="d-flex">
|
|
<div>
|
|
<h4 className="alert-title">Внимание! Массовое изменение данных</h4>
|
|
<div className="text-muted">
|
|
Вы собираетесь изменить <strong>{total}</strong> {total === 1 ? 'запись' : total < 5 ? 'записи' : 'записей'}.
|
|
Пожалуйста, проверьте изменения перед сохранением.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Статистика в карточках */}
|
|
<div className="row g-2 text-center mb-3">
|
|
{added.length > 0 && (
|
|
<div className="col">
|
|
<div className="card border-success">
|
|
<div className="card-body p-3">
|
|
<IconPlus className="text-success mb-2" size={24} />
|
|
<div className="h3 mb-0">{added.length}</div>
|
|
<div className="text-muted small">Добавлено</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{removed.length > 0 && (
|
|
<div className="col">
|
|
<div className="card border-danger">
|
|
<div className="card-body p-3">
|
|
<IconMinus className="text-danger mb-2" size={24} />
|
|
<div className="h3 mb-0">{removed.length}</div>
|
|
<div className="text-muted small">Удалено</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{changed.length > 0 && (
|
|
<div className="col">
|
|
<div className="card border-info">
|
|
<div className="card-body p-3">
|
|
<IconEdit className="text-info mb-2" size={24} />
|
|
<div className="h3 mb-0">{changed.length}</div>
|
|
<div className="text-muted small">Изменено</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Детальный preview изменений */}
|
|
<div className="accordion" id="diffAccordion">
|
|
{/* Добавленные */}
|
|
{added.length > 0 && (
|
|
<div className="accordion-item">
|
|
<h2 className="accordion-header">
|
|
<button
|
|
className="accordion-button collapsed"
|
|
type="button"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<IconPlus className="text-success me-2" size={20} />
|
|
Добавлено ({added.length})
|
|
</button>
|
|
</h2>
|
|
<div id="collapseAdded" className="accordion-collapse collapse show" data-bs-parent="#diffAccordion">
|
|
<div className="accordion-body">
|
|
<ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
|
{addedPreview.map((item, idx) => (
|
|
<li key={idx} className="mb-1">
|
|
<code className="text-success">+ {typeof item === 'string' ? item : JSON.stringify(item)}</code>
|
|
</li>
|
|
))}
|
|
{added.length > maxPreview && (
|
|
<li className="text-muted small">
|
|
... и ещё {added.length - maxPreview} {added.length - maxPreview === 1 ? 'запись' : 'записей'}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Удалённые */}
|
|
{removed.length > 0 && (
|
|
<div className="accordion-item">
|
|
<h2 className="accordion-header">
|
|
<button
|
|
className="accordion-button collapsed"
|
|
type="button"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<IconMinus className="text-danger me-2" size={20} />
|
|
Удалено ({removed.length})
|
|
</button>
|
|
</h2>
|
|
<div id="collapseRemoved" className="accordion-collapse collapse show" data-bs-parent="#diffAccordion">
|
|
<div className="accordion-body">
|
|
<ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
|
{removedPreview.map((item, idx) => (
|
|
<li key={idx} className="mb-1">
|
|
<code className="text-danger">- {typeof item === 'string' ? item : JSON.stringify(item)}</code>
|
|
</li>
|
|
))}
|
|
{removed.length > maxPreview && (
|
|
<li className="text-muted small">
|
|
... и ещё {removed.length - maxPreview} {removed.length - maxPreview === 1 ? 'запись' : 'записей'}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Изменённые */}
|
|
{changed.length > 0 && (
|
|
<div className="accordion-item">
|
|
<h2 className="accordion-header">
|
|
<button
|
|
className="accordion-button collapsed"
|
|
type="button"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<IconEdit className="text-info me-2" size={20} />
|
|
Изменено ({changed.length})
|
|
</button>
|
|
</h2>
|
|
<div id="collapseChanged" className="accordion-collapse collapse show" data-bs-parent="#diffAccordion">
|
|
<div className="accordion-body">
|
|
<ul className="list-unstyled mb-0" style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
|
{changedPreview.map((item, idx) => (
|
|
<li key={idx} className="mb-1">
|
|
<code className="text-info">~ {typeof item === 'string' ? item : JSON.stringify(item)}</code>
|
|
</li>
|
|
))}
|
|
{changed.length > maxPreview && (
|
|
<li className="text-muted small">
|
|
... и ещё {changed.length - maxPreview} {changed.length - maxPreview === 1 ? 'запись' : 'записей'}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="modal-footer">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={onClose}
|
|
disabled={loading}
|
|
>
|
|
Отмена
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={onConfirm}
|
|
disabled={loading}
|
|
>
|
|
{loading && (
|
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
|
)}
|
|
Сохранить {total} {total === 1 ? 'изменение' : total < 5 ? 'изменения' : 'изменений'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default ConfirmDiffModal;
|