Управление категориями

This commit is contained in:
2025-12-11 23:07:22 +03:00
parent 56584cc8ff
commit 8b38d69980
11 changed files with 902 additions and 38 deletions
+1
View File
@@ -62,6 +62,7 @@ async def post_requests(
"toolkits": toolkits, "toolkits": toolkits,
"categories": categories, "categories": categories,
} }
# logger.info(resultData)
case "jurnal_toolkits": case "jurnal_toolkits":
jurnal_toolkits = await StocksRecordsHandler.get() jurnal_toolkits = await StocksRecordsHandler.get()
if jurnal_toolkits: if jurnal_toolkits:
Binary file not shown.
Binary file not shown.
+31 -4
View File
@@ -11,17 +11,17 @@ router = APIRouter()
@router.post("/", summary="Запрос остатка инструмента") @router.post("/", summary="Запрос остатка инструмента")
async def toolkit_request( async def toolkit_request(
request_data: dict = Depends(requestDict), reqData: dict = Depends(requestDict),
): ):
response = {"status": "error", "data": {}} response = {"status": "error", "data": {}}
toolkitId = request_data.get("body").get("toolkitId") toolkitId = reqData.get("body").get("toolkitId")
logger.info(f"Получение запроса остатка инструмента #{toolkitId}") logger.info(f"Получение запроса остатка инструмента #{toolkitId}")
# logger.info(request_data) # logger.info(request_data)
stocks = await StockHandler.getByToolkitId(toolkitId) stocks = await StockHandler.getByToolkitId(toolkitId)
if not stocks: if not stocks:
return response return response
userId = request_data.get("body").get("userId") userId = reqData.get("body").get("userId")
allToolboxes = request_data.get("body").get("allToolboxes") allToolboxes = reqData.get("body").get("allToolboxes")
toolboxes = ( toolboxes = (
await ToolboxHandler.getByOwner(userId) await ToolboxHandler.getByOwner(userId)
if not allToolboxes if not allToolboxes
@@ -73,3 +73,30 @@ async def fill_toolbox():
"placements": [placement.toDict() for placement in placements], "placements": [placement.toDict() for placement in placements],
} }
return response return response
@router.post("/categories_batch", summary="Управление категориями")
async def categories_batch(reqData: dict = Depends(requestDict)):
logger.info(f"Управление категориями")
response = {"status": "error"}
userId = reqData.get("body").get("userId")
changesData = reqData.get("body").get("changes")
success = True
for newCategoryData in changesData.get("create", []):
logger.info(f"Добавление категории: {newCategoryData.get('title')}")
result = await CategoryHandler.add(newCategoryData, userId)
if not result:
success = False
for updateCategoryData in changesData.get("update", []):
logger.info(f"Обновление категории: {updateCategoryData.get('title')}")
result = await CategoryHandler.edit(updateCategoryData, userId)
if not result:
success = False
for deleteCategoryId in changesData.get("delete", []):
logger.info(f"Удаление категории: {deleteCategoryId}")
result = await CategoryHandler.delete(deleteCategoryId, userId)
if not result:
success = False
if success:
response["status"] = "ok"
return response
+848 -28
View File
@@ -240,37 +240,857 @@ function renderSimpleTab(tabId, tabData, title) {
`; `;
} }
function renderToolkitsTab(tabId, toolsArray, categoriesList) {
async function manageCategory(categoriesList) {
// Удаляем старое модальное окно, если оно существует
let modal = document.getElementById('manageCategoryModal');
if (modal) modal.remove();
// Храним изменения
const changes = {
create: [], // новые категории
update: [], // измененные категории
delete: [] // id категорий для удаления
};
// Делаем копию списка категорий для работы с дополнительными полями
const categories = categoriesList.map(cat => ({
...cat,
status: 'unchanged', // unchanged, new, edited, deleted
originalData: null
}));
// Создаём модальное окно
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'manageCategoryModal';
modal.tabIndex = -1;
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-folder me-2"></i>Управление категориями</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="manageCategoryError" class="alert alert-danger d-none m-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
<span id="manageCategoryErrorMessage"></span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="categoriesTable">
<thead class="table-light sticky-top">
<tr>
<th style="width: 35%;">Название</th>
<th style="width: 45%;">Описание</th>
<th style="width: 20%;" class="text-center">Действия</th>
</tr>
</thead>
<tbody id="categoriesListBody">
<!-- Список категорий будет здесь -->
</tbody>
<tfoot class="table-light">
<tr>
<td colspan="3" class="text-center">
<button type="button" class="btn btn-sm btn-outline-primary" id="addCategoryBtn">
<i class="bi bi-plus-circle me-1"></i>Добавить категорию
</button>
</td>
</tr>
<tr id="newCategoryFormRow" class="d-none">
<td colspan="3">
<div class="p-3 border border-primary rounded">
<h6 class="mb-3 text-primary"><i class="bi bi-plus-lg me-1"></i>Новая категория</h6>
<div class="row g-2">
<div class="col-md-12">
<label for="newCategoryTitle" class="form-label required">Название категории</label>
<input type="text" class="form-control form-control-sm" id="newCategoryTitle"
placeholder="Введите название категории" required minlength="2" maxlength="100">
</div>
<div class="col-md-12">
<label for="newCategoryDescription" class="form-label required">Описание категории</label>
<textarea class="form-control form-control-sm" id="newCategoryDescription"
rows="2" placeholder="Описание категории" required maxlength="500"></textarea>
</div>
<div class="col-md-12">
<div class="d-flex justify-content-end gap-2 mt-2">
<button type="button" class="btn btn-sm btn-secondary" id="cancelNewCategoryBtn">
Отмена
</button>
<button type="button" class="btn btn-sm btn-success" id="saveNewCategoryBtn">
<i class="bi bi-check-lg me-1"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Панель изменений -->
<div id="changesPanel" class="border-top p-3">
<h6 class="mb-3"><i class="bi bi-list-check me-2"></i>Планируемые изменения</h6>
<div id="addChangesList" class="mb-3">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle me-1"></i>Добавляем:</h6>
<div id="addChangesItems" class="ps-3">
<!-- Новые категории будут здесь -->
</div>
</div>
<div id="editChangesList" class="mb-3">
<h6 class="text-warning mb-2"><i class="bi bi-pencil-square me-1"></i>Меняем:</h6>
<div id="editChangesItems" class="ps-3">
<!-- Измененные категории будут здесь -->
</div>
</div>
<div id="deleteChangesList" class="mb-3">
<h6 class="text-danger mb-2"><i class="bi bi-trash me-1"></i>Удаляем:</h6>
<div id="deleteChangesItems" class="ps-3">
<!-- Категории для удаления будут здесь -->
</div>
</div>
<div id="noChangesMessage" class="text-muted text-center py-2">
<i class="bi bi-info-circle me-1"></i>Нет запланированных изменений
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<button type="button" class="btn btn-primary" id="saveAllChangesBtn">
<span class="spinner-border spinner-border-sm me-1" id="saveChangesSpinner" style="display: none;"></span>
<span id="saveChangesText">Сохранить все изменения</span>
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
const categoriesListBody = modal.querySelector('#categoriesListBody');
const newCategoryFormRow = modal.querySelector('#newCategoryFormRow');
const addCategoryBtn = modal.querySelector('#addCategoryBtn');
const cancelNewCategoryBtn = modal.querySelector('#cancelNewCategoryBtn');
const saveNewCategoryBtn = modal.querySelector('#saveNewCategoryBtn');
const saveAllChangesBtn = modal.querySelector('#saveAllChangesBtn');
const saveChangesSpinner = modal.querySelector('#saveChangesSpinner');
const saveChangesText = modal.querySelector('#saveChangesText');
const addChangesItems = modal.querySelector('#addChangesItems');
const editChangesItems = modal.querySelector('#editChangesItems');
const deleteChangesItems = modal.querySelector('#deleteChangesItems');
const noChangesMessage = modal.querySelector('#noChangesMessage');
const addChangesList = modal.querySelector('#addChangesList');
const editChangesList = modal.querySelector('#editChangesList');
const deleteChangesList = modal.querySelector('#deleteChangesList');
// Функция для обновления панели изменений
function updateChangesPanel() {
// Очищаем панели
addChangesItems.innerHTML = '';
editChangesItems.innerHTML = '';
deleteChangesItems.innerHTML = '';
// Собираем изменения
const newCategories = categories.filter(cat => cat.status === 'new');
const editedCategories = categories.filter(cat => cat.status === 'edited');
const deletedCategories = categories.filter(cat => cat.status === 'deleted');
// Обновляем отображение панелей
if (newCategories.length > 0) {
addChangesList.classList.remove('d-none');
newCategories.forEach((category, index) => {
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center mb-1';
item.innerHTML = `
<span>${escapeHtml(category.title)}</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="cancelNewCategoryAction(${categories.indexOf(category)})">
<i class="bi bi-x"></i> Отменить
</button>
`;
addChangesItems.appendChild(item);
});
} else {
addChangesList.classList.add('d-none');
}
if (editedCategories.length > 0) {
editChangesList.classList.remove('d-none');
editedCategories.forEach((category, index) => {
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center mb-1';
item.innerHTML = `
<span>${escapeHtml(category.title)}</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="cancelEditCategoryAction(${categories.indexOf(category)})">
<i class="bi bi-x"></i> Отменить
</button>
`;
editChangesItems.appendChild(item);
});
} else {
editChangesList.classList.add('d-none');
}
if (deletedCategories.length > 0) {
deleteChangesList.classList.remove('d-none');
deletedCategories.forEach((category, index) => {
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center mb-1';
item.innerHTML = `
<span>${escapeHtml(category.title)}</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="cancelDeleteCategoryAction(${categories.indexOf(category)})">
<i class="bi bi-x"></i> Отменить
</button>
`;
deleteChangesItems.appendChild(item);
});
} else {
deleteChangesList.classList.add('d-none');
}
// Показываем/скрываем сообщение "нет изменений"
const hasChanges = newCategories.length > 0 || editedCategories.length > 0 || deletedCategories.length > 0;
if (hasChanges) {
noChangesMessage.classList.add('d-none');
} else {
noChangesMessage.classList.remove('d-none');
}
}
// Функция для рендеринга списка категорий
function renderCategoriesList() {
categoriesListBody.innerHTML = '';
categories.forEach((category, index) => {
const status = category.status;
// Определяем стили в зависимости от статуса
let rowClass = '';
let badge = '';
switch (status) {
case 'new':
rowClass = 'table-success';
badge = '<span class="badge bg-success">Новая</span>';
break;
case 'edited':
rowClass = 'table-warning';
badge = '<span class="badge bg-warning">Изменена</span>';
break;
case 'deleted':
rowClass = 'table-danger';
badge = '<span class="badge bg-danger">Удалена</span>';
break;
default:
rowClass = '';
badge = '';
}
// Для удаленных категорий показываем только с кнопкой восстановления
if (status === 'new') {
// Для новых категорий показываем только кнопку отмены
categoriesListBody.innerHTML += `
<tr id="category-row-${index}" class="${rowClass}">
<td>
<div class="fw-semibold">${escapeHtml(category.title)}</div>
${badge}
</td>
<td>
<div class="text-muted small">${escapeHtml(category.description)}</div>
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="cancelNewCategoryAction(${index})" title="Отменить добавление">
<i class="bi bi-x-circle"></i> Отменить
</button>
</td>
</tr>
`;
} else if (status === 'deleted') {
// Для удаленных категорий показываем кнопку восстановления
categoriesListBody.innerHTML += `
<tr id="category-row-${index}" class="${rowClass}">
<td>
<div class="fw-semibold">${escapeHtml(category.title)}</div>
${badge}
</td>
<td>
<div class="text-muted small">${escapeHtml(category.description)}</div>
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-success" onclick="restoreCategory(${index})" title="Восстановить">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</td>
</tr>
`;
} else {
// Для остальных категорий (unchanged, edited) показываем обычные кнопки
categoriesListBody.innerHTML += `
<tr id="category-row-${index}" class="${rowClass}">
<td>
<div class="fw-semibold">${escapeHtml(category.title)}</div>
${badge}
</td>
<td>
<div class="text-muted small">${escapeHtml(category.description)}</div>
<div class="text-muted mt-1">
<small>
<i class="bi bi-clock me-1"></i>
${category.created_at ? new Date(category.created_at).toLocaleDateString('ru-RU') : 'Новая'}
</small>
<small class="ms-3">
<i class="bi bi-pencil me-1"></i>
${category.updated_at ? new Date(category.updated_at).toLocaleDateString('ru-RU') : 'Новая'}
</small>
</div>
</td>
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-primary" onclick="editCategory(${index})" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-outline-danger" onclick="deleteCategory(${index})" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}
});
}
// Функция для экранирования HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Функции для работы с категориями
modal.editCategory = function (index) {
// Сохраняем оригинальные данные если еще не сохранены
if (!categories[index].originalData) {
categories[index].originalData = {
title: categories[index].title,
description: categories[index].description
};
}
// Создаем модальное окно для редактирования
const editModal = document.createElement('div');
editModal.className = 'modal fade';
editModal.id = 'editCategoryModal';
editModal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-pencil-square me-2"></i>Редактирование категории</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="editCategoryTitle" class="form-label required">Название категории</label>
<input type="text" class="form-control" id="editCategoryTitle"
value="${escapeHtml(categories[index].title)}" required minlength="2" maxlength="100">
</div>
<div class="mb-3">
<label for="editCategoryDescription" class="form-label required">Описание категории</label>
<textarea class="form-control" id="editCategoryDescription"
rows="3" required maxlength="500">${escapeHtml(categories[index].description)}</textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-success" id="saveEditCategoryBtn">
Сохранить изменения
</button>
</div>
</div>
</div>
`;
document.body.appendChild(editModal);
const bsEditModal = new bootstrap.Modal(editModal);
// Обработчик сохранения изменений
editModal.querySelector('#saveEditCategoryBtn').addEventListener('click', () => {
const titleInput = editModal.querySelector('#editCategoryTitle');
const descriptionInput = editModal.querySelector('#editCategoryDescription');
const title = titleInput.value.trim();
const description = descriptionInput.value.trim();
// Валидация
if (!title || title.length < 2) {
showInfo('Название категории должно содержать минимум 2 символа', 'error');
titleInput.focus();
return;
}
if (!description || description.length < 2) {
showInfo('Описание категории должно содержать минимум 2 символа', 'error');
descriptionInput.focus();
return;
}
// Проверяем уникальность названия среди других категорий
const duplicate = categories.find((cat, i) =>
i !== index &&
cat.id !== categories[index].id &&
cat.title.toLowerCase() === title.toLowerCase() &&
cat.status !== 'deleted'
);
if (duplicate) {
showInfo('Категория с таким названием уже существует', 'error');
titleInput.focus();
return;
}
// Обновляем данные категории
categories[index].title = title;
categories[index].description = description;
categories[index].status = 'edited';
// Добавляем в изменения, если это существующая категория
if (categories[index].id) {
const updateIndex = changes.update.findIndex(item => item.id === categories[index].id);
if (updateIndex === -1) {
changes.update.push({
id: categories[index].id,
title: title,
description: description
});
} else {
changes.update[updateIndex] = {
id: categories[index].id,
title: title,
description: description
};
}
}
bsEditModal.hide();
setTimeout(() => {
editModal.remove();
renderCategoriesList();
updateChangesPanel();
showInfo('Изменения сохранены', 'success');
}, 300);
});
// Очистка при закрытии модалки
editModal.addEventListener('hidden.bs.modal', () => {
setTimeout(() => {
if (editModal.parentNode) editModal.remove();
}, 300);
});
bsEditModal.show();
setTimeout(() => {
editModal.querySelector('#editCategoryTitle').focus();
}, 100);
};
modal.cancelEditCategoryAction = function (index) {
if (categories[index].originalData) {
// Восстанавливаем исходные данные
categories[index].title = categories[index].originalData.title;
categories[index].description = categories[index].originalData.description;
categories[index].status = 'unchanged';
categories[index].originalData = null;
// Удаляем из изменений
if (categories[index].id) {
const updateIndex = changes.update.findIndex(item => item.id === categories[index].id);
if (updateIndex !== -1) {
changes.update.splice(updateIndex, 1);
}
}
renderCategoriesList();
updateChangesPanel();
showInfo('Изменения отменены', 'info');
}
};
modal.deleteCategory = function (index) {
// Сохраняем оригинальные данные если еще не сохранены
if (!categories[index].originalData) {
categories[index].originalData = {
title: categories[index].title,
description: categories[index].description
};
}
// Создаем модальное окно подтверждения удаления
const deleteModal = document.createElement('div');
deleteModal.className = 'modal fade';
deleteModal.id = 'deleteCategoryModal';
deleteModal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger"><i class="bi bi-trash me-2"></i>Удаление категории</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Внимание!</strong> Категория может быть удалена только если в ней нет инструментов.
При попытке удаления категории, используемой в инструментах, вы получите увдомление об успехе,
при этом категория не будет удалена!
</div>
<div class="mb-2">
<strong>Название:</strong> ${escapeHtml(categories[index].title)}
</div>
<div class="mb-3">
<strong>Описание:</strong> ${escapeHtml(categories[index].description)}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" id="confirmDeleteCategoryBtn">
Удалить
</button>
</div>
</div>
</div>
`;
document.body.appendChild(deleteModal);
const bsDeleteModal = new bootstrap.Modal(deleteModal);
// Обработчик подтверждения удаления
deleteModal.querySelector('#confirmDeleteCategoryBtn').addEventListener('click', () => {
categories[index].status = 'deleted';
// Добавляем в изменения, если это существующая категория
if (categories[index].id) {
const deleteIndex = changes.delete.indexOf(categories[index].id);
if (deleteIndex === -1) {
changes.delete.push(categories[index].id);
}
} else {
// Если это новая категория - удаляем из изменений на создание
const createIndex = changes.create.findIndex(item =>
item.title === categories[index].originalData.title &&
item.description === categories[index].originalData.description
);
if (createIndex !== -1) {
changes.create.splice(createIndex, 1);
}
}
bsDeleteModal.hide();
setTimeout(() => {
deleteModal.remove();
renderCategoriesList();
updateChangesPanel();
showInfo('Категория помечена для удаления', 'warning');
}, 300);
});
// Очистка при закрытии модалки
deleteModal.addEventListener('hidden.bs.modal', () => {
setTimeout(() => {
if (deleteModal.parentNode) deleteModal.remove();
}, 300);
});
bsDeleteModal.show();
};
modal.cancelDeleteCategoryAction = function (index) {
// Восстанавливаем исходные данные
categories[index].title = categories[index].originalData.title;
categories[index].description = categories[index].originalData.description;
categories[index].status = 'unchanged';
categories[index].originalData = null;
// Удаляем из изменений на удаление
if (categories[index].id) {
const deleteIndex = changes.delete.indexOf(categories[index].id);
if (deleteIndex !== -1) {
changes.delete.splice(deleteIndex, 1);
}
}
renderCategoriesList();
updateChangesPanel();
showInfo('Удаление отменено', 'info');
};
modal.restoreCategory = function (index) {
// Восстанавливаем категорию
categories[index].status = 'unchanged';
categories[index].originalData = null;
// Удаляем из изменений на удаление
if (categories[index].id) {
const deleteIndex = changes.delete.indexOf(categories[index].id);
if (deleteIndex !== -1) {
changes.delete.splice(deleteIndex, 1);
}
}
renderCategoriesList();
updateChangesPanel();
showInfo('Категория восстановлена', 'success');
};
modal.cancelNewCategoryAction = function (index) {
// Удаляем новую категорию
if (categories[index].status === 'new') {
// Удаляем из изменений на создание
const createIndex = changes.create.findIndex(item =>
item.title === categories[index].title &&
item.description === categories[index].description
);
if (createIndex !== -1) {
changes.create.splice(createIndex, 1);
}
// Удаляем из массива категорий
categories.splice(index, 1);
renderCategoriesList();
updateChangesPanel();
showInfo('Новая категория удалена', 'info');
}
};
// Инициализируем список категорий
renderCategoriesList();
updateChangesPanel();
// Обработчики событий
addCategoryBtn.addEventListener('click', () => {
newCategoryFormRow.classList.remove('d-none');
addCategoryBtn.disabled = true;
setTimeout(() => {
const titleInput = modal.querySelector('#newCategoryTitle');
if (titleInput) titleInput.focus();
}, 10);
});
cancelNewCategoryBtn.addEventListener('click', () => {
newCategoryFormRow.classList.add('d-none');
addCategoryBtn.disabled = false;
modal.querySelector('#newCategoryTitle').value = '';
modal.querySelector('#newCategoryDescription').value = '';
});
saveNewCategoryBtn.addEventListener('click', () => {
const titleInput = modal.querySelector('#newCategoryTitle');
const descriptionInput = modal.querySelector('#newCategoryDescription');
const title = titleInput.value.trim();
const description = descriptionInput.value.trim();
// Валидация
if (!title || title.length < 2) {
showInfo('Название категории должно содержать минимум 2 символа', 'error');
titleInput.focus();
return;
}
if (!description || description.length < 2) {
showInfo('Описание категории должно содержать минимум 2 символа', 'error');
descriptionInput.focus();
return;
}
// Проверяем уникальность названия
const duplicate = categories.find(cat =>
cat.title.toLowerCase() === title.toLowerCase() &&
cat.status !== 'deleted'
);
if (duplicate) {
showInfo('Категория с таким названием уже существует', 'error');
titleInput.focus();
return;
}
// Добавляем новую категорию
const newCategory = {
title: title,
description: description,
status: 'new',
originalData: null
};
categories.push(newCategory);
changes.create.push({
title: title,
description: description
});
// Сбрасываем форму
titleInput.value = '';
descriptionInput.value = '';
newCategoryFormRow.classList.add('d-none');
addCategoryBtn.disabled = false;
// Обновляем отображение
renderCategoriesList();
updateChangesPanel();
showInfo('Категория добавлена', 'success');
});
// Сохранение всех изменений
saveAllChangesBtn.addEventListener('click', async function () {
// Проверяем, есть ли изменения
const hasChanges = changes.create.length > 0 ||
changes.update.length > 0 ||
changes.delete.length > 0;
if (!hasChanges) {
showInfo('Нет изменений для сохранения', 'info');
return;
}
// Сохраняем исходное состояние кнопки
const originalText = saveChangesText.textContent;
const originalDisabledState = saveAllChangesBtn.disabled;
// Двойное подтверждение
saveChangesText.textContent = 'Нажмите еще раз для подтверждения (10 сек)';
saveAllChangesBtn.disabled = true;
let confirmed = false;
const timeout = setTimeout(() => {
if (!confirmed) {
// Возвращаем кнопку в исходное состояние
saveChangesText.textContent = originalText;
saveAllChangesBtn.disabled = originalDisabledState;
saveChangesSpinner.style.display = 'none';
}
}, 10000);
const confirmHandler = async function () {
confirmed = true;
clearTimeout(timeout);
saveAllChangesBtn.disabled = true;
saveChangesSpinner.style.display = 'inline-block';
try {
// Отправляем запрос на сохранение изменений
const response = await apiRequest('/toolkit/categories_batch', {
changes: changes,
userId: userData.id
}, 'POST');
if (response.status === 'ok') {
showInfo('Изменения успешно сохранены', 'success');
bsModal.hide();
// Обновляем вкладку инструментов
await uploadTab('toolkits');
} else {
throw new Error(response.message || 'Ошибка при сохранении изменений');
}
} catch (error) {
console.error('Ошибка при сохранении изменений:', error);
// Возвращаем кнопку в исходное состояние
saveAllChangesBtn.disabled = false;
saveChangesSpinner.style.display = 'none';
saveChangesText.textContent = originalText;
const errorDiv = modal.querySelector('#manageCategoryError');
const errorMessage = modal.querySelector('#manageCategoryErrorMessage');
if (errorDiv && errorMessage) {
errorMessage.textContent = error.message || 'Произошла ошибка при сохранении изменений';
errorDiv.classList.remove('d-none');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Сбрасываем состояние подтверждения
confirmed = false;
}
};
// Обработчик для второго клика
const secondClickHandler = function () {
saveAllChangesBtn.removeEventListener('click', secondClickHandler);
confirmHandler();
};
saveAllChangesBtn.addEventListener('click', secondClickHandler);
saveAllChangesBtn.disabled = false;
saveChangesText.textContent = 'Подтвердите сохранение (10 сек)';
});
// Очистка при закрытии модалки
modal.addEventListener('hidden.bs.modal', () => {
setTimeout(() => {
if (modal.parentNode) modal.remove();
}, 300);
});
// Делаем функции глобально доступными для обработчиков onclick
window.editCategory = modal.editCategory;
window.cancelEditCategoryAction = modal.cancelEditCategoryAction;
window.deleteCategory = modal.deleteCategory;
window.cancelDeleteCategoryAction = modal.cancelDeleteCategoryAction;
window.restoreCategory = modal.restoreCategory;
window.cancelNewCategoryAction = modal.cancelNewCategoryAction;
// Показываем модалку
bsModal.show();
return new Promise((resolve) => {
modal.addEventListener('hidden.bs.modal', () => {
resolve(null);
});
});
}
function renderToolkitsTab(tabId, toolsList, categoriesArray) {
const tabContent = document.getElementById(`${tabId}-tab-content`); const tabContent = document.getElementById(`${tabId}-tab-content`);
const tabOptionalContent = document.getElementById(`${tabId}-tab-optional-content`);
// Преобразуем объект в массив, если передан объект if (accessData.tools_creation) {
const tools = Array.isArray(toolsArray) ? toolsArray : Object.values(toolsArray); tabOptionalContent.innerHTML = `
<div class="row mb-4">
<div class="col-12">
<button class="btn btn-outline-secondary" id="manageCategoryBtn">
<i class="bi bi-gear-wide-connected me-2"></i>Категории
</button>
</div>
</div>
`;
let uniqueCategories = {}; const manageCategoryBtn = document.getElementById('manageCategoryBtn');
categoriesList.forEach(cat => { manageCategoryBtn.addEventListener('click', () => manageCategory(categoriesArray));
uniqueCategories[cat.id] = { id: cat.id, title: cat.title, description: cat.description }; }
let categoriesData = {};
categoriesArray.forEach(cat => {
categoriesData[cat.id] = { id: cat.id, title: cat.title, description: cat.description };
}); });
tools.forEach(tool => { toolsList.forEach(tool => {
tool['category'] = uniqueCategories[tool.category_id]?.title || ''; tool['category'] = categoriesData[tool.category_id]?.title || '';
tool['category_desc'] = uniqueCategories[tool.category_id]?.description || ''; tool['category_desc'] = categoriesData[tool.category_id]?.description || '';
}); });
// Сортируем инструменты: сначала по названию категории, затем по названию toolsList.sort((a, b) => a.title.localeCompare(b.title, 'ru'));
const sortedTools = [...tools].sort((a, b) => { categoriesArray.sort((a, b) => a.title.localeCompare(b.title, 'ru'));
// Получаем названия категорий
const catA = uniqueCategories[a.category_id]?.title || '';
const catB = uniqueCategories[b.category_id]?.title || '';
// Сначала сравниваем категории
if (catA < catB) return -1;
if (catA > catB) return 1;
// Если категории одинаковые, сравниваем названия инструментов
const titleA = a.title || '';
const titleB = b.title || '';
return titleA.localeCompare(titleB, 'ru');
});
// Создаем HTML структуру // Создаем HTML структуру
tabContent.innerHTML = ` tabContent.innerHTML = `
@@ -286,7 +1106,7 @@ function renderToolkitsTab(tabId, toolsArray, categoriesList) {
data-category="all"> data-category="all">
Все категории Все категории
</button> </button>
${Object.values(uniqueCategories).map(category => ` ${categoriesArray.map(category => `
<button class="btn filter-btn" <button class="btn filter-btn"
data-category="${category.id}"> data-category="${category.id}">
${category.title} ${category.title}
@@ -318,12 +1138,13 @@ function renderToolkitsTab(tabId, toolsArray, categoriesList) {
`; `;
// Рендерим карточки // Рендерим карточки
renderToolkitCards(tabId, sortedTools, uniqueCategories); renderToolkitCards(tabId, toolsList, categoriesData);
// Добавляем обработчики событий для фильтров // Добавляем обработчики событий для фильтров
setupFilters(tabId, tools, uniqueCategories); setupFilters(tabId, toolsList, categoriesData);
} }
// Функция для рендеринга карточек // Функция для рендеринга карточек
function renderToolkitCards(tabId, tools, categoriesMap, filterText = '', categoryFilter = 'all') { function renderToolkitCards(tabId, tools, categoriesMap, filterText = '', categoryFilter = 'all') {
const container = document.getElementById(`${tabId}-cards-container`); const container = document.getElementById(`${tabId}-cards-container`);
@@ -404,7 +1225,6 @@ function renderToolkitCards(tabId, tools, categoriesMap, filterText = '', catego
// Функция для настройки фильтров // Функция для настройки фильтров
function setupFilters(tabId, tools, categoriesMap) { function setupFilters(tabId, tools, categoriesMap) {
const container = document.getElementById(`${tabId}-cards-container`);
const searchInput = document.getElementById(`${tabId}-search-input`); const searchInput = document.getElementById(`${tabId}-search-input`);
const filterButtons = document.querySelectorAll(`#${tabId}-tab-content .filter-btn`); const filterButtons = document.querySelectorAll(`#${tabId}-tab-content .filter-btn`);
Binary file not shown.
Binary file not shown.
+15 -4
View File
@@ -1,4 +1,5 @@
from sqlalchemy import select from sqlalchemy import select
from db.handlers.toolkit import ToolkitHandler
from utils import logger from utils import logger
from db import CRUD from db import CRUD
from db.schemas.categories import Category from db.schemas.categories import Category
@@ -32,15 +33,21 @@ class CategoryHandler:
) )
return newCategory.toDict() return newCategory.toDict()
async def edit(categoryId: int, **kwargs): async def edit(categoryData: dict, userId: int):
categoryId = categoryData.pop("id", None)
if not categoryId:
logger.error("Не указан id категории")
return {}
query = select(Category).where(Category.id == categoryId) query = select(Category).where(Category.id == categoryId)
category = await CRUD.read(query) category = await CRUD.read(query)
if not category: if not category:
logger.error("Категория не найдена") logger.error("Категория не найдена")
return {} return {}
try: try:
user_id = kwargs.get("user_id", None) logger.info(
editedCategory = await category.edit(**kwargs) f"Обновление категории {category.title} -> {categoryData.get('title')}"
)
editedCategory = await category.edit(**categoryData)
except Exception as e: except Exception as e:
logger.error(f"Ошибка обновления категории: {str(e)}") logger.error(f"Ошибка обновления категории: {str(e)}")
return {} return {}
@@ -48,7 +55,7 @@ class CategoryHandler:
logger.error("Категория не обновлена") logger.error("Категория не обновлена")
return {} return {}
await ServiceRecordsHandler.add( await ServiceRecordsHandler.add(
user_id, {f"Обновлена категория {category.title}": editedCategory.toDict()} userId, {f"Обновлена категория {category.title}": editedCategory.toDict()}
) )
logger.info(f"Категория {editedCategory.title} успешно обновлена") logger.info(f"Категория {editedCategory.title} успешно обновлена")
return editedCategory.toDict() return editedCategory.toDict()
@@ -64,6 +71,10 @@ class CategoryHandler:
return [category.toDict() for category in categories] if categories else [] return [category.toDict() for category in categories] if categories else []
async def delete(categoryId: int, user_id: int = None): async def delete(categoryId: int, user_id: int = None):
categoryInUse = await ToolkitHandler.checkCatogoryUse(categoryId)
if categoryInUse:
logger.error("Категория используется в инструментах")
return True
query = select(Category).where(Category.id == categoryId) query = select(Category).where(Category.id == categoryId)
category = await CRUD.read(query) category = await CRUD.read(query)
if not category: if not category:
+5
View File
@@ -160,6 +160,11 @@ class ToolkitHandler:
toolkits = await CRUD.read(query, True) toolkits = await CRUD.read(query, True)
return [toolkit.toDict() for toolkit in toolkits] if toolkits else [] return [toolkit.toDict() for toolkit in toolkits] if toolkits else []
async def checkCatogoryUse(category_id: int):
query = select(Toolkit).where(Toolkit.category_id == category_id)
toolkit = await CRUD.read(query)
return True if toolkit else False
async def delete(toolkitId: int, user_id: int = None): async def delete(toolkitId: int, user_id: int = None):
query = select(Toolkit).where(Toolkit.id == toolkitId) query = select(Toolkit).where(Toolkit.id == toolkitId)
toolkit = await CRUD.read(query) toolkit = await CRUD.read(query)
Binary file not shown.
+2 -2
View File
@@ -23,5 +23,5 @@ class Category(Base):
async def save(self): async def save(self):
return await CRUD.create(self, refresh=True) return await CRUD.create(self, refresh=True)
async def edit(id: int, **kwargs): async def edit(self, **kwargs):
return await CRUD.update(Category, id, **kwargs) return await CRUD.update(Category, self.id, **kwargs)