Kgshop commited on
Commit
42c29db
·
verified ·
1 Parent(s): adf1c73

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +825 -177
app.py CHANGED
@@ -125,6 +125,33 @@ def load_data():
125
  else:
126
  data = {}
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  changed = False
129
  for env_id, env_data in data.items():
130
  if 'products' not in env_data: env_data['products'] = []
@@ -142,25 +169,25 @@ def load_data():
142
  if 'logo_url' not in settings: settings['logo_url'] = DEFAULT_LOGO_URL; changed = True
143
  if 'whatsapp_number' not in settings: settings['whatsapp_number'] = DEFAULT_WHATSAPP_NUMBER; changed = True
144
  if 'track_inventory' not in settings: settings['track_inventory'] = False; changed = True
145
- if 'business_type' not in settings: settings['business_type'] = 'wholesale_retail'; changed = True
146
  if 'customer_fields' not in settings:
147
  settings['customer_fields'] = {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False}
148
  changed = True
149
  if 'socials' not in settings:
150
  settings['socials'] = {
151
- 'wa': {'enabled': True, 'url': ''},
152
- 'ig': {'enabled': True, 'url': ''},
153
- 'tg': {'enabled': True, 'url': ''}
154
  }
155
  changed = True
156
 
157
  for product in env_data['products']:
158
  if 'product_id' not in product: product['product_id'] = uuid4().hex; changed = True
159
  if 'pieces_per_box' not in product: product['pieces_per_box'] = 1; changed = True
 
160
  if 'variants' not in product: product['variants'] = []; changed = True
161
  if 'has_variant_prices' not in product: product['has_variant_prices'] = False; changed = True
162
  if 'stock' not in product: product['stock'] = 0; changed = True
163
- if 'min_order' not in product: product['min_order'] = 1; changed = True
164
  for v in product['variants']:
165
  if 'stock' not in v: v['stock'] = 0; changed = True
166
 
@@ -202,7 +229,7 @@ def get_env_data(env_id):
202
  'logo_url': DEFAULT_LOGO_URL,
203
  'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
204
  'track_inventory': False,
