Files
toolbox/api/static/js/index.js
T

6241 lines
283 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getCookie } from '/static/js/cookies.js';
import { apiRequest } from '/static/js/api.js';
import { showInfo } from '/static/js//toast.js';
let accessData;
let userData;
let currentToolboxData = null;
// Список предопределенных характеристик с разделами
const predefinedSpecs = {
"Универсальные": [
"Диаметр",
"Длина",
"Черновая",
"Чистовая",
"Материал инструмента",
"Покрытие (TiN, TiAlN, AlTiN)",
"Тип хвостовика",
"Назначение",
"По стали",
"По нержавейке",
"По алюминию",
"Твёрдый сплав",
"HSS"
],
"Фрезеровка": [
"Кол-во перьев",
"Тип фрезы (концевая, торцевая, черновая)",
"Угол спирали",
"Геометрия зубьев"
],
"Токарка": [
"Пластины",
"Форма пластины (C, D, V, W, T)",
"Радиус",
"Наружная",
"Внутренняя",
"Резьбовая",
"Шаг",
"Тип державки",
"Направление (правое/левое)",
"Система крепления"
],
"Сверла": [
"Угол заточки (118°, 135°)",
"Тип хвостовика (цилиндрический, Морзе)",
"Глубокое сверление"
],
"Инструмент для ЧПУ": [
"Тип инструмента (фреза, сверло, развертка, гравёр, фасочник)",
"Тип обработки (2D, 3D, контурная, карманная)",
"Ступенчатая геометрия",
"Тип крепления (ER, Weldon, HSK, BT, ISO)",
"Максимальные обороты",
"Максимальная подача",
"Допуск биения",
"Тип спирали (правосторонняя, левосторонняя)",
"Длина режущей части",
"Рабочая часть"
]
};
async function getCookieData() {
accessData = await getCookie('toolbox_access');
userData = await getCookie('toolbox_user');
}
async function openTab(event, tabId) {
// Убираем активный класс со всех вкладок и кнопок
document.querySelectorAll('.tab-nav-btn').forEach(btn => {
btn.classList.remove('active');
btn.querySelector('.nav-icon').classList.remove('text-primary');
btn.querySelector('.nav-icon').classList.add('text-muted');
});
document.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.remove('show', 'active');
});
// Добавляем активный класс выбранной вкладке и кнопке
event.currentTarget.classList.add('active');
event.currentTarget.querySelector('.nav-icon').classList.remove('text-muted');
event.currentTarget.querySelector('.nav-icon').classList.add('text-primary');
document.getElementById(tabId).classList.add('show', 'active');
// Сохраняем выбранную вкладку
saveToStorage('tab', { tabId });
// Загружаем содержимое вкладки
await uploadTab(tabId);
}
function prepareTabs() {
let tabsData = {
'toolbox': {
title: 'Склад',
icon: 'bi-box-seam',
description: 'Управление остатками инструмента на складе'
},
'toolkits': {
title: 'Инструменты',
icon: 'bi-tools',
description: 'Каталог инструментов'
},
};
if (accessData.available_own_toolbox || accessData.refund_request_confirm || accessData.debit_request_confirm) {
tabsData['requests'] = {
title: 'Запросы',
icon: 'bi-chat-left-text',
description: 'Управление запросами на инструменты'
};
}
if (accessData.view_requests) {
tabsData['jurnal_toolkits'] = {
title: 'Журнал перемещений',
icon: 'bi-journal-text',
description: 'Журнал перемещений инструментов'
};
}
if (accessData.view_services) {
tabsData['jurnal_service'] = {
title: 'Сервисный журнал',
icon: 'bi-journal-richtext',
description: 'Журнал сервисных запросов'
};
}
if (accessData.users_view) {
tabsData['users'] = {
title: 'Пользователи',
icon: 'bi-people',
description: 'Управление пользователями'
};
}
const tabs = `
<div class="container-fluid p-0">
<!-- Верхняя панель навигации -->
<div class="row g-0 mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm" style="width: fit-content; margin: 0 auto;">
<div class="card-body p-3">
<div id="mainTabsNavWrapper">
<nav class="nav nav-pills gap-2" id="mainTabsNav" role="tablist">
${Object.entries(tabsData).map(([tabId, tabData], index) => `
<button class="nav-link tab-nav-btn d-flex flex-column align-items-center justify-content-center py-3 px-2"
id="${tabId}-tab"
role="tab"
onclick="openTab(event, '${tabId}')"
style="min-width: 120px; transition: all 0.3s ease;">
<i class="${tabData.icon} nav-icon fs-3 mb-2 text-muted" style="transition: all 0.3s ease;"></i>
<span class="nav-title fw-medium" style="font-size: 0.9rem;">${tabData.title}</span>
<div class="nav-indicator"></div>
</button>
`).join('')}
</nav>
</div>
</div>
</div>
</div>
</div>
<!-- Контент вкладок -->
<div class="row g-0">
<div class="col-12">
<div class="tab-content" id="mainTabsContent">
${Object.entries(tabsData).map(([tabId, tabData]) => `
<div class="tab-pane fade p-0"
id="${tabId}"
role="tabpanel">
<div class="card border-0 shadow-sm mb-1">
<div class="row d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2">
<div class="card-body py-2 col-12 col-md-3">
<div class="d-flex align-items-center">
<div class="bg-primary bg-opacity-10 p-3 rounded me-3">
<i class="${tabData.icon} fs-3 text-primary"></i>
</div>
<div>
<h2 class="mb-1">${tabData.title}</h2>
<p class="text-muted mb-0">${tabData.description}</p>
</div>
</div>
</div>
<div id="${tabId}-tab-optional-content" class="px-4 mt-3 col-12 col-md-9"></div>
</div>
<div id="${tabId}-tab-content" class="px-4">
<div class="text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
</div>
</div>
`).join('')}
</div>
</div>
</div>
</div>
`;
const mainContainer = document.getElementById('mainContent');
mainContainer.insertAdjacentHTML('afterbegin', tabs);
const activeTabData = loadFromStorage('tab') || null;
if (activeTabData) {
const activeTabId = activeTabData.tabId;
const tabBtn = document.getElementById(`${activeTabId}-tab`);
if (tabBtn) {
openTab({ currentTarget: tabBtn }, activeTabId);
}
}
}
async function uploadTab(tabId) {
const cookiesData = { userData, accessData };
try {
const resp = await apiRequest('/', { tabId, cookiesData });
if (resp.status == 'ok') {
fillTab(tabId, resp.data);
} else {
throw new Error(resp.message || 'Ошибка загрузки данных');
}
} catch (error) {
console.error('Error loading tab:', error);
const tabContent = document.getElementById(`${tabId}-tab-content`);
tabContent.innerHTML = `
<div class="alert alert-danger m-4" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
Ошибка при загрузке данных: ${error.message}
</div>
`;
}
}
function fillTab(tabId, tabData) {
try {
switch (tabId) {
case 'toolbox':
renderToolboxTab(tabData);
break;
case 'requests':
renderRequestsTab(tabId, tabData);
break;
case 'toolkits':
renderToolkitsTab(tabId, tabData.toolkits, tabData.categories);
break;
case 'jurnal_toolkits':
renderJurnalToolkitsTab(tabId, tabData);
break;
case 'jurnal_service':
renderJurnalServicesTab(tabId, tabData);
break;
case 'users':
renderSimpleTab(tabId, tabData, 'Пользователи системы');
break;
}
} catch (error) {
console.error('Error filling tab:', error);
const tabContent = document.getElementById(`${tabId}-tab-content`);
tabContent.innerHTML = `
<div class="alert alert-danger m-4" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
Ошибка отображения данных
</div>
`;
}
}
function renderSimpleTab(tabId, tabData, title) {
const tabContent = document.getElementById(`${tabId}-tab-content`);
tabContent.innerHTML = `
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3">${title}</h5>
<div class="row">
${Object.entries(tabData).map(([key, value]) => `
<div class="col-12 col-md-6 mb-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="card-title mb-2">${key}</h6>
<p class="card-text">
${typeof value === 'object' ? JSON.stringify(value, null, 4) : value}
</p>
</div>
</div>
</div>
`).join('')}
</div>
</div>
</div>
</div>
</div>
`;
}
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 tabOptionalContent = document.getElementById(`${tabId}-tab-optional-content`);
const hiddenToolCount = toolsList.filter(tool => tool.hidden).length;
let categoriesData = {};
categoriesArray.forEach(cat => {
categoriesData[cat.id] = { id: cat.id, title: cat.title, description: cat.description };
});
let specData = {}
toolsList.forEach(tool => {
tool['category'] = categoriesData[tool.category_id]?.title || '';
tool['category_desc'] = categoriesData[tool.category_id]?.description || '';
Object.entries(tool.specifications || {}).forEach(([name, value]) => {
if (specData[name]) {
if (!specData[name].includes(value)) {
specData[name].push(value);
}
} else {
specData[name] = [value];
}
});
});
function smartCompare(a, b, locale = 'ru') {
const normalizeNumber = (v) => {
if (typeof v === 'number') return v;
if (typeof v === 'string') {
const n = v.replace(',', '.').trim();
if (!isNaN(n) && n !== '') return Number(n);
}
return null;
};
const numA = normalizeNumber(a);
const numB = normalizeNumber(b);
// Оба — числа
if (numA !== null && numB !== null) {
return numA - numB;
}
// Один число, другой строка → число выше
if (numA !== null) return -1;
if (numB !== null) return 1;
// Оба строки
return String(a).localeCompare(String(b), locale, {
numeric: true,
sensitivity: 'base'
});
}
// Сортируем ключи и значения specData
const sortedSpecData = Object.fromEntries(
Object.entries(specData)
.sort(([keyA], [keyB]) => smartCompare(keyA, keyB))
.map(([key, values]) => [
key,
[...values].sort((a, b) => smartCompare(a, b))
])
);
toolsList.sort((a, b) => a.title.localeCompare(b.title, 'ru'));
categoriesArray.sort((a, b) => a.title.localeCompare(b.title, 'ru'));
if (accessData.tools_creation) {
tabOptionalContent.innerHTML = `
<div class="row align-items-start">
<!-- Категории слева -->
<div class="col-12 col-md-7 mb-3 mb-md-0">
<div class="d-flex flex-wrap gap-2">
${categoriesArray.map(category => `
<button class="btn filter-btn"
data-category="${category.id}">
${category.title}
</button>
`).join('')}
<button class="btn filter-btn active" data-category="all">
Все категории
</button>
</div>
</div>
<!-- Управления справа -->
<div class="col-12 col-md-5 d-flex flex-column align-items-md-end gap-2">
<!-- Группа кнопок -->
<div class="btn-group" role="group" aria-label="Управление категориями и инструментами">
<button class="btn btn-outline-secondary" id="manageCategoryBtn">
<i class="bi bi-gear-wide-connected me-2"></i>Категории
</button>
<button class="btn btn-outline-secondary" id="addToolBtn">
<i class="bi bi-plus-circle me-2"></i>Добавить инструмент
</button>
</div>
<!-- Переключатель -->
<div class="form-check form-switch d-flex align-items-center justify-content-md-end">
<input class="form-check-input" type="checkbox" role="switch" id="showHiddenTools">
<label class="form-check-label ms-2 text-muted" for="showHiddenTools">
Отображать скрытые (${hiddenToolCount})
</label>
</div>
</div>
</div>
`;
const manageCategoryBtn = document.getElementById('manageCategoryBtn');
manageCategoryBtn.addEventListener('click', () => manageCategory(categoriesArray));
const addToolBtn = document.getElementById('addToolBtn');
addToolBtn.addEventListener('click', () => manageToolkit());
} else {
tabOptionalContent.innerHTML = `
<div class="row align-items-start">
<!-- Категории слева -->
<div class="col-12 col-md-7 mb-3 mb-md-0">
<div class="d-flex flex-wrap gap-2">
${categoriesArray.map(category => `
<button class="btn filter-btn"
data-category="${category.id}">
${category.title}
</button>
`).join('')}
<button class="btn filter-btn active" data-category="all">
Все категории
</button>
</div>
</div>
<!-- Управления справа -->
<div class="col-12 col-md-5 d-flex flex-column align-items-md-end gap-2">
<div class="form-check form-switch d-flex align-items-center justify-content-md-end">
<input class="form-check-input" type="checkbox" role="switch" id="showHiddenTools">
<label class="form-check-label ms-2 text-muted" for="showHiddenTools">
Отображать скрытые (${hiddenToolCount})
</label>
</div>
</div>
</div>
`;
}
// Создаем HTML структуру с двумя выпадающими списками
tabContent.innerHTML = `
<div class="row mb-4">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<!-- Блок фильтров -->
<div class="row mb-4 align-items-center">
<!-- Фильтры по параметрам -->
<div class="col-12 col-lg-8 mb-3 mb-lg-0">
<div class="row g-2">
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-tags"></i>
</span>
<select class="form-select" id="${tabId}-param-select">
<option value="">Все параметры</option>
${Object.keys(sortedSpecData).map(param => `
<option value="${param}">${param}</option>
`).join('')}
</select>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-filter"></i>
</span>
<select class="form-select" id="${tabId}-value-select" disabled>
<option value="">Все значения</option>
</select>
</div>
</div>
<div class="col-12 col-md-12 col-lg-4">
<div class="btn-group w-100" role="group" aria-label="Фильтр спецификаций">
<button class="btn btn-primary" type="button" id="${tabId}-find-spec-btn" disabled>
<i class="bi bi-search me-1"></i>Найти
</button>
<button class="btn btn-outline-secondary" type="button" id="${tabId}-reset-spec-btn">
<i class="bi bi-x-circle me-1"></i>Сброс
</button>
</div>
</div>
</div>
</div>
<!-- Поиск -->
<div class="col-12 col-lg-4">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text"
class="form-control"
id="${tabId}-search-input"
placeholder="Поиск по названию и описанию...">
<button class="btn btn-outline-secondary d-none"
type="button"
id="${tabId}-clear-search">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
<!-- Контейнер для карточек -->
<div id="${tabId}-cards-container" class="row g-4">
<!-- Карточки будут вставлены здесь -->
</div>
</div>
</div>
</div>
</div>
`;
// Рендерим карточки
renderToolkitCards(tabId, toolsList, categoriesData);
// Добавляем обработчики событий для фильтров
setupFilters(tabId, toolsList, categoriesData, sortedSpecData);
}
// Функция для рендеринга карточек
function renderToolkitCards(tabId, tools, categoriesMap, filterText = '', categoryFilter = 'all', showHiddenTools = false, specParam = '', specValue = '') {
const container = document.getElementById(`${tabId}-cards-container`);
// Фильтруем инструменты
const filteredTools = tools.filter(tool => {
// Фильтр по категории
if (categoryFilter !== 'all' && tool.category_id !== parseInt(categoryFilter)) {
return false;
}
// Фильтр по скрытым инструментам
if (!showHiddenTools && tool.hidden) {
return false;
}
// Фильтр по поисковому запросу
if (filterText) {
const searchLower = filterText.toLowerCase();
const titleMatch = tool.title.toLowerCase().includes(searchLower);
const descriptionMatch = tool.description.toLowerCase().includes(searchLower);
if (!titleMatch && !descriptionMatch) {
return false;
}
}
// Фильтр по спецификациям
if (specParam && specValue) {
if (!tool.specifications || tool.specifications[specParam] !== specValue) {
return false;
}
}
return true;
});
// Рендерим карточки
if (filteredTools.length === 0) {
container.innerHTML = `
<div class="col-12 text-center py-5">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Ничего не найдено. Попробуйте изменить параметры фильтрации.
</div>
</div>
`;
return;
}
container.innerHTML = filteredTools.map(tool => {
const categoryName = categoriesMap[tool.category_id]?.title || `Категория ${tool.category_id}`;
const description = tool.description || 'Описание отсутствует';
const imageUrl = tool.image?.main || 'static/images/tools/default.png';
return `
<div class="col-12 col-sm-6 col-lg-4 col-xl-3">
<div class="card toolkit-card h-100 border-0 shadow-sm"
data-toolid="${tool.id}">
<div class="position-relative">
<img src="${imageUrl}"
class="card-img-top toolkit-card-img"
alt="${tool.title || 'Инструмент'}"
onerror="this.src='static/images/tools/default.png'">
<span class="position-absolute top-0 end-0 m-2 category-badge bg-primary text-white rounded-pill">
${categoryName}
</span>
${tool.hidden ? `
<span class="position-absolute top-0 start-0 p-1 bg-secondary bg-opacity-50 rounded">
<i class="bi bi-eye-slash text-white fs-5"></i>
</span>
` : ''}
</div>
<div class="card-body d-flex flex-column">
<h5 class="card-title mb-2">${tool.title || 'Без названия'}</h5>
<p class="card-text flex-grow-1 toolkit-description text-muted">
${description}
</p>
<div class="mt-2">
${tool.quantity_min && accessData.view_all_toolboxes ? `
<small class="text-muted">
<i class="bi bi-box me-1"></i>
Мин: ${tool.quantity_min}
${tool.quantity_min_extra ? `(<i class="bi bi-exclamation-triangle"></i> ${tool.quantity_min_extra})` : ''}
</small>
` : ''}
</div>
</div>
</div>
</div>
`;
}).join('');
const cards = container.querySelectorAll('.toolkit-card');
let activeModal = null;
cards.forEach(card => {
card.addEventListener('click', async event => {
if (activeModal) return;
const toolId = event.currentTarget.dataset.toolid;
activeModal = true;
await showToolkitDetailsModal(toolId);
activeModal = null;
});
});
}
// Функция для настройки фильтров
function setupFilters(tabId, tools, categoriesMap, specData) {
const searchInput = document.getElementById(`${tabId}-search-input`);
const filterButtons = document.querySelectorAll(`#${tabId}-tab-optional-content .filter-btn`);
const showHiddenToolsCheckbox = document.getElementById('showHiddenTools');
// Новые элементы для фильтрации по спецификациям
const paramSelect = document.getElementById(`${tabId}-param-select`);
const valueSelect = document.getElementById(`${tabId}-value-select`);
const findSpecBtn = document.getElementById(`${tabId}-find-spec-btn`);
const resetSpecBtn = document.getElementById(`${tabId}-reset-spec-btn`);
const clearSearchBtn = document.getElementById(`${tabId}-clear-search`);
const savedFilters = loadFromStorage(tabId);
const currentFilter = {
category: savedFilters.category || 'all',
search: savedFilters.search || '',
showHidden: savedFilters.showHidden ?? false,
specParam: savedFilters.specParam || '',
specValue: savedFilters.specValue || ''
};
/* ---------- Восстановление UI ---------- */
if (searchInput) {
searchInput.value = currentFilter.search;
}
if (showHiddenToolsCheckbox) {
showHiddenToolsCheckbox.checked = currentFilter.showHidden;
}
filterButtons.forEach(btn => {
btn.classList.toggle(
'active',
btn.dataset.category === currentFilter.category
);
});
// Восстановление выбранного параметра
if (paramSelect && currentFilter.specParam) {
paramSelect.value = currentFilter.specParam;
updateValueSelect(currentFilter.specParam);
// Восстановление выбранного значения после обновления списка значений
setTimeout(() => {
if (valueSelect && currentFilter.specValue) {
valueSelect.value = currentFilter.specValue;
}
}, 0);
}
const render = () => {
renderToolkitCards(
tabId,
tools,
categoriesMap,
currentFilter.search,
currentFilter.category,
currentFilter.showHidden,
currentFilter.specParam,
currentFilter.specValue
);
saveToStorage(tabId, currentFilter);
};
/* ---------- Обновление списка значений при выборе параметра ---------- */
function updateValueSelect(selectedParam) {
if (valueSelect) {
valueSelect.innerHTML = '<option value="">Все значения</option>';
findSpecBtn.disabled = true;
if (selectedParam && specData[selectedParam]) {
valueSelect.disabled = false;
specData[selectedParam].forEach(value => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
valueSelect.appendChild(option);
});
} else {
valueSelect.disabled = true;
}
}
}
/* ---------- Сброс фильтра спецификаций ---------- */
function resetSpecFilter() {
if (paramSelect) {
paramSelect.value = '';
}
if (valueSelect) {
valueSelect.innerHTML = '<option value="">Все значения</option>';
valueSelect.disabled = true;
}
currentFilter.specParam = '';
currentFilter.specValue = '';
render();
}
/* ---------- Обработчик выбора параметра ---------- */
if (paramSelect) {
paramSelect.addEventListener('change', function () {
const selectedParam = this.value;
updateValueSelect(selectedParam);
// Сбрасываем выбранное значение при изменении параметра
if (valueSelect) {
valueSelect.value = '';
}
});
}
/* ---------- Обработчик выбора значения ---------- */
if (valueSelect) {
valueSelect.addEventListener('change', function () {
findSpecBtn.disabled = !this.value;
});
}
/* ---------- Обработчик кнопки "Найти" для спецификаций ---------- */
if (findSpecBtn) {
findSpecBtn.addEventListener('click', function () {
currentFilter.specParam = paramSelect ? paramSelect.value : '';
currentFilter.specValue = valueSelect && !valueSelect.disabled ? valueSelect.value : '';
render();
});
}
/* ---------- Обработчик кнопки "Сброс" для спецификаций ---------- */
if (resetSpecBtn) {
resetSpecBtn.addEventListener('click', resetSpecFilter);
findSpecBtn.disabled = true;
}
/* ---------- Чекбокс ---------- */
if (showHiddenToolsCheckbox) {
showHiddenToolsCheckbox.addEventListener('change', () => {
currentFilter.showHidden = showHiddenToolsCheckbox.checked;
render();
});
}
/* ---------- Категории ---------- */
filterButtons.forEach(button => {
button.addEventListener('click', function () {
filterButtons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
currentFilter.category = this.dataset.category;
render();
});
});
/* ---------- Поиск ---------- */
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentFilter.search = this.value.trim();
render();
}, 300);
});
// Кнопка очистки поиска
if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
currentFilter.search = '';
clearSearchBtn.classList.add('d-none');
render();
});
searchInput.addEventListener('input', function () {
clearSearchBtn.classList.toggle('d-none', !this.value);
});
clearSearchBtn.classList.toggle('d-none', !searchInput.value);
}
}
/* ---------- Первый рендер ---------- */
render();
}
function loadFromStorage(title) {
try {
return JSON.parse(localStorage.getItem(`toolboxStotage:${title}`)) || {};
} catch {
return {};
}
}
function saveToStorage(title, data) {
localStorage.setItem(
`toolboxStotage:${title}`,
JSON.stringify(data)
);
}
function addToolbox(editData = null) {
// Проверяем, существует ли уже модальное окно
let modal = document.getElementById('addToolboxModal');
if (modal) {
modal.remove();
}
// Создаем модальное окно
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'addToolboxModal';
modal.tabIndex = -1;
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addToolboxModalLabel">Добавить новый склад</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<form id="addToolboxForm" novalidate>
<div class="modal-body">
<div class="mb-3">
<label for="toolboxTitle" class="form-label required">Название склада</label>
<input type="text" class="form-control" id="toolboxTitle"
required minlength="3" maxlength="100">
<div class="invalid-feedback">
Пожалуйста, введите название склада (не менее 3 символов)
</div>
</div>
<div class="mb-3">
<label for="toolboxDescription" class="form-label required">Описание склада</label>
<textarea class="form-control" id="toolboxDescription"
rows="2" minlength="3" maxlength="500"></textarea>
<div class="invalid-feedback">
Пожалуйста, введите описание (не менее 3 символов)
</div>
</div>
<div class="mb-3" id="toolboxMonitoringContainer">
<div class="form-check form-switch d-flex flex-column flex-md-row align-items-md-center justify-content-left">
<input class="form-check-input me-2" type="checkbox"
role="switch" id="toolboxMonitoring">
<label class="form-check-label" for="toolboxMonitoring">
Отслеживание остатков
</label>
</div>
<div class="form-text">
Включите для отслеживания минимальных остатков инструментов
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="submit" class="btn btn-primary" id="submitToolboxBtn">
<span class="spinner-border spinner-border-sm me-1"
id="submitToolboxSpinner" style="display: none;"></span>
<span id="submitToolboxText">Добавить склад</span>
</button>
</div>
</form>
</div>
</div>
`;
// Если редактирование
if (editData) {
modal.querySelector('#toolboxTitle').value = editData.title;
modal.querySelector('#toolboxDescription').value = editData.description;
modal.querySelector('#toolboxMonitoring').checked = editData.monitoring;
modal.querySelector('#addToolboxModalLabel').textContent = 'Редактировать склад';
modal.querySelector('#submitToolboxText').textContent = 'Сохранить';
if (editData.owner_id) {
modal.querySelector('#toolboxMonitoringContainer').classList.add('d-none');
}
}
// Добавляем модальное окно в DOM
document.body.appendChild(modal);
// Инициализация модального окна
const bsModal = new bootstrap.Modal(modal);
// Получаем элементы формы
const form = modal.querySelector('#addToolboxForm');
const titleInput = modal.querySelector('#toolboxTitle');
const descriptionInput = modal.querySelector('#toolboxDescription');
const submitBtn = modal.querySelector('#submitToolboxBtn');
const spinner = modal.querySelector('#submitToolboxSpinner');
// Функция валидации формы
function validateForm() {
let isValid = true;
// Валидация названия
if (titleInput.value.length < 3) {
titleInput.classList.add('is-invalid');
isValid = false;
} else {
titleInput.classList.remove('is-invalid');
titleInput.classList.add('is-valid');
}
// Валидация описания (необязательно, но если заполнено - проверяем)
if (descriptionInput.value.length > 0 && descriptionInput.value.length < 3) {
descriptionInput.classList.add('is-invalid');
isValid = false;
} else if (descriptionInput.value.length >= 3) {
descriptionInput.classList.remove('is-invalid');
descriptionInput.classList.add('is-valid');
} else {
descriptionInput.classList.remove('is-invalid', 'is-valid');
}
submitBtn.disabled = !isValid;
return isValid;
}
// Слушатели событий для валидации в реальном времени
titleInput.addEventListener('input', function () {
if (this.value.length >= 3) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid');
if (this.value.length > 0) {
this.classList.add('is-invalid');
} else {
this.classList.remove('is-invalid');
}
}
validateForm();
});
descriptionInput.addEventListener('input', function () {
if (this.value.length === 0) {
this.classList.remove('is-invalid', 'is-valid');
} else if (this.value.length >= 3) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.add('is-invalid');
this.classList.remove('is-valid');
}
validateForm();
});
// Обработчик отправки формы
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (!validateForm()) {
return;
}
// Показываем индикатор загрузки и отключаем кнопку
submitBtn.disabled = true;
spinner.style.display = 'inline-block';
// Собираем данные
const toolboxData = {
title: titleInput.value.trim(),
description: descriptionInput.value.trim(),
monitoring: modal.querySelector('#toolboxMonitoring').checked
};
const userId = userData.id;
let editToolboxData = {}
if (editData) {
Object.keys(toolboxData).forEach(key => {
if (toolboxData[key] !== editData[key]) {
editToolboxData[key] = toolboxData[key];
}
});
}
if (Object.keys(editToolboxData).length === 0 && editData) {
showInfo('Новые данные склада совпадают с текущими', 'warning');
// Возвращаем кнопку в исходное состояние
submitBtn.disabled = false;
spinner.style.display = 'none';
return;
}
try {
// Отправка данных
let method = 'POST'
let sendData = { toolboxData, userId }
if (editData) {
method = 'PUT';
const toolboxId = editData.id;
sendData = { toolboxId, userId, editToolboxData };
}
const response = await apiRequest("/toolbox/", sendData, method);
if (response.status !== 'ok') {
if (!editData) {
throw new Error('Ошибка при добавлении склада');
} else {
throw new Error('Ошибка при обновлении склада');
}
}
// Успешная отправка
bsModal.hide();
// Показываем уведомление об успехе
const successMessageText = editData ? 'Склад успешно обновлен' : 'Склад успешно добавлен';
showInfo(successMessageText, 'success');
// Здесь можно добавить обновление списка складов
await uploadTab('toolbox');
} catch (error) {
console.error('Ошибка при добавлении (обновлении) склада:', error);
// Возвращаем кнопку в исходное состояние
submitBtn.disabled = false;
spinner.style.display = 'none';
// Показываем сообщение об ошибке
const errorMessageText = editData ? 'Ошибка при обновлении склада' : 'Ошибка при добавлении склада';
showInfo(errorMessageText, 'error');
// Можно добавить более детальное сообщение об ошибке
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger mt-3';
errorDiv.innerHTML = !editData ? `
<strong>Ошибка!</strong> Не удалось добавить склад.
Проверьте соединение и попробуйте еще раз.
` : `
<strong>Ошибка!</strong> Не удалось обновить склад.
Проверьте соединение и попробуйте еще раз.
`;
const modalBody = modal.querySelector('.modal-body');
if (!modalBody.querySelector('.alert')) {
modalBody.appendChild(errorDiv);
// Убираем сообщение через 5 секунд
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.remove();
}
}, 5000);
}
}
});
// Очистка при закрытии модального окна
modal.addEventListener('hidden.bs.modal', () => {
// Сбрасываем форму
form.reset();
// Убираем стили валидации
titleInput.classList.remove('is-valid', 'is-invalid');
descriptionInput.classList.remove('is-valid', 'is-invalid');
// Включаем кнопку
submitBtn.disabled = false;
spinner.style.display = 'none';
// Убираем сообщения об ошибках
const alerts = modal.querySelectorAll('.alert');
alerts.forEach(alert => alert.remove());
// Удаляем модальное окно из DOM
setTimeout(() => {
if (modal.parentNode) {
modal.remove();
}
}, 300);
});
// Показываем модальное окно
bsModal.show();
// Фокусируемся на первом поле
setTimeout(() => {
titleInput.focus();
}, 500);
}
function renderToolboxTab(tabData) {
currentToolboxData = tabData;
const tabContent = document.getElementById(`toolbox-tab-content`);
const tabOptionalContent = document.getElementById(`toolbox-tab-optional-content`);
if (!tabData || tabData.length === 0) {
tabContent.innerHTML = `
<div class="text-center py-5">
<i class="bi bi-inboxes display-1 text-muted mb-3"></i>
<h4 class="text-muted">Нет доступных складов</h4>
<p class="text-muted">У вас нет доступа ни к одному складу</p>
</div>
`;
return;
}
// Создаем навигацию по складам
// Сортируем список складов по названию
tabData.sort((a, b) => a.title.localeCompare(b.title, 'ru'));
tabOptionalContent.innerHTML = `
<div class="d-flex flex-wrap gap-2" id="toolboxNav">
${tabData.map((toolbox, index) => `
<button class="btn btn-outline-primary toolbox-nav-btn"
data-toolbox-id="${toolbox.id}">
<i class="bi bi-box-seam me-2"></i>
<span>${toolbox.title}</span>
<div class="toolbox-nav-indicator"></div>
</button>
`).join('')}
</div>
`;
const toolboxNav = document.getElementById('toolboxNav');
toolboxNav.addEventListener('click', async (event) => {
const button = event.target.closest('.toolbox-nav-btn');
if (!button) return;
if (button.dataset.toolboxId) {
try {
await selectToolbox(button.dataset.toolboxId);
} catch (err) {
console.error('Ошибка выбора склада:', err);
}
}
});
// Создаем контейнер для содержимого склада
tabContent.innerHTML = `
<div class="row mt-2 mb-3">
<div class="col-12">
<div class="card border-0 shadow-sm toolbox-content-container">
<div class="card-body d-flex flex-column justify-content-center align-items-center">
<i class="bi bi-box display-1 text-muted mb-3"></i>
<h4 class="text-muted mb-2">Выберите склад для просмотра</h4>
<p class="text-muted text-center">
Для отображения содержимого склада нажмите на одну из кнопок выше
</p>
</div>
</div>
</div>
</div>
`;
if (accessData.manage_toolboxes) {
const addToolboxBtn = document.createElement('button');
addToolboxBtn.className = 'btn btn-outline-success toolbox-nav-btn';
addToolboxBtn.innerHTML = `
<i class="bi bi-plus-square-fill fs-5 me-2"></i>
<span>Добавить</span>
`;
addToolboxBtn.addEventListener('click', function (e) {
e.preventDefault();
addToolbox();
});
document.getElementById('toolboxNav').appendChild(addToolboxBtn);
}
const choiceToolbox = loadFromStorage('toolbox');
if (choiceToolbox.toolboxId) {
(async () => {
await selectToolbox(choiceToolbox.toolboxId);
})().catch(err => {
console.error('Ошибка выбора склада:', err);
});
}
}
// Функция для выбора склада
async function selectToolbox(toolboxId) {
if (typeof toolboxId === 'string') {
try {
toolboxId = parseInt(toolboxId);
} catch (err) {
console.error('Неверный идентификатор склада:', toolboxId);
return;
}
}
document.querySelectorAll('.toolbox-nav-btn')
.forEach(btn => btn.classList.remove('active'));
const selectedBtn = document.querySelector(
`.toolbox-nav-btn[data-toolbox-id="${toolboxId}"]`
);
selectedBtn?.classList.add('active');
saveToStorage('toolbox', { toolboxId });
await loadToolboxContent(toolboxId);
}
async function loadToolboxContent(toolboxId) {
const contentContainer = document.querySelector('.toolbox-content-container');
// Показываем индикатор загрузки
contentContainer.innerHTML = `
<div class="card-body d-flex flex-column justify-content-center align-items-center">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<h4 class="text-muted mb-2">Загрузка содержимого склада</h4>
<p class="text-muted">Пожалуйста, подождите...</p>
</div>
`;
try {
const resp = await apiRequest(`/stocks/`, { toolboxId });
if (resp.status === 'ok') {
const toolboxData = resp.data;
const toolboxInfo = currentToolboxData.find(t => t.id === toolboxId);
const toolboxOwn = toolboxInfo.owner_id === userData.id ? 'Мой склад' : toolboxInfo.owner_id ? 'Склад сотрудника' : 'Общий склад';
function handleEditBtn() {
if (accessData.manage_toolboxes) {
contentContainer.querySelector('#editToolbox').addEventListener('click', () => {
addToolbox(toolboxInfo);
});
} else {
contentContainer.querySelector('#editToolbox').remove();
}
}
function handleFillBtn() {
if (accessData.tools_registration && !toolboxInfo.owner_id) {
contentContainer.querySelector('#fillToolbox').addEventListener('click', async () => {
await fillToolbox(toolboxInfo);
});
} else {
contentContainer.querySelector('#fillToolbox').remove();
}
}
if (toolboxData.length === 0) {
contentContainer.innerHTML = `
<div class="card-header bg-info border-bottom">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-1">${toolboxInfo?.title || 'Склад'}</h5>
<p class="text-muted mb-0 small">${toolboxInfo?.description || 'Описание отсутствует'}</p>
</div>
<button class="btn btn-sm btn-outline-danger" id="editToolbox">
<i class="bi bi-pencil-square"></i> Редактировать
</button>
<button class="btn btn-sm btn-success" id="fillToolbox">
<i class="bi bi-cart-plus-fill me-1"></i>Пополнить
</button>
<span class="badge bg-secondary">${toolboxOwn}</span>
</div>
</div>
<div class="card-body d-flex flex-column justify-content-center align-items-center">
<i class="bi bi-box display-1 text-muted mb-3"></i>
<h4 class="text-muted mb-2">Склад пуст</h4>
<p class="text-muted">В этом складе нет инструментов</p>
<div class="mt-3 d-flex justify-content-end">
<button class="btn btn-danger" id="deleteToolbox">
<i class="bi bi-trash me-2"></i>
Удалить склад
</button>
</div>
</div>
`;
if (!toolboxInfo.owner_id && accessData.manage_toolboxes) {
document.getElementById('deleteToolbox').addEventListener('click', function (e) {
e.preventDefault();
deleteToolbox(toolboxId);
});
} else {
document.getElementById('deleteToolbox').remove();
}
handleEditBtn();
handleFillBtn()
return;
}
// Находим информацию о выбранном складе
const quantityMonitoring = toolboxInfo.monitoring && accessData.view_all_toolboxes;
// Обрабатываем данные в единый список
const processedData = processToolboxData(toolboxData, toolboxId, quantityMonitoring);
const totalQuantity = processedData.reduce((sum, item) => sum + item.totalQuantity, 0);
const totalCost = formatPrice(processedData.reduce((sum, item) => sum + item.totalCost, 0));
const notEnough = processedData.reduce((sum, item) => sum + (item.indicator?.text !== 'Достаточно' ? 1 : 0), 0);
// console.log(processedData);
// Отображаем содержимое склада
contentContainer.innerHTML = `
<div class="card-header bg-info border-bottom">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-1">${toolboxInfo?.title || 'Склад'}</h5>
<p class="text-muted mb-0 small">${toolboxInfo?.description || 'Описание отсутствует'}</p>
</div>
<button class="btn btn-sm btn-outline-danger" id="editToolbox">
<i class="bi bi-pencil-square"></i> Редактировать
</button>
<button class="btn btn-sm btn-success" id="fillToolbox">
<i class="bi bi-cart-plus-fill me-1"></i>Пополнить
</button>
<span class="badge bg-secondary">${toolboxOwn}</span>
</div>
</div>
<div class="card-body p-0">
<div class="border">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="input-group me-2" style="flex: 1; max-width: 500px;">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="toolboxSearch" class="form-control" placeholder="Поиск по всем полям...">
<button class="btn btn-outline-secondary" type="button" id="resetFilter">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
${notEnough > 0 && quantityMonitoring ? `
<div class="d-flex align-items-center">
<button class="btn btn-sm btn-warning position-relative" id="notEnoughBtn">
<i class="bi bi-exclamation-triangle me-1"></i>
<span class="badge rounded-pill bg-danger position-absolute top-0 start-100 translate-middle">
${notEnough}
<span class="visually-hidden">unread messages</span>
</span>
Осталось мало
</button>
</div>
` : ''}
<div class="d-flex align-items-center justify-content-between ">
<span>
Количество позиций: <span class="fw-bold me-2">${processedData.length}</span>
Количество инструментов: <span class="fw-bold me-2">${totalQuantity}</span>
Общая стоимость: <span class="fw-bold me-2">${totalCost}</span>
</span>
</div>
</div>
<div class="p-0">
<div class="table-responsive" style="max-height: 600px;">
<table class="table table-hover table-striped mb-0" id="toolboxItemsTable">
<thead class="table-light sticky-top" style="top: 0;">
<tr>
<th style="width: 60px;"></th>
<th data-sort="title">Название <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="category">Категория <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="totalQuantity">Количество <i class="bi bi-arrow-down-up"></i></th>
${quantityMonitoring ? '<th data-sort="indicator">Статус <i class="bi bi-arrow-down-up"></i></th>' : ''}
<th data-sort="totalCost">Стоимость <i class="bi bi-arrow-down-up"></i></th>
${toolboxOwn === 'Общий склад' ? `<th data-sort="placement">Расположение <i class="bi bi-arrow-down-up"></i></th>` : ''}
<th data-sort="lastUpdated">Последнее изменение <i class="bi bi-arrow-down-up"></i></th>
</tr>
</thead>
<tbody id="toolboxItemsBody">
<!-- Данные будут загружены динамически -->
</tbody>
</table>
</div>
<div class="card-footer">
<nav aria-label="Навигация по страницам">
<ul class="pagination pagination-sm justify-content-center mb-0" id="toolboxPagination">
<!-- Пагинация будет сгенерирована динамически -->
</ul>
</nav>
</div>
</div>
</div>
</div>
`;
handleEditBtn();
handleFillBtn()
// Инициализация таблицы с данными
await initializeToolboxTable(processedData, toolboxOwn, quantityMonitoring);
} else {
throw new Error(resp.message || 'Ошибка загрузки данных склада');
}
} catch (error) {
console.error('Error loading toolbox content:', error);
contentContainer.innerHTML = `
<div class="card-body d-flex flex-column justify-content-center align-items-center">
<i class="bi bi-exclamation-triangle display-1 text-danger mb-3"></i>
<h4 class="text-danger mb-2">Ошибка загрузки</h4>
<p class="text-muted text-center">
Не удалось загрузить содержимое склада<br>
<small class="text-danger">${error.message}</small>
</p>
<button class="btn btn-primary mt-3" id="tryAgainBtn">
<i class="bi bi-arrow-clockwise me-2"></i>
Попробовать снова
</button>
</div>
`;
// Добавляем обработчик для кнопки "Попробовать снова"
contentContainer.querySelector('#tryAgainBtn').addEventListener('click', async () => await selectToolbox(toolboxId));
}
}
async function fillToolbox(toolboxInfo) {
const allToolkitsData = await apiRequest('/toolkit/fill_prepare');
if (allToolkitsData.status !== 'ok') {
showInfo('Ошибка загрузки данных инструментов', 'error');
return;
}
// Удаляем старое модальное окно
let modal = document.getElementById('fillToolboxModal');
if (modal) modal.remove();
// ==============================
// Подготовка данных
// ==============================
const { toolkits, categories, placements } = allToolkitsData.data;
const placementMap = {};
placements
.filter(p => p.toolbox_id === toolboxInfo.id)
.forEach(p => placementMap[p.toolkit_id] = p.placement);
// lower -> category
const categoriesByLower = Object.fromEntries(categories.map(c => [c.title.toLowerCase().trim(), c]));
const toolkitsMap = Object.fromEntries(toolkits.map(t => [t.id, t]));
const toolkitsByCategory = {};
toolkits.forEach(t => {
if (!toolkitsByCategory[t.category_id]) toolkitsByCategory[t.category_id] = [];
toolkitsByCategory[t.category_id].push(t);
});
// lower title -> toolkit within category
const toolkitsLowerByCategory = {};
Object.entries(toolkitsByCategory).forEach(([catId, list]) => {
const map = {};
list.forEach(t => map[t.title.toLowerCase().trim()] = t);
toolkitsLowerByCategory[catId] = map;
});
// ==============================
// Вспомогательные функции
// ==============================
const normalize = s => (s || '').toLowerCase().trim();
// exact match by lower title
const findCategoryByExact = title => {
if (!title) return null;
return categoriesByLower[title.toLowerCase().trim()] || null;
};
const findToolkitByExact = (categoryId, title) => {
if (!categoryId || !title) return null;
const map = toolkitsLowerByCategory[categoryId] || {};
return map[title.toLowerCase().trim()] || null;
};
// format cost
const formatCost = value => {
if (typeof value !== 'number') value = parseFloat(value) || 0;
return formatPrice(value);
};
// debounce
function debounce(fn, delay = 200) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), delay);
};
}
// Inject minimal styles for autocomplete dropdown (only once)
(function injectStyles() {
if (document.getElementById('fillToolboxAutocompleteStyles')) return;
const style = document.createElement('style');
style.id = 'fillToolboxAutocompleteStyles';
style.textContent = `
.ft-autocomplete { position: relative; }
.ft-autocomplete-list {
position: absolute;
z-index: 2000;
left: 0;
right: 0;
max-height: 220px;
overflow: auto;
background: white;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
box-shadow: 0 .25rem .5rem rgba(0,0,0,.08);
}
.ft-autocomplete-item { padding: .375rem .5rem; cursor: pointer; }
.ft-autocomplete-item:hover, .ft-autocomplete-item.active { background: #7abb92ff; }
.ft-autocomplete-empty { padding: .375rem .5rem; color: #6c757d; }
`;
document.head.appendChild(style);
})();
// create suggestions list element for an input (returns container and helper functions)
function createAutocomplete(forInput) {
// wrapper ft-autocomplete should exist around input
let wrapper = forInput.closest('.ft-autocomplete');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'ft-autocomplete';
forInput.parentNode.insertBefore(wrapper, forInput);
wrapper.appendChild(forInput);
}
let list = wrapper.querySelector('.ft-autocomplete-list');
if (!list) {
list = document.createElement('div');
list.className = 'ft-autocomplete-list';
list.style.display = 'none';
wrapper.appendChild(list);
}
function show(items) {
list.innerHTML = '';
if (!items || items.length === 0) {
const empty = document.createElement('div');
empty.className = 'ft-autocomplete-empty';
empty.textContent = 'Ничего не найдено';
list.appendChild(empty);
list.style.display = 'block';
return;
}
items.forEach(it => {
const div = document.createElement('div');
div.className = 'ft-autocomplete-item';
div.textContent = it.title;
div.dataset.valueId = it.id;
div.addEventListener('mousedown', (e) => {
// mousedown чтобы сработало до blur
e.preventDefault();
if (typeof wrapper._onSelect === 'function') wrapper._onSelect(it);
hide();
});
list.appendChild(div);
});
list.style.display = 'block';
}
function hide() {
list.innerHTML = '';
list.style.display = 'none';
}
function onSelect(fn) { wrapper._onSelect = fn; }
return { wrapper, list, show, hide, onSelect };
}
// ==============================
// Создание строки таблицы
// ==============================
function createRow(rowIndex = 0) {
const rowId = `row-${Date.now()}-${rowIndex}`;
return `
<tr id="${rowId}" data-row="${rowId}">
<td>
<div class="ft-autocomplete">
<input type="text" class="form-control form-control-sm category-input" placeholder="Категория..." required autocomplete="off">
</div>
<input type="hidden" class="category-id">
</td>
<td>
<div class="ft-autocomplete">
<input type="text" class="form-control form-control-sm toolkit-input" placeholder="Инструмент..." disabled required autocomplete="off">
</div>
<input type="hidden" class="toolkit-id">
</td>
<td><input type="number" class="form-control form-control-sm quantity" min="1" disabled required></td>
<td><input type="number" class="form-control form-control-sm price" min="0.01" step="0.01" disabled required></td>
<td><input type="text" class="form-control form-control-sm placement" placeholder="Не указано" disabled></td>
<td><input type="text" class="form-control form-control-sm cost" value="0.00 ₽" readonly disabled></td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger remove-row" ${rowIndex === 0 ? 'disabled' : ''}>
<i class="bi bi-x-lg"></i>
</button>
</td>
</tr>
`;
}
// ==============================
// Создаём модалку
// ==============================
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'fillToolboxModal';
modal.tabIndex = -1;
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-cart-plus-fill me-2"></i>Пополнение склада: ${toolboxInfo.title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="fillToolboxForm" novalidate>
<div class="modal-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" id="fillToolboxTable">
<thead class="table-light sticky-top">
<tr>
<th style="width: 20%;">Категория</th>
<th style="width: 25%;">Инструмент</th>
<th style="width: 10%;">Количество</th>
<th style="width: 12%;">Цена, ₽</th>
<th style="width: 15%;">Расположение</th>
<th style="width: 12%;">Стоимость, ₽</th>
<th style="width: 6%;"></th>
</tr>
</thead>
<tbody id="fillToolboxRows"></tbody>
<tfoot class="table-light">
<tr>
<td colspan="2" class="text-end fw-bold">
<button type="button" class="btn btn-sm btn-outline-primary" id="addRowBtn">
<i class="bi bi-plus-circle me-1"></i>Добавить строку
</button>
</td>
<td class="fw-bold text-center" id="totalQuantity">0</td>
<td></td>
<td></td>
<td class="fw-bold text-center" id="totalCost">0.00 ₽</td>
<td></td>
</tr>
<tr>
<td colspan="2" class="text-end">Итого:</td>
<td class="text-center fw-bold" id="totalRowsCount">0 позиций</td>
<td colspan="4"></td>
</tr>
<tr>
<td colspan="7" class="p-3">
<div class="mb-3">
<label for="fillReason" class="form-label required">Обоснование пополнения</label>
<textarea class="form-control" id="fillReason" rows="1"
placeholder="Укажите основание пополнения склада (например, накладная, счёт-фактура и т.д.)"
required minlength="10" maxlength="500"></textarea>
<div class="invalid-feedback">
Пожалуйста, укажите обоснование (не менее 10 символов, не более 500)
</div>
</div>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="modal-footer">
<div id="fillToolboxError" class="alert alert-danger d-none w-100 mb-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
<span id="fillToolboxErrorMessage"></span>
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-success" id="submitFillBtn">
<span class="spinner-border spinner-border-sm me-1" id="submitFillSpinner" style="display: none;"></span>
<span id="submitFillText">Подтвердить пополнение</span>
</button>
</div>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
const rowsContainer = modal.querySelector('#fillToolboxRows');
rowsContainer.innerHTML = createRow(0);
// ==============================
// Автокомплит + логика строки
// ==============================
// Закрытие всех автокомплитов при клике вне
document.addEventListener('click', (e) => {
const openLists = document.querySelectorAll('.ft-autocomplete-list');
openLists.forEach(l => {
if (!l.parentElement.contains(e.target)) l.style.display = 'none';
});
});
// Список для хранения ссылок на автокомплиты
const autocompleteInstances = new Map();
function setupRow(row) {
const rowId = row.id;
const categoryInput = row.querySelector('.category-input');
const categoryIdInput = row.querySelector('.category-id');
const toolkitInput = row.querySelector('.toolkit-input');
const toolkitIdInput = row.querySelector('.toolkit-id');
const qty = row.querySelector('.quantity');
const price = row.querySelector('.price');
const placement = row.querySelector('.placement');
const costInput = row.querySelector('.cost');
// create autocompletes
const catAC = createAutocomplete(categoryInput);
const toolAC = createAutocomplete(toolkitInput);
// Сохраняем ссылки для возможной очистки
autocompleteInstances.set(rowId, { catAC, toolAC });
// CATEGORY behavior
const showCategorySuggestions = debounce(() => {
const q = normalize(categoryInput.value);
let matches;
if (!q) {
// Если поле пустое - показываем все категории
matches = categories
.sort((a, b) => a.title.localeCompare(b.title))
.map(c => ({ id: c.id, title: c.title }));
} else {
// Если есть текст - фильтруем по вхождению
matches = categories
.filter(c => normalize(c.title).includes(q))
.sort((a, b) => a.title.localeCompare(b.title))
.map(c => ({ id: c.id, title: c.title }));
}
catAC.show(matches);
}, 200);
catAC.onSelect((cat) => {
categoryIdInput.value = cat.id;
categoryInput.value = cat.title;
categoryInput.classList.remove('is-invalid');
categoryInput.classList.add('is-valid');
toolkitInput.disabled = false;
toolkitInput.value = '';
toolkitIdInput.value = '';
qty.disabled = true;
price.disabled = true;
placement.disabled = true;
placement.value = '';
costInput.value = '0.00 ₽';
// Фокус на поле инструмента после выбора категории
setTimeout(() => toolkitInput.focus(), 10);
});
categoryInput.addEventListener('focus', function () {
// При фокусе на поле показываем все категории, если поле пустое
if (!categoryInput.value.trim()) {
showCategorySuggestions();
}
});
categoryInput.addEventListener('input', function () {
categoryIdInput.value = '';
categoryInput.classList.remove('is-valid', 'is-invalid');
toolkitInput.disabled = true;
toolkitInput.value = '';
toolkitIdInput.value = '';
qty.disabled = true;
price.disabled = true;
placement.disabled = true;
placement.value = '';
costInput.value = '0.00 ₽';
showCategorySuggestions();
});
categoryInput.addEventListener('blur', function () {
// Небольшая задержка для обработки клика по автокомплиту
setTimeout(() => {
const v = categoryInput.value.trim();
if (!v) return;
const matched = findCategoryByExact(v);
if (matched) {
categoryIdInput.value = matched.id;
categoryInput.value = matched.title;
categoryInput.classList.remove('is-invalid');
categoryInput.classList.add('is-valid');
toolkitInput.disabled = false;
toolkitInput.focus();
} else {
categoryInput.classList.add('is-invalid');
categoryIdInput.value = '';
toolkitInput.disabled = true;
toolkitInput.value = '';
toolkitIdInput.value = '';
qty.disabled = true;
price.disabled = true;
placement.disabled = true;
placement.value = '';
costInput.value = '0.00 ₽';
}
}, 150);
});
// TOOLKIT behavior
const showToolkitSuggestions = debounce(() => {
const catId = categoryIdInput.value;
if (!catId) {
toolAC.hide();
return;
}
const q = normalize(toolkitInput.value);
const pool = toolkitsByCategory[catId] || [];
let matches;
if (!q) {
// Если поле пустое - показываем все инструменты в категории
matches = pool
.sort((a, b) => a.title.localeCompare(b.title))
.map(t => ({ id: t.id, title: t.title }));
} else {
// Если есть текст - фильтруем по вхождению
matches = pool
.filter(t => normalize(t.title).includes(q))
.sort((a, b) => a.title.localeCompare(b.title))
.map(t => ({ id: t.id, title: t.title }));
}
toolAC.show(matches);
}, 200);
toolAC.onSelect((tool) => {
toolkitIdInput.value = tool.id;
toolkitInput.value = tool.title;
toolkitInput.classList.remove('is-invalid');
toolkitInput.classList.add('is-valid');
qty.disabled = false;
price.disabled = false;
placement.disabled = false;
placement.value = placementMap[tool.id] || '';
// Фокус на поле количества после выбора инструмента
setTimeout(() => qty.focus(), 10);
});
toolkitInput.addEventListener('focus', function () {
// При фокусе на поле показываем все инструменты в категории, если поле пустое
const catId = categoryIdInput.value;
if (catId && !toolkitInput.value.trim()) {
showToolkitSuggestions();
}
});
toolkitInput.addEventListener('input', function () {
toolkitIdInput.value = '';
toolkitInput.classList.remove('is-valid', 'is-invalid');
qty.disabled = true;
price.disabled = true;
placement.disabled = true;
placement.value = '';
costInput.value = '0.00 ₽';
showToolkitSuggestions();
});
toolkitInput.addEventListener('blur', function () {
setTimeout(() => {
const v = toolkitInput.value.trim();
const catId = categoryIdInput.value;
if (!v || !catId) return;
const matched = findToolkitByExact(catId, v);
if (matched) {
toolkitIdInput.value = matched.id;
toolkitInput.value = matched.title;
toolkitInput.classList.remove('is-invalid');
toolkitInput.classList.add('is-valid');
qty.disabled = false;
price.disabled = false;
placement.disabled = false;
placement.value = placementMap[matched.id] || '';
qty.focus();
} else {
toolkitInput.classList.add('is-invalid');
toolkitIdInput.value = '';
qty.disabled = true;
price.disabled = true;
placement.disabled = true;
placement.value = '';
costInput.value = '0.00 ₽';
}
}, 150);
});
// recalc cost
function recalc() {
const q = parseFloat(qty.value) || 0;
const p = parseFloat(price.value.toString().replace(',', '.')) || 0;
const c = q * p;
costInput.value = formatCost(c) + ' ₽';
updateTotals();
}
qty.addEventListener('input', recalc);
price.addEventListener('input', recalc);
// Фокус на следующее поле при нажатии Enter
const handleEnterKey = (currentField, nextField) => {
if (nextField && !nextField.disabled) {
nextField.focus();
if (nextField === toolkitInput && !nextField.value.trim()) {
// Если переходим к пустому полю инструмента, показываем все варианты
showToolkitSuggestions();
}
}
};
categoryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleEnterKey(categoryInput, toolkitInput);
}
});
toolkitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleEnterKey(toolkitInput, qty);
}
});
qty.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleEnterKey(qty, price);
}
});
price.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleEnterKey(price, placement);
}
});
placement.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// При Enter в последнем поле - добавить новую строку
modal.querySelector('#addRowBtn').click();
}
});
// remove row
row.querySelector('.remove-row').addEventListener('click', () => {
if (rowsContainer.children.length <= 1) return;
// Очищаем автокомплиты
const instances = autocompleteInstances.get(row.id);
if (instances) {
instances.catAC.hide();
instances.toolAC.hide();
autocompleteInstances.delete(row.id);
}
row.remove();
updateTotals();
// Фокус на первую строку после удаления
const firstRow = rowsContainer.querySelector('tr');
if (firstRow) {
const firstInput = firstRow.querySelector('.category-input');
if (firstInput) firstInput.focus();
}
// Отключаем кнопку удаления, если осталась одна строка
if (rowsContainer.children.length === 1) {
const btn = rowsContainer.querySelector('.remove-row');
if (btn) btn.disabled = true;
}
});
}
// Инициализируем только первую строку
const firstRow = rowsContainer.querySelector('tr');
if (firstRow) setupRow(firstRow);
// add row button
modal.querySelector('#addRowBtn').addEventListener('click', () => {
const newRowHTML = createRow(rowsContainer.children.length);
rowsContainer.insertAdjacentHTML('beforeend', newRowHTML);
// Настройка новой строки
const newRow = rowsContainer.lastElementChild;
setupRow(newRow);
// Включаем кнопки удаления
const removeButtons = rowsContainer.querySelectorAll('.remove-row');
if (removeButtons.length > 1) removeButtons.forEach(b => b.disabled = false);
// Фокус на поле категории в новой строке
setTimeout(() => {
const categoryInput = newRow.querySelector('.category-input');
if (categoryInput) categoryInput.focus();
}, 10);
});
// totals
function updateTotals() {
let totalQty = 0;
let totalCost = 0;
let filled = 0;
rowsContainer.querySelectorAll('tr').forEach(r => {
const qty = parseInt(r.querySelector('.quantity')?.value) || 0;
const cost = parseFloat((r.querySelector('.cost')?.value || '').replace(/[^0-9.,]/g, '').replace(',', '.')) || 0;
if (qty && cost) {
totalQty += qty;
totalCost += cost;
filled++;
}
});
modal.querySelector('#totalQuantity').textContent = totalQty;
modal.querySelector('#totalCost').textContent = formatCost(totalCost) + ' ₽';
modal.querySelector('#totalRowsCount').textContent = filled + ' позиций';
}
// form submit with validation + double confirmation like у тебя
modal.querySelector('#fillToolboxForm').addEventListener('submit', async function (e) {
e.preventDefault();
const reasonInput = modal.querySelector('#fillReason');
const reason = reasonInput.value.trim();
if (reason.length < 10) {
showInfo('Укажите обоснование (минимум 10 символов)', 'error');
return;
}
const rows = rowsContainer.querySelectorAll('tr');
const items = [];
let isValid = true;
let errorMessage = '';
rows.forEach((row, index) => {
if (!isValid) return;
const catId = row.querySelector('.category-id').value;
const toolId = row.querySelector('.toolkit-id').value;
const qty = row.querySelector('.quantity').value;
const price = row.querySelector('.price').value;
// Strict: category and toolkit must match exact list entries
if (!catId) {
isValid = false;
errorMessage = `Выберите категорию из списка в строке ${index + 1}`;
row.querySelector('.category-input').classList.add('is-invalid');
row.querySelector('.category-input').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (!toolId) {
isValid = false;
errorMessage = `Выберите инструмент из списка в строке ${index + 1}`;
row.querySelector('.toolkit-input').classList.add('is-invalid');
row.querySelector('.toolkit-input').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (!qty || !price) {
isValid = false;
errorMessage = `Заполните все обязательные поля в строке ${index + 1}`;
if (!qty) row.querySelector('.quantity').classList.add('is-invalid');
if (!price) row.querySelector('.price').classList.add('is-invalid');
row.querySelector('.quantity').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
items.push({
toolkit_id: parseInt(toolId),
quantity: parseInt(qty),
price: parseFloat(price.toString().replace(',', '.')),
placement: row.querySelector('.placement').value || null
});
});
if (!isValid) {
document.getElementById('fillToolboxErrorMessage').textContent = errorMessage;
document.getElementById('fillToolboxError').classList.remove('d-none');
document.getElementById('fillToolboxError').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (items.length === 0) {
document.getElementById('fillToolboxErrorMessage').textContent = 'Добавьте хотя бы одну позицию';
document.getElementById('fillToolboxError').classList.remove('d-none');
return;
}
const submitBtn = modal.querySelector('#submitFillBtn');
const spinner = modal.querySelector('#submitFillSpinner');
const submitText = modal.querySelector('#submitFillText');
const originalText = submitText.textContent;
submitText.textContent = 'Нажмите еще раз для подтверждения (10 сек)';
submitBtn.disabled = true;
let confirmed = false;
const timeout = setTimeout(() => {
if (!confirmed) {
submitText.textContent = originalText;
submitBtn.disabled = false;
document.getElementById('fillToolboxError').classList.add('d-none');
}
}, 10000);
const confirmHandler = async function (ev) {
ev.preventDefault();
confirmed = true;
clearTimeout(timeout);
submitBtn.disabled = true;
spinner.style.display = 'inline-block';
try {
const response = await apiRequest('/toolbox/fill', {
toolboxId: toolboxInfo.id,
items: items,
reason: reason,
userId: userData.id
}, 'POST');
if (response.status === 'ok') {
showInfo('Склад успешно пополнен', 'success');
bsModal.hide();
await loadToolboxContent(toolboxInfo.id);
} else {
throw new Error(response.message || 'Ошибка при пополнении склада');
}
} catch (error) {
console.error('Ошибка при пополнении склада:', error);
document.getElementById('fillToolboxErrorMessage').textContent =
error.message || 'Произошла ошибка при пополнении склада';
document.getElementById('fillToolboxError').classList.remove('d-none');
submitBtn.disabled = false;
spinner.style.display = 'none';
submitText.textContent = originalText;
}
submitBtn.removeEventListener('click', confirmHandler);
};
submitBtn.addEventListener('click', confirmHandler, { once: true });
submitBtn.disabled = false;
});
// cleanup on modal hide
modal.addEventListener('hidden.bs.modal', () => {
autocompleteInstances.clear();
setTimeout(() => {
if (modal.parentNode) modal.remove();
}, 300);
});
// Простой таймер для восстановления фокуса
let focusRestoreTimer = null;
function restoreFocusIfLost() {
if (modal && bsModal._isShown) {
const activeElement = document.activeElement;
const isFocusInModal = modal.contains(activeElement);
if (!isFocusInModal && activeElement !== document.body) {
// Фокус вне модалки - не восстанавливаем
return;
}
if (!isFocusInModal || activeElement === document.body) {
// Фокус потерян или на body - восстанавливаем
const firstInput = rowsContainer.querySelector('.category-input');
if (firstInput && firstInput.offsetParent !== null) {
firstInput.focus();
}
}
}
}
// Показываем модалку
bsModal.show();
// Устанавливаем фокус после полного отображения модалки
modal.addEventListener('shown.bs.modal', () => {
setTimeout(() => {
const firstCategoryInput = rowsContainer.querySelector('.category-input');
if (firstCategoryInput) {
firstCategoryInput.focus();
// Показываем все категории при первом фокусе
const firstRow = rowsContainer.querySelector('tr');
if (firstRow) {
const catAC = autocompleteInstances.get(firstRow.id)?.catAC;
if (catAC) {
const matches = categories
.sort((a, b) => a.title.localeCompare(b.title))
.map(c => ({ id: c.id, title: c.title }));
catAC.show(matches);
}
}
}
// Запускаем периодическую проверку фокуса
focusRestoreTimer = setInterval(restoreFocusIfLost, 1000);
}, 100);
});
// Останавливаем таймер при закрытии модалки
modal.addEventListener('hidden.bs.modal', () => {
if (focusRestoreTimer) {
clearInterval(focusRestoreTimer);
focusRestoreTimer = null;
}
});
}
async function deleteToolbox(toolboxId) {
// Находим информацию о складе
const toolboxInfo = currentToolboxData.find(t => t.id === toolboxId);
if (!toolboxInfo) {
showInfo('Склад не найден', 'error');
return;
}
// Проверяем, существует ли уже модальное окно
let modal = document.getElementById('deleteToolboxModal');
if (modal) {
modal.remove();
}
// Создаем модальное окно подтверждения
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'deleteToolboxModal';
modal.tabIndex = -1;
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header border-bottom-0 pb-0">
<h5 class="modal-title text-danger mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Удаление склада
</h5>
<button type="button" class="btn-close mb-2" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body pt-0">
<div class="alert alert-warning mt-3" role="alert">
<div class="d-flex">
<i class="bi bi-exclamation-triangle-fill fs-4 me-2"></i>
<div>
<h6 class="alert-heading mb-2">Внимание! Это действие необратимо</h6>
<p class="mb-0">Вы собираетесь удалить склад. Это действие нельзя отменить.</p>
</div>
</div>
</div>
<div class="mb-4">
<p class="mb-2">Вы уверены, что хотите удалить следующий склад?</p>
<div class="card border">
<div class="card-body">
<h6 class="card-title mb-2">${toolboxInfo.title}</h6>
${toolboxInfo.description ? `<p class="text-muted small mb-1">${toolboxInfo.description}</p>` : ''}
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">
<i class="bi bi-calendar me-1"></i>
${toolboxInfo.created_at ? `Создан: ${toolboxInfo.created_at}` : ''}
</small>
<small class="text-muted">
<i class="bi bi-clock me-1"></i>
${toolboxInfo.updated_at ? `Обновлен: ${toolboxInfo.updated_at}` : ''}
</small>
</div>
</div>
</div>
</div>
<div class="alert alert-info" role="alert">
<div class="d-flex align-items-start">
<i class="bi bi-info-circle me-2 mt-1"></i>
<div>
<p class="mb-1">
<strong>Важно:</strong> Если по складу были операции движения инструментов, удаление будет невозможно.
</p>
<p class="mb-0 small">
В случае проблем с удалением обратитесь к администратору системы.
</p>
</div>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="confirmDeleteCheckbox">
<label class="form-check-label" for="confirmDeleteCheckbox">
Я понимаю последствия и хочу удалить этот склад
</label>
</div>
</div>
<div class="modal-footer border-top-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Отмена
</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn" disabled>
<span class="spinner-border spinner-border-sm me-1"
id="deleteSpinner" style="display: none;"></span>
Удалить склад
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Инициализация модального окна
const bsModal = new bootstrap.Modal(modal);
// Получаем элементы
const confirmCheckbox = modal.querySelector('#confirmDeleteCheckbox');
const confirmBtn = modal.querySelector('#confirmDeleteBtn');
const deleteSpinner = modal.querySelector('#deleteSpinner');
// Активация кнопки при подтверждении
confirmCheckbox.addEventListener('change', function () {
confirmBtn.disabled = !this.checked;
});
// Обработчик кнопки удаления
confirmBtn.addEventListener('click', async function () {
if (!confirmCheckbox.checked) return;
// Показываем индикатор загрузки и отключаем кнопку
confirmBtn.disabled = true;
deleteSpinner.style.display = 'inline-block';
try {
// Отправляем запрос на удаление
const userId = userData.id;
const resp = await apiRequest('/toolbox/', { toolboxId, userId }, 'DELETE');
// Проверяем успешность запроса
if (resp.status == 'ok') {
// Успешное удаление
bsModal.hide();
showInfo('Склад успешно удален', 'success');
await uploadTab('toolbox');
} else {
// Обработка ошибок от сервера
let errorMessage = 'Не удалось удалить склад';
if (resp.message) {
errorMessage += ': ' + resp.message;
}
// Показываем конкретное сообщение об ошибке
showInfo(errorMessage, 'error');
// Возвращаем кнопку в исходное состояние
confirmBtn.disabled = false;
deleteSpinner.style.display = 'none';
}
} catch (error) {
console.error('Ошибка при удалении склада:', error);
// Возвращаем кнопку в исходное состояние
confirmBtn.disabled = false;
deleteSpinner.style.display = 'none';
// Показываем общее сообщение об ошибке
showInfo('Произошла ошибка при удалении склада. Попробуйте еще раз.', 'error');
}
});
// Очистка при закрытии модального окна
modal.addEventListener('hidden.bs.modal', () => {
// Удаляем модальное окно из DOM
setTimeout(() => {
if (modal.parentNode) {
modal.remove();
}
}, 300);
});
// Показываем модальное окно
bsModal.show();
}
// Функция обработки данных склада
function processToolboxData(toolboxData, toolboxId, quantityMonitoring) {
const { stocks, toolkits, categories } = toolboxData;
// Создаем мапы для быстрого доступа
const toolkitMap = {};
const categoryMap = {};
toolkits.forEach(toolkit => {
toolkitMap[toolkit.id] = toolkit;
});
categories.forEach(category => {
categoryMap[category.id] = category;
});
// Группируем стоки по инструментам
const groupedStocks = {};
stocks.forEach(stock => {
if (stock.toolbox_id !== toolboxId) return;
const toolkitId = stock.toolkit_id;
if (!groupedStocks[toolkitId]) {
groupedStocks[toolkitId] = {
stocks: [],
placements: new Set()
};
}
groupedStocks[toolkitId].stocks.push(stock);
if (stock.placement) {
groupedStocks[toolkitId].placements.add(stock.placement);
}
});
// Формируем итоговый массив
const result = [];
Object.keys(groupedStocks).forEach(toolkitId => {
const toolkit = toolkitMap[toolkitId];
if (!toolkit) return;
const group = groupedStocks[toolkitId];
const category = categoryMap[toolkit.category_id];
// Рассчитываем общие показатели
const totalQuantity = group.stocks.reduce((sum, stock) => sum + stock.quantity, 0);
const totalCost = group.stocks.reduce((sum, stock) => sum + (stock.quantity * stock.price), 0);
// Определяем статус достаточности
let indicator = null;
if (quantityMonitoring) {
if (totalQuantity >= toolkit.quantity_min) {
indicator = { text: 'Достаточно', class: 'success' };
} else if (totalQuantity >= toolkit.quantity_min_extra) {
indicator = { text: 'Мало', class: 'warning' };
} else {
indicator = { text: 'Критически мало', class: 'danger' };
}
}
// Формируем расположение
let placement = group.placements.size > 0 ?
Array.from(group.placements).join(', ') : 'Своб. расположение';
// Находим дату последнего изменения
const lastUpdated = group.stocks.reduce((latest, stock) => {
const stockDate = new Date(stock.updated_at);
return stockDate > latest ? stockDate : latest;
}, new Date(0));
result.push({
id: parseInt(toolkitId),
toolboxId: toolboxId,
image: toolkit.image?.main || 'static/images/tools/default.png',
images: toolkit.image?.additional || [],
title: toolkit.title,
category: category?.title || 'Без категории',
totalQuantity: totalQuantity,
indicator: indicator,
totalCost: totalCost, // Сохраняем число, форматируем при выводе
placement: placement,
lastUpdated: lastUpdated.toLocaleString('ru-RU'),
available: totalQuantity, // для проверки при операциях
toolkitData: toolkit, // для модального окна
categoryData: category // для модального окна
});
});
return result;
}
// Функция форматирования стоимости с разделителями тысяч
function formatPrice(price) {
return parseFloat(price).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
// Функция инициализации таблицы
async function initializeToolboxTable(data, toolboxOwn, quantityMonitoring) {
let currentPage = 1;
const itemsPerPage = 20;
let currentSort = { field: 'title', direction: 'asc' };
let filteredData = [...data];
// Инициализация пагинации
async function initializePagination() {
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
const paginationContainer = document.getElementById('toolboxPagination');
// Очищаем текущее содержимое
paginationContainer.innerHTML = '';
// Добавляем кнопки пагинации
const prevBtn = document.createElement('li');
prevBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevBtn.innerHTML = `<a class="page-link" href="#">Назад</a>`;
prevBtn.addEventListener('click', (e) => {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
renderTable();
}
});
paginationContainer.appendChild(prevBtn);
// Определяем диапазон отображаемых страниц
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, currentPage + 2);
for (let i = startPage; i <= endPage; i++) {
const pageItem = document.createElement('li');
pageItem.className = `page-item ${i === currentPage ? 'active' : ''}`;
pageItem.innerHTML = `<a class="page-link" href="#">${i}</a>`;
pageItem.addEventListener('click', (e) => {
e.preventDefault();
currentPage = i;
renderTable();
});
paginationContainer.appendChild(pageItem);
}
const nextBtn = document.createElement('li');
nextBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextBtn.innerHTML = `<a class="page-link" href="#">Вперед</a>`;
nextBtn.addEventListener('click', (e) => {
e.preventDefault();
if (currentPage < totalPages) {
currentPage++;
renderTable();
}
});
paginationContainer.appendChild(nextBtn);
// Рендерим данные текущей страницы
await renderTable();
}
// Функция рендеринга таблицы
async function renderTable() {
const tbody = document.getElementById('toolboxItemsBody');
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const pageData = filteredData.slice(startIndex, endIndex);
tbody.innerHTML = '';
pageData.forEach(item => {
const tr = document.createElement('tr');
tr.dataset.id = item.id;
tr.dataset.quantity = item.totalQuantity;
// Определяем, какие кнопки показывать
let actionButtons = '';
if (toolboxOwn === 'Мой склад' || toolboxOwn === 'Склад сотрудника') {
actionButtons = `
<div class="btn-group btn-group-sm action-buttons">
<button class="btn btn-outline-primary" data-action="return" data-id="${item.id}" title="Вернуть">
<i class="bi bi-arrow-left-circle"></i>
</button>
<button class="btn btn-outline-danger" data-action="writeoff" data-id="${item.id}" title="Списать">
<i class="bi bi-trash"></i>
</button>
</div>
`;
} else if (toolboxOwn === 'Общий склад' && accessData.available_own_toolbox) {
actionButtons = `
<div class="btn-group btn-group-sm action-buttons">
<button class="btn btn-outline-success" data-action="get" data-id="${item.id}" title="Получить">
<i class="bi bi-box-arrow-in-down"></i> Получить
</button>
</div>
`;
}
tr.innerHTML = `
<td>
<a href="#" class="toolkit-image-link" data-id="${item.id}">
<img src="${item.image}" alt="${item.title}"
class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;">
</a>
</td>
<td>${item.title}<br>${actionButtons}</td>
<td>${item.category}</td>
<td>${item.totalQuantity}</td>
${quantityMonitoring ?
`<td><span class="badge bg-${item.indicator?.class || 'secondary'}">${item.indicator?.text || '-'}</span></td>` : ''}
<td>${formatPrice(item.totalCost)} ₽</td>
${toolboxOwn === 'Общий склад' ? `<td>${item.placement}</td>` : ''}
<td>${item.lastUpdated}</td>
`;
tbody.appendChild(tr);
// Добавляем обработчики для кнопок в строке
const actionBtn = tr.querySelector('.action-buttons');
if (actionBtn) {
actionBtn.querySelectorAll('button[data-action]').forEach(button => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
const action = e.currentTarget.dataset.action;
const itemId = e.currentTarget.dataset.id;
const selectedItem = data.find(d => d.id == itemId);
if (selectedItem) {
selectedItem.skipRefresh = true;
await showOperationModal(action, selectedItem);
}
});
});
}
});
// Добавляем обработчики для изображений
let activeModal = null;
document.querySelectorAll('.toolkit-image-link').forEach(link => {
link.addEventListener('click', async (e) => {
if (activeModal) return;
e.preventDefault();
const itemId = e.currentTarget.dataset.id;
activeModal = true;
await showToolkitDetailsModal(itemId);
activeModal = null;
});
});
}
function parseDate(d) {
// d = "07.12.2025, 13:19:20"
const [datePart, timePart] = d.split(', ');
const [day, month, year] = datePart.split('.').map(Number);
const [hour, minute, second] = timePart.split(':').map(Number);
return new Date(year, month - 1, day, hour, minute, second);
}
// Функция сортировки
function sortData(field, direction) {
filteredData.sort((a, b) => {
let aValue = a[field];
let bValue = b[field];
// Для числовых полей
if (field === 'totalQuantity') {
aValue = parseFloat(aValue);
bValue = parseFloat(bValue);
}
// Для стоимости
if (field === 'totalCost') {
aValue = parseFloat(a.totalCost);
bValue = parseFloat(b.totalCost);
}
// Для дат
if (field === 'lastUpdated') {
aValue = parseDate(a.lastUpdated);
bValue = parseDate(b.lastUpdated);
}
// Для статуса
if (field === 'indicator') {
const order = { 'danger': 0, 'warning': 1, 'success': 2 };
aValue = order[a.indicator?.class] || 3;
bValue = order[b.indicator?.class] || 3;
}
if (direction === 'asc') {
return aValue > bValue ? 1 : aValue < bValue ? -1 : 0;
} else {
return aValue < bValue ? 1 : aValue > bValue ? -1 : 0;
}
});
}
// Функция фильтрации
async function filterData(searchText) {
if (!searchText.trim()) {
filteredData = [...data];
} else {
const searchLower = searchText.toLowerCase();
filteredData = data.filter(item =>
item.title.toLowerCase().includes(searchLower) ||
item.toolkitData.description.toLowerCase().includes(searchLower)
);
}
currentPage = 1;
sortData(currentSort.field, currentSort.direction);
await initializePagination();
}
async function filterIndicator() {
const searchLower = 'мало';
filteredData = data.filter(item =>
(item.indicator?.text && item.indicator.text.toLowerCase().includes(searchLower))
);
currentPage = 1;
sortData(currentSort.field, currentSort.direction);
await initializePagination();
}
// Инициализация сортировки по заголовкам
document.querySelectorAll('#toolboxItemsTable th[data-sort]').forEach(th => {
th.addEventListener('click', async () => {
const field = th.dataset.sort;
if (currentSort.field === field) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.field = field;
currentSort.direction = 'asc';
}
// Обновляем иконки сортировки
document.querySelectorAll('#toolboxItemsTable th i').forEach(icon => {
icon.className = 'bi bi-arrow-down-up';
});
const currentIcon = th.querySelector('i');
currentIcon.className = currentSort.direction === 'asc' ?
'bi bi-arrow-up' : 'bi bi-arrow-down';
sortData(currentSort.field, currentSort.direction);
await initializePagination();
});
});
// Инициализация поиска
const searchInput = document.getElementById('toolboxSearch');
searchInput.addEventListener('input', async (e) => {
await filterData(e.target.value);
});
// Инициализация кнопки сброса фильтра
document.getElementById('resetFilter').addEventListener('click', async () => {
searchInput.value = '';
searchInput.placeholder = 'Поиск по всем полям...';
await filterData('');
});
// Инициализация кнопки "мало"
try {
document.getElementById('notEnoughBtn').addEventListener('click', async () => {
const showAll = 'Сбросить фильтр -->';
if (searchInput.placeholder === showAll) {
searchInput.placeholder = 'Поиск по всем полям...';
searchInput.value = '';
await filterData('');
return;
}
searchInput.value = '';
searchInput.placeholder = showAll;
await filterIndicator();
});
} catch (_) { }
// Начальная инициализация
sortData(currentSort.field, currentSort.direction);
await initializePagination();
}
async function getToolkitStocks(toolkitId) {
const userId = userData.id;
const allToolboxes = accessData.view_all_toolboxes;
const resp = await apiRequest('/toolkit/', { toolkitId, userId, allToolboxes });
return resp.data;
}
// Функция показа модального окна с деталями инструмента
async function showToolkitDetailsModal(toolkitId) {
const modalId = 'toolkitDetailsModal';
let modal = document.getElementById(modalId);
if (modal) {
modal.remove();
}
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = modalId;
modal.tabIndex = -1;
let toolkitData = {};
const toolkiResponse = await apiRequest('/toolkit/', { toolkitId }, 'GET');
if (toolkiResponse.status === 'ok') {
toolkitData = toolkiResponse.data;
} else {
showInfo('Произошла ошибка', 'error');
return;
}
let categories = {};
try {
const categoriesResponse = await apiRequest('/toolkit/categories', {}, 'GET');
if (categoriesResponse.status === 'ok') {
categories = categoriesResponse.data;
}
} catch (error) {
console.error('Ошибка загрузки категорий:', error);
}
const categoryData = categories[toolkitData.category_id];
const images = toolkitData.image ?
[toolkitData.image.main, ...(toolkitData.image.additional || [])] :
[toolkitData.image?.main || ''];;
let imagesDiv = '';
if (images.length > 1) {
const carouselId = `carousel-${toolkitData.id}`;
imagesDiv = `
<div class="col-md-4">
<div id="${carouselId}" class="carousel slide" data-bs-ride="carousel">
<div class="carousel-inner">
${images.map((img, index) => `
<div class="carousel-item ${index === 0 ? 'active' : ''}">
<a href="${img}" data-lightbox="toolkit-${toolkitData.id}" data-title="${toolkitData.title}">
<img src="${img}" alt="${toolkitData.title}"
class="d-block w-100 rounded mb-3"
style="max-height: 300px; object-fit: contain; cursor: zoom-in;">
</a>
</div>
`).join('')}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#${carouselId}" data-bs-slide="prev"
style="width: 40px; height: 40px; top: 50%; transform: translateY(-50%); left: 10px; opacity: 0.8; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%;">
<span class="carousel-control-prev-icon" style="filter: invert(1); width: 20px; height: 20px;"></span>
<span class="visually-hidden">Предыдущее</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#${carouselId}" data-bs-slide="next"
style="width: 40px; height: 40px; top: 50%; transform: translateY(-50%); right: 10px; opacity: 0.8; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%;">
<span class="carousel-control-next-icon" style="filter: invert(1); width: 20px; height: 20px;"></span>
<span class="visually-hidden">Следующее</span>
</button>
<div class="carousel-indicators" style="bottom: -30px;">
${images.map((_, index) => `
<button type="button" data-bs-target="#${carouselId}"
data-bs-slide-to="${index}"
class="${index === 0 ? 'active' : ''}"
aria-label="Slide ${index + 1}"
style="width: 12px; height: 12px; border-radius: 50%; border: 2px solid #333; background-color: rgba(255, 255, 255, 0.5); margin: 0 3px;"></button>
`).join('')}
</div>
</div>
</div>
`;
} else {
imagesDiv = images[0] ? `
<div class="col-md-4">
<a href="${images[0]}" data-lightbox="toolkit-${toolkitData.id}" data-title="${toolkitData.title}">
<img src="${images[0]}" alt="${toolkitData.title}"
class="img-fluid rounded mb-3"
style="max-height: 300px; object-fit: contain; cursor: zoom-in;">
</a>
</div>
` : '<div class="col-md-4"></div>';
}
// Переменная для хранения данных об остатках (будет загружена при раскрытии аккордеона)
let toolkitStocksData = null;
let isStocksLoading = false;
modal.innerHTML = `
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-tools me-1"></i>${toolkitData.title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
${imagesDiv}
<div class="col-md-8">
<h6><i class="bi bi-info-circle me-1"></i>Описание:</h6>
<p>${toolkitData.description}</p>
<h6 class="mt-3"><i class="bi bi-list me-1"></i>Категория:</h6>
<p><strong>${categoryData.title}</strong> - ${categoryData.description}</p>
${Object.keys(toolkitData.specifications).length > 0 ? `
<h6 class="mt-3"><i class="bi bi-gear me-1"></i>Характеристики:</h6>
<div class="table-responsive">
<table class="table table-sm">
<tbody>
${Object.entries(toolkitData.specifications).map(([key, value]) => `
<tr>
<td style="width: 40%;"><strong>${key}:</strong></td>
<td>${value}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
` : ''}
<!-- Аккордеон для остатков на складах -->
<div class="accordion mt-3" id="stocksAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="stocksHeading">
<button class="accordion-button collapsed" type="button"
data-bs-toggle="collapse" data-bs-target="#stocksCollapse"
aria-expanded="false" aria-controls="stocksCollapse">
<i class="bi bi-box me-2"></i>Остатки на складах
</button>
</h2>
<div id="stocksCollapse" class="accordion-collapse collapse"
aria-labelledby="stocksHeading" data-bs-parent="#stocksAccordion">
<div class="accordion-body">
<div id="stocksLoading" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="mt-2 text-muted">Загрузка данных об остатках...</p>
</div>
<div id="stocksContent" class="d-none">
<!-- Содержимое будет загружено динамически -->
</div>
<div id="stocksError" class="d-none text-center text-danger py-3">
<i class="bi bi-exclamation-triangle me-2"></i>
<span>Не удалось загрузить данные об остатках</span>
</div>
</div>
</div>
</div>
</div>
${toolkitData.external_link ? `
<div class="mt-3">
<a href="${toolkitData.external_link}" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>Внешняя ссылка
</a>
</div>
` : ''}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
`;
if (!toolkitData.hidden) {
if (accessData.tools_edit) {
const footer = modal.querySelector('.modal-footer');
const editButton = document.createElement('button');
editButton.className = 'btn btn-outline-primary';
editButton.textContent = 'Редактировать';
footer.prepend(editButton);
editButton.addEventListener('click', async () => {
try {
modal.querySelector('.btn-close').click();
await manageToolkit(toolkitData, categories, 'update');
} catch (error) {
console.error(error);
}
});
}
if (accessData.tools_creation) {
const footer = modal.querySelector('.modal-footer');
const dubleButton = document.createElement('button');
dubleButton.className = 'btn btn-outline-success';
dubleButton.textContent = 'Скопировать';
footer.prepend(dubleButton);
dubleButton.addEventListener('click', async () => {
try {
modal.querySelector('.btn-close').click();
await manageToolkit(toolkitData, categories, 'copy');
} catch (error) {
console.error(error);
}
});
}
if (accessData.tools_delete) {
const footer = modal.querySelector('.modal-footer');
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-outline-danger';
deleteButton.textContent = 'Удалить';
footer.prepend(deleteButton);
deleteButton.addEventListener('click', async () => {
try {
modal.querySelector('.btn-close').click();
await deleteToolkit(toolkitData);
} catch (error) {
console.error(error);
}
});
}
} else {
if (accessData.tools_delete) {
const footer = modal.querySelector('.modal-footer');
const showButton = document.createElement('button');
showButton.className = 'btn btn-outline-danger';
showButton.textContent = 'Показывать';
footer.prepend(showButton);
showButton.addEventListener('click', async () => {
try {
// Подготавливаем данные для отправки
const formData = { toolkitId: toolkitData.id, userId: userData.id, hidden: false };
setTimeout(() => {
modal.querySelector('.btn-close').click();
}, 300);
// Отправляем запрос на отображение
const response = await apiRequest('/toolkit/hide', formData, 'POST');
// Показываем результат
if (response.status === 'ok') {
// Успешное отображение
// Обновляем список инструментов
if (typeof uploadTab === 'function') {
await uploadTab('toolkits');
}
showInfo(response.message || 'Инструмент успешно отображен', 'success');
} else {
// Ошибка при отображении
showInfo(response.message || 'Произошла ошибка при отображении инструмента', 'danger');
}
} catch (error) {
console.error('Ошибка при скрытии инструмента:', error);
}
});
}
}
// Добавляем модальное окно в DOM
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
// Функция для загрузки данных об остатках
const loadToolkitStocks = async () => {
if (isStocksLoading) return;
const stocksLoading = modal.querySelector('#stocksLoading');
const stocksContent = modal.querySelector('#stocksContent');
const stocksError = modal.querySelector('#stocksError');
try {
isStocksLoading = true;
// Показываем спиннер, скрываем контент и ошибку
stocksLoading.classList.remove('d-none');
stocksContent.classList.add('d-none');
stocksError.classList.add('d-none');
// Загружаем данные
toolkitStocksData = await getToolkitStocks(toolkitData.id);
if (toolkitStocksData) {
// Формируем HTML для остатков
let stocksHtml = '';
if (toolkitStocksData.count > 0) {
stocksHtml = `
<p class="mb-3"><strong>Общее количество:</strong> ${toolkitStocksData.count} шт.</p>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Склад</th>
<th>Количество</th>
<th>Расположение</th>
</tr>
</thead>
<tbody>
${Object.entries(toolkitStocksData.toolboxes || {}).map(([key, value]) => `
<tr>
<td><strong>${key}</strong></td>
<td>${value.count} шт.</td>
<td class="fw-bold">
${value.placement || ''}
${!toolkitData.hidden && value.id && accessData.available_own_toolbox ? `
<button class="btn btn-sm btn-outline-success get-stock-btn ms-2"
data-toolbox-id="${value.id}"
data-id="${toolkitData.id}"
data-available="${value.count}"
data-totalcost="${value.totalCost || 0}"
title="Получить">
<i class="bi bi-box-seam-fill"></i>
</button>
` : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
stocksHtml = `
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
На складах отсутствуют остатки этого инструмента
</div>
`;
}
// Вставляем HTML и показываем контент
stocksContent.innerHTML = stocksHtml;
stocksContent.classList.remove('d-none');
// Добавляем обработчики для кнопок получения
stocksContent.querySelectorAll('.get-stock-btn').forEach(button => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
const action = 'get';
const id = e.currentTarget.dataset.id;
const toolboxId = e.currentTarget.dataset.toolboxId;
const available = e.currentTarget.dataset.available;
const totalQuantity = available;
const title = toolkitData.title;
const totalCost = e.currentTarget.dataset.totalcost;
const skipRefresh = true;
const selectedItem = {
id,
toolboxId,
available,
totalQuantity,
title,
totalCost,
skipRefresh
};
await showOperationModal(action, selectedItem);
modal.querySelector('button[data-bs-dismiss="modal"]').click();
});
});
} else {
throw new Error('Нет данных об остатках');
}
} catch (error) {
console.error('Ошибка при загрузке остатков:', error);
stocksError.classList.remove('d-none');
} finally {
stocksLoading.classList.add('d-none');
isStocksLoading = false;
}
};
// Обработчик события раскрытия аккордеона
const stocksCollapse = modal.querySelector('#stocksCollapse');
stocksCollapse.addEventListener('show.bs.collapse', async () => {
// Загружаем данные только если они еще не загружены
if (!toolkitStocksData && !isStocksLoading) {
await loadToolkitStocks();
}
});
// Обработчик для принудительной перезагрузки данных (например, при повторном открытии аккордеона)
const stocksHeading = modal.querySelector('#stocksHeading');
stocksHeading.addEventListener('click', async (e) => {
// Если данные уже загружены, можно обновить их при повторном клике
const isExpanded = stocksCollapse.classList.contains('show');
if (isExpanded && toolkitStocksData) {
// Можно добавить кнопку обновления или обновлять автоматически
// Для простоты пока оставляем как есть
}
});
// Очистка при закрытии модалки
modal.addEventListener('hidden.bs.modal', () => {
modal.remove();
});
// Инициализация lightbox
setTimeout(() => {
lightbox.option({
'resizeDuration': 200,
'wrapAround': true,
'albumLabel': "Изображение %1 из %2",
'fadeDuration': 300,
'imageFadeDuration': 300
});
}, 100);
}
// Функция создания/редактирования инструмента
async function manageToolkit(toolkitData = null, categories = null, action = 'create') {
if (!toolkitData && action !== 'create') {
showInfo('Произошла ошибка', 'error');
return;
}
if (!categories) {
try {
const categoriesResponse = await apiRequest('/toolkit/categories', {}, 'GET');
if (categoriesResponse.status === 'ok') {
categories = categoriesResponse.data;
} else {
showInfo('Произошла ошибка', 'error');
return;
}
} catch (error) {
console.error('Ошибка загрузки категорий:', error);
}
}
if (action === 'copy') {
toolkitData.title += ' (копия)';
}
// Удаляем старое модальное окно, если оно существует
let modal = document.getElementById('manageToolkitModal');
if (modal) modal.remove();
// Проверяем режим (создание или редактирование)
const isEditMode = !!toolkitData;
const modalTitle = isEditMode ? 'Редактирование инструмента' : 'Создание нового инструмента';
const submitButtonText = isEditMode ? 'Сохранить изменения' : 'Создать инструмент';
// Данные инструмента по умолчанию
const defaultToolkitData = {
title: '',
category_id: '',
description: '',
external_link: '',
quantity_min: null,
quantity_min_extra: null,
specifications: {},
image: {
main: '',
additional: []
}
};
// Объединяем данные
const data = isEditMode ? {
...defaultToolkitData,
...toolkitData,
// Обеспечиваем правильную структуру изображений
image: toolkitData.image ? {
main: typeof toolkitData.image === 'string' ? toolkitData.image : toolkitData.image.main,
additional: toolkitData.images || toolkitData.image.additional || []
} : defaultToolkitData.image
} : defaultToolkitData;
// Состояние изображений - теперь храним объекты с метаданными
let mainImageFile = null;
let mainImagePreview = data.image.main;
// Для дополнительных изображений храним объекты с информацией о типе
let additionalImages = [];
// Инициализируем существующие изображения
if (data.image.additional && Array.isArray(data.image.additional)) {
additionalImages = data.image.additional.map(url => ({
preview: url,
originalUrl: url,
isNew: false,
isFile: false,
file: null
}));
}
// Состояние характеристик
let specifications = { ...data.specifications };
// Создаём модальное окно
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'manageToolkitModal';
modal.tabIndex = -1;
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-tools me-2"></i>${modalTitle}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="manageToolkitForm" novalidate>
<div class="modal-body p-0">
<div id="manageToolkitError" class="alert alert-danger d-none m-3" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
<span id="manageToolkitErrorMessage"></span>
</div>
<div class="row g-0">
<!-- Левая колонка - Основные данные -->
<div class="col-md-6 p-3 border-end">
<h6 class="mb-3"><i class="bi bi-info-circle me-2"></i>Основные данные</h6>
<div class="mb-3">
<label for="toolkitTitle" class="form-label required">Название инструмента</label>
<input type="text" class="form-control" id="toolkitTitle"
value="${data.title || ''}"
placeholder="Введите название инструмента"
required minlength="2" maxlength="200">
</div>
<div class="mb-3">
<label for="toolkitCategory" class="form-label required">Категория</label>
<select class="form-select" id="toolkitCategory" required>
<option value="" disabled>-- Выберите категорию --</option>
${Object.entries(categories).map(([key, value]) => `
<option value="${value.id}" ${value.id == data.category_id ? 'selected' : ''}>
${value.title}
</option>
`).join('')}
</select>
</div>
<div class="mb-3">
<label for="toolkitDescription" class="form-label">Описание</label>
<textarea class="form-control" id="toolkitDescription"
rows="3" placeholder="Описание инструмента"
maxlength="1000">${data.description || ''}</textarea>
</div>
<div class="mb-3">
<label for="toolkitExternalLink" class="form-label">Внешняя ссылка</label>
<input type="url" class="form-control" id="toolkitExternalLink"
value="${data.external_link || ''}"
placeholder="https://example.com">
</div>
<!-- Поля для минимального и критического количества -->
<div class="row mb-3">
<div class="col-md-6">
<label for="toolkitQuantityMin" class="form-label">Низкое количество</label>
<input type="number" class="form-control" id="toolkitQuantityMin"
value="${data.quantity_min !== null && data.quantity_min !== undefined ? data.quantity_min : ''}"
placeholder="20" min="0" step="1">
<div class="form-text text-muted">Предупреждение о низком остатке</div>
</div>
<div class="col-md-6">
<label for="toolkitQuantityMinExtra" class="form-label">Критическое количество</label>
<input type="number" class="form-control" id="toolkitQuantityMinExtra"
value="${data.quantity_min_extra !== null && data.quantity_min_extra !== undefined ? data.quantity_min_extra : ''}"
placeholder="10" min="0" step="1">
<div class="form-text text-muted">Предупреждение о критическом остатке</div>
</div>
</div>
<!-- Характеристики -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><i class="bi bi-gear me-2"></i>Характеристики</h6>
<button type="button" class="btn btn-sm btn-outline-primary" id="addSpecBtn">
<i class="bi bi-plus me-1"></i>Добавить
</button>
</div>
<div id="specificationsList">
<!-- Характеристики будут добавляться сюда -->
</div>
</div>
</div>
<!-- Правая колонка - Изображения -->
<div class="col-md-6 p-3">
<h6 class="mb-3"><i class="bi bi-image me-2"></i>Изображения</h6>
<!-- Основное изображение -->
<div class="mb-4">
<label class="form-label required">Основное изображение</label>
<div class="border rounded p-3 text-center"
id="mainImageDropZone"
style="min-height: 200px; border-style: dashed !important; cursor: pointer;">
<div id="mainImageContent">
${mainImagePreview ? `
<div class="position-relative">
<img src="${mainImagePreview}"
class="img-fluid rounded mb-2"
style="max-height: 150px; object-fit: contain;">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 mt-1 me-1"
id="removeMainImageBtn" title="Заменить">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
<div class="text-muted small">
${mainImageFile ? 'Готово к загрузке' : 'Основное изображение загружено'}
</div>
` : `
<div class="py-5">
<i class="bi bi-cloud-arrow-up display-6 text-muted mb-3"></i>
<p class="mb-1">Перетащите изображение сюда</p>
<p class="text-muted small mb-0">или кликните для выбора файла</p>
<p class="text-muted small">JPG, PNG до 5MB</p>
</div>
`}
</div>
<input type="file" id="mainImageInput" class="d-none" accept="image/jpeg,image/png">
</div>
</div>
<!-- Дополнительные изображения -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label">Дополнительные изображения</label>
<button type="button" class="btn btn-sm btn-outline-primary" id="addAdditionalImageBtn">
<i class="bi bi-plus me-1"></i>Добавить
</button>
</div>
<div id="additionalImagesContainer">
<!-- Дополнительные изображения будут добавляться сюда -->
</div>
<div class="border rounded p-3 text-center d-none"
id="additionalImagesDropZone"
style="min-height: 100px; border-style: dashed !important; cursor: pointer;">
<div id="additionalImagesContent">
<div class="py-4">
<i class="bi bi-cloud-arrow-up text-muted mb-2"></i>
<p class="text-muted small mb-0">Перетащите изображения сюда</p>
<p class="text-muted small">или кликните для выбора файлов</p>
</div>
</div>
<input type="file" id="additionalImagesInput" class="d-none" accept="image/jpeg,image/png" multiple>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary" id="submitToolkitBtn">
<span class="spinner-border spinner-border-sm me-1" id="submitToolkitSpinner" style="display: none;"></span>
<span id="submitToolkitText">${submitButtonText}</span>
</button>
</div>
</form>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
// Получаем элементы DOM
const mainImageDropZone = modal.querySelector('#mainImageDropZone');
const mainImageInput = modal.querySelector('#mainImageInput');
const mainImageContent = modal.querySelector('#mainImageContent');
const removeMainImageBtn = modal.querySelector('#removeMainImageBtn');
const additionalImagesContainer = modal.querySelector('#additionalImagesContainer');
const addAdditionalImageBtn = modal.querySelector('#addAdditionalImageBtn');
const additionalImagesDropZone = modal.querySelector('#additionalImagesDropZone');
const additionalImagesInput = modal.querySelector('#additionalImagesInput');
const specificationsList = modal.querySelector('#specificationsList');
const addSpecBtn = modal.querySelector('#addSpecBtn');
const submitBtn = modal.querySelector('#submitToolkitBtn');
const spinner = modal.querySelector('#submitToolkitSpinner');
const submitText = modal.querySelector('#submitToolkitText');
const errorDiv = modal.querySelector('#manageToolkitError');
const errorMessage = modal.querySelector('#manageToolkitErrorMessage');
// Функция для отображения ошибок
function showError(message) {
errorMessage.textContent = message;
errorDiv.classList.remove('d-none');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Функция для скрытия ошибок
function hideError() {
errorDiv.classList.add('d-none');
}
// Функция для преобразования файла в base64
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = error => reject(error);
});
}
// Функция для обновления превью основного изображения
function updateMainImagePreview() {
if (mainImagePreview) {
mainImageContent.innerHTML = `
<div class="position-relative">
<img src="${mainImagePreview}"
class="img-fluid rounded mb-2"
style="max-height: 150px; object-fit: contain;">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 mt-1 me-1"
id="removeMainImageBtn" title="Заменить">
<i class="bi bi-arrow-repeat"></i>
</button>
</div>
<div class="text-muted small">
${mainImageFile ? 'Готово к загрузке' : 'Основное изображение загружено'}
</div>
`;
// Добавляем обработчик для кнопки удаления
const newRemoveBtn = mainImageContent.querySelector('#removeMainImageBtn');
newRemoveBtn.addEventListener('click', removeMainImage);
} else {
mainImageContent.innerHTML = `
<div class="py-5">
<i class="bi bi-cloud-arrow-up display-6 text-muted mb-3"></i>
<p class="mb-1">Перетащите изображение сюда</p>
<p class="text-muted small mb-0">или кликните для выбора файла</p>
<p class="text-muted small">JPG, PNG до 5MB</p>
</div>
`;
}
}
// Функция для удаления основного изображения
function removeMainImage() {
mainImageFile = null;
mainImagePreview = '';
mainImagePreview = data.image.main; // Возвращаем оригинальное изображение если было
updateMainImagePreview();
}
// Обработчики для основного изображения
mainImageDropZone.addEventListener('click', () => mainImageInput.click());
mainImageDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
mainImageDropZone.style.backgroundColor = '#f8f9fa';
});
mainImageDropZone.addEventListener('dragleave', () => {
mainImageDropZone.style.backgroundColor = '';
});
mainImageDropZone.addEventListener('drop', async (e) => {
e.preventDefault();
mainImageDropZone.style.backgroundColor = '';
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
mainImageFile = file;
mainImagePreview = URL.createObjectURL(file);
updateMainImagePreview();
} else {
showError('Пожалуйста, выберите файл изображения');
}
}
});
mainImageInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
showError('Размер файла не должен превышать 5MB');
return;
}
mainImageFile = file;
mainImagePreview = URL.createObjectURL(file);
updateMainImagePreview();
}
e.target.value = '';
});
// Функция для обновления превью дополнительных изображений
function updateAdditionalImages() {
additionalImagesContainer.innerHTML = '';
if (additionalImages.length > 0) {
additionalImagesDropZone.classList.add('d-none');
additionalImages.forEach((image, index) => {
const isNewFile = image.isFile && image.isNew;
const imgElement = document.createElement('div');
imgElement.className = 'mb-3';
imgElement.innerHTML = `
<div class="border rounded p-2 position-relative">
<div class="row align-items-center">
<div class="col-3">
<img src="${image.preview}"
class="img-fluid rounded"
style="height: 60px; object-fit: contain; background-color: ${!image.isFile ? '#f8f9fa' : 'transparent'}">
</div>
<div class="col-7">
<div class="small">
${isNewFile ? 'Готово к загрузке' : 'Изображение загружено'}
${isNewFile && image.file ? `<div class="text-muted">${image.file.name}</div>` : ''}
</div>
</div>
<div class="col-2 text-end">
<button type="button" class="btn btn-sm btn-danger"
onclick="removeAdditionalImage(${index})" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
`;
additionalImagesContainer.appendChild(imgElement);
});
} else {
additionalImagesDropZone.classList.remove('d-none');
}
}
// Функция для удаления дополнительного изображения
window.removeAdditionalImage = function (index) {
// Освобождаем blob URL если это новый файл
const image = additionalImages[index];
if (image.isFile && image.isNew && image.preview.startsWith('blob:')) {
URL.revokeObjectURL(image.preview);
}
additionalImages.splice(index, 1);
updateAdditionalImages();
};
// Обработчики для дополнительных изображений
addAdditionalImageBtn.addEventListener('click', () => additionalImagesInput.click());
additionalImagesDropZone.addEventListener('click', () => additionalImagesInput.click());
additionalImagesDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
additionalImagesDropZone.style.backgroundColor = '#f8f9fa';
});
additionalImagesDropZone.addEventListener('dragleave', () => {
additionalImagesDropZone.style.backgroundColor = '';
});
additionalImagesDropZone.addEventListener('drop', async (e) => {
e.preventDefault();
additionalImagesDropZone.style.backgroundColor = '';
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length > 0) {
for (const file of imageFiles) {
if (file.size > 5 * 1024 * 1024) {
showError(`Файл ${file.name} превышает 5MB`);
continue;
}
additionalImages.push({
preview: URL.createObjectURL(file),
originalUrl: null,
isNew: true,
isFile: true,
file: file
});
}
updateAdditionalImages();
} else {
showError('Пожалуйста, выберите файлы изображений');
}
});
additionalImagesInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length > 0) {
for (const file of imageFiles) {
if (file.size > 5 * 1024 * 1024) {
showError(`Файл ${file.name} превышает 5MB`);
continue;
}
additionalImages.push({
preview: URL.createObjectURL(file),
originalUrl: null,
isNew: true,
isFile: true,
file: file
});
}
updateAdditionalImages();
}
e.target.value = '';
});
// Функция для обновления списка характеристик
function updateSpecificationsList() {
specificationsList.innerHTML = '';
if (Object.keys(specifications).length === 0) {
specificationsList.innerHTML = `
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle me-2"></i>
Характеристики не добавлены
</div>
`;
return;
}
const table = document.createElement('table');
table.className = 'table table-sm';
table.innerHTML = `
<thead>
<tr>
<th style="width: 30%;">Характеристика</th>
<th style="width: 40%;">Значение</th>
<th style="width: 30%;" class="text-center">Действия</th>
</tr>
</thead>
<tbody>
${Object.entries(specifications).map(([key, value], index) => `
<tr id="spec-row-${index}">
<td>${key}</td>
<td>${value}</td>
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-primary"
onclick="editSpecification('${key}', '${value}')" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-outline-danger"
onclick="removeSpecification('${key}')" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
`;
specificationsList.appendChild(table);
}
// Функция для удаления характеристики
window.removeSpecification = function (key) {
delete specifications[key];
updateSpecificationsList();
showInfo('Характеристика удалена', 'success');
};
// Функция для редактирования характеристики
window.editSpecification = function (oldKey, oldValue) {
addSpecificationModal(oldKey, oldValue);
};
// Функция для добавления/редактирования характеристики
function addSpecificationModal(oldKey = null, oldValue = null) {
const isEditMode = oldKey !== null;
const modalTitle = isEditMode ? 'Редактирование характеристики' : 'Добавление характеристики';
const saveButtonText = isEditMode ? 'Сохранить изменения' : 'Добавить характеристику';
const specModal = document.createElement('div');
specModal.className = 'modal fade';
specModal.id = 'addSpecModal';
specModal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-gear me-2"></i>${modalTitle}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="specName" class="form-label required">Название характеристики</label>
<div class="input-group">
<input type="text" class="form-control" id="specName"
placeholder="Введите название" value="${oldKey || ''}">
<button class="btn btn-outline-secondary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-list"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" style="max-height: 300px; overflow-y: auto;">
${Object.entries(predefinedSpecs).map(([category, items]) => `
<li><h6 class="dropdown-header">${category}</h6></li>
${items.map(item => `
<li>
<button class="dropdown-item" type="button" onclick="selectPredefinedSpec('${item}')">
${item}
</button>
</li>
`).join('')}
`).join('')}
</ul>
</div>
</div>
<div class="mb-3">
<label for="specValue" class="form-label required">Значение</label>
<input type="text" class="form-control" id="specValue"
placeholder="Введите значение" value="${oldValue || ''}">
</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="saveSpecBtn">${saveButtonText}</button>
</div>
</div>
</div>
`;
document.body.appendChild(specModal);
const bsSpecModal = new bootstrap.Modal(specModal);
// Функция выбора предопределенной характеристики
window.selectPredefinedSpec = function (name) {
document.getElementById('specName').value = name;
};
// Обработчик сохранения
specModal.querySelector('#saveSpecBtn').addEventListener('click', () => {
const name = document.getElementById('specName').value.trim();
const value = document.getElementById('specValue').value.trim();
if (!name) {
showError('Введите название характеристики');
return;
}
if (!value) {
showError('Введите значение характеристики');
return;
}
// Если это режим редактирования и название изменилось, удаляем старую характеристику
if (isEditMode && oldKey !== name) {
delete specifications[oldKey];
}
// Добавляем/обновляем характеристику
specifications[name] = value;
updateSpecificationsList();
bsSpecModal.hide();
setTimeout(() => {
specModal.remove();
showInfo(isEditMode ? 'Характеристика обновлена' : 'Характеристика добавлена', 'success');
}, 300);
});
// Очистка при закрытии
specModal.addEventListener('hidden.bs.modal', () => {
setTimeout(() => {
if (specModal.parentNode) specModal.remove();
}, 300);
});
bsSpecModal.show();
setTimeout(() => document.getElementById('specName').focus(), 100);
}
// Обработчик для добавления характеристики
addSpecBtn.addEventListener('click', () => addSpecificationModal());
// Инициализируем списки
updateMainImagePreview();
updateAdditionalImages();
updateSpecificationsList();
// Обработчик отправки формы
modal.querySelector('#manageToolkitForm').addEventListener('submit', async function (e) {
e.preventDefault();
// Собираем данные
const formData = {
title: document.getElementById('toolkitTitle').value.trim(),
category_id: document.getElementById('toolkitCategory').value,
description: document.getElementById('toolkitDescription').value.trim(),
external_link: document.getElementById('toolkitExternalLink').value.trim(),
specifications: specifications,
};
// Добавляем поля количества
const quantityMin = document.getElementById('toolkitQuantityMin').value.trim();
const quantityMinExtra = document.getElementById('toolkitQuantityMinExtra').value.trim();
if (quantityMin) {
formData.quantity_min = parseInt(quantityMin);
}
if (quantityMinExtra) {
formData.quantity_min_extra = parseInt(quantityMinExtra);
}
if (toolkitData?.id) {
formData.id = toolkitData.id;
}
// Валидация
if (!formData.title) {
showError('Введите название инструмента');
return;
}
if (!formData.category_id) {
showError('Выберите категорию');
return;
}
// Проверка значений количества
if (quantityMin && isNaN(parseInt(quantityMin))) {
showError('Низкое количество должно быть числом');
return;
}
if (quantityMinExtra && isNaN(parseInt(quantityMinExtra))) {
showError('Критическое количество должно быть числом');
return;
}
// Подготавливаем изображения
const imageData = {
main: mainImageFile ? await fileToBase64(mainImageFile) : data.image.main,
additional: []
};
// Обрабатываем дополнительные изображения
for (const image of additionalImages) {
if (image.isFile && image.isNew && image.file) {
// Новый файл - конвертируем в base64
imageData.additional.push(await fileToBase64(image.file));
} else if (!image.isFile && image.originalUrl) {
// Существующее изображение - отправляем оригинальный URL
imageData.additional.push(image.originalUrl);
}
}
formData.image = imageData;
// Показываем спиннер
submitBtn.disabled = true;
spinner.style.display = 'inline-block';
hideError();
try {
// Отправляем запрос
const userId = userData.id;
const response = await apiRequest('/toolkit/manage', { action, formData, userId }, 'POST');
if (response.status === 'ok') {
showInfo(isEditMode ? 'Инструмент обновлен' : 'Инструмент создан', 'success');
bsModal.hide();
// Обновляем список инструментов
if (typeof uploadTab === 'function') {
await uploadTab('toolkits');
}
} else {
throw new Error(response.message || 'Ошибка сохранения');
}
} catch (error) {
console.error('Ошибка при сохранении инструмента:', error);
showError(error.message || 'Произошла ошибка при сохранении инструмента');
// Возвращаем кнопку в исходное состояние
submitBtn.disabled = false;
spinner.style.display = 'none';
}
});
// Очистка при закрытии модалки
modal.addEventListener('hidden.bs.modal', () => {
// Освобождаем URL объекты
if (mainImagePreview && mainImagePreview.startsWith('blob:')) {
URL.revokeObjectURL(mainImagePreview);
}
// Освобождаем blob URL для дополнительных изображений
additionalImages.forEach(image => {
if (image.isFile && image.isNew && image.preview.startsWith('blob:')) {
URL.revokeObjectURL(image.preview);
}
});
setTimeout(() => {
if (modal.parentNode) modal.remove();
}, 300);
});
// Показываем модалку
bsModal.show();
return new Promise((resolve) => {
modal.addEventListener('hidden.bs.modal', () => {
resolve(null);
});
});
}
async function deleteToolkit(toolkitData) {
// Создаем модальное окно подтверждения удаления
const modalHTML = `
<div class="modal fade" id="deleteToolkitModal" tabindex="-1" aria-labelledby="deleteToolkitModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteToolkitModalLabel">Подтверждение удаления</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Вы действительно хотите удалить инструмент <strong>"${toolkitData.title}"</strong>?</p>
<div class="alert alert-warning mt-3">
<i class="bi bi-exclamation-triangle-fill"></i>
<strong>Внимание!</strong> Если с этим инструментом уже были операции по движению, удаление будет невозможно.
</div>
<div id="deleteResultMessage" class="d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-outline-warning" id="confirmHideBtn"><i class="bi bi-eye-slash me-2"></i>Скрыть</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn"><i class="bi bi-trash me-2"></i>Удалить</button>
</div>
</div>
</div>
</div>
`;
// Добавляем модальное окно в DOM
const modalContainer = document.createElement('div');
modalContainer.innerHTML = modalHTML;
document.body.appendChild(modalContainer);
const modal = new bootstrap.Modal(document.getElementById('deleteToolkitModal'));
const confirmBtn = document.getElementById('confirmDeleteBtn');
const hideBtn = document.getElementById('confirmHideBtn');
const resultMessage = document.getElementById('deleteResultMessage');
modal.show();
hideBtn.addEventListener('click', async () => {
hideBtn.disabled = true;
hideBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Скрытие...';
try {
// Подготавливаем данные для отправки
const formData = { toolkitId: toolkitData.id, userId: userData.id, hidden: true };
// Отправляем запрос на скрытие
const response = await apiRequest('/toolkit/hide', formData, 'POST');
// Показываем результат
if (response.status === 'ok') {
// Успешное скрытие
resultMessage.className = 'alert alert-success mt-3';
resultMessage.innerHTML = `<i class="bi bi-check-circle-fill"></i> ${response.message || 'Инструмент успешно скрыт'}`;
resultMessage.classList.remove('d-none');
// Показываем общее уведомление
showInfo(response.message || 'Инструмент успешно скрыт', 'success');
setTimeout(() => {
modal.hide();
}, 1000);
// Обновляем список инструментов
if (typeof uploadTab === 'function') {
await uploadTab('toolkits');
}
} else {
// Ошибка при скрытии
resultMessage.className = 'alert alert-danger mt-3';
resultMessage.innerHTML = `<i class="bi bi-exclamation-triangle-fill"></i> ${response.message || 'Произошла ошибка при скрытии инструмента'}`;
resultMessage.classList.remove('d-none');
}
} catch (error) {
console.error('Ошибка при скрытии инструмента:', error);
resultMessage.className = 'alert alert-danger mt-3';
resultMessage.innerHTML = `<i class="bi bi-exclamation-triangle-fill"></i> Произошла ошибка при скрытии инструмента`;
resultMessage.classList.remove('d-none');
hideBtn.disabled = false;
hideBtn.innerHTML = '<i class="bi bi-eye-slash me-2"></i>Скрыть';
}
});
// Обработчик подтверждения удаления
confirmBtn.addEventListener('click', async () => {
// Блокируем кнопку и показываем индикатор загрузки
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Удаление...';
try {
// Подготавливаем данные для отправки
const formData = { id: toolkitData.id };
const userId = userData.id;
// Отправляем запрос на удаление
const response = await apiRequest('/toolkit/manage', {
action: 'delete',
formData,
userId
}, 'POST');
// Показываем результат
if (response.status === 'ok') {
// Успешное удаление
resultMessage.className = 'alert alert-success mt-3';
resultMessage.innerHTML = `<i class="bi bi-check-circle-fill"></i> ${response.message || 'Инструмент успешно удален'}`;
resultMessage.classList.remove('d-none');
if (typeof uploadTab === 'function') {
await uploadTab('toolkits');
}
// Показываем общее уведомление
showInfo(response.message || 'Инструмент успешно удален', 'success');
// Закрываем модальное окно через 2 секунды
setTimeout(() => {
modal.hide();
}, 2000);
} else {
// Ошибка удаления (возможно, есть движения)
resultMessage.className = 'alert alert-danger mt-3';
resultMessage.innerHTML = `<i class="bi bi-exclamation-octagon-fill"></i> ${response.message || 'Не удалось удалить инструмент'}`;
resultMessage.classList.remove('d-none');
// Разблокируем кнопку и возвращаем исходный текст
confirmBtn.disabled = false;
confirmBtn.textContent = 'Удалить';
// Если есть конкретное сообщение о движениях
if (response.message && response.message.includes('движени')) {
resultMessage.innerHTML += '<br><small>Удаление этого инструмента невозможно, так как были операции движения. Его можно только скрыть.</small>';
}
// Показываем общее уведомление
showInfo(response.message || 'Не удалось удалить инструмент', 'danger');
}
} catch (error) {
console.error('Ошибка при удалении инструмента:', error);
resultMessage.className = 'alert alert-danger mt-3';
resultMessage.innerHTML = `<i class="bi bi-exclamation-octagon-fill"></i> Произошла ошибка при удалении: ${error.message}`;
resultMessage.classList.remove('d-none');
// Разблокируем кнопку и возвращаем исходный текст
confirmBtn.disabled = false;
confirmBtn.textContent = 'Удалить';
}
});
// Очистка при закрытии модального окна
document.getElementById('deleteToolkitModal').addEventListener('hidden.bs.modal', () => {
document.body.removeChild(modalContainer);
});
}
// Функция показа модального окна для операций
async function showOperationModal(operation, selectedItem) {
const modalId = 'operationModal';
let modal = document.getElementById(modalId);
if (modal) {
modal.remove();
}
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = modalId;
modal.tabIndex = -1;
const operationTitles = {
'return': 'Возврат инструмента',
'writeoff': 'Списание инструмента',
'get': 'Получение инструмента'
};
// Определяем максимальное доступное количество
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${operationTitles[operation]}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p><strong>${selectedItem.title}</strong> (доступно: ${selectedItem.totalQuantity} шт.)</p>
<div class="d-flex flex-column flex-md-row align-items-center gap-2">
<label for="operationQuantity" class="form-label mb-0">Количество: (макс: ${selectedItem.totalQuantity})</label>
<input type="number" class="form-control" id="operationQuantity" style="max-width: 100px"
min="1" ${(operation === 'writeoff' || operation === 'get') ? `max="${selectedItem.totalQuantity}"` : ''} value="1">
</div>
<div class="my-3">
<label for="operationComment" class="form-label">Обоснование:</label>
<textarea class="form-control" id="operationComment" rows="2"></textarea>
</div>
</div>
<div id="operationError" class="alert alert-danger d-none mx-3" role="alert">
<span><i class="bi bi-exclamation-triangle me-2"></i> <span id="operationErrorMessage">Ошибка выполнения операции</span></span>
</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="confirmOperation">Подтвердить</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
// Валидация ввода количества
const quantityInput = document.getElementById('operationQuantity');
quantityInput.addEventListener('change', function () {
let value = parseInt(this.value);
if (value > selectedItem.totalQuantity) {
this.value = selectedItem.totalQuantity;
} else if (value < 1) {
this.value = 1;
}
});
document.getElementById('confirmOperation').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const btnText = btn.innerHTML;
// Блокируем кнопку + ставим спиннер
btn.disabled = true;
btn.innerHTML = `
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Обработка...
`;
const quantity = parseInt(document.getElementById('operationQuantity').value);
const comment = document.getElementById('operationComment').value;
// Проверка максимального количества для операций списания и получения
if ((operation === 'writeoff' || operation === 'get') && quantity > selectedItem.totalQuantity) {
showError(`Максимально доступное количество: ${selectedItem.totalQuantity}`);
resetButton();
return;
}
if (comment === '') {
showError('Введите обоснование');
resetButton();
return;
}
const success = await actionRequest(operation, quantity, comment, selectedItem);
if (success) {
bsModal.hide();
showInfo(`Запрос на ${operationTitles[operation]} успешно создан`, 'success');
if (!selectedItem.skipRefresh) {
await loadToolboxContent(selectedItem.toolboxId);
}
} else {
showError('Ошибка выполнения операции');
resetButton();
}
function resetButton() {
btn.disabled = false;
btn.innerHTML = btnText;
}
function showError(message) {
document.getElementById('operationError').classList.remove('d-none');
document.getElementById('operationErrorMessage').textContent = message;
}
});
modal.addEventListener('hidden.bs.modal', () => {
modal.remove();
});
}
async function actionRequest(operation, quantity, comment, selectedItem) {
const action = { operation, quantity, comment, selectedItem };
const sendData = { userData, accessData, action };
const resp = await apiRequest('/stocks/action', sendData);
if (resp.status == 'ok') {
return true
} else {
return false
}
}
function renderRequestsTab(tabId, tabData) {
const tabContent = document.getElementById(`${tabId}-tab-content`);
const tabOptionalContent = document.getElementById(`${tabId}-tab-optional-content`);
const { requests, users, toolboxes, toolkits } = tabData;
// Собираем списки для фильтров
const initUsers = [...new Set(requests.map(r => r.init_user_id))];
const userMap = {};
users.forEach(user => {
userMap[user.id] = user.username;
});
const actionTypes = [...new Set(requests.map(r => r.action))];
const ownRequestsCount = requests.filter(r => r.init_user_id === userData.id).length;
// Создаем мапу для toolboxes
const toolboxMap = {};
toolboxes.forEach(box => {
toolboxMap[box.id] = box.title;
});
// Создаем мапу для toolkits
const toolkitMap = {};
toolkits.forEach(kit => {
toolkitMap[kit.id] = kit.title;
});
// Фильтры
let currentFilters = {
user: 'all',
action: 'all',
};
// Рендерим дополнительный контейнер с фильтрами
tabOptionalContent.innerHTML = `
<div class="row align-items-center mb-3">
<!-- Фильтры слева -->
<div class="col-12 col-md-8 mb-2 mb-md-0">
<div class="row g-2">
<div class="col-12 col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<select class="form-select" id="${tabId}-user-filter">
<option value="all">Все пользователи</option>
${initUsers.map(userId => `
<option value="${userId}">${userMap[userId] || `Пользователь ${userId}`}</option>
`).join('')}
</select>
</div>
</div>
<div class="col-12 col-md-4">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-gear"></i>
</span>
<select class="form-select" id="${tabId}-action-filter">
<option value="all">Все действия</option>
${actionTypes.map(action => `
<option value="${action}">${action}</option>
`).join('')}
</select>
</div>
</div>
</div>
</div>
<!-- Кнопки массовых действий справа -->
<div class="col-12 col-md-4">
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
${(accessData.refund_request_confirm || accessData.debit_request_confirm) ? `
<div class="btn-group" role="group">
<button class="btn btn-success" id="${tabId}-accept-all-btn" title="Принять все отобранные запросы">
<i class="bi bi-check-circle me-1"></i>Все принять
</button>
<button class="btn btn-danger" id="${tabId}-reject-all-btn" title="Отклонить все отобранные запросы">
<i class="bi bi-x-circle me-1"></i>Все отклонить
</button>
</div>
` : ''}
${ownRequestsCount > 0 ? `
<button class="btn btn-warning" id="${tabId}-withdraw-all-btn" title="Отозвать все мои запросы">
<i class="bi bi-arrow-counterclockwise me-1"></i>Все отозвать
</button>
` : ''}
</div>
</div>
</div>
`;
// Рендерим основной контейнер с таблицей запросов
tabContent.innerHTML = `
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="${tabId}-requests-table">
<thead class="table-light">
<tr>
<th width="110">Тип</th>
<th width="130">Оформил</th>
<th width="130">Со склада</th>
<th width="130">На склад</th>
<th width="150">Инструмент</th>
<th width="80">Кол-во</th>
<th>Обоснование</th>
<th width="130">Действия</th>
</tr>
</thead>
<tbody id="${tabId}-requests-body">
<!-- Запросы будут вставлены здесь -->
</tbody>
</table>
</div>
<div class="text-center p-3 border-top" id="${tabId}-no-requests" style="display: none;">
<i class="bi bi-inbox fs-1 text-muted"></i>
<p class="text-muted mt-2">Нет запросов по выбранным фильтрам</p>
</div>
</div>
</div>
`;
// Функция для фильтрации запросов
function filterRequests() {
let filtered = requests;
// Фильтр по пользователю
if (currentFilters.user !== 'all') {
filtered = filtered.filter(r => r.init_user_id == currentFilters.user);
}
// Фильтр по типу действия
if (currentFilters.action !== 'all') {
filtered = filtered.filter(r => r.action === currentFilters.action);
}
return filtered;
}
// Функция для рендеринга строк таблицы
function renderRequestsTable() {
const tbody = document.getElementById(`${tabId}-requests-body`);
const noRequestsDiv = document.getElementById(`${tabId}-no-requests`);
const filteredRequests = filterRequests();
if (filteredRequests.length === 0) {
tbody.innerHTML = '';
noRequestsDiv.style.display = 'block';
return;
}
noRequestsDiv.style.display = 'none';
tbody.innerHTML = filteredRequests.map(request => {
// Определяем доступные действия
const actions = [];
// Кнопка отзыва (только для инициатора и неподтвержденных запросов)
if (request.init_user_id === userData.id && request.accepted === null) {
actions.push(`
<button class="btn btn-sm btn-outline-warning withdraw-btn"
data-request-id="${request.id}"
title="Отозвать запрос">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
`);
}
// Кнопки принятия/отклонения (в зависимости от прав)
let canDecide = false;
// Проверяем права в зависимости от типа запроса
if (request.action === 'Возврат' && accessData.refund_request_confirm) {
canDecide = true;
} else if (request.action === 'Списание' && accessData.debit_request_confirm) {
canDecide = true;
} else if (request.action !== 'Возврат' && request.action !== 'Списание' &&
(accessData.refund_request_confirm || accessData.debit_request_confirm)) {
// Для других типов запросов, если есть хотя бы одно из прав
console.warning('Unknown request action', request.action);
canDecide = true;
}
if (canDecide) {
actions.push(`
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success accept-btn"
data-request-id="${request.id}"
title="Принять запрос">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-outline-danger reject-btn"
data-request-id="${request.id}"
title="Отклонить запрос">
<i class="bi bi-x"></i>
</button>
</div>
`);
}
// Если нет доступных действий
if (actions.length === 0) {
actions.push('<span class="text-muted">Нет действий</span>');
}
return `
<tr data-request-id="${request.id}">
<td>
<span class="badge bg-${request.action === 'Возврат' ? 'primary' : 'warning'}">${request.action}</span>
<span class="small text-muted">${request.created_at}</span>
</td>
<td>${userMap[request.init_user_id] || `Пользователь ${request.init_user_id}`}</td>
<td>${request.source_toolbox_id ? (toolboxMap[request.source_toolbox_id] || `Склад ${request.source_toolbox_id}`) : '-'}</td>
<td>${request.target_toolbox_id ? (toolboxMap[request.target_toolbox_id] || `Склад ${request.target_toolbox_id}`) : '-'}</td>
<td>${request.toolkit_id ? (toolkitMap[request.toolkit_id] || `Инструмент ${request.toolkit_id}`) : '-'}</td>
<td>
<span class="badge bg-light text-dark border">${request.quantity}</span>
</td>
<td>
<small class="text-muted">${request.reason || 'Нет обоснования'}</small>
</td>
<td>
<div class="d-flex gap-1">
${actions.join('')}
</div>
</td>
</tr>
`;
}).join('');
}
// Функция для показа модального окна подтверждения
function showConfirmationModal(title, message, onConfirm) {
// Проверяем, есть ли уже модальное окно
let modal = document.getElementById('confirmation-modal');
if (!modal) {
// Создаем модальное окно
modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'confirmation-modal';
modal.tabIndex = -1;
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<p id="confirmation-message"></p>
</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="confirm-action-btn">Подтвердить</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
}
// Устанавливаем содержимое
document.getElementById('confirmation-message').innerHTML = message;
// Очищаем предыдущие обработчики
const confirmBtn = document.getElementById('confirm-action-btn');
const oldConfirmBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(oldConfirmBtn, confirmBtn);
const newConfirmBtn = document.getElementById('confirm-action-btn');
// Устанавливаем новый обработчик
newConfirmBtn.addEventListener('click', function () {
const modalInstance = bootstrap.Modal.getInstance(modal);
modalInstance.hide();
onConfirm();
});
// Показываем модальное окно
const modalInstance = new bootstrap.Modal(modal);
modalInstance.show();
}
async function sendRequestDecision(requestId, accepted, requestResult = null) {
const data = await apiRequest('/records/', { request_id: requestId, user_id: userData.id, accepted: accepted });
if (data.status == 'ok') {
const requestIndex = requests.findIndex(r => r.id == requestId);
if (requestIndex !== -1) {
requests.splice(requestIndex, 1);
}
// Перерисовываем таблицу
renderRequestsTable();
// Показываем уведомление об успехе
requestResult = requestResult === null ? (accepted ? 'Принят' : 'Отклонен') : requestResult;
showInfo(`Запрос успешно ${requestResult}`, 'success');
} else {
const errorMessage = data.message || 'Ошибка сервера';
showInfo(errorMessage, 'error');
throw new Error(errorMessage);
}
}
// Функция для обработки решения по запросу
function handleRequestDecision(requestId, accepted) {
const action = accepted ? 'принять' : 'отклонить';
showConfirmationModal(
`Подтверждение действия`,
`Вы уверены, что хотите ${action} этот запрос?`,
async () => {
await sendRequestDecision(requestId, accepted);
}
);
}
// Функция для отзыва запроса
function handleRequestWithdrawal(requestId) {
showConfirmationModal(
'Отзыв запроса',
'Вы уверены, что хотите отозвать этот запрос?',
async () => {
await sendRequestDecision(requestId, false, 'Отозван');
}
);
}
// Функция для массовых действий
function handleBulkAction(actionType) {
const filteredRequests = filterRequests();
// Фильтруем только те запросы, с которыми можно совершить действие
let applicableRequests = filteredRequests;
if (actionType === 'accept' || actionType === 'reject') {
// Для принятия/отклонения: только ожидающие решения
applicableRequests = filteredRequests.filter(r => r.accepted === null);
// Проверяем права для каждого запроса
applicableRequests = applicableRequests.filter(r => {
if (r.action === 'Возврат') {
return accessData.refund_request_confirm;
} else if (r.action === 'Списание') {
return accessData.debit_request_confirm;
} else {
return accessData.refund_request_confirm || accessData.debit_request_confirm;
}
});
} else if (actionType === 'withdraw') {
// Для отзыва: только мои и ожидающие решения
applicableRequests = filteredRequests.filter(r =>
r.init_user_id === userData.id && r.accepted === null
);
}
if (applicableRequests.length === 0) {
showInfo('Нет подходящих запросов для этого действия', 'warning');
return;
}
const actionName = actionType === 'accept' ? 'принять' :
actionType === 'reject' ? 'отклонить' : 'отозвать';
showConfirmationModal(
'Массовое действие',
`Вы уверены, что хотите <b>${actionName}</b> все отправленные запросы (${applicableRequests.length})?`,
async () => {
// Отправляем запросы на сервер
const promises = applicableRequests.map(request =>
sendRequestDecision(request.id, actionType === 'accept')
);
await Promise.all(promises);
}
);
}
// Назначаем обработчики событий для фильтров
document.getElementById(`${tabId}-user-filter`).addEventListener('change', function () {
currentFilters.user = this.value;
renderRequestsTable();
});
document.getElementById(`${tabId}-action-filter`).addEventListener('change', function () {
currentFilters.action = this.value;
renderRequestsTable();
});
// Назначаем обработчики для массовых действий
const acceptAllBtn = document.getElementById(`${tabId}-accept-all-btn`);
const rejectAllBtn = document.getElementById(`${tabId}-reject-all-btn`);
const withdrawAllBtn = document.getElementById(`${tabId}-withdraw-all-btn`);
if (acceptAllBtn) {
acceptAllBtn.addEventListener('click', () => handleBulkAction('accept'));
}
if (rejectAllBtn) {
rejectAllBtn.addEventListener('click', () => handleBulkAction('reject'));
}
if (withdrawAllBtn) {
withdrawAllBtn.addEventListener('click', () => handleBulkAction('withdraw'));
}
// Назначаем делегированные обработчики для действий в таблице
document.getElementById(`${tabId}-requests-body`).addEventListener('click', function (e) {
const target = e.target;
// Находим ближайшую кнопку или родительскую кнопку
const button = target.closest('.accept-btn, .reject-btn, .withdraw-btn');
if (!button) return;
const requestId = button.dataset.requestId;
if (button.classList.contains('accept-btn')) {
handleRequestDecision(requestId, true);
} else if (button.classList.contains('reject-btn')) {
handleRequestDecision(requestId, false);
} else if (button.classList.contains('withdraw-btn')) {
handleRequestWithdrawal(requestId);
}
});
// Первоначальный рендеринг таблицы
renderRequestsTable();
}
function renderJurnalToolkitsTab(tabId, tabData) {
const tabContent = document.getElementById(`${tabId}-tab-content`);
const tabOptionalContent = document.getElementById(`${tabId}-tab-optional-content`);
const { requests, users, toolboxes, toolkits, startDate, endDate } = tabData;
if (requests.length === 0) {
tabContent.innerHTML = `
<div class="alert alert-info m-4" role="alert">
<i class="bi bi-info-circle me-2"></i>
Нет данных за период ${startDate} - ${endDate}
</div>
`;
return;
}
// Собираем списки для фильтров
const initUsers = [...new Set(requests.map(r => r.init_user_id))];
const userMap = {};
users.forEach(user => {
userMap[user.id] = user.username;
});
const actionTypes = [...new Set(requests.map(r => r.action))];
// Создаем мапу для toolboxes
const toolboxMap = {};
toolboxes.forEach(box => {
toolboxMap[box.id] = box.title;
});
// Создаем мапу для toolkits
const toolkitMap = {};
toolkits.forEach(kit => {
toolkitMap[kit.id] = kit.title;
});
const savedFilters = loadFromStorage(tabId);
// Фильтры
let currentFilters = {
user: savedFilters?.user || 'all',
action: savedFilters?.action || 'all',
status: savedFilters?.status || 'all'
};
// Рендерим дополнительный контейнер с фильтрами
tabOptionalContent.innerHTML = `
<div class="row align-items-center mb-3">
<!-- Фильтры слева -->
<div class="col-12 col-md-8 mb-2 mb-md-0">
<div class="row g-2 gap-1 mb-2">
<div class="col-12 col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<select class="form-select" id="${tabId}-user-filter">
<option value="all">Все пользователи</option>
${initUsers.map(userId => `
<option value="${userId}">${userMap[userId] || `Пользователь ${userId}`}</option>
`).join('')}
</select>
</div>
</div>
<div class="col-12 col-md-3">
<button class="btn btn-outline-secondary" id="${tabId}-filter-reset-btn">
<i class="bi bi-x-circle me-1"></i>Сброс
</button>
</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-5">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-gear"></i>
</span>
<select class="form-select" id="${tabId}-action-filter">
<option value="all">Все действия</option>
${actionTypes.map(action => `
<option value="${action}">${action}</option>
`).join('')}
</select>
</div>
</div>
<div class="col-12 col-md-5 ms-1">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-info-circle"></i>
</span>
<select class="form-select" id="${tabId}-status-filter">
<option value="all">Все статусы</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
</div>
</div>
</div>
</div>
<!-- Фильтры справа -->
<div class="col-12 col-md-4 pe-3">
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
<div class="input-group date">
<span class="input-group-text" style="width: 170px;">
<i class="bi bi-calendar me-1"></i>Дата начала:
</span>
<input type="date" class="form-control" id="${tabId}-date-from">
</div>
<div class="input-group date">
<span class="input-group-text" style="width: 170px;">
<i class="bi bi-calendar me-1"></i>Дата окончания:
</span>
<input type="date" class="form-control" id="${tabId}-date-to">
</div>
<div>
<button class="btn btn-outline-primary" id="${tabId}-date-update-btn">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить журнал
</button>
</div>
</div>
</div>
</div>
`;
const filterResetBtn = document.getElementById(`${tabId}-filter-reset-btn`);
filterResetBtn.addEventListener('click', () => {
currentFilters = {
user: 'all',
action: 'all',
status: 'all'
};
document.getElementById(`${tabId}-user-filter`).value = currentFilters.user;
document.getElementById(`${tabId}-action-filter`).value = currentFilters.action;
document.getElementById(`${tabId}-status-filter`).value = currentFilters.status;
saveToStorage(tabId, currentFilters);
renderRequestsTable();
});
const startDateInput = document.getElementById(`${tabId}-date-from`);
const endDateInput = document.getElementById(`${tabId}-date-to`);
startDateInput.value = startDate;
endDateInput.value = endDate;
const refreshDateBtn = document.getElementById(`${tabId}-date-update-btn`);
refreshDateBtn.addEventListener('click', async () => {
const newStartDate = startDateInput.value;
const newEndDate = endDateInput.value;
const newDateRequestData = {
tabId: tabId,
startDate: newStartDate,
endDate: newEndDate
};
if (newStartDate && newEndDate) {
tabContent.innerHTML = `
<div class="alert alert-info m-4" role="alert">
<i class="bi bi-info-circle me-2"></i>
Загрузка данных...
</div>
`;
const newPeriodData = await apiRequest('/', newDateRequestData);
if (newPeriodData.status == 'ok') {
renderJurnalToolkitsTab(tabId, newPeriodData.data);
}
}
});
// Рендерим основной контейнер с таблицей запросов
tabContent.innerHTML = `
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="${tabId}-requests-table">
<thead class="table-light">
<tr>
<th width="100">Тип</th>
<th width="150">Оформил</th>
<th width="150">Решил</th>
<th width="150">Со склада</th>
<th width="150">На склад</th>
<th width="150">Инструмент</th>
<th width="80">Кол-во</th>
<th>Обоснование</th>
</tr>
</thead>
<tbody id="${tabId}-requests-body">
<!-- Запросы будут вставлены здесь -->
</tbody>
</table>
</div>
<div class="text-center p-3 border-top" id="${tabId}-no-requests" style="display: none;">
<i class="bi bi-inbox fs-1 text-muted"></i>
<p class="text-muted mt-2">Нет запросов по выбранным фильтрам</p>
</div>
</div>
</div>
`;
// Функция для фильтрации запросов
function filterRequests() {
let filtered = requests;
// Фильтр по пользователю
if (currentFilters.user !== 'all') {
filtered = filtered.filter(r => r.init_user_id == currentFilters.user);
document.getElementById(`${tabId}-user-filter`).value = currentFilters.user;
}
// Фильтр по типу действия
if (currentFilters.action !== 'all') {
filtered = filtered.filter(r => r.action === currentFilters.action);
document.getElementById(`${tabId}-action-filter`).value = currentFilters.action;
}
// Фильтр по статусу
if (currentFilters.status !== 'all') {
document.getElementById(`${tabId}-status-filter`).value = currentFilters.status;
switch (currentFilters.status) {
case 'accepted':
filtered = filtered.filter(r => r.accepted === true);
break;
case 'rejected':
filtered = filtered.filter(r => r.accepted === false);
break;
}
}
return filtered;
}
// Функция для рендеринга строк таблицы
function renderRequestsTable() {
const tbody = document.getElementById(`${tabId}-requests-body`);
const noRequestsDiv = document.getElementById(`${tabId}-no-requests`);
const filteredRequests = filterRequests();
if (filteredRequests.length === 0) {
tbody.innerHTML = '';
noRequestsDiv.style.display = 'block';
return;
}
noRequestsDiv.style.display = 'none';
tbody.innerHTML = filteredRequests.map(request => {
// Определяем статус запроса
let statusBadge = '';
if (request.accepted === true) {
statusBadge = '<span class="badge bg-success">Принято</span>';
} else {
statusBadge = '<span class="badge bg-danger">Отклонено</span>';
}
return `
<tr data-request-id="${request.id}">
<td>
<span class="badge bg-${request.action === 'Возврат' ? 'primary' : 'warning'}">${request.action}</span>
${statusBadge}
</td>
<td>${userMap[request.init_user_id] || `Пользователь ${request.init_user_id}`}<br><span class="small text-muted">${request.created_at}</span></td>
<td>${userMap[request.decision_user_id] || `Пользователь ${request.decision_user_id}`}<br><span class="small text-muted">${request.decided_at}</span></td>
<td>${request.source_toolbox_id ? (toolboxMap[request.source_toolbox_id] || `Склад ${request.source_toolbox_id}`) : '-'}</td>
<td>${request.target_toolbox_id ? (toolboxMap[request.target_toolbox_id] || `Склад ${request.target_toolbox_id}`) : '-'}</td>
<td>${request.toolkit_id ? (toolkitMap[request.toolkit_id] || `Инструмент ${request.toolkit_id}`) : '-'}</td>
<td>
<span class="badge bg-light text-dark border">${request.quantity}</span>
</td>
<td>
<small class="text-muted">${request.reason || 'Нет обоснования'}</small>
</td>
</tr>
`;
}).join('');
}
// Назначаем обработчики событий для фильтров
document.getElementById(`${tabId}-user-filter`).addEventListener('change', function () {
currentFilters.user = this.value;
saveToStorage(tabId, currentFilters);
renderRequestsTable();
});
document.getElementById(`${tabId}-action-filter`).addEventListener('change', function () {
currentFilters.action = this.value;
saveToStorage(tabId, currentFilters);
renderRequestsTable();
});
document.getElementById(`${tabId}-status-filter`).addEventListener('change', function () {
currentFilters.status = this.value;
saveToStorage(tabId, currentFilters);
renderRequestsTable();
});
// Первоначальный рендеринг таблицы
renderRequestsTable();
}
function renderJurnalServicesTab(tabId, tabData) {
const tabContent = document.getElementById(`${tabId}-tab-content`);
const tabOptionalContent = document.getElementById(`${tabId}-tab-optional-content`);
const { requests, users, categories, startDate, endDate } = tabData;
if (requests.length === 0) {
tabContent.innerHTML = `
<div class="alert alert-info m-4" role="alert">
<i class="bi bi-info-circle me-2"></i>
Нет данных за период ${startDate} - ${endDate}
</div>
`;
return;
}
// Собираем списки для фильтров
const initUsers = [...new Set(requests.map(r => r.user_id))].filter(id => id !== null);
const userMap = {};
users.forEach(user => {
userMap[user.id] = user.username;
});
const categoriesMap = {};
categories.forEach(cat => {
categoriesMap[cat.id] = { title: cat.title, description: cat.description };
});
// Собираем типы действий (ключи из details)
const actionTypes = [...new Set(requests.map(r => {
const details = r.details;
return Object.keys(details)[0]; // Берем первый ключ как тип действия
}))];
const savedFilters = loadFromStorage(tabId);
// Фильтры
let currentFilters = {
user: savedFilters?.user || 'all',
action: savedFilters?.action || 'all'
};
// Рендерим дополнительный контейнер с фильтрами
tabOptionalContent.innerHTML = `
<div class="row align-items-center mb-3">
<!-- Фильтры слева -->
<div class="col-12 col-md-8 mb-2 mb-md-0">
<div class="row g-2 gap-1 mb-2">
<div class="col-12 col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<select class="form-select" id="${tabId}-user-filter">
<option value="all">Все пользователи</option>
<option value="system">Система</option>
${initUsers.map(userId => `
<option value="${userId}">${userMap[userId] || `Пользователь ${userId}`}</option>
`).join('')}
</select>
</div>
</div>
<div class="col-12 col-md-3">
<button class="btn btn-outline-secondary" id="${tabId}-filter-reset-btn">
<i class="bi bi-x-circle me-1"></i>Сброс
</button>
</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-8">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-gear"></i>
</span>
<select class="form-select" id="${tabId}-action-filter">
<option value="all">Все действия</option>
${actionTypes.map(action => `
<option value="${action}">${action}</option>
`).join('')}
</select>
</div>
</div>
</div>
</div>
<!-- Фильтры справа -->
<div class="col-12 col-md-4 pe-3">
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
<div class="input-group date">
<span class="input-group-text" style="width: 170px;">
<i class="bi bi-calendar me-1"></i>Дата начала:
</span>
<input type="date" class="form-control" id="${tabId}-date-from">
</div>
<div class="input-group date">
<span class="input-group-text" style="width: 170px;">
<i class="bi bi-calendar me-1"></i>Дата окончания:
</span>
<input type="date" class="form-control" id="${tabId}-date-to">
</div>
<div>
<button class="btn btn-outline-primary" id="${tabId}-date-update-btn">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить журнал
</button>
</div>
</div>
</div>
</div>
`;
const filterResetBtn = document.getElementById(`${tabId}-filter-reset-btn`);
filterResetBtn.addEventListener('click', () => {
currentFilters = {
user: 'all',
action: 'all'
};
document.getElementById(`${tabId}-user-filter`).value = currentFilters.user;
document.getElementById(`${tabId}-action-filter`).value = currentFilters.action;
saveToStorage(tabId, currentFilters);
renderServicesTable();
});
const startDateInput = document.getElementById(`${tabId}-date-from`);
const endDateInput = document.getElementById(`${tabId}-date-to`);
startDateInput.value = startDate;
endDateInput.value = endDate;
const refreshDateBtn = document.getElementById(`${tabId}-date-update-btn`);
refreshDateBtn.addEventListener('click', async () => {
const newStartDate = startDateInput.value;
const newEndDate = endDateInput.value;
const newDateRequestData = {
tabId: tabId,
startDate: newStartDate,
endDate: newEndDate
};
if (newStartDate && newEndDate) {
tabContent.innerHTML = `
<div class="alert alert-info m-4" role="alert">
<i class="bi bi-info-circle me-2"></i>
Загрузка данных...
</div>
`;
const newPeriodData = await apiRequest('/', newDateRequestData);
if (newPeriodData.status == 'ok') {
renderJurnalServicesTab(tabId, newPeriodData.data);
}
}
});
// Рендерим основной контейнер с таблицей сервисных событий
tabContent.innerHTML = `
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="${tabId}-services-table">
<thead class="table-light">
<tr>
<th width="110">Дата</th>
<th width="200">Пользователь</th>
<th width="200">Действие</th>
<th>Детали</th>
</tr>
</thead>
<tbody id="${tabId}-services-body">
<!-- События будут вставлены здесь -->
</tbody>
</table>
</div>
<div class="text-center p-3 border-top" id="${tabId}-no-services" style="display: none;">
<i class="bi bi-inbox fs-1 text-muted"></i>
<p class="text-muted mt-2">Нет событий по выбранным фильтрам</p>
</div>
</div>
</div>
`;
// Функция для фильтрации событий
function filterServices() {
let filtered = requests;
// Фильтр по пользователю
if (currentFilters.user !== 'all') {
if (currentFilters.user === 'system') {
filtered = filtered.filter(r => r.user_id === null);
} else {
filtered = filtered.filter(r => r.user_id == currentFilters.user);
}
}
// Фильтр по типу действия
if (currentFilters.action !== 'all') {
filtered = filtered.filter(r => {
const details = r.details;
return Object.keys(details)[0] === currentFilters.action;
});
}
return filtered;
}
// Функция для рендеринга строк таблицы
function renderServicesTable() {
const tbody = document.getElementById(`${tabId}-services-body`);
const noServicesDiv = document.getElementById(`${tabId}-no-services`);
const filteredServices = filterServices();
if (filteredServices.length === 0) {
tbody.innerHTML = '';
noServicesDiv.style.display = 'block';
return;
}
noServicesDiv.style.display = 'none';
tbody.innerHTML = filteredServices.map(service => {
const actionType = Object.keys(service.details)[0];
const actionData = service.details[actionType];
// Определяем пользователя
let userName = 'Система';
if (service.user_id) {
userName = userMap[service.user_id] || `Пользователь ${service.user_id}`;
}
// Форматируем детали в зависимости от типа действия
let detailsHtml = '';
if (actionType === 'Авторизован пользователь') {
// Для авторизации
detailsHtml = `
<div class="fw-semibold">${actionData}</div>
`;
} else if (actionType.includes('Добавлен') || actionType.includes('Обновлен') || actionType.includes('Добавлена')) {
// Для добавления/обновления сущностей
const entityName = actionData.title || actionData.username || actionData.login || '';
detailsHtml = `
<div class="fw-semibold">${entityName}</div>
${actionData.description ? `<div class="text-muted small mt-1">${actionData.description}</div>` : ''}
${actionData.id ? `<div class="text-muted small">ID: ${actionData.id}</div>` : ''}
`;
// Для инструментов добавляем дополнительные поля
if (actionData.specifications && Object.keys(actionData.specifications).length > 0) {
detailsHtml += `<div class="mt-2"><strong>Характеристики:</strong></div>`;
detailsHtml += `<div class="small text-muted">`;
for (const [key, value] of Object.entries(actionData.specifications)) {
detailsHtml += `<div>${key}: ${value}</div>`;
}
detailsHtml += `</div>`;
}
if (actionData.external_link) {
detailsHtml += `<div class="mt-2"><strong>Ссылка:</strong> <a href="${actionData.external_link}" target="_blank">${actionData.external_link}</a></div>`;
}
if (actionData.quantity_min || actionData.quantity_min_extra) {
detailsHtml += `<div class="mt-2"><strong>Мониторинг остатков:</strong></div>`;
if (actionData.quantity_min && actionData.quantity_min_extra) {
detailsHtml += `<div class="small text-muted">Минимальное количество: ${actionData.quantity_min}</div>`;
detailsHtml += `<div class="small text-muted">Минимальное критическое количество: ${actionData.quantity_min_extra}</div>`;
} else if (actionData.quantity_min && !actionData.quantity_min_extra) {
detailsHtml += `<div class="small text-muted">Минимальное количество: ${actionData.quantity_min}</div>`;
} else if (actionData.quantity_min_extra && !actionData.quantity_min) {
detailsHtml += `<div class="small text-muted">Минимальное критическое количество: ${actionData.quantity_min_extra}</div>`;
}
}
if (actionData.category_id) {
detailsHtml += `<div class="mt-2"><span class="fw-bold">Категория:</span> <span class="fw-medium">${categoriesMap[actionData.category_id].title}</span> [${categoriesMap[actionData.category_id].description}]</div>`;
}
if (actionData.image) {
detailsHtml += `<div class="mt-2, fw-bold">Изображения:</div>`;
detailsHtml += `<div class="small text-muted">Основное:<div>`;
detailsHtml += `<div class="mt-2"><img src="${actionData.image.main}" class="img-thumbnail" style="width: 64px; height: 64px;" alt="Основное изображение инструмента">`;
if (actionData.image.additional) {
detailsHtml += `<div class="small text-muted">Дополнительные:<div>`;
detailsHtml += `<div class="d-flex mt-2">`;
actionData.image.additional.forEach(img => {
detailsHtml += `<div><img src="${img}" class="img-thumbnail" style="width: 64px; height: 64px;" alt="Дополнительное изображение инструмента"></div>`;
});
detailsHtml += `</div>`;
}
detailsHtml += `</div>`;
}
}
return `
<tr data-service-id="${service.id}">
<td>
<span class="text-muted">${service.created_at}</span>
</td>
<td>${userName}</td>
<td>
<span class="badge bg-info">${actionType}</span>
</td>
<td>
${detailsHtml}
</td>
</tr>
`;
}).join('');
}
// Назначаем обработчики событий для фильтров
document.getElementById(`${tabId}-user-filter`).addEventListener('change', function () {
currentFilters.user = this.value;
saveToStorage(tabId, currentFilters);
renderServicesTable();
});
document.getElementById(`${tabId}-action-filter`).addEventListener('change', function () {
currentFilters.action = this.value;
saveToStorage(tabId, currentFilters);
renderServicesTable();
});
// Устанавливаем сохраненные значения фильтров
if (savedFilters) {
document.getElementById(`${tabId}-user-filter`).value = currentFilters.user;
document.getElementById(`${tabId}-action-filter`).value = currentFilters.action;
}
// Первоначальный рендеринг таблицы
renderServicesTable();
}
document.addEventListener('DOMContentLoaded', async () => {
await getCookieData();
if (!accessData || !userData) {
console.warn('Access data or user data not found');
return;
}
prepareTabs();
});
window.openTab = openTab;