5903 lines
267 KiB
JavaScript
5903 lines
267 KiB
JavaScript
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':
|
||
renderSimpleTab(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');
|
||
cards.forEach(card => {
|
||
card.addEventListener('click', async event => {
|
||
const toolId = event.currentTarget.dataset.toolid;
|
||
await showToolkitDetailsModal(toolId);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Функция для настройки фильтров
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
// Добавляем обработчики для изображений
|
||
document.querySelectorAll('.toolkit-image-link').forEach(link => {
|
||
link.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
const itemId = e.currentTarget.dataset.id;
|
||
await showToolkitDetailsModal(itemId);
|
||
});
|
||
});
|
||
}
|
||
|
||
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="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="text-muted">${request.created_at}</span></td>
|
||
<td>${userMap[request.decision_user_id] || `Пользователь ${request.decision_user_id}`}<br><span class="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();
|
||
}
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await getCookieData();
|
||
|
||
if (!accessData || !userData) {
|
||
console.warn('Access data or user data not found');
|
||
return;
|
||
}
|
||
|
||
prepareTabs();
|
||
});
|
||
|
||
window.openTab = openTab; |