Update app.py
Browse files
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'] = '
|
| 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': '
|
| 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-
|
| 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="
|
| 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 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 723 |
-
if (businessType === '
|
| 724 |
-
|
| 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}
|
|
|
|
| 732 |
${vStockHtml}
|
| 733 |
</div>
|
| 734 |
-
<div
|
| 735 |
-
<button onclick="updateCart('${p.product_id}',
|
| 736 |
-
<
|
| 737 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 749 |
-
if (businessType === '
|
| 750 |
-
|
| 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}
|
|
|
|
| 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 |
-
${
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 834 |
} else {
|
| 835 |
-
cart[cKey].quantity +
|
| 836 |
}
|
| 837 |
|
| 838 |
-
if (
|
| 839 |
-
if(
|
| 840 |
-
|
| 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
|
| 961 |
-
|
| 962 |
}
|
| 963 |
if(cFields.phone) {
|
| 964 |
const el = document.getElementById('custPhone');
|
| 965 |
-
if(!el
|
| 966 |
-
|
| 967 |
}
|
| 968 |
if(cFields.city) {
|
| 969 |
const el = document.getElementById('custCity');
|
| 970 |
-
if(!el
|
| 971 |
-
|
| 972 |
}
|
| 973 |
if(cFields.address) {
|
| 974 |
const el = document.getElementById('custAddress');
|
| 975 |
-
if(!el
|
| 976 |
-
|
| 977 |
}
|
| 978 |
if(cFields.zip) {
|
| 979 |
const el = document.getElementById('custZip');
|
| 980 |
-
if(!el
|
| 981 |
-
|
| 982 |
}
|
| 983 |
if(fail) {
|
| 984 |
alert('Пожалуйста, заполните все обязательные поля.');
|
|
@@ -1089,8 +1127,9 @@ CATALOG_TEMPLATE = '''
|
|
| 1089 |
if(data.success) {
|
| 1090 |
alert('Возврат успешно проведен!');
|
| 1091 |
closeReturnsModal();
|
|
|
|
| 1092 |
} else {
|
| 1093 |
-
alert('Ошибка проведения возврата
|
| 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="
|
| 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="Название
|
| 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="Название
|
| 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 |
-
|
| 2024 |
-
|
| 2025 |
-
|
| 2026 |
-
|
| 2027 |
-
|
| 2028 |
-
|
| 2029 |
-
|
| 2030 |
-
|
| 2031 |
-
|
| 2032 |
-
|
| 2033 |
-
|
| 2034 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 2070 |
-
.tab-btn.active { background: var(--primary); color:
|
|
|
|
| 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:
|
| 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="
|
| 2106 |
-
<button class="tab-btn" onclick="
|
| 2107 |
</div>
|
| 2108 |
|
| 2109 |
<div id="general" class="tab-content active">
|
| 2110 |
<div class="stats-grid">
|
| 2111 |
<div class="stat-card">
|
| 2112 |
-
<div class="
|
| 2113 |
-
|
| 2114 |
-
|
| 2115 |
-
<i class="fas fa-money-bill-wave icon"></i>
|
| 2116 |
-
</div>
|
| 2117 |
</div>
|
| 2118 |
<div class="stat-card">
|
| 2119 |
-
<div class="
|
| 2120 |
-
|
| 2121 |
-
|
| 2122 |
-
<i class="fas fa-shopping-cart icon"></i>
|
| 2123 |
-
</div>
|
| 2124 |
</div>
|
| 2125 |
<div class="stat-card">
|
| 2126 |
-
<div class="
|
| 2127 |
-
|
| 2128 |
-
|
| 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 |
-
|
| 2150 |
-
<h3>П
|
| 2151 |
<table>
|
| 2152 |
<thead>
|
| 2153 |
<tr>
|
| 2154 |
<th>Сотрудник</th>
|
| 2155 |
-
<th>
|
| 2156 |
-
<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
|
| 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.
|
| 2176 |
}
|
| 2177 |
|
| 2178 |
function setDefaultDates() {
|
|
@@ -2183,7 +2213,8 @@ REPORTS_TEMPLATE = '''
|
|
| 2183 |
}
|
| 2184 |
|
| 2185 |
function resetDates() {
|
| 2186 |
-
|
|
|
|
| 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 =>
|
|
|
|
|
|
|
|
|
|
| 2199 |
}
|
| 2200 |
if (endDate) {
|
| 2201 |
const eDate = new Date(endDate);
|
| 2202 |
-
filteredOrders = filteredOrders.filter(o =>
|
|
|
|
|
|
|
|
|
|
| 2203 |
}
|
| 2204 |
|
| 2205 |
let totalRev = 0;
|
| 2206 |
let totalRet = 0;
|
| 2207 |
-
let ordersCount =
|
| 2208 |
|
| 2209 |
let staffStats = {};
|
| 2210 |
let productSales = {};
|
| 2211 |
|
| 2212 |
filteredOrders.forEach(o => {
|
|
|
|
|
|
|
|
|
|
| 2213 |
if(o.status === 'returned') {
|
| 2214 |
totalRet += o.total_price;
|
| 2215 |
-
|
| 2216 |
-
|
| 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
|
| 2228 |
-
|
| 2229 |
-
|
| 2230 |
-
|
| 2231 |
-
|
| 2232 |
-
|
| 2233 |
-
|
| 2234 |
-
|
| 2235 |
-
|
| 2236 |
-
|
|
|
|
|
|
|
| 2237 |
}
|
| 2238 |
});
|
| 2239 |
|
| 2240 |
-
document.getElementById('totalRevenue').innerText = totalRev.toLocaleString()
|
| 2241 |
document.getElementById('totalOrders').innerText = ordersCount;
|
| 2242 |
-
document.getElementById('totalReturns').innerText = totalRet.toLocaleString()
|
| 2243 |
-
|
| 2244 |
renderTopProducts(productSales);
|
| 2245 |
-
|
| 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,
|
| 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
|
| 2271 |
const tbody = document.getElementById('staffTable');
|
| 2272 |
tbody.innerHTML = '';
|
| 2273 |
-
|
| 2274 |
-
const
|
| 2275 |
-
|
| 2276 |
-
if(
|
| 2277 |
-
tbody.innerHTML = '<tr><td colspan="
|
| 2278 |
return;
|
| 2279 |
}
|
| 2280 |
|
| 2281 |
-
|
| 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].
|
| 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()
|