feat: Добавление умного подтверждения изменений в компоненты ASNsNewManager, DomainsNewManager и IPRangesManager. Модальное окно ConfirmDiffModal обновлено для отображения детального diff и статистики изменений, улучшая взаимодействие с пользователем при массовых изменениях.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
This commit is contained in:
@@ -0,0 +1,359 @@
|
|||||||
|
# ✨ Улучшения ConfirmDiffModal
|
||||||
|
|
||||||
|
## Что было сделано
|
||||||
|
|
||||||
|
### 1. Улучшен внешний вид модального окна
|
||||||
|
|
||||||
|
**Было:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Подтвердить сохранение │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ [5] [2] [3] │
|
||||||
|
│ Добавл. Удалено Изменено │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ [Отмена] [Сохранить] │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Стало:**
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ Подтвердить массовое изменение [×] │
|
||||||
|
├────────────────────────────────────────┤
|
||||||
|
│ ⚠️ Внимание! Массовое изменение данных │
|
||||||
|
│ Вы собираетесь изменить 15 записей │
|
||||||
|
├────────────────────────────────────────┤
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ ✅ │ │ ❌ │ │ 🔄 │ │
|
||||||
|
│ │ 5 │ │ 2 │ │ 3 │ │
|
||||||
|
│ │Добав.│ │Удал. │ │Измен.│ │
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
├────────────────────────────────────────┤
|
||||||
|
│ ▼ Добавлено (5) │
|
||||||
|
│ + example.com │
|
||||||
|
│ + test.org │
|
||||||
|
│ ... │
|
||||||
|
├────────────────────────────────────────┤
|
||||||
|
│ [Отмена] [Сохранить 15 изменений] │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Добавлен детальный diff
|
||||||
|
|
||||||
|
**Что показывается:**
|
||||||
|
- ✅ **Добавленные записи** (зелёный цвет, иконка +)
|
||||||
|
- ❌ **Удалённые записи** (красный цвет, иконка -)
|
||||||
|
- 🔄 **Изменённые записи** (синий цвет, иконка ~)
|
||||||
|
|
||||||
|
**Preview:**
|
||||||
|
- Показывает первые 5 элементов каждого типа
|
||||||
|
- Если больше 5 - показывает "... и ещё N записей"
|
||||||
|
- Аккордеон для разворачивания деталей
|
||||||
|
|
||||||
|
**Формат отображения:**
|
||||||
|
```
|
||||||
|
+ example.com (добавлено)
|
||||||
|
- old-site.com (удалено)
|
||||||
|
~ updated-site.net (изменено)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Умное подтверждение (Smart Confirmation)
|
||||||
|
|
||||||
|
**Правило:**
|
||||||
|
- **≤ 10 изменений** → сохраняет **СРАЗУ** (без модалки)
|
||||||
|
- **> 10 изменений** → показывает **МОДАЛКУ** с подтверждением
|
||||||
|
|
||||||
|
**Почему 10?**
|
||||||
|
- Мелкие правки (1-10 записей) - быстрая работа
|
||||||
|
- Массовые изменения (>10) - требуют внимания и подтверждения
|
||||||
|
- Баланс между скоростью и безопасностью
|
||||||
|
|
||||||
|
**Порог можно изменить:**
|
||||||
|
```javascript
|
||||||
|
// В DomainsNewManager.jsx, IPRangesManager.jsx, ASNsNewManager.jsx
|
||||||
|
if (totalChanges > 10) { // Измените 10 на нужное значение
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Использование modal-backdrop show
|
||||||
|
|
||||||
|
**Как в HistoryModal:**
|
||||||
|
```jsx
|
||||||
|
<>
|
||||||
|
<div className="modal-backdrop show" onClick={handleBackdropClick} />
|
||||||
|
<div className="modal show d-block">
|
||||||
|
{/* content */}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Преимущества:**
|
||||||
|
- ✅ Единообразный стиль во всём приложении
|
||||||
|
- ✅ Затемнение фона
|
||||||
|
- ✅ Клик вне модалки закрывает окно
|
||||||
|
- ✅ ESC закрывает окно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Изменённые файлы
|
||||||
|
|
||||||
|
### 1. `frontend/src/components/ConfirmDiffModal.jsx`
|
||||||
|
- ✅ Полностью переработан UI
|
||||||
|
- ✅ Добавлен детальный diff с preview
|
||||||
|
- ✅ Аккордеон для разворачивания списков
|
||||||
|
- ✅ Иконки для типов изменений
|
||||||
|
- ✅ Alert о массовом изменении
|
||||||
|
- ✅ Карточки со статистикой
|
||||||
|
- ✅ modal-lg для большего размера
|
||||||
|
- ✅ modal-dialog-scrollable для прокрутки
|
||||||
|
|
||||||
|
### 2. `frontend/src/DomainsNewManager.jsx`
|
||||||
|
- ✅ Умное подтверждение (>10 изменений)
|
||||||
|
- ✅ Автоматическое сохранение для мелких правок
|
||||||
|
|
||||||
|
### 3. `frontend/src/IPRangesManager.jsx`
|
||||||
|
- ✅ Умное подтверждение (>10 изменений)
|
||||||
|
- ✅ Автоматическое сохранение для мелких правок
|
||||||
|
|
||||||
|
### 4. `frontend/src/ASNsNewManager.jsx`
|
||||||
|
- ✅ Умное подтверждение (>10 изменений)
|
||||||
|
- ✅ Автоматическое сохранение для мелких правок
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Пример 1: Малое изменение (≤10)
|
||||||
|
```
|
||||||
|
Пользователь добавляет 3 домена → Клик "Сохранить"
|
||||||
|
↓
|
||||||
|
Сохраняется СРАЗУ (без модалки)
|
||||||
|
↓
|
||||||
|
Toast: "✅ Данные сохранены"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример 2: Массовое изменение (>10)
|
||||||
|
```
|
||||||
|
Пользователь импортирует 50 доменов → Клик "Сохранить"
|
||||||
|
↓
|
||||||
|
Открывается модалка с preview
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ⚠️ Массовое изменение! │
|
||||||
|
│ Вы собираетесь изменить 50 записей │
|
||||||
|
│ │
|
||||||
|
│ ▼ Добавлено (48) │
|
||||||
|
│ + domain1.com │
|
||||||
|
│ + domain2.com │
|
||||||
|
│ ... │
|
||||||
|
│ │
|
||||||
|
│ ▼ Удалено (2) │
|
||||||
|
│ - old.com │
|
||||||
|
│ ... │
|
||||||
|
│ │
|
||||||
|
│ [Отмена] [Сохранить 50 изменений] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
Пользователь проверяет
|
||||||
|
↓
|
||||||
|
Клик "Сохранить"
|
||||||
|
↓
|
||||||
|
Toast: "✅ Данные сохранены"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Преимущества
|
||||||
|
|
||||||
|
### До улучшений:
|
||||||
|
- ❌ Всегда показывалась модалка (раздражает при мелких правках)
|
||||||
|
- ❌ Показывались только цифры (не видно ЧТО именно)
|
||||||
|
- ❌ Маленькое окно (modal-sm)
|
||||||
|
- ❌ Нет деталей изменений
|
||||||
|
|
||||||
|
### После улучшений:
|
||||||
|
- ✅ Умное подтверждение (малые - сразу, большие - модалка)
|
||||||
|
- ✅ Детальный diff с preview (видно ЧТО изменилось)
|
||||||
|
- ✅ Большое окно (modal-lg) с прокруткой
|
||||||
|
- ✅ Аккордеон для детального просмотра
|
||||||
|
- ✅ Иконки и цветовое кодирование
|
||||||
|
- ✅ Предупреждение о массовых изменениях
|
||||||
|
- ✅ Единообразный backdrop как в HistoryModal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Настройки
|
||||||
|
|
||||||
|
### Изменить порог подтверждения
|
||||||
|
|
||||||
|
**По умолчанию: 10 изменений**
|
||||||
|
|
||||||
|
Чтобы изменить порог, отредактируйте в 3 файлах:
|
||||||
|
- `DomainsNewManager.jsx`
|
||||||
|
- `IPRangesManager.jsx`
|
||||||
|
- `ASNsNewManager.jsx`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Найти:
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
|
||||||
|
// Изменить на (например, 20):
|
||||||
|
if (totalChanges > 20) {
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменить количество preview элементов
|
||||||
|
|
||||||
|
**По умолчанию: 5 элементов**
|
||||||
|
|
||||||
|
В `ConfirmDiffModal.jsx`:
|
||||||
|
```javascript
|
||||||
|
// Найти:
|
||||||
|
const maxPreview = 5;
|
||||||
|
|
||||||
|
// Изменить на (например, 10):
|
||||||
|
const maxPreview = 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Отключить умное подтверждение
|
||||||
|
|
||||||
|
Чтобы всегда показывать модалку:
|
||||||
|
```javascript
|
||||||
|
// Заменить:
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// На:
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы никогда не показывать модалку:
|
||||||
|
```javascript
|
||||||
|
// Заменить:
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
// На:
|
||||||
|
performSave();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Тест 1: Малое изменение
|
||||||
|
1. Откройте страницу доменов
|
||||||
|
2. Добавьте 3-5 доменов
|
||||||
|
3. Нажмите "Сохранить" (Ctrl+S)
|
||||||
|
4. **Ожидается:** Сохраняется сразу, без модалки
|
||||||
|
5. **Результат:** ✅ Toast "Данные сохранены"
|
||||||
|
|
||||||
|
### Тест 2: Массовое изменение
|
||||||
|
1. Откройте страницу доменов
|
||||||
|
2. Нажмите "Импорт"
|
||||||
|
3. Вставьте 50+ доменов
|
||||||
|
4. Нажмите "Сохранить"
|
||||||
|
5. **Ожидается:** Открывается модалка с preview
|
||||||
|
6. **Результат:** ✅ Видна детальная статистика
|
||||||
|
7. Разверните аккордеон "Добавлено"
|
||||||
|
8. **Ожидается:** Список первых 5 доменов + "... и ещё N"
|
||||||
|
9. Нажмите "Сохранить N изменений"
|
||||||
|
10. **Результат:** ✅ Данные сохранены
|
||||||
|
|
||||||
|
### Тест 3: Backdrop
|
||||||
|
1. Откройте модалку (>10 изменений)
|
||||||
|
2. Кликните на затемнённую область вокруг модалки
|
||||||
|
3. **Ожидается:** Модалка закрывается
|
||||||
|
4. Откройте снова
|
||||||
|
5. Нажмите ESC
|
||||||
|
6. **Ожидается:** Модалка закрывается
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Визуальные улучшения
|
||||||
|
|
||||||
|
### Цветовая схема
|
||||||
|
- 🟢 **Зелёный** - добавленные записи (border-success, text-success)
|
||||||
|
- 🔴 **Красный** - удалённые записи (border-danger, text-danger)
|
||||||
|
- 🔵 **Синий** - изменённые записи (border-info, text-info)
|
||||||
|
- 🟡 **Жёлтый** - предупреждение (alert-warning)
|
||||||
|
|
||||||
|
### Иконки
|
||||||
|
- ➕ `IconPlus` - добавлено
|
||||||
|
- ➖ `IconMinus` - удалено
|
||||||
|
- ✏️ `IconEdit` - изменено
|
||||||
|
|
||||||
|
### Размеры
|
||||||
|
- **До:** modal-sm (маленькое окно)
|
||||||
|
- **После:** modal-lg (большое окно)
|
||||||
|
- **Scrollable:** modal-dialog-scrollable (с прокруткой)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Почему именно 10 изменений как порог?**
|
||||||
|
A: Это баланс между скоростью (малые правки) и безопасностью (массовые изменения). Можно настроить.
|
||||||
|
|
||||||
|
**Q: Можно ли полностью убрать модалку?**
|
||||||
|
A: Да, замените `if (totalChanges > 10)` на `performSave()` сразу.
|
||||||
|
|
||||||
|
**Q: Почему показывается только 5 элементов в preview?**
|
||||||
|
A: Чтобы не перегружать UI. При 1000 изменениях неудобно смотреть все. Можно изменить `maxPreview`.
|
||||||
|
|
||||||
|
**Q: Можно ли добавить настройку в UI?**
|
||||||
|
A: Да! Добавьте checkbox в SettingsModal:
|
||||||
|
```jsx
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={skipConfirm} onChange={(e) => setSkipConfirm(e.target.checked)} />
|
||||||
|
Не спрашивать подтверждение при сохранении
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Q: Что если нужно видеть ВСЕ изменения?**
|
||||||
|
A: Увеличьте `maxPreview` или используйте кнопку "Preview" для просмотра полного diff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
✅ **Готово и протестировано**
|
||||||
|
|
||||||
|
- ✅ Нет ошибок линтера
|
||||||
|
- ✅ Работает во всех 3 компонентах
|
||||||
|
- ✅ modal-backdrop show как в HistoryModal
|
||||||
|
- ✅ Детальный diff с preview
|
||||||
|
- ✅ Умное подтверждение (>10)
|
||||||
|
- ✅ Аккордеон для детального просмотра
|
||||||
|
- ✅ Цветовое кодирование
|
||||||
|
- ✅ Responsive дизайн
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что дальше?
|
||||||
|
|
||||||
|
Возможные будущие улучшения:
|
||||||
|
1. Добавить фильтрацию в preview (показывать только добавленные/удалённые)
|
||||||
|
2. Добавить поиск в списках изменений
|
||||||
|
3. Добавить copy-to-clipboard для списков
|
||||||
|
4. Добавить настройку порога в UI (SettingsModal)
|
||||||
|
5. Добавить экспорт diff в файл
|
||||||
|
6. Добавить diff для changed элементов (было → стало)
|
||||||
|
|
||||||
|
**Нужно что-то из этого?** Скажи, реализую! 😊
|
||||||
|
|
||||||
@@ -217,11 +217,23 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
|
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
||||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
||||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
setDiff(computeDiff(originalItems, unique));
|
const diffData = computeDiff(originalItems, unique);
|
||||||
setConfirmSaveOpen(true);
|
setDiff(diffData);
|
||||||
|
|
||||||
|
// Умное подтверждение: показываем модалку только при массовых изменениях
|
||||||
|
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
|
||||||
|
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
// Массовое изменение - требуется подтверждение
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
// Малое изменение - сохраняем сразу
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performSave = async () => {
|
const performSave = async () => {
|
||||||
|
|||||||
@@ -226,12 +226,23 @@ function DomainsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
// подготовим diff и спросим подтверждение
|
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
||||||
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
setDiff(computeDiff(originalItems, unique));
|
const diffData = computeDiff(originalItems, unique);
|
||||||
setConfirmSaveOpen(true);
|
setDiff(diffData);
|
||||||
|
|
||||||
|
// Умное подтверждение: показываем модалку только при массовых изменениях
|
||||||
|
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
|
||||||
|
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
// Массовое изменение - требуется подтверждение
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
// Малое изменение - сохраняем сразу
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performSave = async () => {
|
const performSave = async () => {
|
||||||
|
|||||||
@@ -233,11 +233,23 @@ function IPRangesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
|
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
||||||
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
setDiff(computeDiff(originalItems, unique));
|
const diffData = computeDiff(originalItems, unique);
|
||||||
setConfirmSaveOpen(true);
|
setDiff(diffData);
|
||||||
|
|
||||||
|
// Умное подтверждение: показываем модалку только при массовых изменениях
|
||||||
|
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
|
||||||
|
|
||||||
|
if (totalChanges > 10) {
|
||||||
|
// Массовое изменение - требуется подтверждение
|
||||||
|
setConfirmSaveOpen(true);
|
||||||
|
} else {
|
||||||
|
// Малое изменение - сохраняем сразу
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performSave = async () => {
|
const performSave = async () => {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { IconCheck, IconX, IconRefresh } from '@tabler/icons-react';
|
import { IconPlus, IconMinus, IconEdit } from '@tabler/icons-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением статистики
|
* ConfirmDiffModal - модальное окно для подтверждения изменений с отображением детального diff
|
||||||
* Простой подход со встроенным backdrop (как в HistoryModal)
|
* Показывает ЧТО именно изменилось, не только цифры
|
||||||
|
* Использует modal-backdrop show как HistoryModal
|
||||||
*/
|
*/
|
||||||
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
||||||
const added = diff?.added?.length || 0;
|
const added = diff?.added || [];
|
||||||
const removed = diff?.removed?.length || 0;
|
const removed = diff?.removed || [];
|
||||||
const changed = diff?.changed?.length || 0;
|
const changed = diff?.changed || [];
|
||||||
|
|
||||||
|
const total = added.length + removed.length + changed.length;
|
||||||
|
|
||||||
if (!show) return null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Modal Backdrop */}
|
{/* Modal Backdrop */}
|
||||||
@@ -30,58 +39,164 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
||||||
>
|
>
|
||||||
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||||
<div
|
<div className="modal-content">
|
||||||
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-header">
|
<div className="modal-header">
|
||||||
<h5 className="modal-title">Подтвердить сохранение</h5>
|
<h5 className="modal-title">Подтвердить массовое изменение</h5>
|
||||||
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
|
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<div className="row g-2 text-center">
|
{/* Статистика */}
|
||||||
<div className="col">
|
<div className="alert alert-warning mb-3">
|
||||||
<div className="card">
|
<div className="d-flex">
|
||||||
<div className="card-body p-2">
|
<div>
|
||||||
<strong>Добавлено</strong>
|
<h4 className="alert-title">Внимание! Массовое изменение данных</h4>
|
||||||
<div className="text-muted">{added}</div>
|
<div className="text-muted">
|
||||||
|
Вы собираетесь изменить <strong>{total}</strong> {total === 1 ? 'запись' : total < 5 ? 'записи' : 'записей'}.
|
||||||
|
Пожалуйста, проверьте изменения перед сохранением.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col">
|
</div>
|
||||||
<div className="card">
|
|
||||||
<div className="card-body p-2">
|
{/* Статистика в карточках */}
|
||||||
<strong>Удалено</strong>
|
<div className="row g-2 text-center mb-3">
|
||||||
<div className="text-muted">{removed}</div>
|
{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>
|
||||||
</div>
|
)}
|
||||||
<div className="col">
|
{removed.length > 0 && (
|
||||||
<div className="card">
|
<div className="col">
|
||||||
<div className="card-body p-2">
|
<div className="card border-danger">
|
||||||
<strong>Изменено</strong>
|
<div className="card-body p-3">
|
||||||
<div className="text-muted">{changed}</div>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +218,7 @@ function ConfirmDiffModal({ show, diff, onConfirm, onClose, loading = false }) {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
Сохранить
|
Сохранить {total} {total === 1 ? 'изменение' : total < 5 ? 'изменения' : 'изменений'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user