feat: Добавление умного подтверждения изменений в компоненты ASNsNewManager, DomainsNewManager и IPRangesManager. Модальное окно ConfirmDiffModal обновлено для отображения детального diff и статистики изменений, улучшая взаимодействие с пользователем при массовых изменениях.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s

This commit is contained in:
2025-10-03 16:08:01 +07:00
parent cd6a971991
commit efbe204cec
5 changed files with 563 additions and 54 deletions
+162 -47
View File
@@ -1,13 +1,16 @@
import { IconCheck, IconX, IconRefresh } from '@tabler/icons-react';
import { IconPlus, IconMinus, IconEdit } from '@tabler/icons-react';
/**
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
* Простой подход со встроенным backdrop (как в HistoryModal)
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением детального diff
* Показывает ЧТО именно изменилось, не только цифры
* Использует modal-backdrop show как HistoryModal
*/
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
const added = diff?.added?.length || 0;
const removed = diff?.removed?.length || 0;
const changed = diff?.changed?.length || 0;
const added = diff?.added || [];
const removed = diff?.removed || [];
const changed = diff?.changed || [];
const total = added.length + removed.length + changed.length;
if (!show) return null;
@@ -17,6 +20,12 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
}
};
// Показываем только первые 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 */}
@@ -30,58 +39,164 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
tabIndex={-1}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div
className="modal-content"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Tab') {
const c = e.currentTarget;
const focusable = c.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}}
>
<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>
<h5 className="modal-title">Подтвердить массовое изменение</h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
</div>
<div className="modal-body">
<div className="row g-2 text-center">
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Добавлено</strong>
<div className="text-muted">{added}</div>
{/* Статистика */}
<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 className="col">
<div className="card">
<div className="card-body p-2">
<strong>Удалено</strong>
<div className="text-muted">{removed}</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>
</div>
<div className="col">
<div className="card">
<div className="card-body p-2">
<strong>Изменено</strong>
<div className="text-muted">{changed}</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>
</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"
data-bs-toggle="collapse"
data-bs-target="#collapseAdded"
>
<IconPlus className="text-success me-2" size={20} />
Добавлено ({added.length})
</button>
</h2>
<div id="collapseAdded" className="accordion-collapse collapse" 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"
data-bs-toggle="collapse"
data-bs-target="#collapseRemoved"
>
<IconMinus className="text-danger me-2" size={20} />
Удалено ({removed.length})
</button>
</h2>
<div id="collapseRemoved" className="accordion-collapse collapse" 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"
data-bs-toggle="collapse"
data-bs-target="#collapseChanged"
>
<IconEdit className="text-info me-2" size={20} />
Изменено ({changed.length})
</button>
</h2>
<div id="collapseChanged" className="accordion-collapse collapse" 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>
@@ -103,7 +218,7 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
{loading && (
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
)}
Сохранить
Сохранить {total} {total === 1 ? 'изменение' : total < 5 ? 'изменения' : 'изменений'}
</button>
</div>
</div>