205
- 'business_type': 'wholesale_retail',
206
  'customer_fields': {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
207
  'socials': {
208
  'wa': {'enabled': True, 'url': ''},
@@ -406,7 +433,7 @@ CATALOG_TEMPLATE = '''
406
  .product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
407
  .product-desc { font-size: 0.8rem; color: var(--text-muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
408
  .product-box-info { font-size: 0.8rem; color: #00b894; font-weight: 600; }
409
- .product-min-order { font-size: 0.8rem; color: #e17055; font-weight: 600; }
410
 
411
  .product-bottom { display: flex; align-items: center; justify-content: space-between; width: 100%; margin-top: 5px; flex-wrap: wrap; gap: 10px; }
412
  .product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
@@ -557,8 +584,8 @@ CATALOG_TEMPLATE = '''
557
 
558
  <div class="customer-form">
559
  {% if mode == 'pos' %}
560
- <input type="text" id="custName" placeholder="Имя клиента (необязательно)">
561
- <input type="text" id="custWhatsapp" placeholder="WhatsApp клиента (необязательно)">
562
  {% else %}
563
  {% if settings.customer_fields.name %} <input type="text" id="custName" placeholder="Ваше Имя" required> {% endif %}
564
  {% if settings.customer_fields.phone %} <input type="text" id="custPhone" placeholder="Номер телефона" required> {% endif %}
@@ -603,8 +630,8 @@ CATALOG_TEMPLATE = '''
603
  const mode = '{{ mode }}';
604
  const staffId = '{{ staff_id }}';
605
  const trackInventory = {{ 'true' if settings.track_inventory else 'false' }};
606
- const businessType = '{{ settings.business_type }}';
607
  const cFields = {{ settings.customer_fields|tojson }};
 
608
 
609
  let cart = {};
610
  let currentGalleryPhotos = [];
@@ -680,7 +707,7 @@ CATALOG_TEMPLATE = '''
680
 
681
  function formatQtyText(qty, ppb) {
682
  ppb = parseInt(ppb) || 1;
683
- if (ppb > 1 && qty >= ppb) {
684
  let boxes = Math.floor(qty / ppb);
685
  let remainder = qty % ppb;
686
  return `${boxes} кор.` + (remainder > 0 ? ` ${remainder} шт.` : '');
@@ -690,6 +717,7 @@ CATALOG_TEMPLATE = '''
690
 
691
  function renderProductCard(p, container) {
692
  const ppb = parseInt(p.pieces_per_box) || 1;
 
693
  const hasPhotos = p.photos && p.photos.length > 0;
694
  const photoUrl = hasPhotos
695
  ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`
@@ -700,12 +728,17 @@ CATALOG_TEMPLATE = '''
700
  const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
701
 
702
  let boxInfoHtml = '';
703
- if (businessType !== 'retail' && ppb > 1) {
704
- boxInfoHtml = `<div class="product-box-info">В коробке: ${ppb} шт</div>`;
705
- }
706
- let minOrderHtml = '';
707
- if (businessType === 'wholesale' && p.min_order > 1) {
708
- minOrderHtml = `<div class="product-min-order">Мин. заказ: ${p.min_order} шт</div>`;
 
 
 
 
 
709
  }
710
 
711
  let variantsHtml = '';
@@ -719,22 +752,26 @@ CATALOG_TEMPLATE = '''
719
  let cKey = getCartKey(p.product_id, idx);
720
  let qty = cart[cKey] ? cart[cKey].quantity : 0;
721
 
722
- let boxPriceHtml = '';
723
- if (businessType === 'wholesale_retail' && ppb > 1) {
724
- boxPriceHtml = ` / ${(vPrice * ppb)} ${currency} за коробку`;
725
  }
726
-
727
  variantsHtml += `
728
  <div class="variant-item">
729
  <div class="variant-info">
730
  <span class="variant-name">${v.name}</span>
731
- <span class="variant-price">${vPrice} ${currency}${boxPriceHtml}</span>
 
732
  ${vStockHtml}
733
  </div>
734
- <div class="quantity-control" style="border:none; background:var(--surface);">
735
- <button onclick="updateCart('${p.product_id}', -1, null, false, '${cKey}')"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
736
- <input type="number" id="qty-${cKey}" value="${qty}" onchange="manualUpdateCart('${cKey}', this.value)">
737
- <button onclick="updateCart('${p.product_id}', 1, null, false, '${cKey}')"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
 
 
 
738
  </div>
739
  </div>
740
  `;
@@ -743,17 +780,17 @@ CATALOG_TEMPLATE = '''
743
  } else {
744
  let mStockHtml = trackInventory ? `<div style="font-size:0.8rem; color:#0984e3; margin-top:4px;">Остаток: ${p.stock || 0} шт</div>` : '';
745
  let qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
746
- let addBoxBtn = (businessType === 'wholesale_retail' && ppb > 1) ? `<button class="box-btn" onclick="updateCart('${p.product_id}', ${ppb})">+ Коробка</button>` : '';
747
 
748
- let boxPriceHtml = '';
749
- if (businessType === 'wholesale_retail' && ppb > 1) {
750
- boxPriceHtml = ` / ${(p.price * ppb)} ${currency} за коробку`;
751
  }
752
 
753
  mainControlsHtml = `
754
  <div class="product-bottom">
755
  <div style="display:flex; flex-direction:column;">
756
- <div class="product-price">${p.price} ${currency}${boxPriceHtml}</div>
 
757
  ${mStockHtml}
758
  </div>
759
  <div class="controls-wrapper">
@@ -780,7 +817,7 @@ CATALOG_TEMPLATE = '''
780
  <div class="product-title">${p.name}</div>
781
  ${descHtml}
782
  ${boxInfoHtml}
783
- ${minOrderHtml}
784
  </div>
785
  </div>
786
  ${variantsHtml}
@@ -812,14 +849,13 @@ CATALOG_TEMPLATE = '''
812
 
813
  let cKey = cartKeyOverride !== null ? cartKeyOverride : productId;
814
  let varIdx = -1;
815
-
816
  if (cKey.includes('___')) {
817
  varIdx = parseInt(cKey.split('___')[1]);
818
  }
819
-
820
- const isNew = !cart[cKey];
821
 
822
- if (isNew) {
 
 
823
  let price = p.price;
824
  let vName = "";
825
  if (varIdx !== -1 && p.variants[varIdx]) {
@@ -829,18 +865,20 @@ CATALOG_TEMPLATE = '''
829
  cart[cKey] = { ...p, quantity: 0, cart_price: price, variant_name: vName, variant_idx: varIdx };
830
  }
831
 
 
832
  if (exactValue !== null) {
833
- cart[cKey].quantity = exactValue;
834
  } else {
835
- cart[cKey].quantity += change;
836
  }
837
 
838
- if (businessType === 'wholesale' && isNew && change > 0 && p.min_order > 1) {
839
- if(cart[cKey].quantity < p.min_order) {
840
- cart[cKey].quantity = p.min_order;
841
- }
842
  }
843
 
 
 
844
  if (cart[cKey].quantity <= 0) {
845
  delete cart[cKey];
846
  const input = document.getElementById(`qty-${cKey}`);
@@ -949,36 +987,36 @@ CATALOG_TEMPLATE = '''
949
  let orderData = { cart: cartArray, mode: mode, staff_id: staffId };
950
 
951
  if (mode === 'pos') {
 
952
  const waEl = document.getElementById('custWhatsapp');
953
- const nameEl = document.getElementById('custName');
954
- orderData.customer_whatsapp = waEl ? waEl.value.trim() : '';
955
  orderData.customer_name = nameEl ? nameEl.value.trim() : '';
 
956
  } else {
957
  let fail = false;
958
  if(cFields.name) {
959
  const el = document.getElementById('custName');
960
- if(!el || !el.value.trim()) fail = true;
961
- else orderData.customer_name = el.value.trim();
962
  }
963
  if(cFields.phone) {
964
  const el = document.getElementById('custPhone');
965
- if(!el || !el.value.trim()) fail = true;
966
- else orderData.customer_phone = el.value.trim();
967
  }
968
  if(cFields.city) {
969
  const el = document.getElementById('custCity');
970
- if(!el || !el.value.trim()) fail = true;
971
- else orderData.customer_city = el.value.trim();
972
  }
973
  if(cFields.address) {
974
  const el = document.getElementById('custAddress');
975
- if(!el || !el.value.trim()) fail = true;
976
- else orderData.customer_address = el.value.trim();
977
  }
978
  if(cFields.zip) {
979
  const el = document.getElementById('custZip');
980
- if(!el || !el.value.trim()) fail = true;
981
- else orderData.customer_zip = el.value.trim();
982
  }
983
  if(fail) {
984
  alert('Пожалуйста, заполните все обязательные поля.');
@@ -1089,8 +1127,9 @@ CATALOG_TEMPLATE = '''
1089
  if(data.success) {
1090
  alert('Возврат успешно проведен!');
1091
  closeReturnsModal();
 
1092
  } else {
1093
- alert('Ошибка проведения возврата: ' + (data.error || ''));
1094
  }
1095
  });
1096
  }
@@ -1242,13 +1281,14 @@ ORDER_TEMPLATE = '''
1242
 
1243
  <div class="info-row">
1244
  <div class="customer-details">
1245
- {% if order.customer_name %}<div>Покупатель: <span>{{ order.customer_name }}</span></div>{% endif %}
1246
  {% if order.status != 'pos' and order.status != 'returned' %}
 
1247
  {% if order.customer_phone %}<div>Телефон: <span>{{ order.customer_phone }}</span></div>{% endif %}
1248
  {% if order.customer_city %}<div>Город: <span>{{ order.customer_city }}</span></div>{% endif %}
1249
  {% if order.customer_address %}<div>Адрес: <span>{{ order.customer_address }}</span></div>{% endif %}
1250
  {% if order.customer_zip %}<div>Индекс: <span>{{ order.customer_zip }}</span></div>{% endif %}
1251
  {% else %}
 
1252
  {% if order.customer_whatsapp %}<div>WhatsApp: <span>{{ order.customer_whatsapp }}</span></div>{% endif %}
1253
  {% endif %}
1254
 
@@ -1312,7 +1352,7 @@ ORDER_TEMPLATE = '''
1312
  </div>
1313
  {% endif %}
1314
  <div style="font-size: 0.85rem; color: #00b894; font-weight: 600;">
1315
- {% if ppb > 1 and boxes > 0 %}
1316
  {{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
1317
  {% else %}
1318
  {{ item.quantity }} шт.
@@ -1321,7 +1361,7 @@ ORDER_TEMPLATE = '''
1321
  </div>
1322
  </div>
1323
  <div class="print-only" style="font-weight: bold;">
1324
- {% if ppb > 1 and boxes > 0 %}
1325
  {{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
1326
  {% else %}
1327
  {{ item.quantity }} шт.
@@ -1643,21 +1683,21 @@ ADMIN_TEMPLATE = '''
1643
  <input type="text" name="whatsapp_number" value="{{ settings.whatsapp_number }}" placeholder="+77001234567" required>
1644
  </div>
1645
 
1646
- <div class="settings-row">
1647
- <label>Логотип (загрузить):</label>
1648
- <input type="file" name="logo" accept="image/*">
1649
- </div>
1650
- <div style="text-align: right; font-size: 0.8rem; color: #636e72;">Текущий логотип: <img src="{{ settings.logo_url }}" style="height:30px; vertical-align:middle; border:1px solid #ccc; border-radius:4px; margin-left:10px;"></div>
1651
-
1652
  <div class="settings-row">
1653
  <label>Тип бизнеса:</label>
1654
  <select name="business_type">
1655
  <option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розница</option>
1656
- <option value="wholesale_retail" {% if settings.business_type == 'wholesale_retail' %}selected{% endif %}>Оптово-розничный</option>
1657
  <option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Оптовый</option>
1658
  </select>
1659
  </div>
1660
 
 
 
 
 
 
 
1661
  <div class="social-settings">
1662
  <div style="font-weight: 600; margin-bottom: 5px;">Поля для клиента (Оформление заказа):</div>
1663
  <div style="display:flex; gap:15px; flex-wrap:wrap; margin-bottom: 15px;">
@@ -1736,16 +1776,12 @@ ADMIN_TEMPLATE = '''
1736
  <input type="number" name="price" placeholder="Цена" step="0.01" class="main-price-input">
1737
  </div>
1738
  <input type="number" name="pieces_per_box" placeholder="В коробке (шт)" value="1" min="1" required style="flex:1;">
 
1739
  {% if settings.track_inventory %}
1740
  <div style="flex:1; display:flex; flex-direction:column; gap:5px;" class="main-stock-container">
1741
  <input type="number" name="stock" placeholder="Остаток" value="0" class="main-stock-input">
1742
  </div>
1743
  {% endif %}
1744
- {% if settings.business_type == 'wholesale' %}
1745
- <div style="flex:1; display:flex; flex-direction:column; gap:5px;" class="min-order-container">
1746
- <input type="number" name="min_order" placeholder="Мин. заказ" value="1">
1747
- </div>
1748
- {% endif %}
1749
  </div>
1750
 
1751
  <div class="variants-container" id="variants-container-add-{{ loop.index }}">
@@ -1753,7 +1789,7 @@ ADMIN_TEMPLATE = '''
1753
  <label style="font-weight:600; font-size:0.9rem;"><i class="fas fa-tags"></i> Варианты товара</label>
1754
  <label style="font-size:0.85rem;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'add-prod-{{ loop.index }}')"> Разные цены</label>
1755
  </div>
1756
- <div id="variants-list-add-{{ loop.index }}"></div>
1757
  <button type="button" class="btn btn-outline" style="padding: 5px 10px; font-size:0.85rem;" onclick="addVariantRow('variants-list-add-{{ loop.index }}')"><i class="fas fa-plus"></i> Добави��ь вариант</button>
1758
  </div>
1759
 
@@ -1778,6 +1814,9 @@ ADMIN_TEMPLATE = '''
1778
  {% endif %}
1779
  <div class="product-details">
1780
  <span class="product-name">{{ product.name }}</span>
 
 
 
1781
  <span class="product-meta">
1782
  {% if product.has_variant_prices %}
1783
  Цена по вариантам
@@ -1817,16 +1856,12 @@ ADMIN_TEMPLATE = '''
1817
  <input type="number" name="price" value="{{ product.price }}" step="0.01" class="main-price-input" {% if not product.has_variant_prices %}required{% endif %}>
1818
  </div>
1819
  <input type="number" name="pieces_per_box" value="{{ product.pieces_per_box|default(1) }}" min="1" required style="flex:1;">
 
1820
  {% if settings.track_inventory %}
1821
  <div style="flex:1; display:flex; flex-direction:column; gap:5px;" class="main-stock-container" {% if product.variants %}style="display:none;"{% endif %}>
1822
  <input type="number" name="stock" value="{{ product.stock }}" class="main-stock-input">
1823
  </div>
1824
  {% endif %}
1825
- {% if settings.business_type == 'wholesale' %}
1826
- <div style="flex:1; display:flex; flex-direction:column; gap:5px;" class="min-order-container">
1827
- <input type="number" name="min_order" value="{{ product.min_order|default(1) }}" placeholder="Мин. заказ">
1828
- </div>
1829
- {% endif %}
1830
  </div>
1831
 
1832
  <div class="variants-container" id="variants-container-edit-{{ product.product_id }}">
@@ -1834,10 +1869,10 @@ ADMIN_TEMPLATE = '''
1834
  <label style="font-weight:600; font-size:0.9rem;"><i class="fas fa-tags"></i> Варианты товара</label>
1835
  <label style="font-size:0.85rem;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'edit-prod-{{ product.product_id }}')" {% if product.has_variant_prices %}checked{% endif %}> Разные цены</label>
1836
  </div>
1837
- <div id="variants-list-edit-{{ product.product_id }}">
1838
  {% for variant in product.variants %}
1839
  <div class="variant-row">
1840
- <input type="text" name="variant_name[]" value="{{ variant.name }}" placeholder="Название варианта" required>
1841
  <input type="number" name="variant_price[]" value="{{ variant.price }}" placeholder="Цена" step="0.01" class="var-price-input" {% if not product.has_variant_prices %}style="display:none;"{% endif %} {% if product.has_variant_prices %}required{% endif %}>
1842
  {% if settings.track_inventory %}
1843
  <input type="number" name="variant_stock[]" value="{{ variant.stock }}" placeholder="Остаток">
@@ -1867,7 +1902,6 @@ ADMIN_TEMPLATE = '''
1867
  </div>
1868
  <script>
1869
  const trackInventory = {{ 'true' if settings.track_inventory else 'false' }};
1870
- const businessType = '{{ settings.business_type }}';
1871
 
1872
  function showLoading(form) {
1873
  const btn = form.querySelector('button[type="submit"]');
@@ -1918,7 +1952,7 @@ ADMIN_TEMPLATE = '''
1918
  let stockHtml = trackInventory ? `<input type="number" name="variant_stock[]" placeholder="Остаток" value="0">` : '';
1919
 
1920
  div.innerHTML = `
1921
- <input type="text" name="variant_name[]" placeholder="Название варианта" required>
1922
  <input type="number" name="variant_price[]" placeholder="Цена" step="0.01" class="var-price-input" style="${hasVariantPrices ? '' : 'display:none;'}" ${hasVariantPrices ? 'required' : ''}>
1923
  ${stockHtml}
1924
  <button type="button" class="remove-variant-btn" onclick="this.parentElement.remove(); updateMainStockVisibility('${formId}')"><i class="fas fa-times-circle"></i></button>
@@ -2020,20 +2054,24 @@ ADMIN_TEMPLATE = '''
2020
  }
2021
 
2022
  function copyToClipboard(text) {
2023
- let textArea = document.createElement("textarea");
2024
- textArea.value = text;
2025
- textArea.style.position = "fixed";
2026
- textArea.style.left = "-999999px";
2027
- document.body.appendChild(textArea);
2028
- textArea.focus();
2029
- textArea.select();
2030
- try {
2031
- document.execCommand('copy');
2032
- alert('Ссылка скопирована!');
2033
- } catch (err) {
2034
- alert('Не удалось скопировать ссылку');
 
 
 
 
 
2035
  }
2036
- document.body.removeChild(textArea);
2037
  }
2038
 
2039
  document.querySelectorAll('.add-product-wrapper').forEach(wrapper => {
@@ -2065,25 +2103,23 @@ REPORTS_TEMPLATE = '''
2065
  .filter-bar { background: var(--surface); padding: 15px 20px; border-radius: 12px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
2066
  .filter-bar input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; outline: none; font-family: inherit; }
2067
 
2068
- .tabs { display: flex; gap: 10px; margin-bottom: 20px; }
2069
- .tab-btn { background: var(--surface); border: 1px solid var(--border); padding: 10px 20px; border-radius: 8px; cursor: pointer; font-weight: 600; }
2070
- .tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
 
2071
  .tab-content { display: none; }
2072
  .tab-content.active { display: block; }
2073
-
2074
  .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }
2075
- .stat-card { background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 10px; }
2076
  .stat-card .title { font-size: 0.9rem; color: #636e72; font-weight: 600; }
2077
  .stat-card .value { font-size: 1.8rem; font-weight: 700; color: var(--text); }
2078
- .stat-card .icon { font-size: 2rem; color: var(--primary); opacity: 0.2; position: absolute; right: 20px; bottom: 20px; }
2079
- .stat-card-inner { position: relative; }
2080
-
2081
- .table-container { background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); overflow-x: auto; }
2082
- .table-container h3 { margin-top: 0; }
2083
- table { width: 100%; border-collapse: collapse; }
2084
- th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
2085
- th { font-weight: 600; color: #636e72; }
2086
 
 
 
 
 
2087
  </style>
2088
  </head>
2089
  <body>
@@ -2098,40 +2134,35 @@ REPORTS_TEMPLATE = '''
2098
  <input type="date" id="dateStart" onchange="updateReports()">
2099
  <span>—</span>
2100
  <input type="date" id="dateEnd" onchange="updateReports()">
2101
- <button class="btn" style="background:var(--success);" onclick="resetDates()">Сброс</button>
2102
  </div>
2103
 
2104
  <div class="tabs">
2105
- <button class="tab-btn active" onclick="showTab('general')">Общий отчет</button>
2106
- <button class="tab-btn" onclick="showTab('staff')">Отчет по сотрудникам</button>
2107
  </div>
2108
 
2109
  <div id="general" class="tab-content active">
2110
  <div class="stats-grid">
2111
  <div class="stat-card">
2112
- <div class="stat-card-inner">
2113
- <div class="title">Общая выручка</div>
2114
- <div class="value" id="totalRevenue">0</div>
2115
- <i class="fas fa-money-bill-wave icon"></i>
2116
- </div>
2117
  </div>
2118
  <div class="stat-card">
2119
- <div class="stat-card-inner">
2120
- <div class="title">Кол-во заказов</div>
2121
- <div class="value" id="totalOrders">0</div>
2122
- <i class="fas fa-shopping-cart icon"></i>
2123
- </div>
2124
  </div>
2125
  <div class="stat-card">
2126
- <div class="stat-card-inner">
2127
- <div class="title">Возвраты (сумма)</div>
2128
- <div class="value" id="totalReturns" style="color:var(--danger);">0</div>
2129
- <i class="fas fa-undo icon"></i>
2130
- </div>
2131
  </div>
2132
  </div>
 
2133
  <div class="table-container">
2134
- <h3>Топ продаваемых товаров</h3>
2135
  <table>
2136
  <thead>
2137
  <tr>
@@ -2144,35 +2175,34 @@ REPORTS_TEMPLATE = '''
2144
  </table>
2145
  </div>
2146
  </div>
2147
-
2148
  <div id="staff" class="tab-content">
2149
- <div class="table-container">
2150
- <h3>Продажи по сотрудникам</h3>
2151
  <table>
2152
  <thead>
2153
  <tr>
2154
  <th>Сотрудник</th>
2155
- <th>Кол-во продаж</th>
2156
- <th>Сумма продаж ({{ currency_code }})</th>
2157
  <th>Сумма возвратов ({{ currency_code }})</th>
2158
- <th>Итого ({{ currency_code }})</th>
2159
  </tr>
2160
  </thead>
2161
  <tbody id="staffTable"></tbody>
2162
  </table>
2163
  </div>
2164
  </div>
2165
-
2166
  </div>
2167
 
2168
  <script>
2169
  const allOrders = {{ orders_json|safe }};
2170
 
2171
- function showTab(tabId) {
2172
  document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
2173
  document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
 
2174
  document.getElementById(tabId).classList.add('active');
2175
- event.target.classList.add('active');
2176
  }
2177
 
2178
  function setDefaultDates() {
@@ -2183,7 +2213,8 @@ REPORTS_TEMPLATE = '''
2183
  }
2184
 
2185
  function resetDates() {
2186
- setDefaultDates();
 
2187
  updateReports();
2188
  }
2189
 
@@ -2191,68 +2222,73 @@ REPORTS_TEMPLATE = '''
2191
  const startDate = document.getElementById('dateStart').value;
2192
  const endDate = document.getElementById('dateEnd').value;
2193
 
2194
- let filteredOrders = allOrders;
2195
 
2196
  if (startDate) {
2197
  const sDate = new Date(startDate);
2198
- filteredOrders = filteredOrders.filter(o => new Date(o.created_at.split(' ')[0]) >= sDate);
 
 
 
2199
  }
2200
  if (endDate) {
2201
  const eDate = new Date(endDate);
2202
- filteredOrders = filteredOrders.filter(o => new Date(o.created_at.split(' ')[0]) <= eDate);
 
 
 
2203
  }
2204
 
2205
  let totalRev = 0;
2206
  let totalRet = 0;
2207
- let ordersCount = 0;
2208
 
2209
  let staffStats = {};
2210
  let productSales = {};
2211
 
2212
  filteredOrders.forEach(o => {
 
 
 
2213
  if(o.status === 'returned') {
2214
  totalRet += o.total_price;
2215
- const staff = o.staff_name || 'Онлайн (Без сотрудника)';
2216
- if (!staffStats[staff]) staffStats[staff] = { sales: 0, returns: 0, count: 0 };
2217
- staffStats[staff].returns += o.total_price;
2218
- } else if (o.status === 'confirmed' || o.status === 'pos') {
2219
- ordersCount++;
2220
  totalRev += o.total_price;
2221
-
2222
- const staff = o.staff_name || 'Онлайн (Без сотрудника)';
2223
- if (!staffStats[staff]) staffStats[staff] = { sales: 0, returns: 0, count: 0 };
2224
- staffStats[staff].sales += o.total_price;
2225
  staffStats[staff].count += 1;
2226
 
2227
- o.cart.forEach(item => {
2228
- if(item.quantity > 0) {
2229
- let pName = item.name;
2230
- if(item.variant_name) pName += ` (${item.variant_name})`;
2231
-
2232
- if(!productSales[pName]) productSales[pName] = { qty: 0, sum: 0 };
2233
- productSales[pName].qty += item.quantity;
2234
- productSales[pName].sum += (item.price * item.quantity);
2235
- }
2236
- });
 
 
2237
  }
2238
  });
2239
 
2240
- document.getElementById('totalRevenue').innerText = totalRev.toLocaleString() + ' {{ currency_code }}';
2241
  document.getElementById('totalOrders').innerText = ordersCount;
2242
- document.getElementById('totalReturns').innerText = totalRet.toLocaleString() + ' {{ currency_code }}';
2243
-
2244
  renderTopProducts(productSales);
2245
- renderStaffReport(staffStats);
2246
  }
2247
 
2248
  function renderTopProducts(data) {
2249
  const tbody = document.getElementById('topProductsTable');
2250
  tbody.innerHTML = '';
2251
 
2252
- const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum).slice(0, 20);
2253
 
2254
  if(sorted.length === 0) {
2255
- tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
2256
  return;
2257
  }
2258
 
@@ -2266,32 +2302,30 @@ REPORTS_TEMPLATE = '''
2266
  `;
2267
  });
2268
  }
2269
-
2270
- function renderStaffReport(data) {
2271
  const tbody = document.getElementById('staffTable');
2272
  tbody.innerHTML = '';
2273
-
2274
- const sorted = Object.keys(data).sort((a,b) => (data[b].sales - data[b].returns) - (data[a].sales - data[a].returns));
2275
-
2276
- if(sorted.length === 0) {
2277
- tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;">Нет данных</td></tr>';
2278
  return;
2279
  }
2280
 
2281
- sorted.forEach(s => {
2282
- const total = data[s].sales - data[s].returns;
2283
  tbody.innerHTML += `
2284
  <tr>
2285
  <td style="font-weight:500;">${s}</td>
 
2286
  <td>${data[s].count}</td>
2287
- <td>${data[s].sales.toLocaleString()}</td>
2288
- <td style="color:var(--danger);">${data[s].returns.toLocaleString()}</td>
2289
- <td style="font-weight:bold;">${total.toLocaleString()}</td>
2290
  </tr>
2291
  `;
2292
  });
2293
  }
2294
-
2295
  setDefaultDates();
2296
  updateReports();
2297
  </script>
@@ -2299,6 +2333,620 @@ REPORTS_TEMPLATE = '''
2299
  </html>
2300
  '''
2301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2302
  if __name__ == '__main__':
2303
  download_db_from_hf()
2304
  load_data()
 
125
  else:
126
  data = {}
127
 
128
+ if 'products' in data or 'categories' in data:
129
+ data = {
130
+ 'default_env': {
131
+ 'products': data.get('products', []),
132
+ 'categories': data.get('categories', []),
133
+ 'orders': data.get('orders', {}),
134
+ 'staff': [],
135
+ 'settings': {
136
+ 'organization_name': 'Default Shop',
137
+ 'admin_password_enabled': False,
138
+ 'admin_password': '',
139
+ 'logo_url': DEFAULT_LOGO_URL,
140
+ 'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
141
+ 'track_inventory': False,
142
+ 'business_type': 'retail',
143
+ 'customer_fields': {
144
+ 'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False
145
+ },
146
+ 'socials': {
147
+ 'wa': {'enabled': True, 'url': 'https://wa.me/77011333885'},
148
+ 'ig': {'enabled': True, 'url': 'https://instagram.com/14sklad_baisat'},
149
+ 'tg': {'enabled': True, 'url': 'https://t.me/posuda15konteiner'}
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
  changed = False
156
  for env_id, env_data in data.items():
157
  if 'products' not in env_data: env_data['products'] = []
 
169
  if 'logo_url' not in settings: settings['logo_url'] = DEFAULT_LOGO_URL; changed = True
170
  if 'whatsapp_number' not in settings: settings['whatsapp_number'] = DEFAULT_WHATSAPP_NUMBER; changed = True
171
  if 'track_inventory' not in settings: settings['track_inventory'] = False; changed = True
172
+ if 'business_type' not in settings: settings['business_type'] = 'retail'; changed = True
173
  if 'customer_fields' not in settings:
174
  settings['customer_fields'] = {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False}
175
  changed = True
176
  if 'socials' not in settings:
177
  settings['socials'] = {
178
+ 'wa': {'enabled': True, 'url': 'https://wa.me/77011333885'},
179
+ 'ig': {'enabled': True, 'url': 'https://instagram.com/14sklad_baisat'},
180
+ 'tg': {'enabled': True, 'url': 'https://t.me/posuda15konteiner'}
181
  }
182
  changed = True
183
 
184
  for product in env_data['products']:
185
  if 'product_id' not in product: product['product_id'] = uuid4().hex; changed = True
186
  if 'pieces_per_box' not in product: product['pieces_per_box'] = 1; changed = True
187
+ if 'min_order_qty' not in product: product['min_order_qty'] = 1; changed = True
188
  if 'variants' not in product: product['variants'] = []; changed = True
189
  if 'has_variant_prices' not in product: product['has_variant_prices'] = False; changed = True
190
  if 'stock' not in product: product['stock'] = 0; changed = True
 
191
  for v in product['variants']:
192
  if 'stock' not in v: v['stock'] = 0; changed = True
193
 
 
229
  'logo_url': DEFAULT_LOGO_URL,
230
  'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
231
  'track_inventory': False,
232
+ 'business_type': 'retail',
233
  'customer_fields': {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
234
  'socials': {
235
  'wa': {'enabled': True, 'url': ''},
 
433
  .product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
434
  .product-desc { font-size: 0.8rem; color: var(--text-muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
435
  .product-box-info { font-size: 0.8rem; color: #00b894; font-weight: 600; }
436
+ .product-min-qty { font-size: 0.8rem; color: #e17055; font-weight: 600; }
437
 
438
  .product-bottom { display: flex; align-items: center; justify-content: space-between; width: 100%; margin-top: 5px; flex-wrap: wrap; gap: 10px; }
439
  .product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
 
584
 
585
  <div class="customer-form">
586
  {% if mode == 'pos' %}
587
+ <input type="text" id="custNamePos" placeholder="Имя клиента (необязательно)">
588
+ <input type="text" id="custWhatsapp" placeholder="WhatsApp клиента (напр. +77001234567) необязательно">
589
  {% else %}
590
  {% if settings.customer_fields.name %} <input type="text" id="custName" placeholder="Ваше Имя" required> {% endif %}
591
  {% if settings.customer_fields.phone %} <input type="text" id="custPhone" placeholder="Номер телефона" required> {% endif %}
 
630
  const mode = '{{ mode }}';
631
  const staffId = '{{ staff_id }}';
632
  const trackInventory = {{ 'true' if settings.track_inventory else 'false' }};
 
633
  const cFields = {{ settings.customer_fields|tojson }};
634
+ const businessType = '{{ settings.business_type|default("retail") }}';
635
 
636
  let cart = {};
637
  let currentGalleryPhotos = [];
 
707
 
708
  function formatQtyText(qty, ppb) {
709
  ppb = parseInt(ppb) || 1;
710
+ if (ppb > 1 && qty >= ppb && businessType !== 'retail') {
711
  let boxes = Math.floor(qty / ppb);
712
  let remainder = qty % ppb;
713
  return `${boxes} кор.` + (remainder > 0 ? ` ${remainder} шт.` : '');
 
717
 
718
  function renderProductCard(p, container) {
719
  const ppb = parseInt(p.pieces_per_box) || 1;
720
+ const minQty = parseInt(p.min_order_qty) || 1;
721
  const hasPhotos = p.photos && p.photos.length > 0;
722
  const photoUrl = hasPhotos
723
  ? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`
 
728
  const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
729
 
730
  let boxInfoHtml = '';
731
+ let addBoxBtn = '';
732
+ let minQtyHtml = '';
733
+
734
+ if (businessType === 'mixed' || businessType === 'wholesale') {
735
+ if (ppb > 1) {
736
+ boxInfoHtml = `<div class="product-box-info">В коробке: ${ppb} шт</div>`;
737
+ addBoxBtn = `<button class="box-btn" onclick="updateCart('${p.product_id}', ${ppb})">+ Коробка</button>`;
738
+ }
739
+ if (businessType === 'wholesale' && minQty > 1) {
740
+ minQtyHtml = `<div class="product-min-qty">Мин. заказ: ${minQty} шт</div>`;
741
+ }
742
  }
743
 
744
  let variantsHtml = '';
 
752
  let cKey = getCartKey(p.product_id, idx);
753
  let qty = cart[cKey] ? cart[cKey].quantity : 0;
754
 
755
+ let vBoxPriceHtml = '';
756
+ if ((businessType === 'mixed' || businessType === 'wholesale') && ppb > 1) {
757
+ vBoxPriceHtml = `<div style="font-size:0.8rem; color:#636e72;">Цена кор: ${vPrice * ppb} ${currency}</div>`;
758
  }
759
+
760
  variantsHtml += `
761
  <div class="variant-item">
762
  <div class="variant-info">
763
  <span class="variant-name">${v.name}</span>
764
+ <span class="variant-price">${vPrice} ${currency}</span>
765
+ ${vBoxPriceHtml}
766
  ${vStockHtml}
767
  </div>
768
+ <div style="display:flex; flex-direction:column; align-items:flex-end; gap:5px;">
769
+ ${businessType !== 'retail' && ppb > 1 ? `<button class="box-btn" style="padding:4px 8px; font-size:0.75rem; height:auto;" onclick="updateCart('${p.product_id}', ${ppb}, null, false, '${cKey}')">+ Кор</button>` : ''}
770
+ <div class="quantity-control" style="border:none; background:var(--surface);">
771
+ <button onclick="updateCart('${p.product_id}', -1, null, false, '${cKey}')"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
772
+ <input type="number" id="qty-${cKey}" value="${qty}" onchange="manualUpdateCart('${cKey}', this.value)">
773
+ <button onclick="updateCart('${p.product_id}', 1, null, false, '${cKey}')"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
774
+ </div>
775
  </div>
776
  </div>
777
  `;
 
780
  } else {
781
  let mStockHtml = trackInventory ? `<div style="font-size:0.8rem; color:#0984e3; margin-top:4px;">Остаток: ${p.stock || 0} шт</div>` : '';
782
  let qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
 
783
 
784
+ let mBoxPriceHtml = '';
785
+ if ((businessType === 'mixed' || businessType === 'wholesale') && ppb > 1) {
786
+ mBoxPriceHtml = `<div style="font-size:0.8rem; color:#636e72;">Цена за кор: ${p.price * ppb} ${currency}</div>`;
787
  }
788
 
789
  mainControlsHtml = `
790
  <div class="product-bottom">
791
  <div style="display:flex; flex-direction:column;">
792
+ <div class="product-price">${p.price} ${currency}</div>
793
+ ${mBoxPriceHtml}
794
  ${mStockHtml}
795
  </div>
796
  <div class="controls-wrapper">
 
817
  <div class="product-title">${p.name}</div>
818
  ${descHtml}
819
  ${boxInfoHtml}
820
+ ${minQtyHtml}
821
  </div>
822
  </div>
823
  ${variantsHtml}
 
849
 
850
  let cKey = cartKeyOverride !== null ? cartKeyOverride : productId;
851
  let varIdx = -1;
 
852
  if (cKey.includes('___')) {
853
  varIdx = parseInt(cKey.split('___')[1]);
854
  }
 
 
855
 
856
+ let minQty = (businessType === 'wholesale') ? (parseInt(p.min_order_qty) || 1) : 1;
857
+
858
+ if (!cart[cKey]) {
859
  let price = p.price;
860
  let vName = "";
861
  if (varIdx !== -1 && p.variants[varIdx]) {
 
865
  cart[cKey] = { ...p, quantity: 0, cart_price: price, variant_name: vName, variant_idx: varIdx };
866
  }
867
 
868
+ let newQty;
869
  if (exactValue !== null) {
870
+ newQty = exactValue;
871
  } else {
872
+ newQty = cart[cKey].quantity + change;
873
  }
874
 
875
+ if (newQty > 0 && newQty < minQty) {
876
+ if (change > 0 || (exactValue !== null && exactValue > 0)) newQty = minQty;
877
+ else newQty = 0;
 
878
  }
879
 
880
+ cart[cKey].quantity = newQty;
881
+
882
  if (cart[cKey].quantity <= 0) {
883
  delete cart[cKey];
884
  const input = document.getElementById(`qty-${cKey}`);
 
987
  let orderData = { cart: cartArray, mode: mode, staff_id: staffId };
988
 
989
  if (mode === 'pos') {
990
+ const nameEl = document.getElementById('custNamePos');
991
  const waEl = document.getElementById('custWhatsapp');
 
 
992
  orderData.customer_name = nameEl ? nameEl.value.trim() : '';
993
+ orderData.customer_whatsapp = waEl ? waEl.value.trim() : '';
994
  } else {
995
  let fail = false;
996
  if(cFields.name) {
997
  const el = document.getElementById('custName');
998
+ if(!el.value.trim()) fail = true;
999
+ orderData.customer_name = el.value.trim();
1000
  }
1001
  if(cFields.phone) {
1002
  const el = document.getElementById('custPhone');
1003
+ if(!el.value.trim()) fail = true;
1004
+ orderData.customer_phone = el.value.trim();
1005
  }
1006
  if(cFields.city) {
1007
  const el = document.getElementById('custCity');
1008
+ if(!el.value.trim()) fail = true;
1009
+ orderData.customer_city = el.value.trim();
1010
  }
1011
  if(cFields.address) {
1012
  const el = document.getElementById('custAddress');
1013
+ if(!el.value.trim()) fail = true;
1014
+ orderData.customer_address = el.value.trim();
1015
  }
1016
  if(cFields.zip) {
1017
  const el = document.getElementById('custZip');
1018
+ if(!el.value.trim()) fail = true;
1019
+ orderData.customer_zip = el.value.trim();
1020
  }
1021
  if(fail) {
1022
  alert('Пожалуйста, заполните все обязательные поля.');
 
1127
  if(data.success) {
1128
  alert('Возврат успешно проведен!');
1129
  closeReturnsModal();
1130
+ window.location.reload();
1131
  } else {
1132
+ alert('Ошибка проведения возврата');
1133
  }
1134
  });
1135
  }
 
1281
 
1282
  <div class="info-row">
1283
  <div class="customer-details">
 
1284
  {% if order.status != 'pos' and order.status != 'returned' %}
1285
+ {% if order.customer_name %}<div>Покупатель: <span>{{ order.customer_name }}</span></div>{% endif %}
1286
  {% if order.customer_phone %}<div>Телефон: <span>{{ order.customer_phone }}</span></div>{% endif %}
1287
  {% if order.customer_city %}<div>Город: <span>{{ order.customer_city }}</span></div>{% endif %}
1288
  {% if order.customer_address %}<div>Адрес: <span>{{ order.customer_address }}</span></div>{% endif %}
1289
  {% if order.customer_zip %}<div>Индекс: <span>{{ order.customer_zip }}</span></div>{% endif %}
1290
  {% else %}
1291
+ <div>Покупатель: <span>{{ order.customer_name if order.customer_name else 'Касса (POS)' }}</span></div>
1292
  {% if order.customer_whatsapp %}<div>WhatsApp: <span>{{ order.customer_whatsapp }}</span></div>{% endif %}
1293
  {% endif %}
1294
 
 
1352
  </div>
1353
  {% endif %}
1354
  <div style="font-size: 0.85rem; color: #00b894; font-weight: 600;">
1355
+ {% if ppb > 1 and boxes > 0 and settings.business_type != 'retail' %}
1356
  {{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
1357
  {% else %}
1358
  {{ item.quantity }} шт.
 
1361
  </div>
1362
  </div>
1363
  <div class="print-only" style="font-weight: bold;">
1364
+ {% if ppb > 1 and boxes > 0 and settings.business_type != 'retail' %}
1365
  {{ boxes }} кор.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
1366
  {% else %}
1367
  {{ item.quantity }} шт.
 
1683
  <input type="text" name="whatsapp_number" value="{{ settings.whatsapp_number }}" placeholder="+77001234567" required>
1684
  </div>
1685
 
 
 
 
 
 
 
1686
  <div class="settings-row">
1687
  <label>Тип бизнеса:</label>
1688
  <select name="business_type">
1689
  <option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розница</option>
1690
+ <option value="mixed" {% if settings.business_type == 'mixed' %}selected{% endif %}>Оптово-розничный</option>
1691
  <option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Оптовый</option>
1692
  </select>
1693
  </div>
1694
 
1695
+ <div class="settings-row">
1696
+ <label>Логотип (загрузить):</label>
1697
+ <input type="file" name="logo" accept="image/*">
1698
+ </div>
1699
+ <div style="text-align: right; font-size: 0.8rem; color: #636e72;">Текущий логотип: <img src="{{ settings.logo_url }}" style="height:30px; vertical-align:middle; border:1px solid #ccc; border-radius:4px; margin-left:10px;"></div>
1700
+
1701
  <div class="social-settings">
1702
  <div style="font-weight: 600; margin-bottom: 5px;">Поля для клиента (Оформление заказа):</div>
1703
  <div style="display:flex; gap:15px; flex-wrap:wrap; margin-bottom: 15px;">
 
1776
  <input type="number" name="price" placeholder="Цена" step="0.01" class="main-price-input">
1777
  </div>
1778
  <input type="number" name="pieces_per_box" placeholder="В коробке (шт)" value="1" min="1" required style="flex:1;">
1779
+ <input type="number" name="min_order_qty" placeholder="Мин. заказ" value="1" min="1" required style="flex:1;">
1780
  {% if settings.track_inventory %}
1781
  <div style="flex:1; display:flex; flex-direction:column; gap:5px;" class="main-stock-container">
1782
  <input type="number" name="stock" placeholder="Остаток" value="0" class="main-stock-input">
1783
  </div>
1784
  {% endif %}
 
 
 
 
 
1785
  </div>
1786
 
1787
  <div class="variants-container" id="variants-container-add-{{ loop.index }}">
 
1789
  <label style="font-weight:600; font-size:0.9rem;"><i class="fas fa-tags"></i> Варианты товара</label>
1790
  <label style="font-size:0.85rem;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'add-prod-{{ loop.index }}')"> Разные цены</label>
1791
  </div>
1792
+ <div id="variants-list-add-{{ loop.index }}" style="display:flex; flex-direction:column; gap:10px;"></div>
1793
  <button type="button" class="btn btn-outline" style="padding: 5px 10px; font-size:0.85rem;" onclick="addVariantRow('variants-list-add-{{ loop.index }}')"><i class="fas fa-plus"></i> Добави��ь вариант</button>
1794
  </div>
1795
 
 
1814
  {% endif %}
1815
  <div class="product-details">
1816
  <span class="product-name">{{ product.name }}</span>
1817
+ {% if product.description %}
1818
+ <span class="product-desc">{{ product.description[:50] }}{{ '...' if product.description|length > 50 else '' }}</span>
1819
+ {% endif %}
1820
  <span class="product-meta">
1821
  {% if product.has_variant_prices %}
1822
  Цена по вариантам
 
1856
  <input type="number" name="price" value="{{ product.price }}" step="0.01" class="main-price-input" {% if not product.has_variant_prices %}required{% endif %}>
1857
  </div>
1858
  <input type="number" name="pieces_per_box" value="{{ product.pieces_per_box|default(1) }}" min="1" required style="flex:1;">
1859
+ <input type="number" name="min_order_qty" value="{{ product.min_order_qty|default(1) }}" min="1" required style="flex:1;">
1860
  {% if settings.track_inventory %}
1861
  <div style="flex:1; display:flex; flex-direction:column; gap:5px;" class="main-stock-container" {% if product.variants %}style="display:none;"{% endif %}>
1862
  <input type="number" name="stock" value="{{ product.stock }}" class="main-stock-input">
1863
  </div>
1864
  {% endif %}
 
 
 
 
 
1865
  </div>
1866
 
1867
  <div class="variants-container" id="variants-container-edit-{{ product.product_id }}">
 
1869
  <label style="font-weight:600; font-size:0.9rem;"><i class="fas fa-tags"></i> Варианты товара</label>
1870
  <label style="font-size:0.85rem;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'edit-prod-{{ product.product_id }}')" {% if product.has_variant_prices %}checked{% endif %}> Разные цены</label>
1871
  </div>
1872
+ <div id="variants-list-edit-{{ product.product_id }}" style="display:flex; flex-direction:column; gap:10px;">
1873
  {% for variant in product.variants %}
1874
  <div class="variant-row">
1875
+ <input type="text" name="variant_name[]" value="{{ variant.name }}" placeholder="Название апр. Красный)" required>
1876
  <input type="number" name="variant_price[]" value="{{ variant.price }}" placeholder="Цена" step="0.01" class="var-price-input" {% if not product.has_variant_prices %}style="display:none;"{% endif %} {% if product.has_variant_prices %}required{% endif %}>
1877
  {% if settings.track_inventory %}
1878
  <input type="number" name="variant_stock[]" value="{{ variant.stock }}" placeholder="Остаток">
 
1902
  </div>
1903
  <script>
1904
  const trackInventory = {{ 'true' if settings.track_inventory else 'false' }};
 
1905
 
1906
  function showLoading(form) {
1907
  const btn = form.querySelector('button[type="submit"]');
 
1952
  let stockHtml = trackInventory ? `<input type="number" name="variant_stock[]" placeholder="Остаток" value="0">` : '';
1953
 
1954
  div.innerHTML = `
1955
+ <input type="text" name="variant_name[]" placeholder="Название апр. Красный)" required>
1956
  <input type="number" name="variant_price[]" placeholder="Цена" step="0.01" class="var-price-input" style="${hasVariantPrices ? '' : 'display:none;'}" ${hasVariantPrices ? 'required' : ''}>
1957
  ${stockHtml}
1958
  <button type="button" class="remove-variant-btn" onclick="this.parentElement.remove(); updateMainStockVisibility('${formId}')"><i class="fas fa-times-circle"></i></button>
 
2054
  }
2055
 
2056
  function copyToClipboard(text) {
2057
+ if (navigator.clipboard && window.isSecureContext) {
2058
+ navigator.clipboard.writeText(text).then(() => alert('Ссылка скопирована!'));
2059
+ } else {
2060
+ let textArea = document.createElement("textarea");
2061
+ textArea.value = text;
2062
+ textArea.style.position = "fixed";
2063
+ textArea.style.left = "-999999px";
2064
+ document.body.appendChild(textArea);
2065
+ textArea.focus();
2066
+ textArea.select();
2067
+ try {
2068
+ document.execCommand('copy');
2069
+ alert('Ссылка скопирована!');
2070
+ } catch (err) {
2071
+ alert('Не удалось скопировать ссылку');
2072
+ }
2073
+ document.body.removeChild(textArea);
2074
  }
 
2075
  }
2076
 
2077
  document.querySelectorAll('.add-product-wrapper').forEach(wrapper => {
 
2103
  .filter-bar { background: var(--surface); padding: 15px 20px; border-radius: 12px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
2104
  .filter-bar input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; outline: none; font-family: inherit; }
2105
 
2106
+ .tabs { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
2107
+ .tab-btn { padding: 10px 20px; border: none; background: #e0e6ed; color: var(--text); border-radius: 8px; cursor: pointer; font-weight: 600; transition: 0.2s; }
2108
+ .tab-btn.active { background: var(--primary); color: #fff; }
2109
+
2110
  .tab-content { display: none; }
2111
  .tab-content.active { display: block; }
2112
+
2113
  .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }
2114
+ .stat-card { background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 10px; position: relative; overflow: hidden; }
2115
  .stat-card .title { font-size: 0.9rem; color: #636e72; font-weight: 600; }
2116
  .stat-card .value { font-size: 1.8rem; font-weight: 700; color: var(--text); }
2117
+ .stat-card .icon { font-size: 3rem; color: var(--primary); opacity: 0.1; position: absolute; right: -10px; bottom: -10px; }
 
 
 
 
 
 
 
2118
 
2119
+ .table-container { background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); overflow-x: auto; margin-bottom: 20px; }
2120
+ table { width: 100%; border-collapse: collapse; min-width: 500px; }
2121
+ th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
2122
+ th { font-weight: 600; color: #636e72; background: #fdfdfd; }
2123
  </style>
2124
  </head>
2125
  <body>
 
2134
  <input type="date" id="dateStart" onchange="updateReports()">
2135
  <span>—</span>
2136
  <input type="date" id="dateEnd" onchange="updateReports()">
2137
+ <button class="btn" style="background:var(--success);" onclick="resetDates()">Сбросить период</button>
2138
  </div>
2139
 
2140
  <div class="tabs">
2141
+ <button class="tab-btn active" onclick="switchTab('general')">Общие показатели</button>
2142
+ <button class="tab-btn" onclick="switchTab('staff')">Отчет по сотрудникам</button>
2143
  </div>
2144
 
2145
  <div id="general" class="tab-content active">
2146
  <div class="stats-grid">
2147
  <div class="stat-card">
2148
+ <div class="title">Общая выручка</div>
2149
+ <div class="value" id="totalRevenue">0</div>
2150
+ <i class="fas fa-money-bill-wave icon"></i>
 
 
2151
  </div>
2152
  <div class="stat-card">
2153
+ <div class="title">Кол-во заказов</div>
2154
+ <div class="value" id="totalOrders">0</div>
2155
+ <i class="fas fa-shopping-cart icon"></i>
 
 
2156
  </div>
2157
  <div class="stat-card">
2158
+ <div class="title">Возвраты (сумма)</div>
2159
+ <div class="value" id="totalReturns" style="color:var(--danger);">0</div>
2160
+ <i class="fas fa-undo icon" style="color:var(--danger);"></i>
 
 
2161
  </div>
2162
  </div>
2163
+
2164
  <div class="table-container">
2165
+ <h3 style="margin-top:0;">Топ продаваемых товаров</h3>
2166
  <table>
2167
  <thead>
2168
  <tr>
 
2175
  </table>
2176
  </div>
2177
  </div>
2178
+
2179
  <div id="staff" class="tab-content">
2180
+ <div class="table-container">
2181
+ <h3 style="margin-top:0;">Показатели сотрудников</h3>
2182
  <table>
2183
  <thead>
2184
  <tr>
2185
  <th>Сотрудник</th>
2186
+ <th>Выручка ({{ currency_code }})</th>
2187
+ <th>Заказов</th>
2188
  <th>Сумма возвратов ({{ currency_code }})</th>
 
2189
  </tr>
2190
  </thead>
2191
  <tbody id="staffTable"></tbody>
2192
  </table>
2193
  </div>
2194
  </div>
 
2195
  </div>
2196
 
2197
  <script>
2198
  const allOrders = {{ orders_json|safe }};
2199
 
2200
+ function switchTab(tabId) {
2201
  document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
2202
  document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
2203
+
2204
  document.getElementById(tabId).classList.add('active');
2205
+ event.currentTarget.classList.add('active');
2206
  }
2207
 
2208
  function setDefaultDates() {
 
2213
  }
2214
 
2215
  function resetDates() {
2216
+ document.getElementById('dateStart').value = '';
2217
+ document.getElementById('dateEnd').value = '';
2218
  updateReports();
2219
  }
2220
 
 
2222
  const startDate = document.getElementById('dateStart').value;
2223
  const endDate = document.getElementById('dateEnd').value;
2224
 
2225
+ let filteredOrders = allOrders.filter(o => o.status === 'confirmed' || o.status === 'pos' || o.status === 'returned');
2226
 
2227
  if (startDate) {
2228
  const sDate = new Date(startDate);
2229
+ filteredOrders = filteredOrders.filter(o => {
2230
+ if(!o.created_at) return false;
2231
+ return new Date(o.created_at.split(' ')[0]) >= sDate;
2232
+ });
2233
  }
2234
  if (endDate) {
2235
  const eDate = new Date(endDate);
2236
+ filteredOrders = filteredOrders.filter(o => {
2237
+ if(!o.created_at) return false;
2238
+ return new Date(o.created_at.split(' ')[0]) <= eDate;
2239
+ });
2240
  }
2241
 
2242
  let totalRev = 0;
2243
  let totalRet = 0;
2244
+ let ordersCount = filteredOrders.length;
2245
 
2246
  let staffStats = {};
2247
  let productSales = {};
2248
 
2249
  filteredOrders.forEach(o => {
2250
+ const staff = o.staff_name || 'Онлайн (Без сотрудника)';
2251
+ if(!staffStats[staff]) staffStats[staff] = { rev: 0, count: 0, ret: 0 };
2252
+
2253
  if(o.status === 'returned') {
2254
  totalRet += o.total_price;
2255
+ staffStats[staff].ret += o.total_price;
2256
+ } else {
 
 
 
2257
  totalRev += o.total_price;
2258
+ staffStats[staff].rev += o.total_price;
 
 
 
2259
  staffStats[staff].count += 1;
2260
 
2261
+ if(o.cart) {
2262
+ o.cart.forEach(item => {
2263
+ if(item.quantity > 0) {
2264
+ let pName = item.name;
2265
+ if(item.variant_name) pName += ` (${item.variant_name})`;
2266
+
2267
+ if(!productSales[pName]) productSales[pName] = { qty: 0, sum: 0 };
2268
+ productSales[pName].qty += item.quantity;
2269
+ productSales[pName].sum += (item.price * item.quantity);
2270
+ }
2271
+ });
2272
+ }
2273
  }
2274
  });
2275
 
2276
+ document.getElementById('totalRevenue').innerText = totalRev.toLocaleString();
2277
  document.getElementById('totalOrders').innerText = ordersCount;
2278
+ document.getElementById('totalReturns').innerText = totalRet.toLocaleString();
2279
+
2280
  renderTopProducts(productSales);
2281
+ renderStaffStats(staffStats);
2282
  }
2283
 
2284
  function renderTopProducts(data) {
2285
  const tbody = document.getElementById('topProductsTable');
2286
  tbody.innerHTML = '';
2287
 
2288
+ const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum).slice(0, 10);
2289
 
2290
  if(sorted.length === 0) {
2291
+ tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных за выбранный период</td></tr>';
2292
  return;
2293
  }
2294
 
 
2302
  `;
2303
  });
2304
  }
2305
+
2306
+ function renderStaffStats(data) {
2307
  const tbody = document.getElementById('staffTable');
2308
  tbody.innerHTML = '';
2309
+
2310
+ const sortedStaff = Object.keys(data).sort((a,b) => data[b].rev - data[a].rev);
2311
+
2312
+ if(sortedStaff.length === 0) {
2313
+ tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;">Нет данных за выбранный период</td></tr>';
2314
  return;
2315
  }
2316
 
2317
+ sortedStaff.forEach(s => {
 
2318
  tbody.innerHTML += `
2319
  <tr>
2320
  <td style="font-weight:500;">${s}</td>
2321
+ <td style="color:#0984e3; font-weight:600;">${data[s].rev.toLocaleString()}</td>
2322
  <td>${data[s].count}</td>
2323
+ <td style="color:var(--danger);">${data[s].ret.toLocaleString()}</td>
 
 
2324
  </tr>
2325
  `;
2326
  });
2327
  }
2328
+
2329
  setDefaultDates();
2330
  updateReports();
2331
  </script>
 
2333
  </html>
2334
  '''
2335
 
2336
+ @app.route('/')
2337
+ def index():
2338
+ return render_template_string(LANDING_PAGE_TEMPLATE)
2339
+
2340
+ @app.route('/admhosto', methods=['GET'])
2341
+ def admhosto():
2342
+ data = load_data()
2343
+ environments_data = []
2344
+ for env_id, env_data in data.items():
2345
+ if env_id == 'default_env':
2346
+ continue
2347
+ settings = env_data.get('settings', {})
2348
+ org_name = settings.get("organization_name", f"Shop {env_id}")
2349
+ environments_data.append({
2350
+ "id": env_id,
2351
+ "org_name": org_name,
2352
+ "pwd_enabled": settings.get("admin_password_enabled", False),
2353
+ "password": settings.get("admin_password", "")
2354
+ })
2355
+ environments_data.sort(key=lambda x: x['id'])
2356
+ return render_template_string(ADMHOSTO_TEMPLATE, environments=environments_data)
2357
+
2358
+ @app.route('/admhosto/create', methods=['POST'])
2359
+ def create_environment():
2360
+ all_data = load_data()
2361
+ while True:
2362
+ new_id = ''.join(random.choices(string.digits, k=6))
2363
+ if new_id not in all_data:
2364
+ break
2365
+ all_data[new_id] = {
2366
+ 'products': [], 'categories': [], 'orders': {}, 'staff': [],
2367
+ 'settings': {
2368
+ "organization_name": f"Shop {new_id}",
2369
+ "admin_password_enabled": False,
2370
+ "admin_password": "",
2371
+ "logo_url": DEFAULT_LOGO_URL,
2372
+ "whatsapp_number": DEFAULT_WHATSAPP_NUMBER,
2373
+ "track_inventory": False,
2374
+ "business_type": "retail",
2375
+ "customer_fields": {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
2376
+ "socials": {
2377
+ 'wa': {'enabled': True, 'url': ''},
2378
+ 'ig': {'enabled': True, 'url': ''},
2379
+ 'tg': {'enabled': True, 'url': ''}
2380
+ }
2381
+ }
2382
+ }
2383
+ save_data(all_data)
2384
+ flash(f'Новая среда с ID {new_id} успешно создана.', 'success')
2385
+ return redirect(url_for('admhosto'))
2386
+
2387
+ @app.route('/admhosto/update_pwd/<env_id>', methods=['POST'])
2388
+ def update_env_pwd(env_id):
2389
+ all_data = load_data()
2390
+ if env_id in all_data:
2391
+ pwd_enabled = 'pwd_enabled' in request.form
2392
+ password = request.form.get('password', '').strip()
2393
+ all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled
2394
+ all_data[env_id]['settings']['admin_password'] = password
2395
+ save_data(all_data)
2396
+ flash(f'Пароль для среды {env_id} обновлен.', 'success')
2397
+ else:
2398
+ flash(f'Среда {env_id} не найдена.', 'error')
2399
+ return redirect(url_for('admhosto'))
2400
+
2401
+ @app.route('/admhosto/delete/<env_id>', methods=['POST'])
2402
+ def delete_environment(env_id):
2403
+ all_data = load_data()
2404
+ if env_id in all_data:
2405
+ del all_data[env_id]
2406
+ save_data(all_data)
2407
+ flash(f'Среда {env_id} была уда��ена.', 'success')
2408
+ else:
2409
+ flash(f'Среда {env_id} не найдена.', 'error')
2410
+ return redirect(url_for('admhosto'))
2411
+
2412
+ @app.route('/<env_id>/login', methods=['GET', 'POST'])
2413
+ def admin_login(env_id):
2414
+ data = get_env_data(env_id)
2415
+ settings = data.get('settings', {})
2416
+
2417
+ if not settings.get('admin_password_enabled'):
2418
+ return redirect(url_for('admin', env_id=env_id))
2419
+
2420
+ if request.method == 'POST':
2421
+ pwd = request.form.get('password', '')
2422
+ if pwd == settings.get('admin_password', ''):
2423
+ session[f'admin_auth_{env_id}'] = True
2424
+ return redirect(url_for('admin', env_id=env_id))
2425
+ else:
2426
+ flash('Неверный пароль', 'error')
2427
+
2428
+ return render_template_string(LOGIN_TEMPLATE, env_id=env_id)
2429
+
2430
+ @app.route('/<env_id>/logout')
2431
+ def admin_logout(env_id):
2432
+ session.pop(f'admin_auth_{env_id}', None)
2433
+ return redirect(url_for('admin_login', env_id=env_id))
2434
+
2435
+ @app.route('/<env_id>/catalog')
2436
+ def catalog(env_id):
2437
+ data = get_env_data(env_id)
2438
+ all_products = data.get('products', [])
2439
+ categories = data.get('categories', [])
2440
+ settings = data.get('settings', {})
2441
+
2442
+ mode = request.args.get('mode', 'online')
2443
+ staff_id = request.args.get('staff_id', '')
2444
+
2445
+ return render_template_string(
2446
+ CATALOG_TEMPLATE,
2447
+ products_json=json.dumps(all_products),
2448
+ categories_json=json.dumps(categories),
2449
+ repo_id=REPO_ID,
2450
+ currency_code=CURRENCY_CODE,
2451
+ settings=settings,
2452
+ env_id=env_id,
2453
+ mode=mode,
2454
+ staff_id=staff_id
2455
+ )
2456
+
2457
+ def deduct_stock(cart_items, products):
2458
+ for item in cart_items:
2459
+ pid = item.get('product_id')
2460
+ vidx = item.get('variant_idx', -1)
2461
+ qty = int(item.get('quantity', 0))
2462
+ for p in products:
2463
+ if p['product_id'] == pid:
2464
+ if vidx != -1 and vidx < len(p.get('variants', [])):
2465
+ p['variants'][vidx]['stock'] = p['variants'][vidx].get('stock', 0) - qty
2466
+ else:
2467
+ p['stock'] = p.get('stock', 0) - qty
2468
+ break
2469
+
2470
+ def restore_stock(c_key, pid, vidx, return_qty, products):
2471
+ for p in products:
2472
+ if p['product_id'] == pid:
2473
+ if vidx != -1 and vidx < len(p.get('variants', [])):
2474
+ p['variants'][vidx]['stock'] = p['variants'][vidx].get('stock', 0) + return_qty
2475
+ else:
2476
+ p['stock'] = p.get('stock', 0) + return_qty
2477
+ break
2478
+
2479
+ @app.route('/<env_id>/api/staff_orders/<staff_id>')
2480
+ def get_staff_orders(env_id, staff_id):
2481
+ data = get_env_data(env_id)
2482
+ orders = data.get('orders', {})
2483
+ staff_orders = [o for o in orders.values() if o.get('staff_id') == staff_id and (o.get('status') == 'pos' or o.get('status') == 'confirmed')]
2484
+ staff_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
2485
+ return jsonify(staff_orders[:20])
2486
+
2487
+ @app.route('/<env_id>/process_return/<order_id>', methods=['POST'])
2488
+ def process_return(env_id, order_id):
2489
+ data = get_env_data(env_id)
2490
+ order = data.get('orders', {}).get(order_id)
2491
+ if not order:
2492
+ return jsonify({"success": False, "error": "Order not found"}), 404
2493
+
2494
+ req_data = request.get_json()
2495
+ returns = req_data.get('returns', {})
2496
+
2497
+ track_inv = data['settings'].get('track_inventory', False)
2498
+
2499
+ for c_key, ret_qty in returns.items():
2500
+ ret_qty = int(ret_qty)
2501
+ if ret_qty <= 0: continue
2502
+
2503
+ for item in order['cart']:
2504
+ if item.get('c_key') == c_key:
2505
+ if ret_qty > item['quantity']:
2506
+ ret_qty = item['quantity']
2507
+
2508
+ item['quantity'] -= ret_qty
2509
+ if track_inv:
2510
+ restore_stock(c_key, item.get('product_id'), item.get('variant_idx', -1), ret_qty, data['products'])
2511
+ break
2512
+
2513
+ order['total_price'] = sum(float(i['price']) * int(i['quantity']) for i in order['cart'])
2514
+ if order['total_price'] <= 0:
2515
+ order['status'] = 'returned'
2516
+
2517
+ save_env_data(env_id, data)
2518
+ return jsonify({"success": True})
2519
+
2520
+ @app.route('/<env_id>/create_order', methods=['POST'])
2521
+ def create_order(env_id):
2522
+ order_data = request.get_json()
2523
+ if not order_data or 'cart' not in order_data:
2524
+ return jsonify({"error": "Bad request"}), 400
2525
+
2526
+ data = get_env_data(env_id)
2527
+
2528
+ cart_items = order_data['cart']
2529
+ total_price = sum(float(item['cart_price']) * int(item['quantity']) for item in cart_items)
2530
+
2531
+ mode = order_data.get('mode', 'online')
2532
+ staff_id = order_data.get('staff_id', '')
2533
+
2534
+ staff_name = ''
2535
+ staff_whatsapp = ''
2536
+ if staff_id:
2537
+ for s in data.get('staff', []):
2538
+ if s['id'] == staff_id:
2539
+ staff_name = s['name']
2540
+ staff_whatsapp = s['whatsapp']
2541
+ break
2542
+
2543
+ order_status = 'pos' if mode == 'pos' else 'pending'
2544
+
2545
+ customer_name = order_data.get('customer_name', '')
2546
+ customer_phone = order_data.get('customer_phone', '')
2547
+ customer_city = order_data.get('customer_city', '')
2548
+ customer_address = order_data.get('customer_address', '')
2549
+ customer_zip = order_data.get('customer_zip', '')
2550
+ customer_whatsapp = order_data.get('customer_whatsapp', '')
2551
+
2552
+ processed_cart = []
2553
+ for item in cart_items:
2554
+ processed_cart.append({
2555
+ "c_key": item.get('c_key'),
2556
+ "product_id": item.get('product_id'),
2557
+ "name": item['name'],
2558
+ "price": float(item['cart_price']),
2559
+ "quantity": int(item['quantity']),
2560
+ "pieces_per_box": int(item.get('pieces_per_box', 1)),
2561
+ "variant_name": item.get('variant_name', ''),
2562
+ "variant_idx": item.get('variant_idx', -1),
2563
+ "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}" if item.get('photos') else "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
2564
+ })
2565
+
2566
+ order_id = f"SA-{datetime.now().strftime('%Y%m%d')}-{str(len(data.get('orders', {}))+1).zfill(3)}"
2567
+
2568
+ new_order = {
2569
+ "id": order_id,
2570
+ "created_at": get_almaty_time(),
2571
+ "cart": processed_cart,
2572
+ "total_price": total_price,
2573
+ "status": order_status,
2574
+ "staff_id": staff_id,
2575
+ "staff_name": staff_name,
2576
+ "staff_whatsapp": staff_whatsapp,
2577
+ "customer_name": customer_name,
2578
+ "customer_phone": customer_phone,
2579
+ "customer_city": customer_city,
2580
+ "customer_address": customer_address,
2581
+ "customer_zip": customer_zip,
2582
+ "customer_whatsapp": customer_whatsapp
2583
+ }
2584
+
2585
+ if order_status == 'pos' and data['settings'].get('track_inventory', False):
2586
+ deduct_stock(processed_cart, data['products'])
2587
+
2588
+ data['orders'][order_id] = new_order
2589
+ save_env_data(env_id, data)
2590
+
2591
+ return jsonify({"order_id": order_id}), 201
2592
+
2593
+ @app.route('/<env_id>/order/<order_id>')
2594
+ def view_order(env_id, order_id):
2595
+ data = get_env_data(env_id)
2596
+ order = data.get('orders', {}).get(order_id)
2597
+ settings = data.get('settings', {})
2598
+
2599
+ if not order:
2600
+ return "Order not found", 404
2601
+
2602
+ return render_template_string(
2603
+ ORDER_TEMPLATE,
2604
+ order=order,
2605
+ settings=settings,
2606
+ currency_code=CURRENCY_CODE,
2607
+ env_id=env_id
2608
+ )
2609
+
2610
+ @app.route('/<env_id>/edit_order/<order_id>', methods=['POST'])
2611
+ def edit_order(env_id, order_id):
2612
+ data = get_env_data(env_id)
2613
+ order = data.get('orders', {}).get(order_id)
2614
+ if not order:
2615
+ return jsonify({"success": False, "error": "Order not found"}), 404
2616
+
2617
+ if order.get('status') != 'pending':
2618
+ return jsonify({"success": False, "error": "Can only edit pending orders"}), 400
2619
+
2620
+ req_data = request.get_json()
2621
+ c_key = req_data.get('c_key')
2622
+ change = req_data.get('change', 0)
2623
+ exact_qty = req_data.get('exact_qty')
2624
+ remove = req_data.get('remove', False)
2625
+
2626
+ for item in order['cart']:
2627
+ if item.get('c_key') == c_key:
2628
+ if remove:
2629
+ order['cart'].remove(item)
2630
+ else:
2631
+ if exact_qty is not None:
2632
+ item['quantity'] = int(exact_qty)
2633
+ else:
2634
+ item['quantity'] += change
2635
+
2636
+ if item['quantity'] <= 0:
2637
+ order['cart'].remove(item)
2638
+ break
2639
+
2640
+ order['total_price'] = sum(float(i['price']) * int(i['quantity']) for i in order['cart'])
2641
+ save_env_data(env_id, data)
2642
+
2643
+ return jsonify({"success": True, "total_price": order['total_price']})
2644
+
2645
+ @app.route('/<env_id>/order_action/<order_id>', methods=['POST'])
2646
+ def order_action(env_id, order_id):
2647
+ data = get_env_data(env_id)
2648
+ order = data.get('orders', {}).get(order_id)
2649
+ if not order:
2650
+ return redirect(url_for('admin', env_id=env_id))
2651
+
2652
+ action = request.form.get('action')
2653
+ if action == 'confirm' and order.get('status') == 'pending':
2654
+ order['status'] = 'confirmed'
2655
+ if data['settings'].get('track_inventory', False):
2656
+ deduct_stock(order['cart'], data['products'])
2657
+ elif action == 'delete':
2658
+ del data['orders'][order_id]
2659
+
2660
+ save_env_data(env_id, data)
2661
+ return redirect(url_for('admin', env_id=env_id))
2662
+
2663
+ @app.route('/<env_id>/reports')
2664
+ def reports(env_id):
2665
+ data = get_env_data(env_id)
2666
+ settings = data.get('settings', {})
2667
+ if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
2668
+ return redirect(url_for('admin_login', env_id=env_id))
2669
+
2670
+ orders_list = list(data.get('orders', {}).values())
2671
+ return render_template_string(
2672
+ REPORTS_TEMPLATE,
2673
+ env_id=env_id,
2674
+ currency_code=CURRENCY_CODE,
2675
+ orders_json=json.dumps(orders_list)
2676
+ )
2677
+
2678
+ @app.route('/<env_id>/admin', methods=['GET', 'POST'])
2679
+ def admin(env_id):
2680
+ data = get_env_data(env_id)
2681
+ settings = data.get('settings', {})
2682
+
2683
+ if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
2684
+ return redirect(url_for('admin_login', env_id=env_id))
2685
+
2686
+ products = data.get('products', [])
2687
+ categories = data.get('categories', [])
2688
+ staff = data.get('staff', [])
2689
+ orders = data.get('orders', {})
2690
+
2691
+ pending_orders = [o for o in orders.values() if o.get('status') == 'pending']
2692
+ pending_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
2693
+
2694
+ if request.method == 'POST':
2695
+ action = request.form.get('action')
2696
+
2697
+ if action == 'add_staff':
2698
+ staff_name = request.form.get('staff_name', '').strip()
2699
+ staff_wa = request.form.get('staff_whatsapp', '').strip()
2700
+ if staff_name and staff_wa:
2701
+ staff.append({'id': uuid4().hex, 'name': staff_name, 'whatsapp': staff_wa})
2702
+ data['staff'] = staff
2703
+ save_env_data(env_id, data)
2704
+
2705
+ elif action == 'delete_staff':
2706
+ sid = request.form.get('staff_id')
2707
+ data['staff'] = [s for s in staff if s['id'] != sid]
2708
+ save_env_data(env_id, data)
2709
+
2710
+ elif action == 'update_settings':
2711
+ settings['organization_name'] = request.form.get('organization_name', '').strip()
2712
+ settings['whatsapp_number'] = request.form.get('whatsapp_number', '').strip()
2713
+ settings['track_inventory'] = 'track_inventory' in request.form
2714
+ settings['business_type'] = request.form.get('business_type', 'retail')
2715
+
2716
+ settings['customer_fields'] = {
2717
+ 'name': 'cf_name' in request.form,
2718
+ 'phone': 'cf_phone' in request.form,
2719
+ 'city': 'cf_city' in request.form,
2720
+ 'address': 'cf_address' in request.form,
2721
+ 'zip': 'cf_zip' in request.form
2722
+ }
2723
+
2724
+ logo_file = request.files.get('logo')
2725
+ if logo_file and logo_file.filename and HF_TOKEN_WRITE:
2726
+ uploads_dir = 'uploads_temp'
2727
+ os.makedirs(uploads_dir, exist_ok=True)
2728
+ ext = os.path.splitext(logo_file.filename)[1].lower()
2729
+ if ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg']:
2730
+ logo_filename = f"logo_{uuid4().hex}{ext}"
2731
+ temp_path = os.path.join(uploads_dir, logo_filename)
2732
+ logo_file.save(temp_path)
2733
+ try:
2734
+ api = HfApi()
2735
+ api.upload_file(
2736
+ path_or_fileobj=temp_path,
2737
+ path_in_repo=f"logos/{logo_filename}",
2738
+ repo_id=REPO_ID,
2739
+ repo_type="dataset",
2740
+ token=HF_TOKEN_WRITE
2741
+ )
2742
+ settings['logo_url'] = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/logos/{logo_filename}"
2743
+ except Exception:
2744
+ pass
2745
+ finally:
2746
+ if os.path.exists(temp_path):
2747
+ os.remove(temp_path)
2748
+
2749
+ settings['socials']['wa']['enabled'] = 'wa_enabled' in request.form
2750
+ settings['socials']['wa']['url'] = request.form.get('wa_url', '').strip()
2751
+ settings['socials']['ig']['enabled'] = 'ig_enabled' in request.form
2752
+ settings['socials']['ig']['url'] = request.form.get('ig_url', '').strip()
2753
+ settings['socials']['tg']['enabled'] = 'tg_enabled' in request.form
2754
+ settings['socials']['tg']['url'] = request.form.get('tg_url', '').strip()
2755
+
2756
+ data['settings'] = settings
2757
+ save_env_data(env_id, data)
2758
+
2759
+ elif action == 'add_category':
2760
+ cat_name = request.form.get('category_name', '').strip()
2761
+ if cat_name and cat_name not in categories:
2762
+ categories.append(cat_name)
2763
+ data['categories'] = categories
2764
+ save_env_data(env_id, data)
2765
+
2766
+ elif action == 'delete_category':
2767
+ cat_name = request.form.get('category_name')
2768
+ if cat_name in categories:
2769
+ categories.remove(cat_name)
2770
+ data['products'] = [p for p in products if p.get('category') != cat_name]
2771
+ data['categories'] = categories
2772
+ save_env_data(env_id, data)
2773
+
2774
+ elif action == 'add_product':
2775
+ name = request.form.get('name', '').strip()
2776
+ price_str = request.form.get('price', '0')
2777
+ price = float(price_str) if price_str else 0.0
2778
+ pieces_per_box = int(request.form.get('pieces_per_box', 1))
2779
+ min_order_qty = int(request.form.get('min_order_qty', 1))
2780
+ main_stock = int(request.form.get('stock', 0))
2781
+ description = request.form.get('description', '').strip()
2782
+ category = request.form.get('category')
2783
+ has_variant_prices = 'has_variant_prices' in request.form
2784
+
2785
+ variant_names = request.form.getlist('variant_name[]')
2786
+ variant_prices = request.form.getlist('variant_price[]')
2787
+ variant_stocks = request.form.getlist('variant_stock[]')
2788
+ variants = []
2789
+
2790
+ for i in range(len(variant_names)):
2791
+ v_name = variant_names[i].strip()
2792
+ if v_name:
2793
+ v_price = price
2794
+ if has_variant_prices and i < len(variant_prices) and variant_prices[i]:
2795
+ v_price = float(variant_prices[i])
2796
+ v_stock = 0
2797
+ if i < len(variant_stocks) and variant_stocks[i]:
2798
+ v_stock = int(variant_stocks[i])
2799
+ variants.append({"name": v_name, "price": v_price, "stock": v_stock})
2800
+
2801
+ uploaded_photos = request.files.getlist('photos')[:10]
2802
+
2803
+ photos_list = []
2804
+ if uploaded_photos and HF_TOKEN_WRITE:
2805
+ uploads_dir = 'uploads_temp'
2806
+ os.makedirs(uploads_dir, exist_ok=True)
2807
+ api = HfApi()
2808
+ for photo in uploaded_photos:
2809
+ if photo and photo.filename:
2810
+ ext = os.path.splitext(photo.filename)[1].lower()
2811
+ if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
2812
+ continue
2813
+ photo_filename = f"{uuid4().hex}{ext}"
2814
+ temp_path = os.path.join(uploads_dir, photo_filename)
2815
+ photo.save(temp_path)
2816
+ try:
2817
+ api.upload_file(
2818
+ path_or_fileobj=temp_path,
2819
+ path_in_repo=f"photos/{photo_filename}",
2820
+ repo_id=REPO_ID,
2821
+ repo_type="dataset",
2822
+ token=HF_TOKEN_WRITE
2823
+ )
2824
+ photos_list.append(photo_filename)
2825
+ except Exception:
2826
+ pass
2827
+ finally:
2828
+ if os.path.exists(temp_path):
2829
+ os.remove(temp_path)
2830
+
2831
+ new_product = {
2832
+ 'product_id': uuid4().hex,
2833
+ 'name': name,
2834
+ 'price': price,
2835
+ 'pieces_per_box': pieces_per_box,
2836
+ 'min_order_qty': min_order_qty,
2837
+ 'stock': main_stock,
2838
+ 'description': description,
2839
+ 'category': category,
2840
+ 'photos': photos_list,
2841
+ 'variants': variants,
2842
+ 'has_variant_prices': has_variant_prices
2843
+ }
2844
+ products.append(new_product)
2845
+ data['products'] = products
2846
+ save_env_data(env_id, data)
2847
+
2848
+ elif action == 'edit_product':
2849
+ pid = request.form.get('product_id')
2850
+ name = request.form.get('name', '').strip()
2851
+ price_str = request.form.get('price', '0')
2852
+ price = float(price_str) if price_str else 0.0
2853
+ pieces_per_box = int(request.form.get('pieces_per_box', 1))
2854
+ min_order_qty = int(request.form.get('min_order_qty', 1))
2855
+ main_stock = int(request.form.get('stock', 0))
2856
+ description = request.form.get('description', '').strip()
2857
+ has_variant_prices = 'has_variant_prices' in request.form
2858
+
2859
+ variant_names = request.form.getlist('variant_name[]')
2860
+ variant_prices = request.form.getlist('variant_price[]')
2861
+ variant_stocks = request.form.getlist('variant_stock[]')
2862
+ variants = []
2863
+
2864
+ for i in range(len(variant_names)):
2865
+ v_name = variant_names[i].strip()
2866
+ if v_name:
2867
+ v_price = price
2868
+ if has_variant_prices and i < len(variant_prices) and variant_prices[i]:
2869
+ v_price = float(variant_prices[i])
2870
+ v_stock = 0
2871
+ if i < len(variant_stocks) and variant_stocks[i]:
2872
+ v_stock = int(variant_stocks[i])
2873
+ variants.append({"name": v_name, "price": v_price, "stock": v_stock})
2874
+
2875
+ uploaded_photos = request.files.getlist('photos')[:10]
2876
+
2877
+ photos_list = []
2878
+ if uploaded_photos and uploaded_photos[0].filename and HF_TOKEN_WRITE:
2879
+ uploads_dir = 'uploads_temp'
2880
+ os.makedirs(uploads_dir, exist_ok=True)
2881
+ api = HfApi()
2882
+ for photo in uploaded_photos:
2883
+ if photo and photo.filename:
2884
+ ext = os.path.splitext(photo.filename)[1].lower()
2885
+ if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
2886
+ continue
2887
+ photo_filename = f"{uuid4().hex}{ext}"
2888
+ temp_path = os.path.join(uploads_dir, photo_filename)
2889
+ photo.save(temp_path)
2890
+ try:
2891
+ api.upload_file(
2892
+ path_or_fileobj=temp_path,
2893
+ path_in_repo=f"photos/{photo_filename}",
2894
+ repo_id=REPO_ID,
2895
+ repo_type="dataset",
2896
+ token=HF_TOKEN_WRITE
2897
+ )
2898
+ photos_list.append(photo_filename)
2899
+ except Exception:
2900
+ pass
2901
+ finally:
2902
+ if os.path.exists(temp_path):
2903
+ os.remove(temp_path)
2904
+
2905
+ for p in products:
2906
+ if p.get('product_id') == pid:
2907
+ p['name'] = name
2908
+ p['price'] = price
2909
+ p['pieces_per_box'] = pieces_per_box
2910
+ p['min_order_qty'] = min_order_qty
2911
+ p['stock'] = main_stock
2912
+ p['description'] = description
2913
+ p['variants'] = variants
2914
+ p['has_variant_prices'] = has_variant_prices
2915
+ if photos_list:
2916
+ p['photos'] = photos_list
2917
+ break
2918
+ data['products'] = products
2919
+ save_env_data(env_id, data)
2920
+
2921
+ elif action == 'delete_product':
2922
+ pid = request.form.get('product_id')
2923
+ data['products'] = [p for p in products if p.get('product_id') != pid]
2924
+ save_env_data(env_id, data)
2925
+
2926
+ return redirect(url_for('admin', env_id=env_id))
2927
+
2928
+ return render_template_string(
2929
+ ADMIN_TEMPLATE,
2930
+ products=products,
2931
+ categories=categories,
2932
+ repo_id=REPO_ID,
2933
+ currency_code=CURRENCY_CODE,
2934
+ env_id=env_id,
2935
+ settings=settings,
2936
+ staff=staff,
2937
+ pending_orders=pending_orders
2938
+ )
2939
+
2940
+ @app.route('/<env_id>/force_upload', methods=['POST'])
2941
+ def force_upload(env_id):
2942
+ upload_db_to_hf()
2943
+ return redirect(url_for('admin', env_id=env_id))
2944
+
2945
+ @app.route('/<env_id>/force_download', methods=['POST'])
2946
+ def force_download(env_id):
2947
+ download_db_from_hf()
2948
+ return redirect(url_for('admin', env_id=env_id))
2949
+
2950
  if __name__ == '__main__':
2951
  download_db_from_hf()
2952
  load_data()