Kgshop commited on
Commit
7d57140
·
verified ·
1 Parent(s): 23cc51f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +326 -210
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  # Импортируем необходимые библиотеки
2
  from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
3
  import json
@@ -359,13 +360,18 @@ def procurement():
359
  logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True)
360
  flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger")
361
 
362
- # GET запрос - рендерим шаблон
363
  page_title = "Закуп материалов"
364
  page_content = PROCUREMENT_CONTENT # Используем переменную с контентом
365
  page_scripts = PROCUREMENT_SCRIPTS # Используем переменную со скриптами
366
- final_html = BASE_TEMPLATE.format(title=page_title, content=page_content, scripts=page_scripts)
367
- # Передаем нужные переменные в render_template_string
368
- return render_template_string(final_html, categories=categories)
 
 
 
 
 
369
 
370
 
371
  # 2. Маршрут "Раскрой"
@@ -495,25 +501,30 @@ def cutting():
495
  logging.error(f"Ошибка при обработке раскроя: {e}", exc_info=True)
496
  flash(f"Произошла внутренняя ошибка при обработке раскроя: {e}", "danger")
497
 
498
- # GET запрос
499
  # Передаем материалы, преобразовав количество в Decimal для отображения
500
  fabrics_dec = []
501
  for f in fabrics:
502
  f_copy = f.copy()
503
- f_copy['quantity'] = to_decimal(f_copy.get('quantity', '0'))
504
  fabrics_dec.append(f_copy)
505
 
506
  fittings_dec = []
507
  for f in fittings:
508
  f_copy = f.copy()
509
- f_copy['quantity'] = to_decimal(f_copy.get('quantity', '0'))
510
  fittings_dec.append(f_copy)
511
 
512
  page_title = "Раскрой ткани"
513
  page_content = CUTTING_CONTENT
514
  page_scripts = CUTTING_SCRIPTS
515
- final_html = BASE_TEMPLATE.format(title=page_title, content=page_content, scripts=page_scripts)
516
- return render_template_string(final_html, fabrics=fabrics_dec, fittings=fittings_dec)
 
 
 
 
 
517
 
518
 
519
  # 3. Маршрут "Пошив"
@@ -701,7 +712,7 @@ def sewing():
701
  'fabric_id': cutting_task['fabric_id'], # Сохраняем для справки
702
  'fabric_name': cutting_task['fabric_name'],
703
  'fittings_consumed': fittings_consumed_list, # Фактически использовано на пошив
704
- 'defects_reported': defect_log_list, # Залогированный брак (уже с правильными типами)
705
  'status': 'pending_qc', # 'pending_qc', 'completed' (packed)
706
  'timestamp_created': current_time,
707
  'timestamp_completed': None, # Заполнится при ОТК
@@ -709,16 +720,17 @@ def sewing():
709
  'qc_defective_quantity': 0
710
  }
711
 
712
- # Присваиваем ID задания на пошив записям в логе брака
713
  for defect in defect_log_list:
714
  defect['sewing_task_id'] = new_sewing_task['id']
715
  # Преобразуем Decimal обратно в строку для JSON, если это ткань
716
- if defect['type'] == 'fabric':
717
  defect['quantity'] = str(defect['quantity'])
 
 
718
 
719
 
720
- # 5. Обновление статуса задания на раскро�� (или частичное обновление?)
721
- # Пока считаем, что отправка на пошив завершает задание на раскрой целиком.
722
  task_updated = False
723
  for i, task in enumerate(data.get('cutting_tasks', [])):
724
  if task.get('id') == cutting_task_id:
@@ -734,9 +746,10 @@ def sewing():
734
  if 'sewing_tasks' not in data: data['sewing_tasks'] = []
735
  data['sewing_tasks'].append(new_sewing_task)
736
 
737
- if defect_log_list:
738
  if 'defect_log' not in data: data['defect_log'] = []
739
- data['defect_log'].extend(defect_log_list)
 
740
 
741
  # Обновляем основной список материалов
742
  data['materials'] = materials_list
@@ -752,20 +765,23 @@ def sewing():
752
  logging.error(f"Ошибка при обработке пошива: {e}", exc_info=True)
753
  flash(f"Произошла внутренняя ошибка при обработке пошива: {e}", "danger")
754
 
755
- # GET запрос
756
  # Преобразуем данные для шаблона
757
  tasks_for_template = []
758
  all_materials_dict = {m['id']: m for m in data.get('materials', [])} # Словарь для быстрого поиска остатков
759
 
760
  for task in pending_cutting_tasks:
761
  task_copy = task.copy()
762
- task_copy['fabric_used_decimal'] = to_decimal(task_copy.get('fabric_used', '0')) # Для возможного отображения
 
 
763
  # Получаем текущие остатки фурнитуры для отображения в деталях
764
  if 'required_fittings' in task_copy:
765
  for fitting in task_copy['required_fittings']:
766
  mat_data = all_materials_dict.get(fitting['fitting_id'])
767
  fitting['available_quantity'] = to_decimal(mat_data.get('quantity', '0')) if mat_data else Decimal('0')
768
  fitting['unit'] = mat_data.get('unit', 'шт') if mat_data else 'шт'
 
769
  tasks_for_template.append(task_copy)
770
 
771
  # Получаем все материалы для выбора брака (с преобразованными количествами)
@@ -779,8 +795,12 @@ def sewing():
779
  page_title = "Пошив изделий"
780
  page_content = SEWING_CONTENT
781
  page_scripts = SEWING_SCRIPTS
782
- final_html = BASE_TEMPLATE.format(title=page_title, content=page_content, scripts=page_scripts)
783
- return render_template_string(final_html,
 
 
 
 
784
  cutting_tasks=tasks_for_template,
785
  all_materials=all_materials_dec) # Передаем материалы с quantity_decimal
786
 
@@ -852,7 +872,6 @@ def qc_packing():
852
  data['qc_packing_items'].append(packed_item)
853
 
854
  # Регистрация брака на этапе ОТК (если есть)
855
- # Здесь мы не списываем материал, а просто логируем брак готового изделия
856
  if quantity_defective > 0:
857
  defect_log_entry = {
858
  'log_id': uuid.uuid4().hex,
@@ -875,9 +894,13 @@ def qc_packing():
875
  task_updated = False
876
  for i, task in enumerate(data.get('sewing_tasks', [])):
877
  if task.get('id') == sewing_task_id:
878
- # Добавляем информацию о результате ОТК в само задание
879
- task['qc_packed_quantity'] = task.get('qc_packed_quantity', 0) + quantity_packed # Накапливаем, если частичная приемка
880
- task['qc_defective_quantity'] = task.get('qc_defective_quantity', 0) + quantity_defective
 
 
 
 
881
  task['status'] = 'completed' # Считаем завершенным после обработки
882
  task['timestamp_completed'] = current_time # Время последнего ОТК
883
  task_updated = True
@@ -895,13 +918,17 @@ def qc_packing():
895
  logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True)
896
  flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger")
897
 
898
- # GET запрос
899
  page_title = "ОТК и Упаковка"
900
  page_content = QC_PACKING_CONTENT
901
  page_scripts = QC_PACKING_SCRIPTS
902
- final_html = BASE_TEMPLATE.format(title=page_title, content=page_content, scripts=page_scripts)
 
 
 
 
903
  # Передаем задачи, ожидающие ОТК, в шаблон
904
- return render_template_string(final_html, sewing_tasks=pending_sewing_tasks)
905
 
906
 
907
  # 5. Маршрут "Админ-панель"
@@ -942,6 +969,11 @@ def admin_panel():
942
  # Используем find_sewing_task_by_id, чтобы получить уже преобразованные числа
943
  task_data = find_sewing_task_by_id(t['id'])
944
  if task_data: # Убедимся, что задача найдена
 
 
 
 
 
945
  sewing_tasks_view.append(task_data)
946
  else:
947
  logging.warning(f"Задача на пошив с ID {t['id']} не найдена при подготовке для админки.")
@@ -959,7 +991,7 @@ def admin_panel():
959
  packed_items_view.append(item_copy)
960
 
961
  defect_log_view = []
962
- total_defect_items = 0 # Будем считать штуки, метры отдельно
963
  total_defect_fabric_m = Decimal('0.00')
964
  total_defect_fittings_pcs = 0
965
  total_defect_finished_pcs = 0
@@ -991,11 +1023,14 @@ def admin_panel():
991
  page_title = "Админ-панель"
992
  page_content = ADMIN_CONTENT
993
  page_scripts = ADMIN_SCRIPTS # Если есть специфичные скрипты для админки
994
- final_html = BASE_TEMPLATE.format(title=page_title, content=page_content, scripts=page_scripts)
 
 
 
995
 
996
  # Передаем подготовленные данные
