diff --git a/api/routers/__init__.py b/api/routers/__init__.py
index 7885478..fcd30fe 100644
--- a/api/routers/__init__.py
+++ b/api/routers/__init__.py
@@ -56,7 +56,7 @@ async def post_requests(
case "toolkits":
toolkits = await ToolkitHandler.getAll()
categories = await CategoryHandler.getAll()
- if toolkits:
+ if toolkits and categories:
resultData["status"] = "ok"
resultData["data"] = {
"toolkits": toolkits,
diff --git a/api/routers/__pycache__/__init__.cpython-313.pyc b/api/routers/__pycache__/__init__.cpython-313.pyc
index d0e02ed..d8cbcd3 100644
Binary files a/api/routers/__pycache__/__init__.cpython-313.pyc and b/api/routers/__pycache__/__init__.cpython-313.pyc differ
diff --git a/api/routers/__pycache__/toolbox.cpython-313.pyc b/api/routers/__pycache__/toolbox.cpython-313.pyc
index be0dbf3..ee76735 100644
Binary files a/api/routers/__pycache__/toolbox.cpython-313.pyc and b/api/routers/__pycache__/toolbox.cpython-313.pyc differ
diff --git a/api/routers/__pycache__/toolkit.cpython-313.pyc b/api/routers/__pycache__/toolkit.cpython-313.pyc
index 660a310..d8e472b 100644
Binary files a/api/routers/__pycache__/toolkit.cpython-313.pyc and b/api/routers/__pycache__/toolkit.cpython-313.pyc differ
diff --git a/api/routers/toolbox.py b/api/routers/toolbox.py
index 26dcb86..1f5ba6d 100644
--- a/api/routers/toolbox.py
+++ b/api/routers/toolbox.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends
+from db.handlers.actions import StocksActions
from db.handlers.stock import StockHandler
from db.handlers.toolbox import ToolboxHandler
from utils import requestDict, logger
@@ -48,3 +49,35 @@ async def delete_toolbox(reqDict=Depends(requestDict)):
if result:
response["status"] = "ok"
return response
+
+
+@router.post("/fill", summary="Заполнение ящика")
+async def fill_toolbox(reqDict=Depends(requestDict)):
+ logger.info(f"Заполнение ящика")
+ logger.info(reqDict.get("body"))
+ response = {"status": "error"}
+ toolboxId = reqDict.get("body").get("toolboxId")
+ userId = reqDict.get("body").get("userId")
+ reason = reqDict.get("body").get("reason")
+ items = reqDict.get("body").get("items")
+ successCount = 0
+ for item in items:
+ success = await StocksActions.registration(
+ item.get("toolkit_id"),
+ toolboxId,
+ userId,
+ item.get("quantity"),
+ item.get("price"),
+ item.get("placement"),
+ reason,
+ )
+ if success:
+ successCount += 1
+ if successCount == len(items):
+ response["status"] = "ok"
+ else:
+ response["message"] = (
+ f"Оприходовано {successCount} записей из {len(items)}. Проверьте остатки и повторите попытку"
+ )
+ logger.info(response)
+ return response
diff --git a/api/routers/toolkit.py b/api/routers/toolkit.py
index a519130..88bfff4 100644
--- a/api/routers/toolkit.py
+++ b/api/routers/toolkit.py
@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends
-from db.handlers.stock import StockHandler
+from db.handlers.categories import CategoryHandler
+from db.handlers.stock import PlacementHandler, StockHandler
from db.handlers.toolbox import ToolboxHandler
+from db.handlers.toolkit import ToolkitHandler
from utils import requestDict, logger
@@ -55,3 +57,20 @@ async def toolkit_request(
response["status"] = "ok"
response["data"] = stocksData
return response
+
+
+@router.post("/fill_prepare", summary="Подготовка заполнения ящика")
+async def fill_toolbox():
+ logger.info(f"Подготовка заполнения ящика")
+ response = {"status": "error"}
+ toolkits = await ToolkitHandler.getAll()
+ categories = await CategoryHandler.getAll()
+ placements = await PlacementHandler.getAll()
+ if toolkits and categories:
+ response["status"] = "ok"
+ response["data"] = {
+ "toolkits": sorted(toolkits, key=lambda toolkit: toolkit["title"]),
+ "categories": sorted(categories, key=lambda category: category["title"]),
+ "placements": [placement.toDict() for placement in placements],
+ }
+ return response
diff --git a/api/static/js/index.js b/api/static/js/index.js
index 4f61fea..7eeae57 100644
--- a/api/static/js/index.js
+++ b/api/static/js/index.js
@@ -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 = `
+
+ `;
+
+ 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 `
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ `;
+ }
+
+ // Инициализация первой строки
+ 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 = '';
+ 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) {
diff --git a/api/static/js/toast.js b/api/static/js/toast.js
index a57791a..392bd24 100644
--- a/api/static/js/toast.js
+++ b/api/static/js/toast.js
@@ -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 = `
-