Завершена работа со складами

This commit is contained in:
2025-12-10 23:03:32 +03:00
parent 197a4d0b1e
commit fcbe25f7ec
14 changed files with 689 additions and 34 deletions
+500 -5
View File
@@ -879,8 +879,8 @@ async function loadToolboxContent(toolboxId) {
function handleFillBtn() {
if (accessData.tools_registration && !toolboxInfo.owner_id) {
contentContainer.querySelector('#fillToolbox').addEventListener('click', () => {
fillToolbox(toolboxInfo);
contentContainer.querySelector('#fillToolbox').addEventListener('click', async () => {
await fillToolbox(toolboxInfo);
});
} else {
contentContainer.querySelector('#fillToolbox').remove();
@@ -1047,9 +1047,504 @@ async function loadToolboxContent(toolboxId) {
}
}
function fillToolbox(toolboxInfo) {
console.log(toolboxInfo);
showInfo('Функционал еще в разработке', 'warning');
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();
}
// Создаем модальное окно
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" aria-label="Закрыть"></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 { toolkits, categories, placements } = allToolkitsData.data;
const placementMap = {};
// Создаем карту расположений для текущего склада
placements
.filter(p => p.toolbox_id === toolboxInfo.id)
.forEach(p => {
placementMap[p.toolkit_id] = p.placement;
});
// Создаем карту инструментов по категориям
const toolkitsByCategory = {};
toolkits.forEach(tool => {
if (!toolkitsByCategory[tool.category_id]) {
toolkitsByCategory[tool.category_id] = [];
}
toolkitsByCategory[tool.category_id].push(tool);
});
// Функция для форматирования стоимости с разделителями
function formatCost(value) {
if (typeof value !== 'number') {
value = parseFloat(value) || 0;
}
return formatPrice(value);
}
// Функция для создания строки формы
function createRow(rowIndex = 0) {
const rowId = `row-${Date.now()}-${rowIndex}`;
return `
<tr id="${rowId}" data-row-id="${rowId}">
<td>
<select class="form-select form-select-sm category-select"
data-row="${rowId}" required>
<option value="">Выберите категорию</option>
${categories.map(cat =>
`<option value="${cat.id}">${cat.title}</option>`
).join('')}
</select>
</td>
<td>
<select class="form-select form-select-sm toolkit-select"
data-row="${rowId}" disabled required>
<option value="">Выберите инструмент</option>
</select>
</td>
<td>
<input type="number" class="form-control form-control-sm quantity"
data-row="${rowId}" min="1" step="1"
placeholder="0" disabled required>
</td>
<td>
<input type="number" class="form-control form-control-sm price"
data-row="${rowId}" min="0.01" step="0.01"
placeholder="0.00" disabled required>
</td>
<td>
<input type="text" class="form-control form-control-sm placement"
data-row="${rowId}" placeholder="Не указано" disabled>
</td>
<td>
<input type="text" class="form-control form-control-sm cost"
data-row="${rowId}" value="0.00 ₽" readonly>
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger remove-row"
data-row="${rowId}" ${rowIndex === 0 ? 'disabled' : ''}>
<i class="bi bi-x-lg"></i>
</button>
</td>
</tr>
`;
}
// Инициализация первой строки
const rowsContainer = document.getElementById('fillToolboxRows');
rowsContainer.innerHTML = createRow(0);
// Добавляем обработчики событий
setupEventHandlers();
// Функция для обновления итогов
function updateTotals() {
const rows = rowsContainer.querySelectorAll('tr');
let totalQuantity = 0;
let totalCost = 0;
let filledRows = 0;
rows.forEach(row => {
const quantityInput = row.querySelector('.quantity');
const costInput = row.querySelector('.cost');
if (quantityInput && costInput &&
quantityInput.value && costInput.value) {
const quantity = parseInt(quantityInput.value) || 0;
const cost = parseFloat(costInput.value.replace(/[^0-9.,]/g, '').replace(',', '.')) || 0;
totalQuantity += quantity;
totalCost += cost;
filledRows++;
}
});
document.getElementById('totalQuantity').textContent = totalQuantity;
document.getElementById('totalCost').textContent = formatCost(totalCost) + ' ₽';
document.getElementById('totalRowsCount').textContent =
`${filledRows} позиций`;
}
// Функция для расчета стоимости строки
function calculateRowCost(row) {
const quantityInput = row.querySelector('.quantity');
const priceInput = row.querySelector('.price');
const costInput = row.querySelector('.cost');
if (!quantityInput || !priceInput || !costInput) return;
const quantity = parseFloat(quantityInput.value) || 0;
const price = parseFloat(priceInput.value.replace(',', '.')) || 0;
const cost = quantity * price;
costInput.value = formatCost(cost) + ' ₽';
updateTotals();
}
// Функция для настройки обработчиков событий
function setupEventHandlers() {
// Обработчик добавления строки
document.getElementById('addRowBtn').addEventListener('click', function () {
const newRow = createRow(rowsContainer.children.length);
rowsContainer.insertAdjacentHTML('beforeend', newRow);
// Обновляем доступность кнопок удаления
const removeButtons = rowsContainer.querySelectorAll('.remove-row');
if (removeButtons.length > 1) {
removeButtons.forEach(btn => btn.disabled = false);
}
// Настраиваем обработчики для новой строки
const newRowElement = rowsContainer.lastElementChild;
setupRowHandlers(newRowElement);
});
// Настраиваем обработчики для всех строк
rowsContainer.querySelectorAll('tr').forEach(row => {
setupRowHandlers(row);
});
// Валидация поля обоснования
const reasonInput = document.getElementById('fillReason');
reasonInput.addEventListener('input', function () {
if (this.value.length >= 10) {
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else if (this.value.length > 0) {
this.classList.add('is-invalid');
this.classList.remove('is-valid');
} else {
this.classList.remove('is-invalid', 'is-valid');
}
});
// Обработчик отправки формы
document.getElementById('fillToolboxForm').addEventListener('submit', async function (e) {
e.preventDefault();
const submitBtn = document.getElementById('submitFillBtn');
const spinner = document.getElementById('submitFillSpinner');
const submitText = document.getElementById('submitFillText');
// Проверка заполнения всех обязательных полей
const rows = rowsContainer.querySelectorAll('tr');
const items = [];
let isValid = true;
let errorMessage = '';
// Проверка обоснования
const reason = reasonInput.value.trim();
if (reason.length < 10) {
isValid = false;
errorMessage = 'Введите обоснование (не менее 10 символов)';
reasonInput.classList.add('is-invalid');
reasonInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
reasonInput.classList.remove('is-invalid');
reasonInput.classList.add('is-valid');
}
// Проверка строк с инструментами
rows.forEach((row, index) => {
const categorySelect = row.querySelector('.category-select');
const toolkitSelect = row.querySelector('.toolkit-select');
const quantityInput = row.querySelector('.quantity');
const priceInput = row.querySelector('.price');
if (!categorySelect.value || !toolkitSelect.value ||
!quantityInput.value || !priceInput.value) {
isValid = false;
errorMessage = `Заполните все обязательные поля в строке ${index + 1}`;
// Подсвечиваем проблемные поля
[categorySelect, toolkitSelect, quantityInput, priceInput].forEach(field => {
if (!field.value) {
field.classList.add('is-invalid');
}
});
return;
}
items.push({
toolkit_id: parseInt(toolkitSelect.value),
quantity: parseInt(quantityInput.value),
price: parseFloat(priceInput.value.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;
}
// Запрос подтверждения (двойное нажатие в течение 10 секунд)
const originalText = submitText.textContent;
submitText.textContent = 'Нажмите еще раз для подтверждения (10 сек)';
submitBtn.disabled = true;
let confirmed = false;
let timeout = setTimeout(() => {
if (!confirmed) {
submitText.textContent = originalText;
submitBtn.disabled = false;
document.getElementById('fillToolboxError').classList.add('d-none');
}
}, 10000);
submitBtn.addEventListener('click', async function confirmHandler(e) {
e.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);
}, { once: true });
submitBtn.disabled = false;
});
}
// Функция для настройки обработчиков строки
function setupRowHandlers(row) {
const rowId = row.id;
// Обработчик выбора категории
const categorySelect = row.querySelector('.category-select');
categorySelect.addEventListener('change', function () {
this.classList.remove('is-invalid');
const toolkitSelect = row.querySelector('.toolkit-select');
const quantityInput = row.querySelector('.quantity');
const priceInput = row.querySelector('.price');
const placementInput = row.querySelector('.placement');
// Очищаем и деактивируем зависимые поля
toolkitSelect.innerHTML = '<option value="">Выберите инструмент</option>';
toolkitSelect.disabled = !this.value;
quantityInput.disabled = !this.value;
priceInput.disabled = !this.value;
placementInput.disabled = !this.value;
if (!this.value) {
quantityInput.value = '';
priceInput.value = '';
placementInput.value = '';
row.querySelector('.cost').value = '0.00 ₽';
updateTotals();
return;
}
// Заполняем список инструментов выбранной категории
const categoryId = parseInt(this.value);
const categoryTools = toolkitsByCategory[categoryId] || [];
categoryTools.forEach(tool => {
const option = document.createElement('option');
option.value = tool.id;
option.textContent = tool.title;
toolkitSelect.appendChild(option);
});
});
// Обработчик выбора инструмента
const toolkitSelect = row.querySelector('.toolkit-select');
toolkitSelect.addEventListener('change', function () {
this.classList.remove('is-invalid');
if (!this.value) return;
const toolId = parseInt(this.value);
// Автозаполнение расположения, если есть
if (placementMap[toolId]) {
row.querySelector('.placement').value = placementMap[toolId];
}
});
// Обработчики изменения количества и цены
const quantityInput = row.querySelector('.quantity');
const priceInput = row.querySelector('.price');
[quantityInput, priceInput].forEach(input => {
input.addEventListener('input', function () {
this.classList.remove('is-invalid');
calculateRowCost(row);
});
});
// Обработчик удаления строки
const removeBtn = row.querySelector('.remove-row');
removeBtn.addEventListener('click', function () {
if (rowsContainer.children.length <= 1) return;
row.remove();
updateTotals();
// Если осталась одна строка, делаем кнопку удаления недоступной
if (rowsContainer.children.length === 1) {
rowsContainer.querySelector('.remove-row').disabled = true;
}
});
}
// Очистка при закрытии модального окна
modal.addEventListener('hidden.bs.modal', () => {
setTimeout(() => {
if (modal.parentNode) {
modal.remove();
}
}, 300);
});
// Показываем модальное окно
bsModal.show();
// Устанавливаем фокус на первую категорию
setTimeout(() => {
const firstCategorySelect = rowsContainer.querySelector('.category-select');
if (firstCategorySelect) {
firstCategorySelect.focus();
}
}, 500);
}
async function deleteToolbox(toolboxId) {
+7 -7
View File
@@ -18,28 +18,28 @@ export function showInfo(message, type = 'info') {
// Определяем классы в зависимости от типа
const typeConfig = {
'success': { bgClass: 'bg-success', icon: 'bi-check-circle', delay: 5000 },
'error': { bgClass: 'bg-danger', icon: 'bi-exclamation-circle', delay: 10000 },
'info': { bgClass: 'bg-info', icon: 'bi-info-circle', delay: 3000 },
'warning': { bgClass: 'bg-warning', icon: 'bi-exclamation-triangle', delay: 8000 }
'success': { bgClass: 'bg-success', icon: 'bi-check-circle', delay: 5000, textColor: 'text-white' },
'error': { bgClass: 'bg-danger', icon: 'bi-exclamation-circle', delay: 10000, textColor: 'text-white' },
'info': { bgClass: 'bg-info', icon: 'bi-info-circle', delay: 3000, textColor: 'text-dark' },
'warning': { bgClass: 'bg-warning', icon: 'bi-exclamation-triangle', delay: 8000, textColor: 'text-dark' }
};
const config = typeConfig[type] || typeConfig.info;
// Создаем тост
const toast = document.createElement('div');
toast.className = `toast ${config.bgClass} text-white`;
toast.className = `toast ${config.bgClass} ${config.textColor} shadow-sm`;
toast.id = toastId;
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = `
<div class="toast-header ${config.bgClass} text-white">
<div class="toast-header ${config.bgClass} ${config.textColor}">
<i class="bi ${config.icon} me-2"></i>
<strong class="me-auto">Уведомление</strong>
<small>Только что</small>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
<button type="button" class="btn-close btn-close-dark" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body">
${message}