997
  return render_template_string(
998
- final_html,
999
  materials=materials_view,
1000
  cutting_tasks=cutting_tasks_view,
1001
  sewing_tasks=sewing_tasks_view,
@@ -1127,51 +1162,51 @@ def download_hf():
1127
 
1128
  # --- HTML Шаблоны (как строки Python) ---
1129
 
1130
- # Базовый шаблон с плейсхолдерами
1131
  BASE_TEMPLATE = """
1132
  <!DOCTYPE html>
1133
  <html lang="ru">
1134
  <head>
1135
  <meta charset="UTF-8">
1136
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1137
- <title>{title} - Текстиль Учет</title>
1138
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
1139
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1140
  <style>
1141
- body {{
1142
  background-color: #f8f9fa; /* Светлый фон */
1143
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1144
  padding-top: 70px; /* Отступ для фиксированной навигации */
1145
  padding-bottom: 60px; /* Отступ для футера */
1146
  position: relative;
1147
  min-height: 100vh;
1148
- }}
1149
- .navbar {{
1150
  background-color: #343a40; /* Темная навигация */
1151
  box-shadow: 0 2px 4px rgba(0,0,0,.1);
1152
- }}
1153
- .navbar-brand, .nav-link {{
1154
  color: #f8f9fa !important; /* Светлый текст */
1155
- }}
1156
- .navbar-brand:hover, .nav-link:hover {{
1157
  color: #adb5bd !important; /* Чуть темнее при наведении */
1158
- }}
1159
- .nav-link.active {{
1160
  font-weight: bold;
1161
  color: #ffffff !important;
1162
  border-bottom: 2px solid #0d6efd; /* Подчеркивание активного пункта */
1163
- }}
1164
- .card {{
1165
  margin-bottom: 1.5rem;
1166
  border: none; /* Убираем стандартную рамку */
1167
  border-radius: 0.5rem; /* Скругление углов */
1168
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); /* Тень */
1169
  transition: transform 0.2s ease-in-out;
1170
- }}
1171
- .card:hover {{
1172
  /* transform: translateY(-3px); Легкий подъем при наведении - убрал, может мешать */
1173
- }}
1174
- .card-header {{
1175
  background-color: #6c757d; /* Серая шапка */
1176
  color: white;
1177
  font-weight: 600;
@@ -1180,86 +1215,98 @@ BASE_TEMPLATE = """
1180
  border-top-right-radius: 0.5rem;
1181
  display: flex;
1182
  align-items: center;
1183
- }}
1184
- .card-header i {{
1185
  margin-right: 0.5rem; /* Отступ для иконки в шапке */
1186
- }}
1187
- .btn-primary {{
1188
  background-color: #0d6efd;
1189
  border-color: #0d6efd;
1190
- }}
1191
- .btn-primary:hover {{
1192
  background-color: #0b5ed7;
1193
  border-color: #0a58ca;
1194
- }}
1195
- .btn-success {{
1196
  background-color: #198754;
1197
  border-color: #198754;
1198
- }}
1199
- .btn-danger {{
1200
  background-color: #dc3545;
1201
  border-color: #dc3545;
1202
- }}
1203
- .btn-warning {{
1204
  background-color: #ffc107;
1205
  border-color: #ffc107;
1206
  color: #212529; /* Темный текст для желтой кнопки */
1207
- }}
1208
- .btn-info {{
1209
  background-color: #0dcaf0;
1210
  border-color: #0dcaf0;
1211
  color: #000;
1212
- }}
1213
- .table-hover tbody tr:hover {{
1214
  background-color: rgba(0, 0, 0, 0.05); /* Легкое выделение строки таблицы */
1215
  cursor: pointer; /* Намек на интерактивность (если будет) */
1216
- }}
1217
- .container-main {{ /* Переименовал, чтобы не конфликтовать с Bootstrap */
1218
  max-width: 1400px; /* Чуть шире контейнер */
1219
  margin: 0 auto; /* Центрирование */
1220
  padding-left: 15px;
1221
  padding-right: 15px;
1222
- }}
1223
  /* Статусы */
