2781 lines
121 KiB
JavaScript
2781 lines
121 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;
|
||
|
||
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');
|
||
|
||
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) {
|
||
tabsData['requests'] = {
|
||
title: 'Запросы',
|
||
icon: 'bi-chat-left-text',
|
||
description: 'Управление запросами на инструменты'
|
||
};
|
||
}
|
||
|
||
if (accessData.view_services) {
|
||
tabsData['jurnal_toolkits'] = {
|
||
title: 'Журнал перемещений',
|
||
icon: 'bi-journal-text',
|
||
description: 'Журнал перемещений инструментов'
|
||
};
|
||
}
|
||
|
||
if (accessData.view_requests) {
|
||
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="d-flex flex-column flex-md-row align-items-md-center justify-content-between mb-2">
|
||
|
||
<div class="card-body py-2">
|
||
<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" style="max-width: 65%;"></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);
|
||
}
|
||
|
||
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':
|
||
renderSimpleTab(tabId, tabData, 'Запросы на инструменты');
|
||
break;
|
||
case 'toolkits':
|
||
renderToolkitsTab(tabId, tabData.toolkits, tabData.categories);
|
||
break;
|
||
case 'jurnal_toolkits':
|
||
renderSimpleTab(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>
|
||
`;
|
||
}
|
||
|
||
function renderToolkitsTab(tabId, toolsArray, categoriesList) {
|
||
const tabContent = document.getElementById(`${tabId}-tab-content`);
|
||
|
||
// Преобразуем объект в массив, если передан объект
|
||
const tools = Array.isArray(toolsArray) ? toolsArray : Object.values(toolsArray);
|
||
|
||
let uniqueCategories = {};
|
||
categoriesList.forEach(cat => {
|
||
uniqueCategories[cat.id] = { id: cat.id, title: cat.title, description: cat.description };
|
||
});
|
||
|
||
tools.forEach(tool => {
|
||
tool['category'] = uniqueCategories[tool.category_id]?.title || '';
|
||
tool['category_desc'] = uniqueCategories[tool.category_id]?.description || '';
|
||
});
|
||
|
||
// Сортируем инструменты: сначала по названию категории, затем по названию
|
||
const sortedTools = [...tools].sort((a, b) => {
|
||
// Получаем названия категорий
|
||
const catA = uniqueCategories[a.category_id]?.title || '';
|
||
const catB = uniqueCategories[b.category_id]?.title || '';
|
||
|
||
// Сначала сравниваем категории
|
||
if (catA < catB) return -1;
|
||
if (catA > catB) return 1;
|
||
|
||
// Если категории одинаковые, сравниваем названия инструментов
|
||
const titleA = a.title || '';
|
||
const titleB = b.title || '';
|
||
return titleA.localeCompare(titleB, 'ru');
|
||
});
|
||
|
||
// Создаем HTML структуру
|
||
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">
|
||
<div class="col-12 col-md-8 mb-3 mb-md-0">
|
||
<div class="d-flex flex-wrap gap-2">
|
||
<button class="btn filter-btn active"
|
||
data-category="all">
|
||
Все категории
|
||
</button>
|
||
${Object.values(uniqueCategories).map(category => `
|
||
<button class="btn filter-btn"
|
||
data-category="${category.id}">
|
||
${category.title}
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="col-12 col-md-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="Поиск по названию и описанию...">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Контейнер для карточек -->
|
||
<div id="${tabId}-cards-container" class="row g-4">
|
||
<!-- Карточки будут вставлены здесь -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Рендерим карточки
|
||
renderToolkitCards(tabId, sortedTools, uniqueCategories);
|
||
|
||
// Добавляем обработчики событий для фильтров
|
||
setupFilters(tabId, tools, uniqueCategories);
|
||
}
|
||
|
||
// Функция для рендеринга карточек
|
||
function renderToolkitCards(tabId, tools, categoriesMap, filterText = '', categoryFilter = 'all') {
|
||
const container = document.getElementById(`${tabId}-cards-container`);
|
||
|
||
// Фильтруем инструменты
|
||
const filteredTools = tools.filter(tool => {
|
||
// Фильтр по категории
|
||
const categoryMatch = categoryFilter === 'all' || tool.category_id == categoryFilter;
|
||
|
||
// Фильтр по тексту
|
||
const searchMatch = !filterText ||
|
||
(tool.title && tool.title.toLowerCase().includes(filterText.toLowerCase())) ||
|
||
(tool.description && tool.description.toLowerCase().includes(filterText.toLowerCase()));
|
||
|
||
return categoryMatch && searchMatch;
|
||
});
|
||
|
||
// Рендерим карточки
|
||
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>
|
||
</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 ? `
|
||
<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;
|
||
const tool = tools.find(t => t.id == toolId);
|
||
await showToolkitDetailsModal(tool);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Функция для настройки фильтров
|
||
function setupFilters(tabId, tools, categoriesMap) {
|
||
const container = document.getElementById(`${tabId}-cards-container`);
|
||
const searchInput = document.getElementById(`${tabId}-search-input`);
|
||
const filterButtons = document.querySelectorAll(`#${tabId}-tab-content .filter-btn`);
|
||
|
||
// Текущие значения фильтров
|
||
let currentFilter = {
|
||
category: 'all',
|
||
search: ''
|
||
};
|
||
|
||
// Обработчик для кнопок категорий
|
||
filterButtons.forEach(button => {
|
||
button.addEventListener('click', function () {
|
||
// Убираем активный класс у всех кнопок
|
||
filterButtons.forEach(btn => btn.classList.remove('active'));
|
||
// Добавляем активный класс текущей кнопке
|
||
this.classList.add('active');
|
||
|
||
currentFilter.category = this.dataset.category;
|
||
renderToolkitCards(tabId, tools, categoriesMap, currentFilter.search, currentFilter.category);
|
||
});
|
||
});
|
||
|
||
// Обработчик для поля поиска
|
||
if (searchInput) {
|
||
let searchTimeout;
|
||
searchInput.addEventListener('input', function () {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
currentFilter.search = this.value.trim();
|
||
renderToolkitCards(tabId, tools, categoriesMap, currentFilter.search, currentFilter.category);
|
||
}, 300);
|
||
});
|
||
|
||
// Очистка поиска
|
||
searchInput.insertAdjacentHTML('afterend', `
|
||
<button class="btn btn-outline-secondary d-none"
|
||
type="button"
|
||
id="${tabId}-clear-search">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
`);
|
||
|
||
const clearBtn = document.getElementById(`${tabId}-clear-search`);
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', function () {
|
||
searchInput.value = '';
|
||
currentFilter.search = '';
|
||
renderToolkitCards(tabId, tools, categoriesMap, '', currentFilter.category);
|
||
this.classList.add('d-none');
|
||
});
|
||
|
||
searchInput.addEventListener('input', function () {
|
||
clearBtn.classList.toggle('d-none', !this.value);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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'));
|
||
|
||
const toolboxNav = `
|
||
<div class="d-flex flex-wrap gap-2" id="toolboxNav">
|
||
${tabData.map((toolbox, index) => `
|
||
<button class="btn btn-outline-primary toolbox-nav-btn d-flex align-items-center mb-2"
|
||
onclick="selectToolbox(${toolbox.id}, ${index})"
|
||
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 toolboxContent = `
|
||
<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>
|
||
`;
|
||
|
||
tabOptionalContent.innerHTML = toolboxNav;
|
||
|
||
if (accessData.manage_toolboxes) {
|
||
|
||
const addToolboxBtn = document.createElement('button');
|
||
addToolboxBtn.className = 'btn btn-outline-success toolbox-nav-btn d-flex align-items-center mb-2';
|
||
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);
|
||
}
|
||
|
||
tabContent.innerHTML = toolboxContent;
|
||
}
|
||
|
||
// Функция для выбора склада
|
||
window.selectToolbox = async function (toolboxId, index) {
|
||
// Убираем активный класс со всех кнопок складов
|
||
document.querySelectorAll('.toolbox-nav-btn').forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
|
||
// Добавляем активный класс выбранной кнопке
|
||
const selectedBtn = document.querySelector(`.toolbox-nav-btn[data-toolbox-id="${toolboxId}"]`);
|
||
if (selectedBtn) {
|
||
selectedBtn.classList.add('active');
|
||
}
|
||
|
||
// Загружаем содержимое склада
|
||
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" onclick="selectToolbox(${toolboxId})">
|
||
<i class="bi bi-arrow-clockwise me-2"></i>
|
||
Попробовать снова
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
|
||
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');
|
||
const tbody = document.getElementById('toolboxItemsBody');
|
||
|
||
// Очищаем текущее содержимое
|
||
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) {
|
||
await showOperationModal(action, selectedItem);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
// Добавляем обработчики для изображений
|
||
document.querySelectorAll('.toolkit-image-link').forEach(link => {
|
||
link.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
const itemId = e.currentTarget.dataset.id;
|
||
const item = data.find(d => d.id == itemId);
|
||
if (item) {
|
||
await showToolkitDetailsModal(item);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
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(item) {
|
||
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 images = [];
|
||
|
||
if (typeof item.image === 'string') {
|
||
images = item.image ?
|
||
[item.image, ...(item.images || [])] :
|
||
[item.image];
|
||
} else {
|
||
images = item.image ?
|
||
[item.image.main, ...(item.image.additional || [])] :
|
||
[item.image.main];;
|
||
}
|
||
let imagesDiv = '';
|
||
|
||
if (images.length > 1) {
|
||
const carouselId = `carousel-${item.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-${item.id}" data-title="${item.title}">
|
||
<img src="${img}" alt="${item.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 = `
|
||
<div class="col-md-4">
|
||
<a href="${images[0]}" data-lightbox="toolkit-${item.id}" data-title="${item.title}">
|
||
<img src="${images[0]}" alt="${item.title}"
|
||
class="img-fluid rounded mb-3"
|
||
style="max-height: 300px; object-fit: contain; cursor: zoom-in;">
|
||
</a>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const description = item.description || item.toolkitData?.description || 'Нет описания';
|
||
const specifications = item.toolkitData?.specifications || item.specifications || {};
|
||
const external_link = item.external_link || item.toolkitData?.external_link || null;
|
||
const category_desc = item.categoryData?.description || item.category_desc || '';
|
||
const toolkitStocks = await getToolkitStocks(item.id);
|
||
|
||
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>${item.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>${description}</p>
|
||
|
||
<h6 class="mt-3"><i class="bi bi-list me-1"></i>Категория:</h6>
|
||
<p><strong>${item.category}</strong> - ${category_desc}</p>
|
||
|
||
${specifications ? `
|
||
<h6 class="mt-3"><i class="bi bi-gear me-1"></i>Характеристики:</h6>
|
||
<table class="table table-sm">
|
||
${Object.entries(specifications).map(([key, value]) => `
|
||
<tr>
|
||
<td style="width: 40%"><strong>${key}:</strong></td>
|
||
<td>${value}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</table>
|
||
` : ''}
|
||
|
||
${toolkitStocks ? `
|
||
<h6 class="mt-3"><i class="bi bi-box me-1"></i>Остатки на складах: <strong>${toolkitStocks.count}</strong> шт.</h6>
|
||
<table class="table table-sm">
|
||
${Object.entries(toolkitStocks.toolboxes).map(([key, value]) => `
|
||
<tr>
|
||
<td style="width: 60%"><strong>${key}:</strong></td>
|
||
<td>${value.count} шт.${value.id && accessData.available_own_toolbox ? `
|
||
<button class="btn btn-sm btn-outline-success" data-action="get"
|
||
data-toolbox_id="${value.id}" data-id="${item.id}"
|
||
data-available="${value.count}" data-totalcost="${value.totalCost}"
|
||
title="Получить">
|
||
<i class="bi bi-box-arrow-in-down"></i>
|
||
</button>
|
||
` : ''}
|
||
</td>
|
||
<td class="fw-bold">${value.placement || ''}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</table>
|
||
` : ''}
|
||
|
||
${external_link ? `
|
||
<a href="${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 class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Добавляем обработчики для кнопок в строке
|
||
modal.querySelectorAll('button[data-action]').forEach(button => {
|
||
button.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
const action = e.currentTarget.dataset.action;
|
||
const id = e.currentTarget.dataset.id;
|
||
const toolboxId = e.currentTarget.dataset.toolbox_id;
|
||
const available = e.currentTarget.dataset.available;
|
||
const totalQuantity = available;
|
||
const title = item.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();
|
||
});
|
||
});
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const bsModal = new bootstrap.Modal(modal);
|
||
bsModal.show();
|
||
|
||
modal.addEventListener('hidden.bs.modal', () => {
|
||
modal.remove();
|
||
});
|
||
|
||
// После создания модального окна добавьте инициализацию lightbox
|
||
lightbox.option({
|
||
'resizeDuration': 200,
|
||
'wrapAround': true,
|
||
'albumLabel': "Изображение %1 из %2",
|
||
'fadeDuration': 300,
|
||
'imageFadeDuration': 300
|
||
});
|
||
}
|
||
|
||
// Функция показа модального окна для операций
|
||
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 formatKey(key) {
|
||
const keyMap = {
|
||
'id': 'ID',
|
||
'title': 'Название',
|
||
'description': 'Описание',
|
||
'owner_id': 'ID владельца',
|
||
'monitoring': 'Мониторинг',
|
||
'created_at': 'Дата создания',
|
||
'updated_at': 'Дата обновления'
|
||
};
|
||
return keyMap[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
|
||
}
|
||
|
||
function formatValue(value) {
|
||
if (value === null || value === undefined) return '—';
|
||
if (typeof value === 'boolean') return value ? 'Да' : 'Нет';
|
||
if (typeof value === 'object') return JSON.stringify(value);
|
||
return value.toString();
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await getCookieData();
|
||
|
||
if (!accessData || !userData) {
|
||
console.warn('Access data or user data not found');
|
||
return;
|
||
}
|
||
|
||
prepareTabs();
|
||
});
|
||
|
||
window.openTab = openTab; |