1224
- .status-pending {{ color: #fd7e14; font-weight: bold; }} /* Оранжевый - ожидает */
1225
- .status-completed {{ color: #198754; font-weight: bold; }} /* Зеленый - завершено */
1226
- .status-pending_qc {{ color: #ffc107; font-weight: bold; }} /* Желтый - ожидает ОТК */
1227
 
1228
- .flash-messages .alert {{
1229
  margin-bottom: 1rem;
1230
  border-radius: 0.5rem;
1231
- }}
1232
  /* Стиль для динамически добавляемых строк */
1233
- .dynamic-row, .dynamic-fitting-row, .dynamic-defect-row {{
1234
  border: 1px solid #eee;
1235
  padding: 15px;
1236
  margin-bottom: 15px;
1237
  border-radius: 5px;
1238
  background-color: #fdfdfd;
1239
  position: relative; /* Для позиционирования кнопки удаления */
1240
- }}
1241
- .remove-row-btn, .remove-fitting-row-btn, .remove-defect-row-btn {{
1242
  position: absolute;
1243
  top: 10px;
1244
  right: 10px;
1245
- /* margin-top: 25px; Выравнивание кнопки удаления - старый вариант */
1246
- }}
1247
  /* Responsive table */
1248
- .table-responsive {{
1249
  margin-bottom: 1rem;
1250
- }}
1251
- .table th, .table td {{
1252
  vertical-align: middle; /* Выравнивание по центру */
1253
- }}
1254
- .table td small {{
 
1255
  color: #6c757d; /* Серый цвет для мелкого текста */
1256
  display: block; /* ID на новой строке */
1257
- }}
1258
- .table .badge {{ /* Стили для бэйджей в таблицах */
 
1259
  font-size: 0.8em;
1260
- }}
 
 
 
 
 
 
 
 
 
 
1261
  /* Footer */
1262
- .footer {{
1263
  position: absolute;
1264
  bottom: 0;
1265
  width: 100%;
@@ -1268,19 +1315,19 @@ BASE_TEMPLATE = """
1268
  text-align: center;
1269
  padding: 10px 0;
1270
  font-size: 0.9em;
1271
- }}
1272
  /* Скрытие стрелок у number инпутов */
1273
  input[type=number]::-webkit-inner-spin-button,
1274
- input[type=number]::-webkit-outer-spin-button {{
1275
  -webkit-appearance: none;
1276
  margin: 0;
1277
- }}
1278
- input[type=number] {{
1279
  -moz-appearance: textfield; /* Firefox */
1280
- }}
1281
- .form-text {{
1282
  font-size: 0.8rem;
1283
- }}
1284
  </style>
1285
  </head>
1286
  <body>
@@ -1310,13 +1357,13 @@ BASE_TEMPLATE = """
1310
  </li>
1311
  </ul>
1312
  <!-- Кнопки Hugging Face справа -->
1313
- <div class="d-flex">
1314
- <form method="POST" action="{{ url_for('backup_hf') }}" class="me-2">
1315
  <button type="submit" class="btn btn-sm btn-outline-light" title="Создать резервную копию на Hugging Face">
1316
  <i class="fas fa-cloud-upload-alt"></i> Backup
1317
  </button>
1318
  </form>
1319
- <form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ОСТОРОЖНО! Это перезапишет локальные данные последней версией с Hugging Face. Вы уверены?');">
1320
  <button type="submit" class="btn btn-sm btn-outline-warning" title="Скачать базу данных с Hugging Face (перезапишет локальную)">
1321
  <i class="fas fa-cloud-download-alt"></i> Download DB
1322
  </button>
@@ -1332,7 +1379,13 @@ BASE_TEMPLATE = """
1332
  {% if messages %}
1333
  <div class="flash-messages">
1334
  {% for category, message in messages %}
1335
- <div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show" role="alert">
 
 
 
 
 
 
1336
  {{ message }}
1337
  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
1338
  </div>
@@ -1342,7 +1395,7 @@ BASE_TEMPLATE = """
1342
  {% endwith %}
1343
 
1344
  {# Основной контент страницы #}
1345
- {content}
1346
 
1347
  </div>
1348
 
@@ -1354,12 +1407,12 @@ BASE_TEMPLATE = """
1354
 
1355
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
1356
  {# Скрипты для конкретной страницы #}
1357
- {scripts}
1358
  </body>
1359
  </html>
1360
  """
1361
 
1362
- # --- Контент и скрипты для страниц ---
1363
 
1364
  PROCUREMENT_CONTENT = """
1365
  <div class="card">
@@ -1430,7 +1483,10 @@ PROCUREMENT_SCRIPTS = """
1430
  function addRow() {
1431
  const container = document.getElementById('material-rows');
1432
  const firstRow = container.querySelector('.dynamic-row'); // Берем первую строку как шаблон
1433
- if (!firstRow) return; // Защита, если первая строка удалена
 
 
 
1434
  const newRow = firstRow.cloneNode(true);
1435
 
1436
  // Очищаем значения в новой строке
@@ -1465,12 +1521,11 @@ PROCUREMENT_SCRIPTS = """
1465
 
1466
  container.appendChild(newRow);
1467
  attachCategoryChangeEvent(newRow); // Добавляем обработчик для новой строки
1468
- // updateRemoveButtons не нужна, если кнопка всегда видима и позиционируется абсолютно
1469
  }
1470
 
1471
  function removeRow(button) {
1472
  const row = button.closest('.dynamic-row');
1473
- // Позволяем удалять любую строку, даже последнюю, т.к. можно добавить новую
1474
  if (row) {
1475
  row.remove();
1476
  }
@@ -1509,14 +1564,16 @@ PROCUREMENT_SCRIPTS = """
1509
 
1510
  // Инициализация при загрузке страницы
1511
  document.addEventListener('DOMContentLoaded', () => {
1512
- document.querySelectorAll('.dynamic-row').forEach(row => {
1513
- attachCategoryChangeEvent(row);
1514
- const categorySelect = row.querySelector('.category-select');
1515
- if (categorySelect) handleCategoryChange(categorySelect); // Инициализируем видимость поля новой категории
1516
- });
1517
-
1518
- // Если изначально только одна строка, скроем кнопку удаления
1519
- // updateRemoveButtons(); // Раскомментировать, если нужна логика скрытия кнопки на 1 строке
 
 
1520
  });
1521
  </script>
1522
  """
@@ -1596,11 +1653,11 @@ CUTTING_SCRIPTS = """
1596
  if (selectedOption && selectedOption.value) {
1597
  const quantity = selectedOption.getAttribute('data-quantity'); // Уже содержит запятую, если надо
1598
  const unit = selectedOption.getAttribute('data-unit');
1599
- availableDiv.textContent = `В наличии: ${quantity} ${unit}`;
1600
- unitSpan.textContent = unit; // Обновляем единицу измерения в поле расхода
1601
  } else {
1602
- availableDiv.textContent = '';
1603
- unitSpan.textContent = 'м'; // Сброс на метры по умолчанию
1604
  }
1605
  }
1606
 
@@ -1673,15 +1730,18 @@ CUTTING_SCRIPTS = """
1673
  // Инициализация при загрузке
1674
  document.addEventListener('DOMContentLoaded', () => {
1675
  updateAvailableQuantity(); // Вызываем при загрузк��
1676
- document.querySelectorAll('.dynamic-fitting-row').forEach(row => {
 
1677
  attachFittingChangeEvent(row);
1678
  const fittingSelect = row.querySelector('.fitting-select');
1679
  if (fittingSelect) handleFittingChange(fittingSelect); // Инициализируем показ доступности
1680
  });
1681
- // Если нет строк фурнитуры, добавим одну пустую для удобства
1682
- if (!document.querySelector('.dynamic-fitting-row')) {
1683
- // Не будем добавлять автоматически, чтобы не мешать, если фурнитура не нужна.
1684
- // Пользователь может нажать кнопку "Добавить фурнитуру"
 
 
1685
  }
1686
  });
1687
  </script>
@@ -1794,14 +1854,14 @@ SEWING_SCRIPTS = """
1794
 
1795
 
1796
  if (selectedOption && selectedOption.value) {
1797
- const fabricName = selectedOption.getAttribute('data-fabric-name');
1798
- const cutQuantity = selectedOption.getAttribute('data-cut-quantity');
1799
 
1800
- document.getElementById('detail-fabric-name').textContent = fabricName;
1801
- document.getElementById('detail-cut-quantity').textContent = cutQuantity;
1802
- document.getElementById('detail-fabric-used').textContent = selectedOption.getAttribute('data-fabric-used');
1803
 
1804
- maxSewnSpan.textContent = cutQuantity;
1805
  if(sewnInput) sewnInput.max = cutQuantity; // Устанавливаем максимум для поля ввода
1806
 
1807
  // Предзаполняем название изделия на основе ткани, если поле пустое
@@ -1811,34 +1871,44 @@ SEWING_SCRIPTS = """
1811
 
1812
 
1813
  const fittingsList = document.getElementById('detail-fittings-list');
1814
- if(fittingsList) fittingsList.innerHTML = ''; // Очищаем список
1815
- const fittingsData = JSON.parse(selectedOption.getAttribute('data-fittings'));
1816
-
1817
- if (fittingsData && fittingsData.length > 0) {
1818
- fittingsData.forEach(fitting => {
1819
- // Ищем фурнитуру в селекте брака, чтобы показать остаток
1820
- let availableQtyStr = 'Н/Д';
1821
- const defectSelect = document.querySelector('.defect-material-select'); // Берем любой селект для поиска
1822
- if (defectSelect) {
1823
- const option = defectSelect.querySelector(`option[value="${fitting.fitting_id}"]`);
1824
- if (option) {
1825
- availableQtyStr = `${option.getAttribute('data-quantity')} ${option.getAttribute('data-unit')}`;
1826
- }
 
 
 
 
 
 
 
 
 
 
 
1827
  } else {
1828
- // Если селекта брака нет, инфо об остатках не показать
1829
- availableQtyStr = '(см. склад)';
 
 
1830
  }
1831
-
1832
- const listItem = document.createElement('li');
1833
- listItem.textContent = `${fitting.fitting_name}: ${fitting.quantity_needed} шт. (Доступно: ${availableQtyStr})`;
1834
- fittingsList.appendChild(listItem);
1835
- });
1836
- } else if (fittingsList) {
1837
- const listItem = document.createElement('li');
1838
- listItem.textContent = 'Фурнитура не указана в задании на раскрой.';
1839
- listItem.classList.add('text-muted');
1840
- fittingsList.appendChild(listItem);
1841
- }
1842
 
1843
  if(detailsDiv) detailsDiv.style.display = 'block';
1844
  } else {
@@ -1864,6 +1934,14 @@ SEWING_SCRIPTS = """
1864
  newRow.querySelectorAll('input[type="text"]').forEach(input => input.value = '');
1865
  const availabilityDiv = newRow.querySelector('.defect-availability');
1866
  if(availabilityDiv) availabilityDiv.textContent = '';
 
 
 
 
 
 
 
 
1867
 
1868
  // Кнопка удаления
1869
  const removeBtn = newRow.querySelector('.remove-defect-row-btn');
@@ -1930,16 +2008,17 @@ SEWING_SCRIPTS = """
1930
  // Инициализация
1931
  document.addEventListener('DOMContentLoaded', () => {
1932
  showTaskDetails(); // Показать детали для выбранного по умолчанию (если есть)
1933
- document.querySelectorAll('.dynamic-defect-row').forEach(row => {
 
1934
  attachDefectChangeEvent(row);
1935
  const defectSelect = row.querySelector('.defect-material-select');
1936
  if (defectSelect) handleDefectChange(defectSelect); // Инициализируем доступность и тип инпута
1937
  });
1938
- // Если нет строк брака, добавим одну пустую
1939
- if (!document.querySelector('.dynamic-defect-row') && document.getElementById('defect-rows')) {
1940
- // Не добавляем автоматически
1941
- // addDefectRow();
1942
- }
1943
  });
1944
  </script>
1945
  """
@@ -1997,7 +2076,7 @@ QC_PACKING_SCRIPTS = """
1997
 
1998
  function updateQcQuantities() {
1999
  const select = document.getElementById('sewing_task_id');
2000
- const selectedOption = select.options[select.selectedIndex];
2001
  const packedInput = document.getElementById('quantity_packed');
2002
  const defectiveInput = document.getElementById('quantity_defective');
2003
  const totalInfo = document.getElementById('qc-total-info');
@@ -2015,7 +2094,7 @@ QC_PACKING_SCRIPTS = """
2015
  }
2016
  if(totalInfo) {
2017
  totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака не должна превышать это число.`;
2018
- totalInfo.classList.remove('text-danger');
2019
  }
2020
  // Вызываем валидацию сразу
2021
  validateQcSum();
@@ -2030,7 +2109,7 @@ QC_PACKING_SCRIPTS = """
2030
  defectiveInput.max = '';
2031
  defectiveInput.value = '0';
2032
  }
2033
- if(totalInfo) totalInfo.textContent = '';
2034
  }
2035
  }
2036
 
@@ -2045,43 +2124,61 @@ QC_PACKING_SCRIPTS = """
2045
  const defective = parseInt(defectiveInput.value) || 0;
2046
  const totalProcessed = packed + defective;
2047
 
 
 
2048
  if (maxAllowedQuantity === 0 && totalProcessed > 0) {
2049
  // Если задание не выбрано, но что-то введено
2050
  totalInfo.textContent = 'Сначала выберите задание на пошив!';
2051
  totalInfo.classList.add('text-danger');
2052
  return false;
2053
  }
 
 
 
 
 
2054
 
2055
  if (totalProcessed > maxAllowedQuantity) {
2056
  totalInfo.textContent = `Ошибка: Сумма (${totalProcessed}) превышает количество сшитых (${maxAllowedQuantity})!`;
2057
  totalInfo.classList.add('text-danger');
2058
  return false; // Сумма неверна
2059
- } else {
2060
- totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака (${totalProcessed}) не должна превышать это число.`;
2061
- totalInfo.classList.remove('text-danger');
 
 
 
 
 
2062
  return true; // Сумма верна
2063
  }
2064
  }
2065
 
2066
  function validateQcForm() {
2067
  // Дополнительная проверка перед отправкой формы
2068
- const isValidSum = validateQcSum();
2069
  if (!isValidSum) {
2070
- alert('Ошибка в количестве упакованных или бракованных изделий. Проверьте введенные значения.');
2071
  return false; // Предотвратить отправку формы
2072
  }
2073
 
 
 
 
 
 
 
 
 
2074
  const packedInput = document.getElementById('quantity_packed');
2075
  const defectiveInput = document.getElementById('quantity_defective');
2076
  const packed = parseInt(packedInput.value) || 0;
2077
  const defective = parseInt(defectiveInput.value) || 0;
2078
-
2079
  if (packed === 0 && defective === 0) {
2080
  alert('Укажите количество упакованных или бракованных изделий (хотя бы одно должно быть больше н��ля).');
2081
  return false;
2082
  }
2083
 
2084
-
2085
  return true; // Разрешить отправку формы
2086
  }
2087
 
@@ -2260,8 +2357,8 @@ ADMIN_CONTENT = """
2260
  <tr class="material-row" data-name="{{ material.name|lower }}" data-category="{{ material.category|default('Без категории')|lower }}">
2261
  <td title="{{ material.id }}"><small>{{ material.id[:8] }}...</small></td>
2262
  <td>{{ material.name }}</td>
2263
- <td><span class="badge bg-info">{{ material.category | default('Без категории') }}</span></td>
2264
- <td>{{ 'Ткань' if material.type == 'fabric' else 'Фурнитура' }}</td>
2265
  {# Используем quantity_dec для отображения #}
2266
  <td data-sort="{{ material.quantity_dec }}">{{ material.quantity_dec|string|replace('.', ',') }}</td>
2267
  <td>{{ material.unit }}</td>
@@ -2471,7 +2568,7 @@ ADMIN_CONTENT = """
2471
  <td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td>
2472
  <td>{{ defect.quantity_view }}</td> {# Используем quantity_view #}
2473
  <td>{{ defect.unit }}</td>
2474
- <td>{{ defect.stage|replace('_', ' ')|title }}</td>
2475
  <td>{{ defect.reason | default('Не указана') }}</td>
2476
  <td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td>
2477
  <td title="{{ defect.sewing_task_id }}"><small>{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...</small></td>
@@ -2495,8 +2592,8 @@ const searchInput = document.getElementById('material-search');
2495
  const tableBody = document.getElementById('materials-table')?.querySelector('tbody'); // Добавил ? для безопасности
2496
 
2497
  if (searchInput && tableBody) {
2498
- searchInput.addEventListener('keyup', function() {
2499
- const searchTerm = searchInput.value.toLowerCase();
2500
  const rows = tableBody.querySelectorAll('tr.material-row'); // Используем класс для точности
2501
 
2502
  rows.forEach(row => {
@@ -2518,23 +2615,39 @@ function sortTable(columnIndex, tableId, isNumeric = false) {
2518
  if (!table) return;
2519
  const tbody = table.querySelector('tbody');
2520
  if (!tbody) return;
 
 
 
 
 
2521
  const rows = Array.from(tbody.querySelectorAll('tr'));
2522
- const headerCell = table.querySelector(`thead th:nth-child(${columnIndex + 1})`); // +1 т.к. nth-child(1) - первый
 
 
 
2523
 
2524
  // Определяем направление сортировки
2525
- let currentDir = headerCell?.dataset.sortDir || 'asc'; // Используем data-атрибут для хранения направления
2526
  let newDir = currentDir === 'asc' ? 'desc' : 'asc';
2527
 
2528
- // Сбрасываем направления у других колонок
2529
- table.querySelectorAll('thead th').forEach(th => th.dataset.sortDir = '');
 
 
 
 
 
 
 
2530
  // Устанавливаем новое направление для текущей колонки
2531
- if (headerCell) headerCell.dataset.sortDir = newDir;
2532
 
2533
 
2534
  rows.sort((a, b) => {
2535
  let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`);
2536
  let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`);
2537
 
 
2538
  let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : '';
2539
  let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : '';
2540
 
@@ -2543,20 +2656,17 @@ function sortTable(columnIndex, tableId, isNumeric = false) {
2543
  // Преобразуем в число, заменяя запятую на точку и обрабатывая ошибки
2544
  valA = parseFloat(String(valA).replace(',', '.')) || 0;
2545
  valB = parseFloat(String(valB).replace(',', '.')) || 0;
 
 
 
2546
 
2547
  } else {
2548
  // Текстовое сравнение без учета регистра
2549
  valA = valA.toLowerCase();
2550
  valB = valB.toLowerCase();
 
 
2551
  }
2552
-
2553
- if (valA < valB) {
2554
- return newDir === 'asc' ? -1 : 1;
2555
- }
2556
- if (valA > valB) {
2557
- return newDir === 'asc' ? 1 : -1;
2558
- }
2559
- return 0; // Равны
2560
  });
2561
 
2562
  // Удаляем старые строки и вставляем отсортированные
@@ -2565,38 +2675,44 @@ function sortTable(columnIndex, tableId, isNumeric = false) {
2565
  }
2566
  rows.forEach(row => tbody.appendChild(row));
2567
 
2568
- // Обновляем иконки сортировки
2569
- table.querySelectorAll('thead th i.fa-sort').forEach(icon => {
2570
- icon.classList.remove('fa-sort-up', 'fa-sort-down');
2571
- icon.classList.add('fa-sort'); // Сброс на базовую иконку
2572
- });
2573
- if (headerCell) {
2574
- const icon = headerCell.querySelector('i.fa-sort');
2575
- if (icon) {
2576
- icon.classList.remove('fa-sort');
2577
- icon.classList.add(newDir === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
2578
- }
2579
- }
2580
  }
2581
 
2582
- // --- Активация табов Bootstrap (на случай если что-то не так с авто-активацией) ---
2583
  document.addEventListener('DOMContentLoaded', function () {
2584
- var triggerTabList = [].slice.call(document.querySelectorAll('#adminTabs button'))
2585
  triggerTabList.forEach(function (triggerEl) {
2586
- var tabTrigger = new bootstrap.Tab(triggerEl)
 
 
 
 
 
 
2587
 
2588
  triggerEl.addEventListener('click', function (event) {
2589
- event.preventDefault()
2590
- tabTrigger.show()
2591
- })
2592
- })
 
 
 
2593
 
2594
  // Активируем первую вкладку при загрузке, если ни одна не активна
2595
- const firstTab = document.querySelector('#adminTabs button');
2596
- if (firstTab && !document.querySelector('#adminTabs button.active')) {
2597
- const tab = new bootstrap.Tab(firstTab);
2598
- tab.show();
2599
- }
 
 
 
2600
  });
2601
 
2602
  </script>
@@ -2624,4 +2740,4 @@ if __name__ == '__main__':
2624
  logging.info("Запуск Flask приложения на порту 7860...")
2625
  # debug=False для продакшена
2626
  # host='0.0.0.0' для доступа из сети
2627
- app.run(debug=True, host='0.0.0.0', port=7860)
 
1
+
2
  # Импортируем необходимые библиотеки
3
  from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
4
  import json
 
360
  logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True)
361
  flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger")
362
 
363
+ # --- GET запрос ---
364
  page_title = "Закуп материалов"
365
  page_content = PROCUREMENT_CONTENT # Используем переменную с контентом
366
  page_scripts = PROCUREMENT_SCRIPTS # Используем переменную со скриптами
367
+
368
+ # Заменяем маркеры в базовом шаблоне
369
+ html = BASE_TEMPLATE.replace('__TITLE__', page_title)
370
+ html = html.replace('__CONTENT__', page_content)
371
+ html = html.replace('__SCRIPTS__', page_scripts)
372
+
373
+ # Передаем нужные переменные в render_template_string для рендеринга Jinja внутри контента
374
+ return render_template_string(html, categories=categories)
375
 
376
 
377
  # 2. Маршрут "Раскрой"
 
501
  logging.error(f"Ошибка при обработке раскроя: {e}", exc_info=True)
502
  flash(f"Произошла внутренняя ошибка при обработке раскроя: {e}", "danger")
503
 
504
+ # --- GET запрос ---
505
  # Передаем материалы, преобразовав количество в Decimal для отображения
506
  fabrics_dec = []
507
  for f in fabrics:
508
  f_copy = f.copy()
509
+ f_copy['quantity_dec'] = to_decimal(f_copy.get('quantity', '0')) # Используем новое имя ключа
510
  fabrics_dec.append(f_copy)
511
 
512
  fittings_dec = []
513
  for f in fittings:
514
  f_copy = f.copy()
515
+ f_copy['quantity_dec'] = to_decimal(f_copy.get('quantity', '0')) # Используем новое имя ключа
516
  fittings_dec.append(f_copy)
517
 
518
  page_title = "Раскрой ткани"
519
  page_content = CUTTING_CONTENT
520
  page_scripts = CUTTING_SCRIPTS
521
+
522
+ html = BASE_TEMPLATE.replace('__TITLE__', page_title)
523
+ html = html.replace('__CONTENT__', page_content)
524
+ html = html.replace('__SCRIPTS__', page_scripts)
525
+
526
+ # Передаем переменные в render_template_string
527
+ return render_template_string(html, fabrics=fabrics_dec, fittings=fittings_dec)
528
 
529
 
530
  # 3. Маршрут "Пошив"
 
712
  'fabric_id': cutting_task['fabric_id'], # Сохраняем для справки
713
  'fabric_name': cutting_task['fabric_name'],
714
  'fittings_consumed': fittings_consumed_list, # Фактически использовано на пошив
715
+ 'defects_reported': [], # Заполним ниже, преобразовав Decimal в строки
716
  'status': 'pending_qc', # 'pending_qc', 'completed' (packed)
717
  'timestamp_created': current_time,
718
  'timestamp_completed': None, # Заполнится при ОТК
 
720
  'qc_defective_quantity': 0
721
  }
722
 
723
+ # Присваиваем ID задания на пошив и преобразуем Decimal в строку для JSON
724
  for defect in defect_log_list:
725
  defect['sewing_task_id'] = new_sewing_task['id']
726
  # Преобразуем Decimal обратно в строку для JSON, если это ткань
727
+ if isinstance(defect['quantity'], Decimal):
728
  defect['quantity'] = str(defect['quantity'])
729
+ # Добавляем в список задания
730
+ new_sewing_task['defects_reported'].append(defect)
731
 
732
 
733
+ # 5. Обновление статуса задания на раскрой
 
734
  task_updated = False
735
  for i, task in enumerate(data.get('cutting_tasks', [])):
736
  if task.get('id') == cutting_task_id:
 
746
  if 'sewing_tasks' not in data: data['sewing_tasks'] = []
747
  data['sewing_tasks'].append(new_sewing_task)
748
 
749
+ if defect_log_list: # Если был брак
750
  if 'defect_log' not in data: data['defect_log'] = []
751
+ # Добавляем уже обработанные записи из new_sewing_task['defects_reported']
752
+ data['defect_log'].extend(new_sewing_task['defects_reported'])
753
 
754
  # Обновляем основной список материалов
755
  data['materials'] = materials_list
 
765
  logging.error(f"Ошибка при обработке пошива: {e}", exc_info=True)
766
  flash(f"Произошла внутренняя ошибка при обработке пошива: {e}", "danger")
767
 
768
+ # --- GET запрос ---
769
  # Преобразуем данные для шаблона
770
  tasks_for_template = []
771
  all_materials_dict = {m['id']: m for m in data.get('materials', [])} # Словарь для быстрого поиска остатков
772
 
773
  for task in pending_cutting_tasks:
774
  task_copy = task.copy()
775
+ # Преобразуем числа для отображения и data-атрибутов
776
+ task_copy['cut_items_quantity_int'] = int(task_copy.get('cut_items_quantity', 0))
777
+ task_copy['fabric_used_decimal'] = to_decimal(task_copy.get('fabric_used', '0'))
778
  # Получаем текущие остатки фурнитуры для отображения в деталях
779
  if 'required_fittings' in task_copy:
780
  for fitting in task_copy['required_fittings']:
781
  mat_data = all_materials_dict.get(fitting['fitting_id'])
782
  fitting['available_quantity'] = to_decimal(mat_data.get('quantity', '0')) if mat_data else Decimal('0')
783
  fitting['unit'] = mat_data.get('unit', 'шт') if mat_data else 'шт'
784
+ fitting['quantity_needed_int'] = int(fitting.get('quantity_needed',0)) # Преобразуем для JSON
785
  tasks_for_template.append(task_copy)
786
 
787
  # Получаем все материалы для выбора брака (с преобразованными количествами)
 
795
  page_title = "Пошив изделий"
796
  page_content = SEWING_CONTENT
797
  page_scripts = SEWING_SCRIPTS
798
+
799
+ html = BASE_TEMPLATE.replace('__TITLE__', page_title)
800
+ html = html.replace('__CONTENT__', page_content)
801
+ html = html.replace('__SCRIPTS__', page_scripts)
802
+
803
+ return render_template_string(html,
804
  cutting_tasks=tasks_for_template,
805
  all_materials=all_materials_dec) # Передаем материалы с quantity_decimal
806
 
 
872
  data['qc_packing_items'].append(packed_item)
873
 
874
  # Регистрация брака на этапе ОТК (если есть)
 
875
  if quantity_defective > 0:
876
  defect_log_entry = {
877
  'log_id': uuid.uuid4().hex,
 
894
  task_updated = False
895
  for i, task in enumerate(data.get('sewing_tasks', [])):
896
  if task.get('id') == sewing_task_id:
897
+ # Обновляем информацию о результате ОТК в само задание
898
+ # Убедимся, что читаем существующие значения как int
899
+ packed_before = int(task.get('qc_packed_quantity', 0))
900
+ defective_before = int(task.get('qc_defective_quantity', 0))
901
+
902
+ task['qc_packed_quantity'] = packed_before + quantity_packed
903
+ task['qc_defective_quantity'] = defective_before + quantity_defective
904
  task['status'] = 'completed' # Считаем завершенным после обработки
905
  task['timestamp_completed'] = current_time # Время последнего ОТК
906
  task_updated = True
 
918
  logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True)
919
  flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger")
920
 
921
+ # --- GET запрос ---
922
  page_title = "ОТК и Упаковка"
923
  page_content = QC_PACKING_CONTENT
924
  page_scripts = QC_PACKING_SCRIPTS
925
+
926
+ html = BASE_TEMPLATE.replace('__TITLE__', page_title)
927
+ html = html.replace('__CONTENT__', page_content)
928
+ html = html.replace('__SCRIPTS__', page_scripts)
929
+
930
  # Передаем задачи, ожидающие ОТК, в шаблон
931
+ return render_template_string(html, sewing_tasks=pending_sewing_tasks)
932
 
933
 
934
  # 5. Маршрут "Админ-панель"
 
969
  # Используем find_sewing_task_by_id, чтобы получить уже преобразованные числа
970
  task_data = find_sewing_task_by_id(t['id'])
971
  if task_data: # Убедимся, что задача найдена
972
+ # Преобразуем Decimal в строку для дефектов ткани перед передачей в шаблон
973
+ if 'defects_reported' in task_data:
974
+ for defect in task_data['defects_reported']:
975
+ if isinstance(defect.get('quantity'), Decimal):
976
+ defect['quantity'] = str(defect['quantity'])
977
  sewing_tasks_view.append(task_data)
978
  else:
979
  logging.warning(f"Задача на пошив с ID {t['id']} не найдена при подготовке для админки.")
 
991
  packed_items_view.append(item_copy)
992
 
993
  defect_log_view = []
994
+ # total_defect_items = 0 # Будем считать штуки, метры отдельно
995
  total_defect_fabric_m = Decimal('0.00')
996
  total_defect_fittings_pcs = 0
997
  total_defect_finished_pcs = 0
 
1023
  page_title = "Админ-панель"
1024
  page_content = ADMIN_CONTENT
1025
  page_scripts = ADMIN_SCRIPTS # Если есть специфичные скрипты для админки
1026
+
1027
+ html = BASE_TEMPLATE.replace('__TITLE__', page_title)
1028
+ html = html.replace('__CONTENT__', page_content)
1029
+ html = html.replace('__SCRIPTS__', page_scripts)
1030
 
1031
  # Передаем подготовленные данные
1032
  return render_template_string(
1033
+ html,
1034
  materials=materials_view,
1035
  cutting_tasks=cutting_tasks_view,
1036
  sewing_tasks=sewing_tasks_view,
 
1162
 
1163
  # --- HTML Шаблоны (как строки Python) ---
1164
 
1165
+ # Базовый шаблон с уникальными маркерами вместо .format() плейсхолдеров
1166
  BASE_TEMPLATE = """
1167
  <!DOCTYPE html>
1168
  <html lang="ru">
1169
  <head>
1170
  <meta charset="UTF-8">
1171
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1172
+ <title>__TITLE__ - Текстиль Учет</title>
1173
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
1174
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
1175
  <style>
1176
+ body {
1177
  background-color: #f8f9fa; /* Светлый фон */
1178
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1179
  padding-top: 70px; /* Отступ для фиксированной навигации */
1180
  padding-bottom: 60px; /* Отступ для футера */
1181
  position: relative;
1182
  min-height: 100vh;
1183
+ }
1184
+ .navbar {
1185
  background-color: #343a40; /* Темная навигация */
1186
  box-shadow: 0 2px 4px rgba(0,0,0,.1);
1187
+ }
1188
+ .navbar-brand, .nav-link {
1189
  color: #f8f9fa !important; /* Светлый текст */
1190
+ }
1191
+ .navbar-brand:hover, .nav-link:hover {
1192
  color: #adb5bd !important; /* Чуть темнее при наведении */
1193
+ }
1194
+ .nav-link.active {
1195
  font-weight: bold;
1196
  color: #ffffff !important;
1197
  border-bottom: 2px solid #0d6efd; /* Подчеркивание активного пункта */
1198
+ }
1199
+ .card {
1200
  margin-bottom: 1.5rem;
1201
  border: none; /* Убираем стандартную рамку */
1202
  border-radius: 0.5rem; /* Скругление углов */
1203
  box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); /* Тень */
1204
  transition: transform 0.2s ease-in-out;
1205
+ }
1206
+ .card:hover {
1207
  /* transform: translateY(-3px); Легкий подъем при наведении - убрал, может мешать */
1208
+ }
1209
+ .card-header {
1210
  background-color: #6c757d; /* Серая шапка */
1211
  color: white;
1212
  font-weight: 600;
 
1215
  border-top-right-radius: 0.5rem;
1216
  display: flex;
1217
  align-items: center;
1218
+ }
1219
+ .card-header i {
1220
  margin-right: 0.5rem; /* Отступ для иконки в шапке */
1221
+ }
1222
+ .btn-primary {
1223
  background-color: #0d6efd;
1224
  border-color: #0d6efd;
1225
+ }
1226
+ .btn-primary:hover {
1227
  background-color: #0b5ed7;
1228
  border-color: #0a58ca;
1229
+ }
1230
+ .btn-success {
1231
  background-color: #198754;
1232
  border-color: #198754;
1233
+ }
1234
+ .btn-danger {
1235
  background-color: #dc3545;
1236
  border-color: #dc3545;
1237
+ }
1238
+ .btn-warning {
1239
  background-color: #ffc107;
1240
  border-color: #ffc107;
1241
  color: #212529; /* Темный текст для желтой кнопки */
1242
+ }
1243
+ .btn-info {
1244
  background-color: #0dcaf0;
1245
  border-color: #0dcaf0;
1246
  color: #000;
1247
+ }
1248
+ .table-hover tbody tr:hover {
1249
  background-color: rgba(0, 0, 0, 0.05); /* Легкое выделение строки таблицы */
1250
  cursor: pointer; /* Намек на интерактивность (если будет) */
1251
+ }
1252
+ .container-main { /* Переименовал, чтобы не конфликтовать с Bootstrap */
1253
  max-width: 1400px; /* Чуть шире контейнер */
1254
  margin: 0 auto; /* Центрирование */
1255
  padding-left: 15px;
1256
  padding-right: 15px;
1257
+ }
1258
  /* Статусы */
1259
+ .status-pending { color: #fd7e14; font-weight: bold; } /* Оранжевый - ожидает */
1260
+ .status-completed { color: #198754; font-weight: bold; } /* Зеленый - завершено */
1261
+ .status-pending_qc { color: #ffc107; font-weight: bold; } /* Желтый - ожидает ОТК */
1262
 
1263
+ .flash-messages .alert {
1264
  margin-bottom: 1rem;
1265
  border-radius: 0.5rem;
1266
+ }
1267
  /* Стиль для динамически добавляемых строк */
1268
+ .dynamic-row, .dynamic-fitting-row, .dynamic-defect-row {
1269
  border: 1px solid #eee;
1270
  padding: 15px;
1271
  margin-bottom: 15px;
1272
  border-radius: 5px;
1273
  background-color: #fdfdfd;
1274
  position: relative; /* Для позиционирования кнопки удаления */
1275
+ }
1276
+ .remove-row-btn, .remove-fitting-row-btn, .remove-defect-row-btn {
1277
  position: absolute;
1278
  top: 10px;
1279
  right: 10px;
1280
+ z-index: 10; /* Чтобы кнопка была поверх инпутов */
1281
+ }
1282
  /* Responsive table */
1283
+ .table-responsive {
1284
  margin-bottom: 1rem;
1285
+ }
1286
+ .table th, .table td {
1287
  vertical-align: middle; /* Выравнивание по центру */
1288
+ font-size: 0.9rem; /* Уменьшил шрифт в таблицах */
1289
+ }
1290
+ .table td small {
1291
  color: #6c757d; /* Серый цвет для мелкого текста */
1292
  display: block; /* ID на новой строке */
1293
+ font-size: 0.75rem;
1294
+ }
1295
+ .table .badge { /* Стили для бэйджей в таблицах */
1296
  font-size: 0.8em;
1297
+ }
1298
+ .table th i.fa-sort,
1299
+ .table th i.fa-sort-up,
1300
+ .table th i.fa-sort-down {
1301
+ margin-left: 5px;
1302
+ color: #adb5bd;
1303
+ cursor: pointer;
1304
+ }
1305
+ .table th:hover i {
1306
+ color: #343a40;
1307
+ }
1308
  /* Footer */
1309
+ .footer {
1310
  position: absolute;
1311
  bottom: 0;
1312
  width: 100%;
 
1315
  text-align: center;
1316
  padding: 10px 0;
1317
  font-size: 0.9em;
1318
+ }
1319
  /* Скрытие стрелок у number инпутов */
1320
  input[type=number]::-webkit-inner-spin-button,
1321
+ input[type=number]::-webkit-outer-spin-button {
1322
  -webkit-appearance: none;
1323
  margin: 0;
1324
+ }
1325
+ input[type=number] {
1326
  -moz-appearance: textfield; /* Firefox */
1327
+ }
1328
+ .form-text {
1329
  font-size: 0.8rem;
1330
+ }
1331
  </style>
1332
  </head>
1333
  <body>
 
1357
  </li>
1358
  </ul>
1359
  <!-- Кнопки Hugging Face справа -->
1360
+ <div class="d-flex align-items-center"> {# Выравнивание по центру #}
1361
+ <form method="POST" action="{{ url_for('backup_hf') }}" class="me-2 mb-0"> {# Убрал нижний маргин #}
1362
  <button type="submit" class="btn btn-sm btn-outline-light" title="Создать резервную копию на Hugging Face">
1363
  <i class="fas fa-cloud-upload-alt"></i> Backup
1364
  </button>
1365
  </form>
1366
+ <form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ОСТОРОЖНО! Это перезапишет локальные данные последней версией с Hugging Face. Вы уверены?');" class="mb-0"> {# Убрал нижний маргин #}
1367
  <button type="submit" class="btn btn-sm btn-outline-warning" title="Скачать базу данных с Hugging Face (перезапишет локальную)">
1368
  <i class="fas fa-cloud-download-alt"></i> Download DB
1369
  </button>
 
1379
  {% if messages %}
1380
  <div class="flash-messages">
1381
  {% for category, message in messages %}
1382
+ {# Используем стандартные классы bootstrap для категорий flash #}
1383
+ {% set alert_class = 'alert-info' %} {# Default #}
1384
+ {% if category == 'danger' %} {% set alert_class = 'alert-danger' %}
1385
+ {% elif category == 'success' %} {% set alert_class = 'alert-success' %}
1386
+ {% elif category == 'warning' %} {% set alert_class = 'alert-warning' %}
1387
+ {% endif %}
1388
+ <div class="alert {{ alert_class }} alert-dismissible fade show" role="alert">
1389
  {{ message }}
1390
  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
1391
  </div>
 
1395
  {% endwith %}
1396
 
1397
  {# Основной контент страницы #}
1398
+ __CONTENT__
1399
 
1400
  </div>
1401
 
 
1407
 
1408
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
1409
  {# Скрипты для конкретной страницы #}
1410
+ __SCRIPTS__
1411
  </body>
1412
  </html>
1413
  """
1414
 
1415
+ # --- Контент и скрипты для страниц (остаются без изменений) ---
1416
 
1417
  PROCUREMENT_CONTENT = """
1418
  <div class="card">
 
1483
  function addRow() {
1484
  const container = document.getElementById('material-rows');
1485
  const firstRow = container.querySelector('.dynamic-row'); // Берем первую строку как шаблон
1486
+ if (!firstRow) { // Если строк нет, возможно, создать базовую? Или просто ничего не делать.
1487
+ console.warn("No template row found to clone.");
1488
+ return;
1489
+ }
1490
  const newRow = firstRow.cloneNode(true);
1491
 
1492
  // Очищаем значения в новой строке
 
1521
 
1522
  container.appendChild(newRow);
1523
  attachCategoryChangeEvent(newRow); // Добавляем обработчик для новой строки
 
1524
  }
1525
 
1526
  function removeRow(button) {
1527
  const row = button.closest('.dynamic-row');
1528
+ // Позволяем удалять любую строку
1529
  if (row) {
1530
  row.remove();
1531
  }
 
1564
 
1565
  // Инициализация при загрузке страницы
1566
  document.addEventListener('DOMContentLoaded', () => {
1567
+ // Если изначально нет строк, добавим одну пустую
1568
+ if (!document.querySelector('#material-rows .dynamic-row')) {
1569
+ // Не добавляем автоматически, пользователь нажмет кнопку сам
1570
+ } else {
1571
+ document.querySelectorAll('.dynamic-row').forEach(row => {
1572
+ attachCategoryChangeEvent(row);
1573
+ const categorySelect = row.querySelector('.category-select');
1574
+ if (categorySelect) handleCategoryChange(categorySelect); // Инициализируем видимость поля новой категории
1575
+ });
1576
+ }
1577
  });
1578
  </script>
1579
  """
 
1653
  if (selectedOption && selectedOption.value) {
1654
  const quantity = selectedOption.getAttribute('data-quantity'); // Уже содержит запятую, если надо
1655
  const unit = selectedOption.getAttribute('data-unit');
1656
+ if (availableDiv) availableDiv.textContent = `В наличии: ${quantity} ${unit}`;
1657
+ if (unitSpan) unitSpan.textContent = unit; // Обновляем единицу измерения в поле расхода
1658
  } else {
1659
+ if (availableDiv) availableDiv.textContent = '';
1660
+ if (unitSpan) unitSpan.textContent = 'м'; // Сброс на метры по умолчанию
1661
  }
1662
  }
1663
 
 
1730
  // Инициализация при загрузке
1731
  document.addEventListener('DOMContentLoaded', () => {
1732
  updateAvailableQuantity(); // Вызываем при загрузк��
1733
+ const fittingRows = document.querySelectorAll('.dynamic-fitting-row');
1734
+ fittingRows.forEach(row => {
1735
  attachFittingChangeEvent(row);
1736
  const fittingSelect = row.querySelector('.fitting-select');
1737
  if (fittingSelect) handleFittingChange(fittingSelect); // Инициализируем показ доступности
1738
  });
1739
+ // Если нет строк фурнитуры ИЗНАЧАЛЬНО (например, после ошибки формы),
1740
+ // гарантируем, что хотя бы одна строка есть для добавления.
1741
+ // Но не добавляем, если форма загрузилась чисто.
1742
+ if (fittingRows.length === 0 && document.getElementById('fittings-rows')) {
1743
+ // Можно раскомментировать, если нужна хотя бы одна строка всегда
1744
+ // addFittingRow();
1745
  }
1746
  });
1747
  </script>
 
1854
 
1855
 
1856
  if (selectedOption && selectedOption.value) {
1857
+ const fabricName = selectedOption.getAttribute('data-fabric-name') || 'N/A';
1858
+ const cutQuantity = selectedOption.getAttribute('data-cut-quantity') || '0';
1859
 
1860
+ if (document.getElementById('detail-fabric-name')) document.getElementById('detail-fabric-name').textContent = fabricName;
1861
+ if (document.getElementById('detail-cut-quantity')) document.getElementById('detail-cut-quantity').textContent = cutQuantity;
1862
+ if (document.getElementById('detail-fabric-used')) document.getElementById('detail-fabric-used').textContent = selectedOption.getAttribute('data-fabric-used') || 'N/A';
1863
 
1864
+ if(maxSewnSpan) maxSewnSpan.textContent = cutQuantity;
1865
  if(sewnInput) sewnInput.max = cutQuantity; // Устанавливаем максимум для поля ввода
1866
 
1867
  // Предзаполняем название изделия на основе ткани, если поле пустое
 
1871
 
1872
 
1873
  const fittingsList = document.getElementById('detail-fittings-list');
1874
+ if(fittingsList) {
1875
+ fittingsList.innerHTML = ''; // Очищаем список
1876
+ try {
1877
+ const fittingsData = JSON.parse(selectedOption.getAttribute('data-fittings') || '[]');
1878
+
1879
+ if (fittingsData && fittingsData.length > 0) {
1880
+ fittingsData.forEach(fitting => {
1881
+ // Ищем фурнитуру в селекте брака, чтобы показать остаток
1882
+ let availableQtyStr = 'Н/Д';
1883
+ const defectSelect = document.querySelector('.defect-material-select'); // Берем любой селект для поиска
1884
+ if (defectSelect) {
1885
+ const option = defectSelect.querySelector(`option[value="${fitting.fitting_id}"]`);
1886
+ if (option) {
1887
+ availableQtyStr = `${option.getAttribute('data-quantity')} ${option.getAttribute('data-unit')}`;
1888
+ }
1889
+ } else {
1890
+ // Если селекта брака нет, инфо об остатках не показать
1891
+ availableQtyStr = '(см. склад)';
1892
+ }
1893
+
1894
+ const listItem = document.createElement('li');
1895
+ listItem.textContent = `${fitting.fitting_name || '?'}: ${fitting.quantity_needed_int || '?'} шт. (Доступно: ${availableQtyStr})`;
1896
+ fittingsList.appendChild(listItem);
1897
+ });
1898
  } else {
1899
+ const listItem = document.createElement('li');
1900
+ listItem.textContent = 'Фурнитура не указана в задании на раскрой.';
1901
+ listItem.classList.add('text-muted');
1902
+ fittingsList.appendChild(listItem);
1903
  }
1904
+ } catch (e) {
1905
+ console.error("Error parsing fittings data:", e);
1906
+ const listItem = document.createElement('li');
1907
+ listItem.textContent = 'Ошибка загрузки данных фурнитуры.';
1908
+ listItem.classList.add('text-danger');
1909
+ fittingsList.appendChild(listItem);
1910
+ }
1911
+ } // end if fittingsList
 
 
 
1912
 
1913
  if(detailsDiv) detailsDiv.style.display = 'block';
1914
  } else {
 
1934
  newRow.querySelectorAll('input[type="text"]').forEach(input => input.value = '');
1935
  const availabilityDiv = newRow.querySelector('.defect-availability');
1936
  if(availabilityDiv) availabilityDiv.textContent = '';
1937
+ // Сброс инпута количества
1938
+ const qtyInput = newRow.querySelector('.defect-quantity-input');
1939
+ if(qtyInput){
1940
+ qtyInput.inputMode = 'text';
1941
+ qtyInput.placeholder = 'Количество';
1942
+ qtyInput.step = 'any';
1943
+ }
1944
+
1945
 
1946
  // Кнопка удаления
1947
  const removeBtn = newRow.querySelector('.remove-defect-row-btn');
 
2008
  // Инициализация
2009
  document.addEventListener('DOMContentLoaded', () => {
2010
  showTaskDetails(); // Показать детали для выбранного по умолчанию (если есть)
2011
+ const defectRows = document.querySelectorAll('.dynamic-defect-row');
2012
+ defectRows.forEach(row => {
2013
  attachDefectChangeEvent(row);
2014
  const defectSelect = row.querySelector('.defect-material-select');
2015
  if (defectSelect) handleDefectChange(defectSelect); // Инициализируем доступность и тип инпута
2016
  });
2017
+ // Если нет строк брака ИЗНАЧАЛЬНО, гарантируем наличие одной строки
2018
+ if (defectRows.length === 0 && document.getElementById('defect-rows')) {
2019
+ // Не добавляем автоматически
2020
+ // addDefectRow();
2021
+ }
2022
  });
2023
  </script>
2024
  """
 
2076
 
2077
  function updateQcQuantities() {
2078
  const select = document.getElementById('sewing_task_id');
2079
+ const selectedOption = select ? select.options[select.selectedIndex] : null;
2080
  const packedInput = document.getElementById('quantity_packed');
2081
  const defectiveInput = document.getElementById('quantity_defective');
2082
  const totalInfo = document.getElementById('qc-total-info');
 
2094
  }
2095
  if(totalInfo) {
2096
  totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака не должна превышать это число.`;
2097
+ totalInfo.classList.remove('text-danger', 'text-success');
2098
  }
2099
  // Вызываем валидацию сразу
2100
  validateQcSum();
 
2109
  defectiveInput.max = '';
2110
  defectiveInput.value = '0';
2111
  }
2112
+ if(totalInfo) totalInfo.textContent = 'Выберите задание на пошив.';
2113
  }
2114
  }
2115
 
 
2124
  const defective = parseInt(defectiveInput.value) || 0;
2125
  const totalProcessed = packed + defective;
2126
 
2127
+ totalInfo.classList.remove('text-danger', 'text-success'); // Сброс классов
2128
+
2129
  if (maxAllowedQuantity === 0 && totalProcessed > 0) {
2130
  // Если задание не выбрано, но что-то введено
2131
  totalInfo.textContent = 'Сначала выберите задание на пошив!';
2132
  totalInfo.classList.add('text-danger');
2133
  return false;
2134
  }
2135
+ if (maxAllowedQuantity === 0 && totalProcessed === 0) {
2136
+ totalInfo.textContent = 'Выберите задание на пошив.';
2137
+ return false; // Не валидно, если задание не выбрано
2138
+ }
2139
+
2140
 
2141
  if (totalProcessed > maxAllowedQuantity) {
2142
  totalInfo.textContent = `Ошибка: Сумма (${totalProcessed}) превышает количество сшитых (${maxAllowedQuantity})!`;
2143
  totalInfo.classList.add('text-danger');
2144
  return false; // Сумма неверна
2145
+ } else if (totalProcessed === 0) {
2146
+ totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Укажите кол-во упакованных или брака.`;
2147
+ // Можно не считать ошибкой, но форма не должна отправляться
2148
+ return false; // Не валидно, если сумма 0
2149
+ }
2150
+ else {
2151
+ totalInfo.textContent = `Всего было сшито: ${maxAllowedQuantity} ед. Сумма упакованных и брака (${totalProcessed}) корректна.`;
2152
+ totalInfo.classList.add('text-success'); // Показываем успех
2153
  return true; // Сумма верна
2154
  }
2155
  }
2156
 
2157
  function validateQcForm() {
2158
  // Дополнительная проверка перед отправкой формы
2159
+ const isValidSum = validateQcSum(); // Выполняем проверку и обновляем сообщение
2160
  if (!isValidSum) {
2161
+ alert('Ошибка в количестве упакованных или бракованных изделий. Проверьте введенные значения и сообщение под полями ввода.');
2162
  return false; // Предотвратить отправку формы
2163
  }
2164
 
2165
+ // Проверка, что выбрано задание
2166
+ const select = document.getElementById('sewing_task_id');
2167
+ if (!select || !select.value) {
2168
+ alert('Пожалуйста, выберите задание на пошив.');
2169
+ return false;
2170
+ }
2171
+
2172
+ // Проверка, что хотя бы одно поле больше нуля (уже делается в validateQcSum)
2173
  const packedInput = document.getElementById('quantity_packed');
2174
  const defectiveInput = document.getElementById('quantity_defective');
2175
  const packed = parseInt(packedInput.value) || 0;
2176
  const defective = parseInt(defectiveInput.value) || 0;
 
2177
  if (packed === 0 && defective === 0) {
2178
  alert('Укажите количество упакованных или бракованных изделий (хотя бы одно должно быть больше н��ля).');
2179
  return false;
2180
  }
2181
 
 
2182
  return true; // Разрешить отправку формы
2183
  }
2184
 
 
2357
  <tr class="material-row" data-name="{{ material.name|lower }}" data-category="{{ material.category|default('Без категории')|lower }}">
2358
  <td title="{{ material.id }}"><small>{{ material.id[:8] }}...</small></td>
2359
  <td>{{ material.name }}</td>
2360
+ <td><span class="badge bg-info text-dark">{{ material.category | default('Без категории') }}</span></td>
2361
+ <td><span class="badge {{ 'bg-primary' if material.type == 'fabric' else 'bg-secondary' }}">{{ 'Ткань' if material.type == 'fabric' else 'Фурнитура' }}</span></td>
2362
  {# Используем quantity_dec для отображения #}
2363
  <td data-sort="{{ material.quantity_dec }}">{{ material.quantity_dec|string|replace('.', ',') }}</td>
2364
  <td>{{ material.unit }}</td>
 
2568
  <td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td>
2569
  <td>{{ defect.quantity_view }}</td> {# Используем quantity_view #}
2570
  <td>{{ defect.unit }}</td>
2571
+ <td><span class="badge {{ 'bg-warning text-dark' if defect.stage == 'qc_packing' else 'bg-danger' }}">{{ defect.stage|replace('_', ' ')|title }}</span></td>
2572
  <td>{{ defect.reason | default('Не указана') }}</td>
2573
  <td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td>
2574
  <td title="{{ defect.sewing_task_id }}"><small>{{ defect.sewing_task_id[:8] if defect.sewing_task_id else 'N/A' }}...</small></td>
 
2592
  const tableBody = document.getElementById('materials-table')?.querySelector('tbody'); // Добавил ? для безопасности
2593
 
2594
  if (searchInput && tableBody) {
2595
+ searchInput.addEventListener('input', function() { // Используем 'input' для немедленной реакции
2596
+ const searchTerm = searchInput.value.toLowerCase().trim();
2597
  const rows = tableBody.querySelectorAll('tr.material-row'); // Используем класс для точности
2598
 
2599
  rows.forEach(row => {
 
2615
  if (!table) return;
2616
  const tbody = table.querySelector('tbody');
2617
  if (!tbody) return;
2618
+
2619
+ // Сохраняем строки заголовка, если они есть (например, <thead>)
2620
+ const headerRow = table.querySelector('thead tr');
2621
+ if (!headerRow) return; // Нужна строка заголовка
2622
+
2623
  const rows = Array.from(tbody.querySelectorAll('tr'));
2624
+ if (rows.length < 2) return; // Нечего сортировать
2625
+
2626
+ const headerCell = headerRow.querySelector(`th:nth-child(${columnIndex + 1})`); // +1 т.к. nth-child(1) - первый
2627
+ if (!headerCell) return; // Не найдена ячейка заголовка
2628
 
2629
  // Определяем направление сортировки
2630
+ let currentDir = headerCell.dataset.sortDir || 'asc'; // Используем data-атрибут для хранения направления
2631
  let newDir = currentDir === 'asc' ? 'desc' : 'asc';
2632
 
2633
+ // Сбрасываем направления у других колонок и иконки
2634
+ headerRow.querySelectorAll('th').forEach((th, index) => {
2635
+ const icon = th.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down');
2636
+ if (icon && index !== columnIndex) {
2637
+ icon.classList.remove('fa-sort-up', 'fa-sort-down');
2638
+ icon.classList.add('fa-sort');
2639
+ }
2640
+ th.dataset.sortDir = ''; // Сброс направления для всех, кроме текущей
2641
+ });
2642
  // Устанавливаем новое направление для текущей колонки
2643
+ headerCell.dataset.sortDir = newDir;
2644
 
2645
 
2646
  rows.sort((a, b) => {
2647
  let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`);
2648
  let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`);
2649
 
2650
+ // Используем data-sort атрибут если он есть, иначе текст ячейки
2651
  let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : '';
2652
  let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : '';
2653
 
 
2656
  // Преобразуем в число, заменяя запятую на точку и обрабатывая ошибки
2657
  valA = parseFloat(String(valA).replace(',', '.')) || 0;
2658
  valB = parseFloat(String(valB).replace(',', '.')) || 0;
2659
+ // Для корректной сортировки чисел
2660
+ const comparison = valA - valB;
2661
+ return newDir === 'asc' ? comparison : -comparison;
2662
 
2663
  } else {
2664
  // Текстовое сравнение без учета регистра
2665
  valA = valA.toLowerCase();
2666
  valB = valB.toLowerCase();
2667
+ const comparison = valA.localeCompare(valB); // Используем localeCompare для корректной сортировки строк
2668
+ return newDir === 'asc' ? comparison : -comparison;
2669
  }
 
 
 
 
 
 
 
 
2670
  });
2671
 
2672
  // Удаляем старые строки и вставляем отсортированные
 
2675
  }
2676
  rows.forEach(row => tbody.appendChild(row));
2677
 
2678
+ // Обновляем иконку сортировки для текущей колонки
2679
+ const icon = headerCell.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down');
2680
+ if (icon) {
2681
+ icon.classList.remove('fa-sort', 'fa-sort-up', 'fa-sort-down');
2682
+ icon.classList.add(newDir === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
2683
+ }
 
 
 
 
 
 
2684
  }
2685
 
2686
+ // --- Активация табов Bootstrap ---
2687
  document.addEventListener('DOMContentLoaded', function () {
2688
+ var triggerTabList = [].slice.call(document.querySelectorAll('#adminTabs button[data-bs-toggle="tab"]'))
2689
  triggerTabList.forEach(function (triggerEl) {
2690
+ // Проверяем, существует ли уже экземпляр Tab
2691
+ var tabInstance = bootstrap.Tab.getInstance(triggerEl);
2692
+ if (!tabInstance) {
2693
+ tabTrigger = new bootstrap.Tab(triggerEl); // Создаем, только если нет
2694
+ } else {
2695
+ tabTrigger = tabInstance; // Используем существующий
2696
+ }
2697
 
2698
  triggerEl.addEventListener('click', function (event) {
2699
+ event.preventDefault();
2700
+ // Переключаем таб, если он не активен
2701
+ if (!triggerEl.classList.contains('active')) {
2702
+ tabTrigger.show();
2703
+ }
2704
+ });
2705
+ });
2706
 
2707
  // Активируем первую вкладку при загрузке, если ни одна не активна
2708
+ // (Bootstrap 5 обычно делает это сам, но можно оставить для надежности)
2709
+ const firstTabEl = document.querySelector('#adminTabs button[data-bs-toggle="tab"]');
2710
+ const activeTabEl = document.querySelector('#adminTabs button[data-bs-toggle="tab"].active');
2711
+ if (firstTabEl && !activeTabEl) {
2712
+ var firstTab = bootstrap.Tab.getInstance(firstTabEl) || new bootstrap.Tab(firstTabEl);
2713
+ firstTab.show();
2714
+ }
2715
+
2716
  });
2717
 
2718
  </script>
 
2740
  logging.info("Запуск Flask приложения на порту 7860...")
2741
  # debug=False для продакшена
2742
  # host='0.0.0.0' для доступа из сети
2743
+ app.run(debug=True, host='0.0.0.0', port=7860)