full / app.py
Kgshop's picture
Update app.py
93a44e2 verified
import os
import base64
import json
import threading
import time
from datetime import datetime, timedelta
from uuid import uuid4
import random
import string
import tempfile
import io
from PIL import Image, ImageOps
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import requests
load_dotenv()
app = Flask(__name__)
app.secret_key = 'super_secret_key_store_app_123_gippo_env'
app.config.update(
SESSION_COOKIE_SAMESITE='None',
SESSION_COOKIE_SECURE=True,
PERMANENT_SESSION_LIFETIME=timedelta(days=30)
)
DATA_FILE = 'data.json'
SYNC_FILES = [DATA_FILE]
REPO_ID = os.getenv("REPO_ID", "Kgshop/fullmeta")
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
DEFAULT_WHATSAPP_NUMBER = "+77470623684"
DEFAULT_LOGO_URL = "https://huggingface.co/spaces/Metapp/Tech/resolve/main/1776929812446-019db944-b5db-7524-8f44-73942d70a0f8.png"
data_lock = threading.Lock()
def get_almaty_time():
return (datetime.utcnow() + timedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')
def download_db_from_hf(specific_file=None, retries=3, delay=5):
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
hf_hub_download(
repo_id=REPO_ID,
filename=file_name,
repo_type="dataset",
token=token_to_use,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True,
resume_download=False
)
success = True
break
except RepositoryNotFoundError:
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with data_lock:
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(DATA_FILE)) or '.', text=True)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump({}, f)
os.replace(temp_path, DATA_FILE)
except Exception:
pass
success = False
break
except requests.exceptions.RequestException:
pass
except Exception:
pass
if attempt < retries:
time.sleep(delay)
if not success:
all_successful = False
return all_successful
def upload_db_to_hf(specific_file=None):
if not HF_TOKEN_WRITE:
return
try:
api = HfApi()
files_to_upload = [specific_file] if specific_file else SYNC_FILES
for file_name in files_to_upload:
if os.path.exists(file_name):
try:
api.upload_file(
path_or_fileobj=file_name,
path_in_repo=file_name,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Sync {file_name} {get_almaty_time()}"
)
except Exception:
pass
except Exception:
pass
def process_and_upload_image(file_obj, repo_path, size=(512, 512)):
if not HF_TOKEN_WRITE:
return None
try:
img = Image.open(file_obj).convert('RGB')
img = ImageOps.fit(img, size, Image.Resampling.LANCZOS)
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=85)
buf.seek(0)
filename = f"{uuid4().hex}.jpg"
api = HfApi()
api.upload_file(
path_or_fileobj=buf,
path_in_repo=f"{repo_path}/{filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
return filename
except Exception:
return None
def periodic_backup():
while True:
time.sleep(1800)
upload_db_to_hf()
def load_data():
with data_lock:
data = {}
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
raise FileNotFoundError
except (FileNotFoundError, json.JSONDecodeError):
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
except Exception:
data = {}
else:
data = {}
if 'products' in data or 'categories' in data:
data = {
'default_env': {
'products': data.get('products', []),
'categories': data.get('categories',[]),
'category_photos': data.get('category_photos', {}),
'orders': data.get('orders', {}),
'staff': [],
'catalog_users': [],
'inventory_history':[],
'settings': {
'organization_name': 'Default Shop',
'admin_password_enabled': False,
'admin_password': '',
'logo_url': DEFAULT_LOGO_URL,
'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
'invoice_contacts': '',
'currency': 'T',
'track_inventory': False,
'use_barcodes': False,
'business_type': 'mixed',
'system_mode': 'both',
'hide_stock_online': False,
'closed_catalog_enabled': False,
'theme': 'light',
'customer_fields': {
'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False
},
'socials': {
'wa': {'enabled': True, 'url': 'https://wa.me/77011333885'},
'ig': {'enabled': True, 'url': 'https://instagram.com/14sklad_baisat'},
'tg': {'enabled': True, 'url': 'https://t.me/posuda15konteiner'}
}
}
}
}
changed = False
for env_id, env_data in data.items():
if 'products' not in env_data: env_data['products'] =[]
if 'categories' not in env_data: env_data['categories'] =[]
if 'category_photos' not in env_data: env_data['category_photos'] = {}
if 'orders' not in env_data: env_data['orders'] = {}
if 'staff' not in env_data: env_data['staff'] = []
if 'catalog_users' not in env_data: env_data['catalog_users'] = []
if 'inventory_history' not in env_data: env_data['inventory_history'] = []
if 'settings' not in env_data:
env_data['settings'] = {}
changed = True
settings = env_data['settings']
if 'organization_name' not in settings: settings['organization_name'] = f'Shop {env_id}'; changed = True
if 'admin_password_enabled' not in settings: settings['admin_password_enabled'] = False; changed = True
if 'admin_password' not in settings: settings['admin_password'] = ''; changed = True
if 'logo_url' not in settings: settings['logo_url'] = DEFAULT_LOGO_URL; changed = True
if 'whatsapp_number' not in settings: settings['whatsapp_number'] = DEFAULT_WHATSAPP_NUMBER; changed = True
if 'invoice_contacts' not in settings: settings['invoice_contacts'] = ''; changed = True
if 'currency' not in settings: settings['currency'] = 'T'; changed = True
if 'track_inventory' not in settings: settings['track_inventory'] = False; changed = True
if 'use_barcodes' not in settings: settings['use_barcodes'] = False; changed = True
if 'business_type' not in settings: settings['business_type'] = 'mixed'; changed = True
if 'system_mode' not in settings: settings['system_mode'] = 'both'; changed = True
if 'hide_stock_online' not in settings: settings['hide_stock_online'] = False; changed = True
if 'closed_catalog_enabled' not in settings: settings['closed_catalog_enabled'] = False; changed = True
if 'theme' not in settings: settings['theme'] = 'light'; changed = True
if 'customer_fields' not in settings:
settings['customer_fields'] = {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False}
changed = True
if 'socials' not in settings:
settings['socials'] = {
'wa': {'enabled': True, 'url': 'https://wa.me/77011333885'},
'ig': {'enabled': True, 'url': 'https://instagram.com/14sklad_baisat'},
'tg': {'enabled': True, 'url': 'https://t.me/posuda15konteiner'}
}
changed = True
for product in env_data['products']:
if 'product_id' not in product: product['product_id'] = uuid4().hex; changed = True
if 'pieces_per_box' not in product: product['pieces_per_box'] = ""; changed = True
if 'box_price' not in product: product['box_price'] = ""; changed = True
if 'min_order' not in product: product['min_order'] = ""; changed = True
if 'barcode' not in product: product['barcode'] = ""; changed = True
if 'variants' not in product: product['variants'] =[]; changed = True
if 'has_variant_prices' not in product: product['has_variant_prices'] = False; changed = True
if 'stock' not in product: product['stock'] = ""; changed = True
if 'is_available' not in product: product['is_available'] = True; changed = True
if 'wholesale_tiers' not in product: product['wholesale_tiers'] = []; changed = True
for v in product['variants']:
if 'stock' not in v: v['stock'] = ""; changed = True
if 'box_price' not in v: v['box_price'] = ""; changed = True
if 'barcode' not in v: v['barcode'] = ""; changed = True
if 'pieces_per_box' not in v: v['pieces_per_box'] = product.get('pieces_per_box', ""); changed = True
if 'is_available' not in v: v['is_available'] = True; changed = True
if 'wholesale_tiers' not in v: v['wholesale_tiers'] = []; changed = True
for order_id, order in env_data['orders'].items():
if 'status' not in order: order['status'] = 'confirmed'; changed = True
if 'staff_name' not in order: order['staff_name'] = ''; changed = True
if 'assembled' not in order: order['assembled'] = {}; changed = True
if 'global_discount' not in order: order['global_discount'] = 0; changed = True
for item in order.get('cart', []):
if 'discount' not in item: item['discount'] = 0; changed = True
if 'category' not in item: item['category'] = 'Без категории'; changed = True
if 'wholesale_tiers' not in item: item['wholesale_tiers'] = []; changed = True
if changed or not os.path.exists(DATA_FILE):
try:
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(DATA_FILE)) or '.', text=True)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f)
os.replace(temp_path, DATA_FILE)
except Exception:
pass
return data
def save_data(data):
try:
if not isinstance(data, dict):
return
with data_lock:
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(DATA_FILE)) or '.', text=True)
with os.fdopen(fd, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
os.replace(temp_path, DATA_FILE)
upload_db_to_hf(specific_file=DATA_FILE)
except Exception:
pass
def get_env_data(env_id):
all_data = load_data()
if env_id not in all_data:
all_data[env_id] = {
'products': [],
'categories':[],
'category_photos': {},
'orders': {},
'staff': [],
'catalog_users': [],
'inventory_history':[],
'settings': {
'organization_name': f'Shop {env_id}',
'admin_password_enabled': False,
'admin_password': '',
'logo_url': DEFAULT_LOGO_URL,
'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
'invoice_contacts': '',
'currency': 'T',
'track_inventory': False,
'use_barcodes': False,
'business_type': 'mixed',
'system_mode': 'both',
'hide_stock_online': False,
'closed_catalog_enabled': False,
'theme': 'light',
'customer_fields': {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
'socials': {
'wa': {'enabled': True, 'url': ''},
'ig': {'enabled': True, 'url': ''},
'tg': {'enabled': True, 'url': ''}
}
}
}
save_data(all_data)
return all_data[env_id]
def save_env_data(env_id, env_data):
all_data = load_data()
all_data[env_id] = env_data
save_data(all_data)
def update_order_totals(order, business_type):
total = 0
global_discount = float(order.get('global_discount', 0))
for i in order['cart']:
qty = int(i.get('quantity', 0))
if qty <= 0:
continue
ppb = int(i.get('pieces_per_box', 1))
c_price = float(i.get('price', 0))
c_box_price = float(i.get('cart_box_price', 0))
item_discount = float(i.get('discount', 0))
base_price = c_price
tiers = i.get('wholesale_tiers', [])
if business_type == 'wholesale' and tiers:
valid_tiers = [t for t in tiers if qty >= t.get('qty', 0)]
if valid_tiers:
valid_tiers.sort(key=lambda x: x['qty'], reverse=True)
base_price = float(valid_tiers[0]['price'])
elif business_type in ['mixed', 'wholesale'] and c_box_price > 0 and ppb > 1 and qty >= ppb:
base_price = c_box_price / ppb
discounted_price = max(0, base_price - item_discount)
item_total = discounted_price * qty
i['calculated_price'] = round(discounted_price, 2)
total += item_total
total = max(0, total - global_discount)
order['total_price'] = round(total, 2)
def is_order_fully_assembled(order):
if order.get('status') not in['confirmed', 'pos']:
return True
assembled_data = order.get('assembled', {})
for item in order.get('cart',[]):
qty = int(item.get('quantity', 0))
if qty > 0:
c_key = item.get('c_key')
assembled_qty = int(assembled_data.get(c_key, 0))
if assembled_qty < qty:
return False
return True
def deduct_stock(cart_items, products):
for item in cart_items:
pid = item.get('product_id')
vidx = item.get('variant_idx', -1)
qty = int(item.get('quantity', 0))
for p in products:
if p['product_id'] == pid:
if vidx != -1 and vidx < len(p.get('variants',[])):
current_s = p['variants'][vidx].get('stock')
if current_s != "" and current_s is not None:
p['variants'][vidx]['stock'] = int(current_s) - qty
else:
current_s = p.get('stock')
if current_s != "" and current_s is not None:
p['stock'] = int(current_s) - qty
break
def restore_stock(c_key, pid, vidx, return_qty, products):
for p in products:
if p['product_id'] == pid:
if vidx != -1 and vidx < len(p.get('variants',[])):
current_s = p['variants'][vidx].get('stock')
if current_s != "" and current_s is not None:
p['variants'][vidx]['stock'] = int(current_s) + return_qty
else:
current_s = p.get('stock')
if current_s != "" and current_s is not None:
p['stock'] = int(current_s) + return_qty
break
def get_low_stock_items(products):
low_stock =[]
for p in products:
if p.get('variants'):
for vidx, v in enumerate(p['variants']):
s = v.get('stock')
if s != "" and s is not None and str(s).lstrip('-').isdigit() and int(s) < 100:
low_stock.append({"name": p['name'], "variant": v.get('name'), "stock": int(s), "category": p.get('category', '')})
else:
s = p.get('stock')
if s != "" and s is not None and str(s).lstrip('-').isdigit() and int(s) < 100:
low_stock.append({"name": p['name'], "variant": "", "stock": int(s), "category": p.get('category', '')})
return low_stock
LANDING_PAGE_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> MetaStore</title>
<style>
body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; }
iframe { border: none; width: 100%; height: 100%; }
</style>
</head>
<body>
<iframe src="https://v0-ai-agent-landing-page-smoky-six.vercel.app/"></iframe>
</body>
</html>
'''
LOGIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.login-container { background: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 350px; }
h2 { color: #135D66; margin-bottom: 20px; }
input[type="password"] { width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 1rem; }
button { width: 100%; padding: 12px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 1rem; transition: background 0.3s; }
button:hover { background-color: #77E4D8; }
.error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="login-container">
<h2>Вход</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="error">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ request.full_path }}">
<input type="password" name="password" placeholder="Введите пароль" required autofocus>
<button type="submit">Войти</button>
</form>
</div>
</body>
</html>
'''
CATALOG_LOGIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход в каталог</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Montserrat', sans-serif; background-color: #f4f6f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.login-container { background: #fff; padding: 40px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); text-align: center; width: 100%; max-width: 350px; }
h2 { color: #135D66; margin-bottom: 20px; font-size: 1.4rem; }
input[type="text"] { width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 1rem; text-align: center; letter-spacing: 2px; }
button { width: 100%; padding: 12px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 1rem; transition: background 0.3s; }
button:hover { background-color: #77E4D8; }
.error { color: #E57373; margin-bottom: 15px; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="login-container">
<h2>Закрытый каталог</h2>
<p style="font-size: 0.9rem; color: #666; margin-bottom: 20px;">Введите 6-значный пароль для доступа</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="error">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ request.full_path }}">
<input type="text" name="password" placeholder="Пароль" required autofocus maxlength="6">
<button type="submit">Войти</button>
</form>
</div>
</body>
</html>
'''
ADMHOSTO_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Управление</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg-light: #f4f6f9; --bg-medium: #135D66; --accent: #48D1CC; --accent-hover: #77E4D8; --text-dark: #333; --text-on-accent: #003C43; --danger: #E57373; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg-light); color: var(--text-dark); padding: 20px; }
.container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
h1 { font-weight: 600; color: var(--bg-medium); margin-bottom: 25px; text-align: center; }
.section { margin-bottom: 30px; }
.add-env-form { margin-bottom: 20px; text-align: center; }
#search-env { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; font-size: 1rem; font-family: 'Montserrat', sans-serif; }
.button { padding: 10px 18px; border: none; border-radius: 6px; background-color: var(--accent); color: var(--text-on-accent); font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; text-decoration: none; display: inline-flex; align-items: center; gap: 5px; }
.button:hover { background-color: var(--accent-hover); }
.env-list { list-style: none; padding: 0; }
.env-item { background: #fdfdff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 15px; }
.env-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.env-id { font-weight: 600; color: var(--bg-medium); font-size: 1.2rem; }
.env-actions { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
.env-pwd { background: #f1f3f5; padding: 10px; border-radius: 6px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: space-between; }
.env-pwd input[type="text"] { padding: 5px; border: 1px solid #ccc; border-radius: 4px; }
.env-pwd select { padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-family: inherit; }
.delete-button { background-color: var(--danger); color: white; }
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; text-align: center; }
.message.success { background-color: #d4edda; color: #155724; }
.message.error { background-color: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<h1><i class="fas fa-server"></i> Среды</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="section">
<form method="POST" action="{{ url_for('create_environment') }}" class="add-env-form">
<button type="submit" class="button"><i class="fas fa-plus-circle"></i> Создать</button>
</form>
</div>
<div class="section">
<input type="text" id="search-env" placeholder="Поиск...">
</div>
<div class="section">
<ul class="env-list">
{% for env in environments %}
<li class="env-item">
<div class="env-header">
<span class="env-id">{{ env.org_name }} (ID: {{ env.id }})</span>
<div class="env-actions">
<form method="POST" action="/admhosto/update_mode/{{ env.id }}" style="margin:0;">
<select name="system_mode" onchange="this.form.submit()">
<option value="both" {% if env.system_mode == 'both' %}selected{% endif %}>2 в 1</option>
<option value="internal" {% if env.system_mode == 'internal' %}selected{% endif %}>Внутренний учет</option>
<option value="external" {% if env.system_mode == 'external' %}selected{% endif %}>Внешний учет</option>
<option value="light_external" {% if env.system_mode == 'light_external' %}selected{% endif %}>Лайт внешка</option>
</select>
</form>
<a href="{{ url_for('admin', env_id=env.id) }}" class="button" target="_blank"><i class="fas fa-tools"></i> Админ</a>
<form method="POST" action="/admhosto/clear_history/{{ env.id }}" onsubmit="let p=prompt('Введите пароль (admin) для удаления истории:');if(p){this.pwd.value=p;return true;}return false;" style="display:inline;">
<input type="hidden" name="pwd" value="">
<button type="submit" class="button" style="background:#e17055; color:white;"><i class="fas fa-eraser"></i> Сброс истории</button>
</form>
<form method="POST" action="{{ url_for('delete_environment', env_id=env.id) }}" style="display:inline;" onsubmit="return confirm('Точно удалить {{ env.id }}?');">
<button type="submit" class="button delete-button"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div class="env-pwd">
<form method="POST" action="{{ url_for('update_env_pwd', env_id=env.id) }}" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<label><input type="checkbox" name="pwd_enabled" {% if env.pwd_enabled %}checked{% endif %}> Пароль</label>
<input type="text" name="password" value="{{ env.password }}" placeholder="Пароль">
<button type="submit" class="button" style="padding: 5px 10px; font-size: 0.9rem;">Сохранить</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
<script>
document.getElementById('search-env').addEventListener('input', function() {
const searchTerm = this.value.toLowerCase().trim();
const envItems = document.querySelectorAll('.env-item');
envItems.forEach(item => {
const envId = item.querySelector('.env-id').textContent.toLowerCase();
if (envId.includes(searchTerm)) { item.style.display = 'flex'; } else { item.style.display = 'none'; }
});
});
</script>
</body>
</html>
'''
CATALOG_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>{{ settings.organization_name }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
<style>
:root { --primary: #1a1a1a; --bg: #f8f9fa; --surface: #ffffff; --text: #2d3436; --text-muted: #636e72; --border: #edf2f7; --accent: #25D366; }
{% if settings.theme == 'dark' %}
:root { --primary: #bb86fc; --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --text-muted: #a0a0a0; --border: #333333; --accent: #03dac6; }
{% elif settings.theme == 'magma' %}
:root { --primary: #ff8c00; --bg: #2a0800; --surface: #3f1100; --text: #ffeedd; --text-muted: #cc8877; --border: #551100; --accent: #ff4500; }
{% elif settings.theme == 'ocean' %}
:root { --primary: #00a8ff; --bg: #0a1b2a; --surface: #112840; --text: #e0f0ff; --text-muted: #8eb3d0; --border: #1a4060; --accent: #00d2d3; }
{% elif settings.theme == 'forest' %}
:root { --primary: #2ed573; --bg: #0b1e13; --surface: #132e1b; --text: #e0ffe0; --text-muted: #8ebd90; --border: #1e4d29; --accent: #7bed9f; }
{% elif settings.theme == 'cyberpunk' %}
:root { --primary: #f1c40f; --bg: #000000; --surface: #111111; --text: #00ffcc; --text-muted: #ff00ff; --border: #333333; --accent: #ff0055; }
{% endif %}
* { margin: 0; padding: 0; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-tap-highlight-color: transparent; }
body { background-color: var(--bg); color: var(--text); padding-bottom: calc(90px + env(safe-area-inset-bottom)); }
.top-logo-container { background: var(--surface); padding: max(15px, env(safe-area-inset-top)) 20px 10px; text-align: center; border-bottom: 1px solid var(--border); display: flex; justify-content: center; align-items: center; transition: all 0.3s ease; }
.top-logo { max-width: 100%; height: auto; max-height: 80px; object-fit: contain; transition: all 0.3s ease; }
.top-logo.logo-square {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
box-shadow: 0 4px 10px rgba(0,0,0,0.05);
}
@media (max-width: 768px) {
.top-logo-container.wide-mode { padding: max(0px, env(safe-area-inset-top)) 0 0 0; border-bottom: none; }
.top-logo.logo-wide {
width: 100%;
max-height: none;
object-fit: cover;
display: block;
border-radius: 0;
}
}
.header { display: flex; align-items: center; justify-content: space-between; padding: 15px 20px; background: var(--surface); box-shadow: 0 2px 10px rgba(0,0,0,0.03); position: sticky; top: 0; z-index: 100; }
.header h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.5px; }
.back-btn { display: none; font-size: 1.2rem; cursor: pointer; color: var(--text); margin-right: 15px; padding: 5px; }
.search-bar { padding: 15px 20px; background: var(--surface); border-bottom: 1px solid var(--border); }
.search-container { position: relative; display: flex; align-items: center; background: var(--bg); border-radius: 12px; padding: 0 15px; border: 1px solid transparent; transition: all 0.2s; }
.search-container:focus-within { border-color: #dcdde1; background: var(--surface); box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.search-container i.fa-search { color: var(--text-muted); font-size: 0.9rem; }
.search-bar input { width: 100%; padding: 12px 10px; border: none; background: transparent; outline: none; font-size: 0.95rem; color: var(--text); }
.categories-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; padding: 20px; }
.category-item { background: var(--surface); padding: 20px 15px; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.03); transition: transform 0.2s; text-align: center; }
.category-item:active { transform: scale(0.96); }
.category-item span.name { font-size: 0.95rem; font-weight: 600; line-height: 1.3; }
.category-item span.count { color: var(--text-muted); font-size: 0.8rem; background: var(--bg); padding: 4px 10px; border-radius: 20px; }
.products-container { display: none; padding: 20px; flex-direction: column; gap: 15px; }
.product-card { background: var(--surface); border-radius: 16px; padding: 15px; display: flex; flex-direction: column; box-shadow: 0 4px 15px rgba(0,0,0,0.03); width: 100%; gap: 10px; transition: opacity 0.3s, filter 0.3s; }
.product-main-content { display: flex; width: 100%; gap: 15px; align-items: stretch; }
.product-img-wrapper { position: relative; width: 100px; height: 100px; flex-shrink: 0; }
.product-img { width: 100%; height: 100%; border-radius: 12px; object-fit: cover; cursor: pointer; background: var(--bg); border: 1px solid var(--border); transition: filter 0.3s; }
.photo-count { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.6); color: white; font-size: 0.7rem; padding: 2px 6px; border-radius: 10px; pointer-events: none; }
.product-info { flex-grow: 1; display: flex; flex-direction: column; min-width: 0; justify-content: flex-start; gap: 4px; }
.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; }
.product-desc { font-size: 0.8rem; color: var(--text-muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-box-info { font-size: 0.8rem; color: #00b894; font-weight: 600; }
.product-bottom { display: flex; align-items: center; justify-content: space-between; width: 100%; margin-top: 5px; flex-wrap: wrap; gap: 10px; }
.product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
.controls-wrapper { display: flex; gap: 8px; align-items: center; margin-left: auto; }
.quantity-control { display: flex; align-items: center; background: var(--bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
.quantity-control button { border: none; background: transparent; width: 32px; height: 32px; font-size: 1.1rem; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
.quantity-control button:active { background: var(--border); }
.quantity-control button:disabled { color: #555; cursor: not-allowed; }
.quantity-control input { width: 36px; height: 32px; border: none; text-align: center; background: transparent; font-weight: 600; font-size: 0.95rem; color: var(--primary); outline: none; }
.quantity-control input:disabled { color: #555; }
.quantity-control input[type="number"]::-webkit-inner-spin-button,
.quantity-control input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.quantity-control input[type="number"] { -moz-appearance: textfield; }
.box-btn { background: var(--primary); color: #fff; border: none; border-radius: 8px; padding: 0 10px; height: 32px; font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
.box-btn:active { opacity: 0.8; }
.box-btn:disabled { background: #555; cursor: not-allowed; }
.variants-list { display: flex; flex-direction: column; gap: 8px; margin-top: 5px; width: 100%; }
.variant-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg); padding: 10px; border-radius: 8px; flex-wrap: wrap; gap: 10px; border: 1px solid var(--border); transition: opacity 0.3s; }
.variant-info { display: flex; flex-direction: column; flex: 1; min-width: 120px; }
.variant-name { font-weight: 600; font-size: 0.9rem; }
.variant-price { font-size: 0.85rem; color: var(--primary); font-weight: 500; }
.variant-stock { font-size: 0.8rem; color: #0984e3; margin-top: 2px; }
.cart-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.06); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: none; justify-content: space-between; align-items: center; z-index: 100; border-top-left-radius: 20px; border-top-right-radius: 20px; }
.cart-info { display: flex; flex-direction: column; }
.cart-total { font-size: 1.25rem; font-weight: 800; color: var(--primary); }
.checkout-btn { background: var(--primary); color: #fff; padding: 12px 28px; border: none; border-radius: 12px; font-weight: 600; font-size: 1rem; cursor: pointer; box-shadow: 0 4px 12px rgba(26,26,26,0.2); transition: transform 0.2s; }
.checkout-btn:active { transform: scale(0.95); }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); z-index: 200; justify-content: center; align-items: flex-end; opacity: 0; transition: opacity 0.3s; }
.modal-overlay.active { opacity: 1; }
.modal-content { background: var(--surface); color: var(--text); width: 100%; max-height: 85vh; border-radius: 24px 24px 0 0; padding: 25px 20px calc(25px + env(safe-area-inset-bottom)); overflow-y: auto; display: flex; flex-direction: column; transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1); }
.modal-overlay.active .modal-content { transform: translateY(0); }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.modal-header h2 { font-size: 1.3rem; font-weight: 700; }
.modal-close { font-size: 1.5rem; cursor: pointer; border: none; background: var(--bg); width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--text); }
.customer-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 25px; }
.customer-form input { padding: 14px; border: 1px solid var(--border); border-radius: 12px; font-size: 0.95rem; background: var(--bg); color: var(--text); outline: none; transition: border-color 0.2s; }
.customer-form input:focus { border-color: var(--primary); background: var(--surface); }
.cart-item-list { display: flex; flex-direction: column; gap: 15px; margin-bottom: 25px; }
.cart-item { display: flex; justify-content: space-between; align-items: center; background: var(--bg); padding: 15px; border-radius: 12px; flex-wrap: wrap; gap: 10px; }
.cart-item-name { flex: 1; min-width: 120px; font-size: 0.95rem; font-weight: 500; line-height: 1.3; }
.cart-item-controls { display: flex; align-items: center; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
.cart-item-controls button { border: none; background: transparent; width: 30px; height: 30px; font-size: 1rem; cursor: pointer; color: var(--primary); }
.cart-item-controls button:active { background: var(--border); }
.cart-item-controls input { width: 35px; text-align: center; font-weight: 600; font-size: 0.9rem; border: none; background: transparent; color: var(--primary); outline: none; }
.cart-item-controls input[type="number"]::-webkit-inner-spin-button,
.cart-item-controls input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.cart-item-controls input[type="number"] { -moz-appearance: textfield; }
.cart-item-price { font-weight: 700; color: var(--primary); min-width: 70px; text-align: right; }
.cart-item-delete { color: #ff7675; background: none; border: none; font-size: 1.1rem; cursor: pointer; padding: 5px; }
.confirm-btn { background: var(--accent); color: #fff; width: 100%; padding: 16px; border: none; border-radius: 14px; font-size: 1.1rem; font-weight: 700; cursor: pointer; box-shadow: 0 4px 15px rgba(37,211,102,0.3); }
.gallery-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; z-index: 300; justify-content: center; align-items: center; flex-direction: column; }
.gallery-close { position: absolute; top: max(20px, env(safe-area-inset-top)); right: 20px; color: #fff; font-size: 2rem; cursor: pointer; background: rgba(0,0,0,0.5); width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: none; z-index: 302; }
.gallery-img-container { position: relative; width: 100%; height: 70vh; display: flex; align-items: center; justify-content: center; }
.gallery-img { max-width: 100%; max-height: 100%; object-fit: contain; }
.gallery-nav { position: absolute; top: 50%; transform: translateY(-50%); color: #fff; font-size: 2rem; background: rgba(0,0,0,0.5); border: none; width: 50px; height: 50px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 301; }
.gallery-nav.prev { left: 10px; }
.gallery-nav.next { right: 10px; }
.gallery-dots { display: flex; gap: 8px; margin-top: 20px; }
.gallery-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,0.3); transition: background 0.3s; }
.gallery-dot.active { background: #fff; }
.floating-socials { position: fixed; bottom: max(100px, calc(100px + env(safe-area-inset-bottom))); right: 15px; display: flex; flex-direction: column; gap: 12px; z-index: 90; }
.social-btn { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 1.6rem; text-decoration: none; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s; }
.social-btn:active { transform: scale(0.9); }
.btn-float-wa { background: #25D366; }
.btn-float-ig { background: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%); }
.btn-float-tg { background: #0088cc; }
.btn-float-returns { background: #e17055; }
.btn-float-history { background: #0984e3; }
.staff-banner { background: #ffeaa7; color: #d63031; text-align: center; padding: 10px; font-weight: 600; font-size: 0.9rem; }
.returns-list { display: flex; flex-direction: column; gap: 15px; }
.return-order-item { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 15px; }
.return-item-row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; font-size: 0.9rem; border-top: 1px dashed #ccc; padding-top: 10px; }
.return-input { width: 50px; padding: 5px; border: 1px solid #ccc; border-radius: 6px; text-align: center; }
.process-return-btn { background: #e17055; color: white; border: none; padding: 8px 15px; border-radius: 8px; cursor: pointer; margin-top: 10px; width: 100%; font-weight: 600; }
.history-btn { background: #0984e3; color: white; border: none; padding: 8px 15px; border-radius: 8px; cursor: pointer; margin-top: 10px; width: 100%; font-weight: 600; text-decoration: none; display: block; text-align: center; box-sizing: border-box; }
{% if settings.theme in ['dark', 'magma', 'ocean', 'forest', 'cyberpunk'] %}
.category-item, .product-card, .header, .search-bar, .cart-bar, .modal-content { box-shadow: 0 4px 15px rgba(0,0,0,0.5); border: 1px solid var(--border); }
{% endif %}
@media (min-width: 768px) {
.categories-container { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.products-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); }
.modal-content { max-width: 500px; margin: 0 auto; border-radius: 24px; top: 50%; transform: translateY(-50%) scale(0.9); bottom: auto; position: relative; max-height: 90vh; }
.modal-overlay.active .modal-content { transform: translateY(-50%) scale(1); }
.cart-bar { max-width: 500px; left: 50%; transform: translateX(-50%); border-radius: 20px 20px 0 0; }
}
</style>
</head>
<body>
<script>
document.addEventListener("DOMContentLoaded", function() {
const logoImg = document.querySelector('.top-logo');
if (logoImg) {
const checkRatio = function() {
const ratio = logoImg.naturalWidth / logoImg.naturalHeight;
if (ratio >= 1.5) {
logoImg.classList.add('logo-wide');
logoImg.closest('.top-logo-container').classList.add('wide-mode');
} else if (ratio >= 0.8 && ratio <= 1.2) {
logoImg.classList.add('logo-square');
}
};
if (logoImg.complete) {
checkRatio();
} else {
logoImg.onload = checkRatio;
}
}
});
</script>
{% if mode == 'pos' %}
<div class="staff-banner">Режим кассы: Быстрое оформление</div>
{% endif %}
<div class="top-logo-container">
<img src="{{ settings.logo_url }}" class="top-logo" alt="Логотип">
</div>
<div class="header">
<div style="display: flex; align-items: center;">
<i class="fas fa-arrow-left back-btn" id="backBtn" onclick="showCategories()"></i>
<h1 id="pageTitle">{{ settings.organization_name }}</h1>
</div>
</div>
<div class="search-bar" id="searchBar">
<div class="search-container">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="Поиск товаров..." oninput="filterCategories()">
{% if settings.use_barcodes %}
<i class="fas fa-barcode" style="cursor:pointer; padding: 10px; color: var(--primary);" onclick="startScanner(val => { document.getElementById('searchInput').value = val; filterCategories(); })"></i>
{% endif %}
</div>
</div>
<div class="categories-container" id="categoriesContainer"></div>
<div class="products-container" id="productsContainer"></div>
<div class="floating-socials">
{% if mode == 'pos' and staff_id %}
<a href="#" class="social-btn btn-float-history" onclick="openStaffHistoryModal()"><i class="fas fa-list-alt"></i></a>
<a href="#" class="social-btn btn-float-returns" onclick="openReturnsModal()"><i class="fas fa-undo"></i></a>
{% endif %}
{% if mode != 'pos' %}
{% if settings.socials.wa.enabled and settings.socials.wa.url %}
{% for link in settings.socials.wa.url.split() %}
{% if link.strip() %}
<a href="{{ link.strip() }}" class="social-btn btn-float-wa" target="_blank"><i class="fab fa-whatsapp"></i></a>
{% endif %}
{% endfor %}
{% endif %}
{% if settings.socials.ig.enabled and settings.socials.ig.url %}
{% for link in settings.socials.ig.url.split() %}
{% if link.strip() %}
<a href="{{ link.strip() }}" class="social-btn btn-float-ig" target="_blank"><i class="fab fa-instagram"></i></a>
{% endif %}
{% endfor %}
{% endif %}
{% if settings.socials.tg.enabled and settings.socials.tg.url %}
{% for link in settings.socials.tg.url.split() %}
{% if link.strip() %}
<a href="{{ link.strip() }}" class="social-btn btn-float-tg" target="_blank"><i class="fab fa-telegram-plane"></i></a>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
</div>
<div class="cart-bar" id="cartBar">
<div class="cart-info">
<span style="font-size: 0.85rem; color: var(--text-muted); font-weight: 500;">Сумма заказа:</span>
<span class="cart-total"><span id="cartTotalSum">0</span> {{ currency_code }}</span>
</div>
<button class="checkout-btn" onclick="openCartModal()">Корзина <i class="fas fa-shopping-bag" style="margin-left:5px;"></i></button>
</div>
<div class="modal-overlay" id="cartModal" onclick="if(event.target === this) closeCartModal()">
<div class="modal-content">
<div class="modal-header">
<h2>Ваш заказ</h2>
<button class="modal-close" onclick="closeCartModal()"><i class="fas fa-times"></i></button>
</div>
<div class="cart-item-list" id="cartItemList"></div>
<div class="customer-form">
{% if mode == 'pos' %}
<div style="margin-top: 5px; margin-bottom: 15px; background: var(--bg); padding: 10px; border-radius: 12px; border: 1px solid var(--border);">
<label style="font-size: 0.9rem; font-weight: 600; display:block; margin-bottom:5px;">Общая скидка на чек (сумма)</label>
<input type="number" id="globalDiscountVal" value="0" min="0" onchange="updateCartUI()" style="width: 100%; border:none; background:var(--surface); padding:10px; border-radius:8px; font-weight:600; outline:none; color: var(--text);">
</div>
<input type="text" id="custNamePos" placeholder="Имя клиента (необязательно)">
<input type="text" id="custWhatsapp" placeholder="WhatsApp клиента (напр. +77001234567) необязательно">
{% else %}
{% if settings.customer_fields.name %} <input type="text" id="custName" placeholder="Ваше Имя" required> {% endif %}
{% if settings.customer_fields.phone %} <input type="text" id="custPhone" placeholder="Номер телефона" required> {% endif %}
{% if settings.customer_fields.city %} <input type="text" id="custCity" placeholder="Город" required> {% endif %}
{% if settings.customer_fields.address %} <input type="text" id="custAddress" placeholder="Адрес доставки" required> {% endif %}
{% if settings.customer_fields.zip %} <input type="text" id="custZip" placeholder="Индекс" required> {% endif %}
{% endif %}
</div>
<button class="confirm-btn" onclick="submitOrder()">Оформить заказ</button>
</div>
</div>
<div class="modal-overlay" id="returnsModal" onclick="if(event.target === this) closeReturnsModal()">
<div class="modal-content">
<div class="modal-header">
<h2>Мои продажи (Возврат)</h2>
<button class="modal-close" onclick="closeReturnsModal()"><i class="fas fa-times"></i></button>
</div>
<div id="returnsContent" class="returns-list">
<div style="text-align:center; padding:20px;"><i class="fas fa-spinner fa-spin"></i> Загрузка...</div>
</div>
</div>
</div>
<div class="modal-overlay" id="staffHistoryModal" onclick="if(event.target === this) closeStaffHistoryModal()">
<div class="modal-content">
<div class="modal-header">
<h2>Мои накладные</h2>
<button class="modal-close" onclick="closeStaffHistoryModal()"><i class="fas fa-times"></i></button>
</div>
<div style="margin-bottom: 15px; display:flex; gap:10px;">
<input type="date" id="historyDateFilter" style="padding: 10px; border: 1px solid var(--border); border-radius: 8px; flex: 1; background: var(--bg); color: var(--text);" onchange="loadStaffHistory()">
</div>
<div id="staffHistoryContent" class="returns-list">
<div style="text-align:center; padding:20px;"><i class="fas fa-spinner fa-spin"></i> Загрузка...</div>
</div>
</div>
</div>
<div class="gallery-modal" id="galleryModal">
<button class="gallery-close" onclick="closeGallery()"><i class="fas fa-times"></i></button>
<div class="gallery-img-container" id="gallerySwipeArea">
<button class="gallery-nav prev" onclick="prevPhoto(event)"><i class="fas fa-chevron-left"></i></button>
<img src="" class="gallery-img" id="galleryImage">
<button class="gallery-nav next" onclick="nextPhoto(event)"><i class="fas fa-chevron-right"></i></button>
</div>
<div class="gallery-dots" id="galleryDots"></div>
</div>
<div class="modal-overlay" id="scannerModal" style="z-index:9999;">
<div style="background:var(--surface); color:var(--text); padding:20px; border-radius:12px; width:100%; max-width:400px; text-align:center; position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); border: 1px solid var(--border);">
<h3 style="margin-top:0;">Сканирование</h3>
<div id="reader" style="width:100%; min-height:300px; margin-bottom:15px; background:var(--bg);"></div>
<button class="btn btn-danger" style="background:#ff7675; color:white; border:none; padding:10px 20px; border-radius:8px; font-weight:bold; cursor:pointer;" onclick="stopScanner()">Отмена</button>
</div>
</div>
<script>
const products = {{ products_json|safe }};
const categoriesList = {{ categories_json|safe }};
const categoryPhotos = {{ category_photos_json|safe }};
const repoId = '{{ repo_id }}';
const currency = '{{ currency_code }}';
const envId = '{{ env_id }}';
const mode = '{{ mode }}';
const staffId = '{{ staff_id }}';
const trackInventory = {{ 'true' if settings.track_inventory else 'false' }};
const hideStockOnline = {{ 'true' if settings.hide_stock_online else 'false' }};
const businessType = '{{ settings.business_type }}';
const cFields = {{ settings.customer_fields|tojson }};
let cart = {};
let currentGalleryPhotos =[];
let currentGalleryIndex = 0;
function init() {
renderCategories();
updateCartUI();
let today = new Date();
let offset = today.getTimezoneOffset() * 60000;
let localToday = new Date(today.getTime() - offset).toISOString().split('T')[0];
document.getElementById('historyDateFilter').value = localToday;
}
function getCartKey(productId, variantIdx) {
return (variantIdx !== undefined && variantIdx !== null && variantIdx !== -1) ? `${productId}___${variantIdx}` : productId;
}
function renderCategories() {
const container = document.getElementById('categoriesContainer');
const prodContainer = document.getElementById('productsContainer');
prodContainer.style.display = 'none';
container.style.display = 'grid';
document.getElementById('backBtn').style.display = 'none';
document.getElementById('pageTitle').innerText = 'Каталог';
container.innerHTML = '';
categoriesList.forEach(cat => {
const catProducts = products.filter(p => p.category === cat);
const count = catProducts.length;
const photoFileName = categoryPhotos[cat];
let iconHtml = `<div style="background: var(--bg); width: 60px; height: 60px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 5px;">
<i class="fas fa-box-open" style="font-size: 1.5rem; color: var(--primary);"></i>
</div>`;
if (photoFileName) {
iconHtml = `<img src="https://huggingface.co/datasets/${repoId}/resolve/main/category_photos/${photoFileName}" style="width: 60px; height: 60px; border-radius: 12px; object-fit: cover; margin-bottom: 5px; border: 1px solid var(--border);">`;
}
const div = document.createElement('div');
div.className = 'category-item';
div.onclick = () => showProducts(cat);
div.innerHTML = `
${iconHtml}
<span class="name">${cat}</span>
<span class="count">${count} шт</span>
`;
container.appendChild(div);
});
}
function showCategories() {
document.getElementById('searchInput').value = '';
renderCategories();
}
function filterCategories() {
const query = document.getElementById('searchInput').value.toLowerCase();
if (!query) {
renderCategories();
return;
}
document.getElementById('categoriesContainer').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'flex';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = 'Поиск';
container.innerHTML = '';
const matchedProducts = products.filter(p => {
if(p.name.toLowerCase().includes(query) || p.category.toLowerCase().includes(query)) return true;
if(p.barcode && p.barcode.toLowerCase().includes(query)) return true;
if(p.product_id && p.product_id.toLowerCase().includes(query)) return true;
if(p.variants && p.variants.some(v => v.barcode && v.barcode.toLowerCase().includes(query))) return true;
return false;
});
if(matchedProducts.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">Ничего не найдено</div>';
} else {
matchedProducts.forEach(p => renderProductCard(p, container));
}
}
function formatQtyText(qty, ppb) {
if (businessType === 'retail') {
return `${qty} шт.`;
}
ppb = parseInt(ppb) || 1;
if (ppb > 1 && qty >= ppb) {
let boxes = Math.floor(qty / ppb);
let remainder = qty % ppb;
return `${boxes} кор.` + (remainder > 0 ? ` ${remainder} шт.` : '');
}
return `${qty} шт.`;
}
function renderProductCard(p, container) {
const ppb = parseInt(p.pieces_per_box) || 1;
const hasPhotos = p.photos && p.photos.length > 0;
const photoUrl = hasPhotos
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`
: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNhMGEwYTAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0Ij7QndC10YIg0YTQvtGC0L48L3RleHQ+PC9zdmc+';
const photoIndicator = hasPhotos && p.photos.length > 1 ? `<div class="photo-count"><i class="fas fa-images"></i> ${p.photos.length}</div>` : '';
const imgClick = hasPhotos ? `onclick="openGallery('${p.product_id}')"` : '';
const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
let isMainAvailable = p.is_available !== false;
let cardStyle = !isMainAvailable ? 'opacity: 0.6; filter: grayscale(100%);' : '';
let mainDisabledAttr = !isMainAvailable ? 'disabled' : '';
let boxInfoHtml = '';
if (businessType !== 'retail') {
if (ppb > 1) boxInfoHtml += `<div class="product-box-info">В упаковке: ${ppb} шт</div>`;
}
if (businessType === 'wholesale') {
let minO = parseInt(p.min_order) || 1;
if (minO > 1) boxInfoHtml += `<div style="font-size:0.8rem; color:#e17055; font-weight:600;">Мин. заказ: ${minO} шт</div>`;
}
let showStock = trackInventory && !(mode !== 'pos' && hideStockOnline);
let variantsHtml = '';
let mainControlsHtml = '';
let moq = (businessType === 'wholesale' && parseInt(p.min_order) > 0) ? parseInt(p.min_order) : 1;
if (p.variants && p.variants.length > 0) {
variantsHtml = `<div class="variants-list">`;
p.variants.forEach((v, idx) => {
let vAvailable = v.is_available !== false && isMainAvailable;
let vDisabledAttr = !vAvailable ? 'disabled' : '';
let vStyle = !vAvailable ? 'opacity: 0.6;' : '';
let vPrice = p.has_variant_prices ? v.price : p.price;
let vBoxPrice = p.has_variant_prices ? (v.box_price || '') : (p.box_price || '');
let vStockHtml = showStock && v.stock !== "" && v.stock !== null ? `<div class="variant-stock">Остаток: ${v.stock} шт</div>` : '';
if (!vAvailable) {
vStockHtml = `<div class="variant-stock" style="color:#e17055; font-weight:bold;">Нет в наличии</div>`;
}
let cKey = getCartKey(p.product_id, idx);
let qty = cart[cKey] ? cart[cKey].quantity : 0;
let vPpb = parseInt(v.pieces_per_box) || ppb;
let priceText = `${vPrice} ${currency}`;
if (businessType === 'mixed' && vBoxPrice && vPpb > 1) {
priceText += `<br><span style="font-size:0.8rem; color:var(--text-muted);">Упаковка: ${vBoxPrice} ${currency}</span>`;
}
if (businessType === 'wholesale') {
let tiers = v.wholesale_tiers || [];
if (!p.has_variant_prices) tiers = p.wholesale_tiers || [];
if (tiers.length > 0) {
tiers.sort((a,b) => a.qty - b.qty);
priceText += `<br><div style="font-size:0.8rem; color:#0984e3; margin-top:4px;">`;
tiers.forEach(t => {
priceText += `От ${t.qty} шт: ${t.price} ${currency}<br>`;
});
priceText += `</div>`;
}
}
let addBoxBtnVariant = '';
if (businessType !== 'retail' && vPpb > 1) {
addBoxBtnVariant = `<button class="box-btn" style="height:32px; margin-right:5px;" onclick="updateCart('${p.product_id}', ${vPpb}, null, false, '${cKey}', ${moq})" ${vDisabledAttr}>+ Упаковка</button>`;
}
variantsHtml += `
<div class="variant-item" style="${vStyle}">
<div class="variant-info">
<span class="variant-name">${v.name}</span>
<span class="variant-price">${priceText}</span>
${vStockHtml}
</div>
<div style="display:flex; align-items:center;">
${addBoxBtnVariant}
<div class="quantity-control" style="border:none; background:var(--surface);">
<button onclick="updateCart('${p.product_id}', -1, null, false, '${cKey}', ${moq})" ${vDisabledAttr}><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
<input type="number" id="qty-${cKey}" value="${qty}" onchange="manualUpdateCart('${cKey}', this.value, ${moq})" ${vDisabledAttr}>
<button onclick="updateCart('${p.product_id}', 1, null, false, '${cKey}', ${moq})" ${vDisabledAttr}><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
</div>
</div>
</div>
`;
});
variantsHtml += `</div>`;
} else {
let mStockHtml = showStock && p.stock !== "" && p.stock !== null ? `<div style="font-size:0.8rem; color:#0984e3; margin-top:4px;">Остаток: ${p.stock} шт</div>` : '';
if (!isMainAvailable) {
mStockHtml = `<div style="font-size:0.8rem; color:#e17055; margin-top:4px; font-weight:bold;">Нет в наличии</div>`;
}
let qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
let addBoxBtn = '';
if (businessType !== 'retail' && ppb > 1) {
addBoxBtn = `<button class="box-btn" onclick="updateCart('${p.product_id}', ${ppb}, null, false, null, ${moq})" ${mainDisabledAttr}>+ Упаковка</button>`;
}
let priceText = `${p.price} ${currency}`;
if (businessType === 'mixed' && p.box_price && ppb > 1) {
priceText += `<br><span style="font-size:0.8rem; color:var(--text-muted);">Упаковка: ${p.box_price} ${currency}</span>`;
}
if (businessType === 'wholesale') {
let tiers = p.wholesale_tiers || [];
if (tiers.length > 0) {
tiers.sort((a,b) => a.qty - b.qty);
priceText += `<br><div style="font-size:0.8rem; color:#0984e3; margin-top:4px;">`;
tiers.forEach(t => {
priceText += `От ${t.qty} шт: ${t.price} ${currency}<br>`;
});
priceText += `</div>`;
}
}
mainControlsHtml = `
<div class="product-bottom">
<div style="display:flex; flex-direction:column;">
<div class="product-price">${priceText}</div>
${mStockHtml}
</div>
<div class="controls-wrapper">
${addBoxBtn}
<div class="quantity-control">
<button onclick="updateCart('${p.product_id}', -1, null, false, null, ${moq})" ${mainDisabledAttr}><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
<input type="number" id="qty-${p.product_id}" value="${qty}" onchange="manualUpdateCart('${p.product_id}', this.value, ${moq})" ${mainDisabledAttr}>
<button onclick="updateCart('${p.product_id}', 1, null, false, null, ${moq})" ${mainDisabledAttr}><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
</div>
</div>
</div>
`;
}
const div = document.createElement('div');
div.className = 'product-card';
div.style = cardStyle;
div.innerHTML = `
<div class="product-main-content">
<div class="product-img-wrapper" ${imgClick}>
<img src="${photoUrl}" class="product-img">
${photoIndicator}
</div>
<div class="product-info">
<div class="product-title">${p.name}</div>
<div style="font-size:0.75rem; color:var(--text-muted); margin-bottom:5px;">ID: ${p.product_id}</div>
${descHtml}
${boxInfoHtml}
</div>
</div>
${variantsHtml}
${mainControlsHtml}
`;
container.appendChild(div);
}
function showProducts(category) {
document.getElementById('categoriesContainer').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'flex';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = category;
container.innerHTML = '';
const catProducts = products.filter(p => p.category === category);
if(catProducts.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">В этой категории пока нет товаров</div>';
} else {
catProducts.forEach(p => renderProductCard(p, container));
}
}
function updateCart(productId, change, exactValue = null, fromCartModal = false, cartKeyOverride = null, moq = 1) {
const p = products.find(x => x.product_id === productId);
if (!p) return;
if (p.is_available === false && change > 0) return;
let cKey = cartKeyOverride !== null ? cartKeyOverride : productId;
let varIdx = -1;
if (cKey.includes('___')) {
varIdx = parseInt(cKey.split('___')[1]);
if (p.variants[varIdx] && p.variants[varIdx].is_available === false && change > 0) return;
}
let pStock = "";
let pPpb = parseInt(p.pieces_per_box) || 1;
if (varIdx !== -1 && p.variants[varIdx]) {
pStock = p.variants[varIdx].stock;
if(p.variants[varIdx].pieces_per_box) {
pPpb = parseInt(p.variants[varIdx].pieces_per_box) || pPpb;
}
} else {
pStock = p.stock;
}
if (!cart[cKey]) {
let price = p.price;
let bPrice = p.box_price || 0;
let vName = "";
let tiers = p.wholesale_tiers || [];
if (varIdx !== -1 && p.variants[varIdx]) {
if (p.has_variant_prices) {
price = p.variants[varIdx].price;
bPrice = p.variants[varIdx].box_price || 0;
tiers = p.variants[varIdx].wholesale_tiers || [];
} else {
tiers = p.wholesale_tiers || [];
}
vName = p.variants[varIdx].name;
}
cart[cKey] = { ...p, quantity: 0, cart_price: price, cart_box_price: bPrice, pieces_per_box: pPpb, variant_name: vName, variant_idx: varIdx, discount: 0, wholesale_tiers: tiers };
}
let currentQty = cart[cKey].quantity;
let newQty = currentQty;
if (exactValue !== null) {
newQty = exactValue;
} else {
newQty += change;
}
if (trackInventory && pStock !== "" && pStock !== null) {
let maxStock = parseInt(pStock);
if (!isNaN(maxStock)) {
if (moq > maxStock && maxStock !== 0 && newQty > 0) {
alert('Недостаточно товара для минимального заказа. Доступно: ' + maxStock + ', Мин: ' + moq);
newQty = 0;
} else if (newQty > maxStock) {
alert('Остаток товара превышен. Доступно: ' + maxStock);
newQty = maxStock;
}
}
}
if (newQty > 0 && newQty < moq) {
if (change > 0) newQty = moq;
else newQty = 0;
}
cart[cKey].quantity = newQty;
if (cart[cKey].quantity <= 0) {
delete cart[cKey];
const input = document.getElementById(`qty-${cKey}`);
if(input) input.value = 0;
} else {
const input = document.getElementById(`qty-${cKey}`);
if(input) input.value = cart[cKey].quantity;
}
updateCartUI();
}
function manualUpdateCart(cKey, val, moq) {
let num = parseInt(val);
if (isNaN(num) || num < 0) num = 0;
const pId = cKey.split('___')[0];
updateCart(pId, 0, num, false, cKey, moq);
}
function manualUpdateCartFromModal(cKey, val, moq) {
let num = parseInt(val);
if (isNaN(num) || num < 0) num = 0;
const pId = cKey.split('___')[0];
updateCart(pId, 0, num, true, cKey, moq);
}
function calculateItemPrice(item) {
let ppb = parseInt(item.pieces_per_box) || 1;
let qty = item.quantity;
let cBoxPrice = parseFloat(item.cart_box_price) || 0;
let cPrice = parseFloat(item.cart_price) || 0;
let disc = parseFloat(item.discount) || 0;
let unit = cPrice;
if (businessType === 'wholesale' && item.wholesale_tiers && item.wholesale_tiers.length > 0) {
let validTiers = item.wholesale_tiers.filter(t => qty >= t.qty).sort((a, b) => b.qty - a.qty);
if (validTiers.length > 0) {
unit = validTiers[0].price;
}
} else if ((businessType === 'mixed' || businessType === 'wholesale') && cBoxPrice > 0 && ppb > 1 && qty >= ppb) {
unit = cBoxPrice / ppb;
}
return Math.max(0, unit - disc) * qty;
}
function updateCartUI() {
let total = 0;
for (let cKey in cart) {
total += calculateItemPrice(cart[cKey]);
}
let globalDiscInput = document.getElementById('globalDiscountVal');
let globalDisc = globalDiscInput ? parseFloat(globalDiscInput.value) || 0 : 0;
if(globalDisc < 0) globalDisc = 0;
total = total - globalDisc;
if(total < 0) total = 0;
const cartBar = document.getElementById('cartBar');
if (total > 0 || Object.keys(cart).length > 0) {
cartBar.style.display = 'flex';
document.getElementById('cartTotalSum').innerText = Math.round(total * 100) / 100;
} else {
cartBar.style.display = 'none';
closeCartModal();
}
if (document.getElementById('cartModal').classList.contains('active')) {
renderCartModalItems();
}
}
function renderCartModalItems() {
const list = document.getElementById('cartItemList');
list.innerHTML = '';
for (let cKey in cart) {
const item = cart[cKey];
const ppb = parseInt(item.pieces_per_box) || 1;
const formattedQty = formatQtyText(item.quantity, ppb);
const pId = item.product_id;
let moq = (businessType === 'wholesale' && parseInt(item.min_order) > 0) ? parseInt(item.min_order) : 1;
let nameDisplay = item.name;
if (item.variant_name) {
nameDisplay += ` <div style="color:var(--text-muted); font-size:0.85rem;">(${item.variant_name})</div>`;
}
let itemTotal = calculateItemPrice(item);
list.innerHTML += `
<div class="cart-item">
<div class="cart-item-name">
${nameDisplay}
<div style="font-size: 0.8rem; color: #00b894; margin-top:2px;">${formattedQty}</div>
</div>
<div style="display:flex; flex-direction:column; align-items:flex-end; gap:5px;">
<div style="display:flex; align-items:center; gap: 10px;">
<div class="cart-item-controls">
<button onclick="updateCart('${pId}', -1, null, true, '${cKey}', ${moq})"><i class="fas fa-minus" style="font-size:0.8rem;"></i></button>
<input type="number" value="${item.quantity}" onchange="manualUpdateCartFromModal('${cKey}', this.value, ${moq})">
<button onclick="updateCart('${pId}', 1, null, true, '${cKey}', ${moq})"><i class="fas fa-plus" style="font-size:0.8rem;"></i></button>
</div>
<button class="cart-item-delete" onclick="updateCart('${pId}', 0, 0, true, '${cKey}', ${moq})"><i class="fas fa-trash-alt"></i></button>
</div>
<div class="cart-item-price">${Math.round(itemTotal * 100) / 100} ${currency}</div>
</div>
</div>
`;
}
}
function openCartModal() {
renderCartModalItems();
const modal = document.getElementById('cartModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
}
function closeCartModal() {
const modal = document.getElementById('cartModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
for (let cKey in cart) {
let input = document.getElementById(`qty-${cKey}`);
if (input) input.value = cart[cKey].quantity;
}
}
function submitOrder() {
const cartArray = Object.keys(cart).map(k => {
return { c_key: k, calculated_price: calculateItemPrice(cart[k]) / cart[k].quantity, discount: cart[k].discount || 0, ...cart[k] }
});
if(cartArray.length === 0) return;
let globalDiscInput = document.getElementById('globalDiscountVal');
let globalDisc = globalDiscInput ? parseFloat(globalDiscInput.value) || 0 : 0;
let orderData = { cart: cartArray, mode: mode, staff_id: staffId, global_discount: globalDisc };
if (mode === 'pos') {
const waEl = document.getElementById('custWhatsapp');
const nameEl = document.getElementById('custNamePos');
orderData.customer_whatsapp = waEl ? waEl.value.trim() : '';
orderData.customer_name = nameEl ? nameEl.value.trim() : '';
} else {
let fail = false;
if(cFields.name) {
const el = document.getElementById('custName');
if(!el.value.trim()) fail = true;
orderData.customer_name = el.value.trim();
}
if(cFields.phone) {
const el = document.getElementById('custPhone');
if(!el.value.trim()) fail = true;
orderData.customer_phone = el.value.trim();
}
if(cFields.city) {
const el = document.getElementById('custCity');
if(!el.value.trim()) fail = true;
orderData.customer_city = el.value.trim();
}
if(cFields.address) {
const el = document.getElementById('custAddress');
if(!el.value.trim()) fail = true;
orderData.customer_address = el.value.trim();
}
if(cFields.zip) {
const el = document.getElementById('custZip');
if(!el.value.trim()) fail = true;
orderData.customer_zip = el.value.trim();
}
if(fail) {
alert('Пожалуйста, заполните все обязательные поля.');
return;
}
}
const btn = document.querySelector('.confirm-btn');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Оформление...';
btn.disabled = true;
fetch(`/${envId}/create_order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
})
.then(r => r.json())
.then(data => {
if(data.order_id) {
cart = {};
window.location.href = `/${envId}/order/${data.order_id}?mode=${mode}&staff_id=${staffId}`;
}
})
.catch(() => {
btn.innerHTML = 'Оформить заказ';
btn.disabled = false;
alert('Произошла ошибка. Попробуйте еще раз.');
});
}
function openReturnsModal() {
const modal = document.getElementById('returnsModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
fetch(`/${envId}/api/staff_orders/${staffId}`)
.then(r => r.json())
.then(data => {
const content = document.getElementById('returnsContent');
content.innerHTML = '';
if(data.length === 0) {
content.innerHTML = '<div style="text-align:center; padding:20px;">Нет продаж для возврата</div>';
return;
}
data.forEach(order => {
let itemsHtml = '';
order.cart.forEach(item => {
let maxRet = item.quantity;
if(maxRet > 0) {
itemsHtml += `
<div class="return-item-row">
<div style="flex:1;">${item.name} ${item.variant_name ? `(${item.variant_name})` : ''} <br><span style="color:var(--text-muted);">Куплено: ${item.quantity} шт</span></div>
<div style="display:flex; align-items:center; gap:5px;">
Вернуть: <input type="number" class="return-input" id="ret_${order.id}_${item.c_key}" value="0" min="0" max="${maxRet}" style="background:var(--surface); color:var(--text);">
</div>
</div>
`;
}
});
if(itemsHtml) {
content.innerHTML += `
<div class="return-order-item">
<div><b>№ ${order.id}</b> <span style="float:right; color:var(--text-muted); font-size:0.85rem;">${order.created_at}</span></div>
<div style="font-size:0.9rem; margin-top:5px;">Сумма: ${order.total_price} ${currency}</div>
${itemsHtml}
<button class="process-return-btn" onclick="processReturn('${order.id}')">Провести возврат</button>
</div>
`;
}
});
});
}
function closeReturnsModal() {
const modal = document.getElementById('returnsModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function processReturn(orderId) {
const inputs = document.querySelectorAll(`input[id^="ret_${orderId}_"]`);
let returns = {};
let hasReturn = false;
inputs.forEach(inp => {
let val = parseInt(inp.value);
if(val > 0) {
let cKey = inp.id.replace(`ret_${orderId}_`, '');
returns[cKey] = val;
hasReturn = true;
}
});
if(!hasReturn) {
alert('Укажите количество для возврата');
return;
}
fetch(`/${envId}/process_return/${orderId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ returns: returns })
})
.then(r => r.json())
.then(data => {
if(data.success) {
alert('Возврат успешно проведен!');
closeReturnsModal();
window.location.reload();
} else {
alert('Ошибка проведения возврата');
}
});
}
function openStaffHistoryModal() {
const modal = document.getElementById('staffHistoryModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
loadStaffHistory();
}
function closeStaffHistoryModal() {
const modal = document.getElementById('staffHistoryModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function loadStaffHistory() {
const dateStr = document.getElementById('historyDateFilter').value;
fetch(`/${envId}/api/staff_orders/${staffId}?date=${dateStr}`)
.then(r => r.json())
.then(data => {
const content = document.getElementById('staffHistoryContent');
content.innerHTML = '';
if(data.length === 0) {
content.innerHTML = '<div style="text-align:center; padding:20px;">За этот день нет накладных</div>';
return;
}
data.forEach(order => {
content.innerHTML += `
<div class="return-order-item" style="margin-bottom:10px;">
<div><b>№ ${order.id}</b> <span style="float:right; color:var(--text-muted); font-size:0.85rem;">${order.created_at.split(' ')[1]}</span></div>
<div style="font-size:0.9rem; margin-top:5px; margin-bottom:10px;">Сумма: ${order.total_price} ${currency}</div>
<a href="/${envId}/assembly/${order.id}" class="history-btn">Сборка накладной</a>
</div>
`;
});
});
}
function openGallery(productId) {
const product = products.find(p => p.product_id === productId);
if (!product || !product.photos || product.photos.length === 0) return;
currentGalleryPhotos = product.photos.map(p => `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p}`);
currentGalleryIndex = 0;
document.getElementById('galleryModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
updateGalleryView();
}
function closeGallery() {
document.getElementById('galleryModal').style.display = 'none';
document.body.style.overflow = '';
}
function updateGalleryView() {
document.getElementById('galleryImage').src = currentGalleryPhotos[currentGalleryIndex];
const dotsContainer = document.getElementById('galleryDots');
dotsContainer.innerHTML = '';
if(currentGalleryPhotos.length > 1) {
currentGalleryPhotos.forEach((_, index) => {
const dot = document.createElement('div');
dot.className = `gallery-dot ${index === currentGalleryIndex ? 'active' : ''}`;
dotsContainer.appendChild(dot);
});
document.querySelectorAll('.gallery-nav').forEach(el => el.style.display = 'flex');
} else {
document.querySelectorAll('.gallery-nav').forEach(el => el.style.display = 'none');
}
}
function nextPhoto(e) {
if(e) e.stopPropagation();
if(currentGalleryPhotos.length <= 1) return;
currentGalleryIndex = (currentGalleryIndex + 1) % currentGalleryPhotos.length;
updateGalleryView();
}
function prevPhoto(e) {
if(e) e.stopPropagation();
if(currentGalleryPhotos.length <= 1) return;
currentGalleryIndex = (currentGalleryIndex - 1 + currentGalleryPhotos.length) % currentGalleryPhotos.length;
updateGalleryView();
}
let touchstartX = 0;
let touchendX = 0;
const swipeArea = document.getElementById('gallerySwipeArea');
swipeArea.addEventListener('touchstart', e => { touchstartX = e.changedTouches[0].screenX; });
swipeArea.addEventListener('touchend', e => {
touchendX = e.changedTouches[0].screenX;
if (touchstartX - touchendX > 50) nextPhoto();
if (touchendX - touchstartX > 50) prevPhoto();
});
function startScanner(callback) {
document.getElementById('scannerModal').style.display = 'block';
const html5QrCode = new Html5Qrcode("reader");
const config = { fps: 10, qrbox: { width: 250, height: 250 } };
html5QrCode.start({ facingMode: "environment" }, config, (text) => {
html5QrCode.stop().then(() => {
document.getElementById('scannerModal').style.display = 'none';
callback(text);
});
}).catch(err => {
console.log(err);
alert('Не удалось запустить камеру');
document.getElementById('scannerModal').style.display = 'none';
});
window.currentScanner = html5QrCode;
}
function stopScanner() {
if(window.currentScanner) {
window.currentScanner.stop().catch(()=>{});
}
document.getElementById('scannerModal').style.display = 'none';
}
init();
</script>
</body>
</html>
'''
ORDER_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Накладная №{{ order.id }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f6f8; --surface: #ffffff; --text: #2d3436; --border: #dfe6e9; --wa: #25D366; --print: #1e272e; --primary: #1a1a1a; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { margin: 0; padding: max(20px, env(safe-area-inset-top)) 20px calc(100px + env(safe-area-inset-bottom)); background: var(--bg); display: flex; flex-direction: column; align-items: center; color: var(--text); }
.invoice-box { background: var(--surface); width: 100%; max-width: 900px; padding: 30px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); border-radius: 16px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; border-bottom: 2px solid var(--border); padding-bottom: 15px; flex-wrap: wrap; gap: 10px; }
.header h1 { margin: 0; font-size: 1.8rem; font-weight: 800; }
.info-row { display: flex; justify-content: space-between; margin-bottom: 20px; font-size: 1rem; flex-wrap: wrap; gap: 15px; }
.customer-details { display: flex; flex-direction: column; gap: 6px; }
.customer-details span { font-weight: 600; color: #1a1a1a; }
.table-responsive { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: 20px; border-radius: 8px; border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; min-width: 600px; }
th, td { border-bottom: 1px solid var(--border); padding: 12px; text-align: center; font-size: 0.95rem; }
th { background: #fafafa; font-weight: 700; color: #636e72; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 0.5px; }
.img-cell img { width: 45px; height: 45px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; }
.total-row { background: #fafafa; font-weight: 800; }
.total-row td { font-size: 1.1rem; border-bottom: none; }
.cart-item-controls { display: inline-flex; align-items: center; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; margin-bottom: 5px; }
.cart-item-controls button { border: none; background: #f8f9fa; width: 30px; height: 30px; font-size: 1rem; cursor: pointer; color: var(--primary); transition: background 0.2s; }
.cart-item-controls button:active { background: #e0e0e0; }
.cart-item-controls input { width: 40px; text-align: center; font-weight: 600; font-size: 0.95rem; border: none; background: transparent; color: var(--primary); outline: none; }
.cart-item-controls input[type="number"]::-webkit-inner-spin-button,
.cart-item-controls input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.cart-item-controls input[type="number"] { -moz-appearance: textfield; }
.screen-only { display: block; }
.print-only { display: none; }
.action-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); box-shadow: 0 -4px 20px rgba(0,0,0,0.08); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: flex; gap: 15px; z-index: 100; justify-content: center; border-top-left-radius: 20px; border-top-right-radius: 20px; }
.action-bar-inner { display: flex; gap: 15px; width: 100%; max-width: 900px; }
.btn { flex: 1; padding: 15px 10px; border-radius: 12px; border: none; font-size: 1rem; font-weight: 700; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; gap: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: transform 0.2s; white-space: nowrap; }
.btn:active { transform: scale(0.96); }
.btn-wa { background: var(--wa); box-shadow: 0 4px 15px rgba(37,211,102,0.3); }
.btn-print { background: var(--print); }
.btn-home { background: #0984e3; box-shadow: 0 4px 15px rgba(9,132,227,0.3); flex: 0 0 auto; padding: 15px 20px; }
#loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255,255,255,0.8); z-index: 999; justify-content: center; align-items: center; font-size: 2rem; color: var(--primary); }
@media print {
body { background: #fff; padding: 0; }
.invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; }
.table-responsive { border: none; overflow: visible; }
table { min-width: 100%; }
th, td { border: 1px solid #000; }
.action-bar, .screen-only { display: none !important; }
.print-only { display: block !important; }
}
@media (max-width: 600px) {
.header h1 { font-size: 1.4rem; }
.info-row { font-size: 0.9rem; }
.invoice-box { padding: 20px 15px; }
.btn { font-size: 0.9rem; flex-direction: column; padding: 10px; gap: 4px; }
.btn i { font-size: 1.2rem; }
}
</style>
</head>
<body>
<div id="loadingOverlay"><i class="fas fa-spinner fa-spin"></i></div>
<div class="invoice-box">
<div style="text-align: center; margin-bottom: 25px;">
<img src="{{ settings.logo_url }}" style="max-height: 80px; max-width: 100%; object-fit: contain;">
<div style="font-size: 1.2rem; font-weight: 700; margin-top: 10px;">{{ settings.organization_name }}</div>
{% if settings.invoice_contacts %}
<div style="margin-top: 10px; font-size: 0.95rem; font-weight: 600;">
{% for contact in settings.invoice_contacts.split(',') %}
{% if contact.strip() %}
<div style="margin-top: 2px;">{{ contact.strip() }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="header">
<h1>Накладная</h1>
<div style="text-align: right;">
<div style="font-size: 1.1rem; font-weight: bold;">№ {{ order.id }}</div>
<div style="color: #636e72; font-size: 0.9rem;">{{ order.created_at }}</div>
</div>
</div>
<div class="info-row">
<div class="customer-details">
{% if order.status != 'pos' and order.status != 'returned' %}
{% if order.customer_name %}<div>Покупатель: <span>{{ order.customer_name }}</span></div>{% endif %}
{% if order.customer_phone %}<div>Телефон: <span>{{ order.customer_phone }}</span></div>{% endif %}
{% if order.customer_city %}<div>Город: <span>{{ order.customer_city }}</span></div>{% endif %}
{% if order.customer_address %}<div>Адрес: <span>{{ order.customer_address }}</span></div>{% endif %}
{% if order.customer_zip %}<div>Индекс: <span>{{ order.customer_zip }}</span></div>{% endif %}
{% else %}
<div>Покупатель: <span>{{ order.customer_name if order.customer_name else 'Касса (POS)' }}</span></div>
{% if order.customer_whatsapp %}<div>WhatsApp: <span>{{ order.customer_whatsapp }}</span></div>{% endif %}
{% endif %}
{% if order.staff_name %}
<div style="margin-top:5px; color:#0984e3;">Сотрудник: <span>{{ order.staff_name }}</span></div>
{% endif %}
</div>
<div style="font-weight: 600;">Статус:
{% if order.status == 'pending' %}
<span style="color: #f39c12;">Ожидает подтверждения</span>
{% elif order.status == 'confirmed' %}
<span style="color: #00b894;">Подтвержден</span>
{% elif order.status == 'pos' %}
<span style="color: #00b894;">Выдан (Касса)</span>
{% elif order.status == 'returned' %}
<span style="color: #e17055;">Возврат</span>
{% else %}
<span>{{ order.status }}</span>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th style="width: 50px;">№</th>
<th style="text-align: left;">Наименование</th>
<th>Фото</th>
<th>Кол-во</th>
<th>Цена со скидкой</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
{% for item in order.cart %}
{% set ppb = item.pieces_per_box|default(1)|int %}
{% set boxes = item.quantity // ppb %}
{% set remainder = item.quantity % ppb %}
{% set assembled = order.assembled.get(item.c_key, 0) if order.assembled else 0 %}
{% if item.quantity > 0 %}
<tr>
<td>{{ loop.index }}</td>
<td style="text-align: left; font-weight: 500;">
{{ item.name }}
<div style="font-size: 0.75rem; color: #b2bec3; margin-top: 2px;">Категория: {{ item.category }}</div>
{% if item.variant_name %}
<div style="font-size: 0.85rem; color: #636e72;">Вариант: {{ item.variant_name }}</div>
{% endif %}
<div style="font-size: 0.8rem; color: {% if assembled == item.quantity %}#00b894{% else %}#0984e3{% endif %}; margin-top: 4px; font-weight:600;">Собрано: {{ assembled }} / {{ item.quantity }}</div>
{% if item.discount and item.discount > 0 %}
<div style="font-size: 0.8rem; color: #e17055; margin-top: 2px;">Скидка: -{{ item.discount }} {{ currency_code }} за ед.</div>
{% endif %}
</td>
<td class="img-cell"><img src="{{ item.photo_url }}" alt="img"></td>
<td style="text-align: center;">
<div class="screen-only">
<div style="display:flex; flex-direction:column; align-items:center; gap:4px;">
{% if order.status == 'pending' %}
<div style="display:flex; align-items:center; gap:8px;">
<div class="cart-item-controls">
<button onclick="updateItem('{{ item.c_key }}', -1)"><i class="fas fa-minus" style="font-size:0.7rem;"></i></button>
<input type="number" value="{{ item.quantity }}" onchange="manualUpdateOrder('{{ item.c_key }}', this.value)">
<button onclick="updateItem('{{ item.c_key }}', 1)"><i class="fas fa-plus" style="font-size:0.7rem;"></i></button>
</div>
<button onclick="updateItem('{{ item.c_key }}', 0, true)" style="color: #ff7675; background: none; border: none; font-size: 1.1rem; cursor: pointer; padding: 5px;"><i class="fas fa-trash-alt"></i></button>
</div>
{% endif %}
<div style="font-size: 0.85rem; color: #00b894; font-weight: 600;">
{% if settings.business_type != 'retail' and ppb > 1 and boxes > 0 %}
{{ boxes }} уп.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
{% else %}
{{ item.quantity }} шт.
{% endif %}
</div>
</div>
</div>
<div class="print-only" style="font-weight: bold;">
{% if settings.business_type != 'retail' and ppb > 1 and boxes > 0 %}
{{ boxes }} уп.{% if remainder > 0 %} {{ remainder }} шт.{% endif %}
{% else %}
{{ item.quantity }} шт.
{% endif %}
</div>
</td>
<td>
{{ item.calculated_price | round(2) }}
</td>
<td>{{ (item.calculated_price * item.quantity) | round(2) }}</td>
</tr>
{% endif %}
{% endfor %}
<tr class="total-row">
<td colspan="5" style="text-align: right; padding-right: 20px;">
{% if order.global_discount > 0 %}
<div style="color:#e17055; font-size:0.9rem; margin-bottom:5px;">Применена общая скидка: -{{ order.global_discount }} {{ currency_code }}</div>
{% endif %}
Итого:
</td>
<td>{{ order.total_price }} {{ currency_code }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="action-bar">
<div class="action-bar-inner">
<a href="/{{ env_id }}/catalog?mode={{ request.args.get('mode', '') }}&staff_id={{ request.args.get('staff_id', '') }}" class="btn btn-home"><i class="fas fa-home"></i></a>
<button class="btn btn-print" onclick="window.print()"><i class="fas fa-print"></i> Печать</button>
<button class="btn btn-wa" onclick="sendToWA()"><i class="fab fa-whatsapp" style="font-size: 1.2rem;"></i> WhatsApp</button>
</div>
</div>
<script>
const envId = '{{ env_id }}';
function sendToWA() {
let msg = `Здравствуйте! ${'{{ order.status }}' === 'pos' ? 'Ваша накладная' : 'Мой заказ'} №{{ order.id }}\nСсылка: ${window.location.href.split('?')[0]}`;
let targetPhone = '{{ order.staff_whatsapp if order.staff_whatsapp else settings.whatsapp_number }}';
{% if order.status == 'pos' and order.customer_whatsapp %}
targetPhone = '{{ order.customer_whatsapp }}';
{% endif %}
window.open(`https://api.whatsapp.com/send?phone=${encodeURIComponent(targetPhone)}&text=${encodeURIComponent(msg)}`, '_blank');
}
{% if order.status == 'pending' %}
function updateItem(cKey, change, isRemove = false) {
document.getElementById('loadingOverlay').style.display = 'flex';
fetch(`/${envId}/edit_order/{{ order.id }}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ c_key: cKey, change: change, remove: isRemove })
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert('Ошибка обновления');
document.getElementById('loadingOverlay').style.display = 'none';
}
})
.catch(() => {
alert('Произошла ошибка');
document.getElementById('loadingOverlay').style.display = 'none';
});
}
function manualUpdateOrder(cKey, val) {
let num = parseInt(val);
if (isNaN(num) || num < 0) return;
document.getElementById('loadingOverlay').style.display = 'flex';
fetch(`/${envId}/edit_order/{{ order.id }}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ c_key: cKey, exact_qty: num })
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert('Ошибка обновления');
document.getElementById('loadingOverlay').style.display = 'none';
}
})
.catch(() => {
alert('Произошла ошибка');
document.getElementById('loadingOverlay').style.display = 'none';
});
}
{% endif %}
</script>
</body>
</html>
'''
ASSEMBLY_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Сборка №{{ order.id }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f6f8; --surface: #ffffff; --text: #2d3436; --border: #dfe6e9; --primary: #0984e3; --success: #00b894; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { margin: 0; padding: max(20px, env(safe-area-inset-top)) 20px calc(100px + env(safe-area-inset-bottom)); background: var(--bg); color: var(--text); }
.container { max-width: 800px; margin: 0 auto; }
.header { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); margin-bottom: 20px; text-align: center; }
.header h1 { margin: 0 0 10px 0; font-size: 1.5rem; }
.copy-btn { background: var(--primary); color: #fff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; }
.item-card { background: var(--surface); padding: 15px; border-radius: 12px; margin-bottom: 15px; display: flex; align-items: center; gap: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); flex-wrap: wrap; }
.item-img { width: 60px; height: 60px; border-radius: 8px; object-fit: cover; border: 1px solid #eee; flex-shrink: 0; }
.item-info { flex: 1; min-width: 200px; }
.item-name { font-weight: 600; font-size: 1rem; }
.item-variant { font-size: 0.85rem; color: #636e72; margin-top: 2px; }
.item-target { font-size: 0.9rem; font-weight: bold; margin-top: 5px; }
.assembly-controls { display: flex; align-items: center; background: var(--bg); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
.assembly-controls button { border: none; background: #f8f9fa; width: 40px; height: 40px; font-size: 1.2rem; cursor: pointer; color: var(--primary); transition: background 0.2s; }
.assembly-controls button:active { background: #e0e0e0; }
.assembly-controls input { width: 50px; text-align: center; font-weight: 700; font-size: 1rem; border: none; background: transparent; color: var(--primary); outline: none; }
.assembly-controls input[type="number"]::-webkit-inner-spin-button,
.assembly-controls input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.status-badge { font-size: 0.8rem; padding: 4px 8px; border-radius: 12px; font-weight: 600; }
.status-done { background: #d4edda; color: #155724; }
.status-pending { background: #fff3cd; color: #856404; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<a href="/{{ env_id }}/assembly_list" style="display:inline-block; margin-bottom: 10px; color: var(--primary); text-decoration: none; font-weight: bold;"><i class="fas fa-arrow-left"></i> Назад</a>
<h1>Сборка накладной № {{ order.id }}</h1>
<div style="color: #636e72; margin-bottom: 15px;">{{ order.created_at }}</div>
<button class="copy-btn" onclick="copyLink()"><i class="fas fa-link"></i> Скопировать ссылку</button>
</div>
{% for item in order.cart %}
{% if item.quantity > 0 %}
{% set assembled = order.assembled.get(item.c_key, 0) if order.assembled else 0 %}
<div class="item-card" id="card_{{ item.c_key }}">
<img src="{{ item.photo_url }}" class="item-img">
<div class="item-info">
<div class="item-name">{{ item.name }}</div>
{% if item.variant_name %}
<div class="item-variant">Вариант: {{ item.variant_name }}</div>
{% endif %}
<div class="item-target">Нужно: {{ item.quantity }} шт.</div>
<div style="margin-top: 5px;">
<span id="badge_{{ item.c_key }}" class="status-badge {% if assembled >= item.quantity %}status-done{% else %}status-pending{% endif %}">
{% if assembled >= item.quantity %}Собрано{% else %}В процессе{% endif %}
</span>
</div>
</div>
<div class="assembly-controls">
<button onclick="changeQty('{{ item.c_key }}', -1, {{ item.quantity }})"><i class="fas fa-minus"></i></button>
<input type="number" id="qty_{{ item.c_key }}" value="{{ assembled }}" onchange="setQty('{{ item.c_key }}', this.value, {{ item.quantity }})">
<button onclick="changeQty('{{ item.c_key }}', 1, {{ item.quantity }})"><i class="fas fa-plus"></i></button>
</div>
</div>
{% endif %}
{% endfor %}
<div style="text-align: center; margin-top: 20px;">
<button class="copy-btn" style="background: var(--success); width: 100%; justify-content: center; font-size: 1.1rem; padding: 15px;" onclick="finishAssembly()">
<i class="fas fa-check-circle"></i> Закончить сборку (пересчитать сумму)
</button>
</div>
</div>
<script>
const envId = '{{ env_id }}';
const orderId = '{{ order.id }}';
function copyLink() {
let dummy = document.createElement('input');
document.body.appendChild(dummy);
dummy.value = window.location.href;
dummy.select();
document.execCommand('copy');
document.body.removeChild(dummy);
alert('Ссылка скопирована!');
}
function updateBackend(cKey, qty, maxQty) {
fetch(`/${envId}/api/assembly/${orderId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ c_key: cKey, qty: qty })
})
.then(r => r.json())
.then(data => {
if(data.success) {
const badge = document.getElementById(`badge_${cKey}`);
if(qty >= maxQty) {
badge.className = 'status-badge status-done';
badge.innerText = 'Собрано';
} else {
badge.className = 'status-badge status-pending';
badge.innerText = 'В процессе';
}
}
});
}
function changeQty(cKey, diff, maxQty) {
let input = document.getElementById(`qty_${cKey}`);
let val = parseInt(input.value) || 0;
val += diff;
if(val < 0) val = 0;
if(val > maxQty) val = maxQty;
input.value = val;
updateBackend(cKey, val, maxQty);
}
function setQty(cKey, val, maxQty) {
let num = parseInt(val) || 0;
if(num < 0) num = 0;
if(num > maxQty) num = maxQty;
document.getElementById(`qty_${cKey}`).value = num;
updateBackend(cKey, num, maxQty);
}
function finishAssembly() {
if(!confirm('Завершить сборку? Несобранные позиции будут удалены, сумма пересчитана.')) return;
fetch(`/${envId}/api/finish_assembly/${orderId}`, {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.href = `/${envId}/assembly_list`;
} else {
alert('Ошибка завершения сборки');
}
});
}
</script>
</body>
</html>
'''
HISTORY_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }}</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f6f9; --surface: #ffffff; --text: #2d3436; --border: #e0e6ed; --primary: #0984e3; --success: #00b894; --warning: #f39c12; --danger: #e17055; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg); color: var(--text); padding: 20px; margin: 0; }
.container { max-width: 1200px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: center; background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
.header h1 { margin: 0; font-size: 1.5rem; }
.btn { padding: 10px 15px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; background: var(--primary); }
.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); }
.filter-bar input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; outline: none; font-family: inherit; }
.search-input { flex: 1; min-width: 200px; }
.table-container { background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 800px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
th { font-weight: 600; color: #636e72; background: #fafafa; }
.badge { padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
.b-pending { background: #fff3cd; color: #856404; }
.b-confirmed { background: #d4edda; color: #155724; }
.b-pos { background: #d4edda; color: #155724; }
.b-returned { background: #f8d7da; color: #721c24; }
.action-btns { display: flex; gap: 5px; }
.action-btns a { padding: 6px 10px; border-radius: 6px; font-size: 0.85rem; color: #fff; text-decoration: none; font-weight: bold; }
.btn-view { background: #34495e; }
.btn-assemble { background: #e67e22; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-list"></i> {{ page_title }}</h1>
<a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> Назад в панель</a>
</div>
<div class="filter-bar">
<input type="text" id="searchBox" class="search-input" placeholder="Поиск по номеру, клиенту или сотруднику..." oninput="filterOrders()">
<span style="font-weight: 500;">Период:</span>
<input type="date" id="dateStart" onchange="filterOrders()">
<span>—</span>
<input type="date" id="dateEnd" onchange="filterOrders()">
<button class="btn" style="background:var(--success);" onclick="clearDates()">Сбросить даты</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Номер</th>
<th>Дата</th>
<th>Клиент / Сотрудник</th>
<th>Сумма ({{ currency_code }})</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="ordersTableBody">
</tbody>
</table>
</div>
</div>
<script>
const orders = {{ orders_json|safe }};
const envId = '{{ env_id }}';
const sysMode = '{{ sys_mode }}';
function renderTable(data) {
const tbody = document.getElementById('ordersTableBody');
tbody.innerHTML = '';
if(data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;">Нет данных</td></tr>';
return;
}
data.forEach(o => {
let badgeClass = 'b-pending';
let statusText = o.status;
if(o.status === 'confirmed') { badgeClass = 'b-confirmed'; statusText = 'Подтвержден'; }
if(o.status === 'pos') { badgeClass = 'b-pos'; statusText = 'Касса'; }
if(o.status === 'returned') { badgeClass = 'b-returned'; statusText = 'Возврат'; }
if(o.status === 'pending') { statusText = 'Ожидает'; }
let clientInfo = o.customer_name ? o.customer_name : 'Касса / Онлайн';
let staffInfo = o.staff_name ? `<br><span style="font-size:0.85rem; color:#0984e3;">${o.staff_name}</span>` : '';
tbody.innerHTML += `
<tr class="order-row">
<td><strong>${o.id}</strong></td>
<td>${o.created_at}</td>
<td>${clientInfo}${staffInfo}</td>
<td><strong>${o.total_price}</strong></td>
<td><span class="badge ${badgeClass}">${statusText}</span></td>
<td>
<div class="action-btns">
<a href="/${envId}/order/${o.id}" target="_blank" class="btn-view"><i class="fas fa-eye"></i> Накладная</a>
${['confirmed', 'pos'].includes(o.status) && sysMode !== 'light_external' ? `<a href="/${envId}/assembly/${o.id}" class="btn-assemble"><i class="fas fa-box-open"></i> Сборка</a>` : ''}
</div>
</td>
</tr>
`;
});
}
function filterOrders() {
const query = document.getElementById('searchBox').value.toLowerCase();
const dStart = document.getElementById('dateStart').value;
const dEnd = document.getElementById('dateEnd').value;
let filtered = orders;
if (query) {
filtered = filtered.filter(o =>
o.id.toLowerCase().includes(query) ||
(o.customer_name && o.customer_name.toLowerCase().includes(query)) ||
(o.staff_name && o.staff_name.toLowerCase().includes(query))
);
}
if (dStart) {
const sDate = new Date(dStart);
filtered = filtered.filter(o => new Date(o.created_at.split(' ')[0]) >= sDate);
}
if (dEnd) {
const eDate = new Date(dEnd);
filtered = filtered.filter(o => new Date(o.created_at.split(' ')[0]) <= eDate);
}
renderTable(filtered);
}
function clearDates() {
document.getElementById('dateStart').value = '';
document.getElementById('dateEnd').value = '';
filterOrders();
}
renderTable(orders);
</script>
</body>
</html>
'''
ADMIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Админ-панель</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://unpkg.com/html5-qrcode" type="text/javascript"></script>
<style>
:root { --primary: #2d3436; --bg: #f4f6f9; --surface: #ffffff; --border: #e0e6ed; --danger: #ff7675; --success: #00b894; --info: #0984e3; --warning: #f39c12; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: #2d3436; }
.container { max-width: 1000px; margin: 0 auto; }
.header-panel { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
.btn { padding: 12px 20px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; font-size: 0.95rem; transition: opacity 0.2s; }
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--info); }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); padding: 8px 15px; font-size: 0.85rem; }
.btn-warning { background: var(--warning); padding: 8px 15px; font-size: 0.85rem; }
.btn-dark { background: var(--primary); }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--primary); }
.sync-panel { display: flex; gap: 10px; margin-bottom: 25px; flex-wrap: wrap; }
.sync-panel form { flex: 1; min-width: 200px; }
.sync-panel button { width: 100%; }
.card { background: var(--surface); padding: 20px; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; }
.card h2 { margin-top: 0; margin-bottom: 15px; font-size: 1.2rem; }
input[type="text"], input[type="number"], input[type="url"], select, textarea { width: 100%; padding: 12px 15px; border: 1px solid var(--border); border-radius: 10px; font-size: 0.95rem; outline: none; transition: border-color 0.2s; background: #fafafa; }
input[type="text"]:focus, input[type="number"]:focus, input[type="url"]:focus, select:focus, textarea:focus { border-color: var(--info); background: #fff; }
textarea { resize: vertical; min-height: 80px; font-family: inherit; }
.add-cat-form { display: flex; gap: 10px; flex-wrap: wrap; }
.add-cat-form input { flex: 1; min-width: 200px; }
.add-cat-form button { white-space: nowrap; }
.search-bar-admin { position: relative; margin-bottom: 20px; }
.search-bar-admin i.fa-search { position: absolute; left: 15px; top: 50%; transform: translateY(-50%); color: #636e72; }
.search-bar-admin input { padding-left: 40px; background: var(--surface); border: none; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
.category-block { border: 1px solid var(--border); margin-bottom: 15px; border-radius: 12px; overflow: hidden; background: #fff; }
.category-header { background: #fafafa; padding: 15px 20px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.2s; }
.category-header:hover { background: #f0f0f0; }
.category-content { padding: 0; display: none; }
.category-content.active { display: block; }
.product-item { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 10px; }
.product-item:last-child { border-bottom: none; }
.product-info { display: flex; align-items: center; gap: 15px; min-width: 250px; flex: 1; }
.product-img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; background: #fafafa; }
.product-details { display: flex; flex-direction: column; }
.product-name { font-weight: 600; font-size: 0.95rem; }
.product-desc { font-size: 0.85rem; color: #636e72; margin-top: 2px; }
.product-meta { font-size: 0.8rem; color: #b2bec3; margin-top: 4px; }
.product-actions { display: flex; gap: 5px; }
.add-product-wrapper { display: none; }
.add-product-wrapper.active { display: block; }
.toggle-add-product { width: 100%; text-align: center; background: #fafafa; padding: 15px; cursor: pointer; color: var(--success); font-weight: 600; transition: background 0.2s; border-bottom: 1px solid var(--border); }
.toggle-add-product:hover { background: #f0f0f0; }
.add-product-form { background: #fdfdfd; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
.form-group { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 150px; }
.form-group label { font-size: 0.85rem; font-weight: 600; color: #636e72; }
.form-row { display: flex; gap: 15px; flex-wrap: wrap; }
.file-input-wrapper { position: relative; width: 100%; }
input[type="file"] { width: 100%; padding: 10px; border: 1px dashed #ccc; border-radius: 10px; background: #fafafa; font-size: 0.9rem; }
.settings-block { display: flex; flex-direction: column; gap: 15px; }
.settings-row { display: flex; align-items: center; gap: 15px; flex-wrap: wrap; }
.settings-row label { flex: 1; min-width: 120px; font-weight: 500; }
.settings-row input[type="text"], .settings-row input[type="url"], .settings-row input[type="file"], .settings-row select { flex: 3; }
.social-settings { display: flex; flex-direction: column; gap: 10px; padding: 15px; background: #fafafa; border-radius: 10px; border: 1px solid var(--border); }
.social-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.social-item label { display: flex; align-items: center; gap: 5px; width: 150px; cursor: pointer; }
.variants-container { background: #f4f6f9; padding: 15px; border-radius: 10px; border: 1px dashed var(--border); display: flex; flex-direction: column; gap: 10px; }
.variant-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; background: #fff; padding: 10px; border-radius: 8px; border: 1px solid var(--border); }
.remove-variant-btn { color: var(--danger); background: none; border: none; font-size: 1.2rem; cursor: pointer; padding: 12px 5px; flex: 0 0 auto; }
.order-item { background: #fff; padding: 15px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 10px; }
.order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 10px; }
.order-actions { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.staff-item { display: flex; flex-direction: column; gap: 10px; background: #fff; padding: 15px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 10px; }
.badge { background: var(--danger); color: white; padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; margin-left: 5px; }
@media (max-width: 600px) {
.header-panel { flex-direction: column; align-items: stretch; text-align: center; }
.product-item { flex-direction: column; align-items: stretch; }
.product-info { width: 100%; }
.product-actions { align-self: flex-end; }
.form-row { flex-direction: column; gap: 10px; }
.variant-row { flex-direction: column; align-items: stretch; }
.remove-variant-btn { width: 100%; text-align: right; padding: 5px; }
}
</style>
</head>
<body>
<div class="container">
{% set sys_mode = settings.system_mode|default('both') %}
<div class="header-panel">
<h1><i class="fas fa-cog"></i> Админ-панель ({{ env_id }})</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
{% if sys_mode != 'light_external' %}
<a href="/{{ env_id }}/reports" class="btn btn-primary" style="background:#8e44ad;"><i class="fas fa-chart-line"></i> Отчеты</a>
{% endif %}
{% if sys_mode not in ['external', 'light_external'] %}
<a href="/{{ env_id }}/inventory" class="btn btn-primary" style="background:#27ae60;">
<i class="fas fa-boxes"></i> Остатки
{% if low_stock_count > 0 %}<span class="badge" style="background:#e17055;">{{ low_stock_count }}</span>{% endif %}
</a>
{% endif %}
{% if sys_mode == 'both' %}
<a href="/{{ env_id }}/history" class="btn btn-primary" style="background:#34495e;"><i class="fas fa-history"></i> История накладных и заказов</a>
{% elif sys_mode in ['external', 'light_external'] %}
<a href="/{{ env_id }}/history" class="btn btn-primary" style="background:#34495e;"><i class="fas fa-history"></i> История заказов</a>
{% else %}
<a href="/{{ env_id }}/history" class="btn btn-primary" style="background:#34495e;"><i class="fas fa-history"></i> История накладных</a>
{% endif %}
{% if sys_mode != 'light_external' %}
<a href="/{{ env_id }}/assembly_list" class="btn btn-primary" style="background:#e67e22;">
<i class="fas fa-box-open"></i> Сборка
{% if unassembled_count > 0 %}<span class="badge">{{ unassembled_count }}</span>{% endif %}
</a>
{% endif %}
{% if sys_mode != 'internal' %}
<a href="/{{ env_id }}/catalog" class="btn btn-primary"><i class="fas fa-store"></i> В каталог</a>
{% endif %}
{% if settings.admin_password_enabled %}
<a href="/{{ env_id }}/logout" class="btn btn-danger" style="padding: 8px 15px;"><i class="fas fa-sign-out-alt"></i> Выход</a>
{% endif %}
</div>
</div>
<div class="sync-panel">
<form method="POST" action="/{{ env_id }}/force_upload" onsubmit="showLoading(this)">
<button type="submit" class="btn btn-success"><i class="fas fa-cloud-upload-alt"></i> Сохранить на сервер</button>
</form>
<form method="POST" action="/{{ env_id }}/force_download" onsubmit="showLoading(this)">
<button type="submit" class="btn btn-info" style="background:#0984e3;"><i class="fas fa-cloud-download-alt"></i> Скачать с сервера</button>
</form>
</div>
<div class="category-block" style="margin-bottom: 10px;">
<div class="category-header" onclick="toggleCategory('orders-panel')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-orders-panel" style="color: #636e72;"></i>
<span style="font-size: 1.1rem; color: #d35400;"><i class="fas fa-shopping-basket" style="margin-right:5px;"></i> Онлайн заказы {% if pending_orders|length > 0 %}<span class="badge">{{ pending_orders|length }}</span>{% endif %}</span>
</div>
</div>
<div class="category-content" id="orders-panel" style="padding: 20px; background: #fafafa;">
{% if pending_orders %}
{% for order in pending_orders %}
<div class="order-item">
<div class="order-header">
<span>Заказ №{{ order.id }}</span>
<span style="color: #636e72; font-weight: normal;">{{ order.created_at }}</span>
</div>
<div style="font-size: 0.9rem; margin-bottom: 5px;">
<div><b>Клиент:</b> {{ order.customer_name }} ({{ order.customer_phone }})</div>
{% if order.staff_name %}<div><b>Сотрудник:</b> {{ order.staff_name }}</div>{% endif %}
<div><b>Сумма:</b> {{ order.total_price }} {{ currency_code }}</div>
</div>
<div class="order-actions">
<a href="/{{ env_id }}/order/{{ order.id }}" class="btn btn-outline" target="_blank" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-eye"></i> Накладная</a>
<button type="button" class="btn btn-warning" style="padding: 5px 10px; font-size: 0.85rem;" onclick="document.getElementById('discountModal-{{ order.id }}').style.display='flex'"><i class="fas fa-percent"></i> Сделать скидку</button>
<form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0;">
<input type="hidden" name="action" value="confirm">
<button type="submit" class="btn btn-success" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-check"></i> Подтвердить</button>
</form>
<form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0;" onsubmit="return confirm('Удалить заказ?');">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 0.85rem;"><i class="fas fa-trash"></i> Удалить</button>
</form>
</div>
</div>
<div class="modal-overlay" id="discountModal-{{ order.id }}" style="z-index:9999; display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
<div style="background:#fff; padding:20px; border-radius:12px; width:100%; max-width:500px; max-height: 90vh; overflow-y: auto;">
<h3 style="margin-top:0;">Скидка для заказа №{{ order.id }}</h3>
<form method="POST" action="/{{ env_id }}/apply_discount/{{ order.id }}">
<div style="margin-bottom: 15px;">
<label style="font-weight: bold; display:block; margin-bottom:5px;">Общая скидка на чек (сумма, {{ currency_code }}):</label>
<input type="number" name="global_discount" value="{{ order.global_discount }}" min="0" step="0.01">
</div>
<hr style="border: 0; border-top: 1px solid #ccc; margin: 15px 0;">
<h4 style="margin-top:0;">Скидка на позиции (сумма за ед., {{ currency_code }}):</h4>
{% for item in order.cart %}
<div style="margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;">
<div style="font-size:0.9rem; flex:1;">{{ item.name }} ({{ item.quantity }} шт.) <br> <span style="color:#636e72;">Цена за ед: {{ item.price }} {{ currency_code }}</span></div>
<input type="number" name="item_discount_{{ item.c_key }}" value="{{ item.discount }}" min="0" step="0.01" style="width: 100px;">
</div>
{% endfor %}
<div style="display:flex; gap:10px; margin-top:20px;">
<button type="submit" class="btn btn-success" style="flex:1;">Сохранить скидку</button>
<button type="button" class="btn btn-danger" style="flex:1;" onclick="document.getElementById('discountModal-{{ order.id }}').style.display='none'">Отмена</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% else %}
<p style="text-align: center; color: #636e72;">Нет новых онлайн заказов</p>
{% endif %}
</div>
</div>
{% if sys_mode != 'internal' %}
<div class="category-block" style="margin-bottom: 10px;">
<div class="category-header" onclick="toggleCategory('catalog-users-panel')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-catalog-users-panel" style="color: #636e72;"></i>
<span style="font-size: 1.1rem; color: #8e44ad;"><i class="fas fa-user-lock" style="margin-right:5px;"></i> Пользователи (Закрытый каталог)</span>
</div>
</div>
<div class="category-content" id="catalog-users-panel" style="padding: 20px; background: #fafafa;">
<form method="POST" style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom: 20px;">
<input type="hidden" name="action" value="add_catalog_user">
<input type="text" name="user_name" placeholder="Имя пользователя" required>
<button type="submit" class="btn btn-info" style="background:#8e44ad;"><i class="fas fa-plus"></i> Добавить</button>
</form>
<div id="catalogUsersListContainer">
{% for u in catalog_users %}
<div class="staff-item">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap: wrap; gap: 10px;">
<span style="font-weight:bold;">{{ u.name }}</span>
<span style="background: #e8daef; padding: 5px 10px; border-radius: 8px; font-family: monospace; font-size: 1.1rem; color: #8e44ad;">Пароль: {{ u.password }}</span>
<form method="POST" style="margin:0;" onsubmit="return confirm('Удалить пользователя?');">
<input type="hidden" name="action" value="delete_catalog_user">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button type="submit" class="btn btn-danger" style="padding: 4px 8px;"><i class="fas fa-times"></i></button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if sys_mode != 'light_external' %}
<div class="category-block" style="margin-bottom: 10px;">
<div class="category-header" onclick="toggleCategory('staff-panel')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-staff-panel" style="color: #636e72;"></i>
<span style="font-size: 1.1rem; color: #2980b9;"><i class="fas fa-users" style="margin-right:5px;"></i> Персонал</span>
</div>
</div>
<div class="category-content" id="staff-panel" style="padding: 20px; background: #fafafa;">
<form method="POST" style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom: 20px;">
<input type="hidden" name="action" value="add_staff">
<input type="text" name="staff_name" placeholder="Имя сотрудника" required>
<input type="text" name="staff_whatsapp" placeholder="WhatsApp (напр. +77001234567)" required>
<button type="submit" class="btn btn-info"><i class="fas fa-plus"></i> Добавить</button>
</form>
<div class="search-bar-admin" style="margin-bottom:15px;">
<i class="fas fa-search"></i>
<input type="text" id="staffSearch" placeholder="Поиск сотрудника..." oninput="filterStaff()">
</div>
<div id="staffListContainer">
{% for s in staff %}
<div class="staff-item">
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="font-weight:bold;" class="staff-name-text">{{ s.name }} ({{ s.whatsapp }})</span>
<form method="POST" style="margin:0;" onsubmit="return confirm('Удалить сотрудника?');">
<input type="hidden" name="action" value="delete_staff">
<input type="hidden" name="staff_id" value="{{ s.id }}">
<button type="submit" class="btn btn-danger" style="padding: 4px 8px;"><i class="fas fa-times"></i></button>
</form>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
{% if sys_mode != 'internal' %}
<button class="btn btn-outline" style="font-size:0.8rem;" onclick="copyToClipboard('{{ request.host_url[:-1] }}{{ url_for('catalog', env_id=env_id) }}?staff_id={{ s.id }}&mode=online')"><i class="fas fa-link"></i> Ссылка (Онлайн)</button>
{% endif %}
{% if sys_mode != 'external' %}
<button class="btn btn-outline" style="font-size:0.8rem;" onclick="copyToClipboard('{{ request.host_url[:-1] }}{{ url_for('catalog', env_id=env_id) }}?staff_id={{ s.id }}&mode=pos')"><i class="fas fa-cash-register"></i> Ссылка (Касса)</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="category-block" style="margin-bottom: 20px;">
<div class="category-header" onclick="toggleCategory('settings-panel')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-settings-panel" style="color: #636e72;"></i>
<span style="font-size: 1.1rem;"><i class="fas fa-sliders-h" style="color:var(--info); margin-right:5px;"></i> Настройка магазина</span>
</div>
</div>
<div class="category-content" id="settings-panel" style="padding: 20px;">
<form method="POST" enctype="multipart/form-data" class="settings-block" onsubmit="showLoading(this)">
<input type="hidden" name="action" value="update_settings">
<div class="settings-row">
<label>Название магазина:</label>
<input type="text" name="organization_name" value="{{ settings.organization_name }}" required>
</div>
<div class="settings-row">
<label>Тема каталога:</label>
<select name="theme">
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Светлая (по умолчанию)</option>
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Темная</option>
<option value="magma" {% if settings.theme == 'magma' %}selected{% endif %}>Магма</option>
<option value="ocean" {% if settings.theme == 'ocean' %}selected{% endif %}>Океан</option>
<option value="forest" {% if settings.theme == 'forest' %}selected{% endif %}>Лес</option>
<option value="cyberpunk" {% if settings.theme == 'cyberpunk' %}selected{% endif %}>Киберпанк</option>
</select>
</div>
<div class="settings-row">
<label>Тип бизнеса:</label>
<select name="business_type">
<option value="retail" {% if settings.business_type == 'retail' %}selected{% endif %}>Розница</option>
<option value="mixed" {% if settings.business_type == 'mixed' %}selected{% endif %}>Оптово-розничный</option>
<option value="wholesale" {% if settings.business_type == 'wholesale' %}selected{% endif %}>Оптовый</option>
</select>
</div>
<div class="settings-row">
<label>Валюта:</label>
<select name="currency">
<option value="T" {% if settings.currency == 'T' %}selected{% endif %}>Тенге (T)</option>
<option value="С" {% if settings.currency == 'С' %}selected{% endif %}>Кыргызский сом (С)</option>
<option value="Сум" {% if settings.currency == 'Сум' %}selected{% endif %}>Узбекский сум (Сум)</option>
<option value="$" {% if settings.currency == '$' %}selected{% endif %}>Доллар США ($)</option>
</select>
</div>
<div class="settings-row">
<label>WhatsApp магазина:</label>
<input type="text" name="whatsapp_number" value="{{ settings.whatsapp_number }}" placeholder="+77001234567" required>
</div>
<div class="settings-row">
<label>Контактные номера на накладной (через запятую):</label>
<input type="text" name="invoice_contacts" value="{{ settings.invoice_contacts }}" placeholder="+77001234567, +77007654321">
</div>
<div class="settings-row">
<label>Логотип (загрузить):</label>
<input type="file" name="logo" accept="image/*">
</div>
<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>
<div class="social-settings">
<div style="font-weight: 600; margin-bottom: 5px;">Поля для клиента (Оформление заказа):</div>
<div style="display:flex; gap:15px; flex-wrap:wrap; margin-bottom: 15px;">
<label><input type="checkbox" name="cf_name" {% if settings.customer_fields.name %}checked{% endif %}> Имя</label>
<label><input type="checkbox" name="cf_phone" {% if settings.customer_fields.phone %}checked{% endif %}> Телефон</label>
<label><input type="checkbox" name="cf_city" {% if settings.customer_fields.city %}checked{% endif %}> Город</label>
<label><input type="checkbox" name="cf_address" {% if settings.customer_fields.address %}checked{% endif %}> Адрес</label>
<label><input type="checkbox" name="cf_zip" {% if settings.customer_fields.zip %}checked{% endif %}> Индекс</label>
</div>
{% if sys_mode not in ['external', 'light_external'] %}
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px;">Учет товара:</div>
<label><input type="checkbox" name="track_inventory" {% if settings.track_inventory %}checked{% endif %}> Включить остатки на складе</label>
<label><input type="checkbox" name="use_barcodes" {% if settings.use_barcodes %}checked{% endif %}> Использовать штрих-коды</label>
{% if sys_mode == 'both' %}
<label><input type="checkbox" name="hide_stock_online" {% if settings.hide_stock_online %}checked{% endif %}> Клиент не видит остатков (в каталоге для онлайн заказов)</label>
{% endif %}
{% endif %}
{% if sys_mode != 'internal' %}
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; margin-top: 10px;">Доступ к каталогу:</div>
<label><input type="checkbox" name="closed_catalog_enabled" {% if settings.closed_catalog_enabled %}checked{% endif %}> Включить закрытый каталог (доступ только по паролю из раздела пользователей)</label>
{% endif %}
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; margin-top: 10px;">Безопасность (Админ-панель):</div>
<div class="social-item" style="margin-bottom: 10px;">
<label style="width: auto; margin-right: 15px;"><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %}> Пароль на вход</label>
<input type="text" name="admin_password" value="{{ settings.admin_password }}" placeholder="Установите пароль" style="flex: 1; min-width: 150px;">
</div>
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; margin-top: 10px;">Социальные сети (каждая ссылка с новой строки или через пробел):</div>
<div class="social-item" style="align-items: flex-start;">
<label style="margin-top: 10px;"><input type="checkbox" name="wa_enabled" {% if settings.socials.wa.enabled %}checked{% endif %}> <i class="fab fa-whatsapp" style="color:#25D366; font-size:1.2rem;"></i> WhatsApp</label>
<textarea name="wa_url" placeholder="https://wa.me/..." style="flex:1; min-height:60px;">{{ settings.socials.wa.url }}</textarea>
</div>
<div class="social-item" style="align-items: flex-start;">
<label style="margin-top: 10px;"><input type="checkbox" name="ig_enabled" {% if settings.socials.ig.enabled %}checked{% endif %}> <i class="fab fa-instagram" style="color:#E1306C; font-size:1.2rem;"></i> Instagram</label>
<textarea name="ig_url" placeholder="https://instagram.com/..." style="flex:1; min-height:60px;">{{ settings.socials.ig.url }}</textarea>
</div>
<div class="social-item" style="align-items: flex-start;">
<label style="margin-top: 10px;"><input type="checkbox" name="tg_enabled" {% if settings.socials.tg.enabled %}checked{% endif %}> <i class="fab fa-telegram" style="color:#0088cc; font-size:1.2rem;"></i> Telegram</label>
<textarea name="tg_url" placeholder="https://t.me/..." style="flex:1; min-height:60px;">{{ settings.socials.tg.url }}</textarea>
</div>
</div>
<button type="submit" class="btn btn-success" style="align-self: flex-end;"><i class="fas fa-save"></i> Сохранить настройки</button>
</form>
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; margin-top: 20px; color: var(--danger);">Сброс данных:</div>
<form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить ВСЮ историю продаж и заказов? Это действие необратимо!');">
<input type="hidden" name="action" value="clear_history">
<button type="submit" class="btn btn-danger"><i class="fas fa-eraser"></i> Сбросить историю продаж</button>
</form>
</div>
</div>
<div class="card">
<h2>Управление категориями</h2>
<form method="POST" enctype="multipart/form-data" class="add-cat-form">
<input type="hidden" name="action" value="add_category">
<input type="text" name="category_name" placeholder="Название новой категории" required autocomplete="off">
<input type="file" name="category_photo" accept="image/*" title="Фото категории">
<button type="submit" class="btn btn-dark"><i class="fas fa-plus"></i> Добавить</button>
</form>
</div>
<div class="search-bar-admin">
<i class="fas fa-search"></i>
<input type="text" id="adminSearch" placeholder="Поиск по категориям и товарам (ID)..." oninput="filterAdmin()">
</div>
{% for category in categories %}
<div class="category-block">
<div class="category-header" onclick="toggleCategory('cat-{{ loop.index }}')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-cat-{{ loop.index }}" style="color: #636e72;"></i>
<span class="cat-title-text"><i class="fas fa-folder-open" style="color:var(--info); margin-right:5px;"></i> {{ category }}</span>
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<form method="POST" style="margin:0;" onclick="event.stopPropagation();">
<input type="hidden" name="action" value="move_category">
<input type="hidden" name="direction" value="up">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="btn btn-outline" style="padding: 2px 6px;"><i class="fas fa-arrow-up"></i></button>
</form>
<form method="POST" style="margin:0;" onclick="event.stopPropagation();">
<input type="hidden" name="action" value="move_category">
<input type="hidden" name="direction" value="down">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="btn btn-outline" style="padding: 2px 6px;"><i class="fas fa-arrow-down"></i></button>
</form>
<form method="POST" style="margin:0;" onclick="event.stopPropagation();" onsubmit="return confirm('Удалить категорию и все ее товары?');">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category }}">
<button type="submit" class="btn btn-danger" style="margin-left:10px;"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div class="category-content" id="cat-{{ loop.index }}">
<form method="POST" enctype="multipart/form-data" style="margin-bottom: 15px; padding: 15px; background: #fff; border-bottom: 1px solid var(--border); display:flex; gap:10px; flex-wrap: wrap;">
<input type="hidden" name="action" value="edit_category">
<input type="hidden" name="old_category_name" value="{{ category }}">
<div style="display:flex; flex-direction:column; gap:10px; flex:1; min-width: 200px;">
<input type="text" name="new_category_name" value="{{ category }}" required>
<div>
<label style="font-size:0.8rem;">Фото категории (опционально, 512x512):</label>
<input type="file" name="category_photo" accept="image/*">
</div>
</div>
{% if category_photos.get(category) %}
<div style="margin-right: 10px;">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/category_photos/{{ category_photos.get(category) }}" style="width:60px;height:60px;object-fit:cover;border-radius:8px;border:1px solid var(--border);">
</div>
{% endif %}
<button type="submit" class="btn btn-warning" style="align-self: flex-start;"><i class="fas fa-edit"></i> Сохранить категорию</button>
</form>
<div class="toggle-add-product" onclick="toggleAddProduct('add-prod-{{ loop.index }}')">
<i class="fas fa-plus"></i> Добавить товар
</div>
<div class="add-product-wrapper" id="add-prod-{{ loop.index }}">
<form class="add-product-form" method="POST" enctype="multipart/form-data" onsubmit="showLoading(this)">
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="category" value="{{ category }}">
<div style="font-weight: 600; font-size: 0.9rem; color: #636e72;">Новый товар в категории "{{ category }}"</div>
<div class="form-row">
<div class="form-group" style="flex:2;">
<label>Название товара</label>
<input type="text" name="name" placeholder="Введите название" required autocomplete="off">
</div>
<div class="form-group" style="flex:1;">
<label>Наличие</label>
<select name="is_available">
<option value="1" selected>В наличии</option>
<option value="0">Нет в наличии</option>
</select>
</div>
{% if settings.use_barcodes and sys_mode not in ['external', 'light_external'] %}
<div class="form-group main-barcode-container">
<label>Штрих-код</label>
<div style="display:flex; gap:5px;">
<input type="text" name="barcode" placeholder="Штрих-код" class="main-barcode-input">
<button type="button" class="btn btn-outline" style="padding: 12px;" onclick="startScanner(val => this.previousElementSibling.value = val)"><i class="fas fa-barcode"></i></button>
</div>
</div>
{% endif %}
<div class="form-group main-price-container">
<label>Цена за ед.</label>
<input type="number" name="price" placeholder="Цена" step="0.01" class="main-price-input" required>
</div>
{% if settings.business_type != 'retail' %}
<div class="form-group">
<label>В уп/кор (шт)</label>
<input type="number" name="pieces_per_box" placeholder="Шт в уп." min="1" required>
</div>
{% endif %}
{% if settings.business_type == 'mixed' %}
<div class="form-group main-box-price-container">
<label>Цена за упаковку</label>
<input type="number" name="box_price" placeholder="Опционально" step="0.01" class="main-box-price-input">
</div>
{% endif %}
{% if settings.business_type == 'wholesale' %}
<div class="form-group">
<label>Мин. заказ (шт)</label>
<input type="number" name="min_order" placeholder="Мин. кол-во" min="1" required>
</div>
{% endif %}
{% if settings.track_inventory and sys_mode not in ['external', 'light_external'] %}
<div class="form-group main-stock-container">
<label>Остаток на складе</label>
<input type="number" name="stock" placeholder="Остаток" class="main-stock-input">
</div>
{% endif %}
</div>
{% if settings.business_type == 'wholesale' %}
<div class="form-group main-tiers-container">
<label>Оптовые цены (от кол-ва)</label>
<div id="main-tiers-list-add-{{ loop.index }}"></div>
<button type="button" class="btn btn-outline" style="padding: 5px; font-size:0.8rem; margin-top:5px;" onclick="addTierRow('main-tiers-list-add-{{ loop.index }}', 'main')"><i class="fas fa-plus"></i> Добавить оптовую цену</button>
</div>
{% endif %}
<div class="variants-container" id="variants-container-add-{{ loop.index }}">
<div style="display:flex; justify-content:space-between; align-items:center;">
<label style="font-weight:600; font-size:0.9rem;"><i class="fas fa-tags"></i> Варианты товара</label>
<label style="font-size:0.85rem;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'add-prod-{{ loop.index }}')"> Разные цены</label>
</div>
<div id="variants-list-add-{{ loop.index }}"></div>
<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>
</div>
<div class="form-group" style="width: 100%;">
<label>Описание товара</label>
<textarea name="description" placeholder="Описание товара (необязательно)"></textarea>
</div>
<div class="file-input-wrapper">
<label style="font-size: 0.85rem; font-weight: 600; color: #636e72; display:block; margin-bottom:5px;">Фотографии товара (до 10 шт)</label>
<input type="file" name="photos" accept="image/*" multiple max="10">
</div>
<button type="submit" class="btn btn-success" style="width: 100%; justify-content: center;"><i class="fas fa-check"></i> Сохранить товар</button>
</form>
</div>
{% for product in products %}
{% if product.category == category %}
<div class="product-item" data-pid="{{ product.product_id }}">
<div class="product-info">
{% if product.photos and product.photos|length > 0 %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product.photos[0] }}" class="product-img" style="{% if product.is_available == False %}filter: grayscale(100%);{% endif %}">
{% else %}
<div class="product-img" style="display:flex;align-items:center;justify-content:center;color:#ccc;"><i class="fas fa-image"></i></div>
{% endif %}
<div class="product-details">
<span class="product-name" style="{% if product.is_available == False %}color:#b2bec3; text-decoration:line-through;{% endif %}">{{ product.name }}</span>
<div style="font-size:0.8rem; color:#b2bec3;">ID: {{ product.product_id }}</div>
{% if product.description %}
<span class="product-desc">{{ product.description[:50] }}{{ '...' if product.description|length > 50 else '' }}</span>
{% endif %}
<span class="product-meta">
{% if product.is_available == False %}
<strong style="color:var(--danger);">Нет в наличии</strong> •
{% endif %}
{% if product.has_variant_prices %}
Цена по вариантам
{% else %}
{{ product.price }} {{ currency_code }}
{% if settings.business_type == 'mixed' and product.box_price %} (Уп: {{ product.box_price }}){% endif %}
{% endif %}
{% if settings.business_type != 'retail' %}
• В уп: {{ product.pieces_per_box|default(1) }} шт
{% endif %}
{% if settings.business_type == 'wholesale' and product.min_order %}
• Мин: {{ product.min_order }} шт
{% endif %}
{% if settings.track_inventory and sys_mode not in ['external', 'light_external'] %}
{% if not product.variants %}
• Остаток: {{ product.stock if product.stock != "" else "0" }}
{% else %}
• Остаток по вариантам
{% endif %}
{% endif %}
</span>
</div>
</div>
<div class="product-actions" style="align-items: center;">
<form method="POST" style="margin:0;">
<input type="hidden" name="action" value="move_product">
<input type="hidden" name="direction" value="up">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<button type="submit" class="btn btn-outline" style="padding: 5px;"><i class="fas fa-arrow-up"></i></button>
</form>
<form method="POST" style="margin:0;">
<input type="hidden" name="action" value="move_product">
<input type="hidden" name="direction" value="down">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<button type="submit" class="btn btn-outline" style="padding: 5px; margin-right:10px;"><i class="fas fa-arrow-down"></i></button>
</form>
<button class="btn btn-warning" onclick="toggleEditProduct('edit-prod-{{ product.product_id }}')"><i class="fas fa-edit"></i></button>
<form method="POST" style="margin:0;" onsubmit="return confirm('Удалить товар?');">
<input type="hidden" name="action" value="delete_product">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<button type="submit" class="btn btn-danger"><i class="fas fa-times"></i></button>
</form>
</div>
<div class="add-product-wrapper" id="edit-prod-{{ product.product_id }}" style="width: 100%; margin-top: 15px; border-top: 1px dashed var(--border); padding-top: 15px;">
<form class="add-product-form" method="POST" enctype="multipart/form-data" onsubmit="showLoading(this)" style="padding: 0;">
<input type="hidden" name="action" value="edit_product">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<input type="hidden" name="category" value="{{ category }}">
<div style="font-weight: 600; font-size: 0.9rem; color: #636e72;">Редактирование товара</div>
<div class="form-row">
<div class="form-group" style="flex:2;">
<label>Название товара</label>
<input type="text" name="name" value="{{ product.name }}" required autocomplete="off">
</div>
<div class="form-group" style="flex:1;">
<label>Наличие</label>
<select name="is_available">
<option value="1" {% if product.is_available != False %}selected{% endif %}>В наличии</option>
<option value="0" {% if product.is_available == False %}selected{% endif %}>Нет в наличии</option>
</select>
</div>
{% if settings.use_barcodes and sys_mode not in ['external', 'light_external'] %}
<div class="form-group main-barcode-container" {% if product.variants %}style="display:none;"{% endif %}>
<label>Штрих-код</label>
<div style="display:flex; gap:5px;">
<input type="text" name="barcode" value="{{ product.barcode }}" class="main-barcode-input">
<button type="button" class="btn btn-outline" style="padding: 12px;" onclick="startScanner(val => this.previousElementSibling.value = val)"><i class="fas fa-barcode"></i></button>
</div>
</div>
{% endif %}
<div class="form-group main-price-container" {% if product.has_variant_prices %}style="display:none;"{% endif %}>
<label>Цена за ед.</label>
<input type="number" name="price" value="{{ product.price }}" step="0.01" class="main-price-input" {% if not product.has_variant_prices %}required{% endif %}>
</div>
{% if settings.business_type != 'retail' %}
<div class="form-group">
<label>В уп/кор (шт)</label>
<input type="number" name="pieces_per_box" value="{{ product.pieces_per_box }}" min="1" required>
</div>
{% endif %}
{% if settings.business_type == 'mixed' %}
<div class="form-group main-box-price-container" {% if product.has_variant_prices %}style="display:none;"{% endif %}>
<label>Цена за упаковку</label>
<input type="number" name="box_price" value="{{ product.box_price }}" step="0.01" class="main-box-price-input">
</div>
{% endif %}
{% if settings.business_type == 'wholesale' %}
<div class="form-group">
<label>Мин. заказ (шт)</label>
<input type="number" name="min_order" value="{{ product.min_order }}" min="1" required>
</div>
{% endif %}
{% if settings.track_inventory and sys_mode not in ['external', 'light_external'] %}
<div class="form-group main-stock-container" {% if product.variants %}style="display:none;"{% endif %}>
<label>Остаток на складе</label>
<input type="number" name="stock" value="{{ product.stock }}" class="main-stock-input">
</div>
{% endif %}
</div>
{% if settings.business_type == 'wholesale' %}
<div class="form-group main-tiers-container" {% if product.has_variant_prices %}style="display:none;"{% endif %}>
<label>Оптовые цены (от кол-ва)</label>
<div id="main-tiers-list-edit-{{ product.product_id }}">
{% for tier in product.wholesale_tiers %}
<div style="display:flex; gap:5px; margin-top:5px;">
<input type="number" name="main_tier_qty[]" value="{{ tier.qty }}" placeholder="От (шт)" required style="width:80px;">
<input type="number" name="main_tier_price[]" value="{{ tier.price }}" placeholder="Цена" step="0.01" required>
<button type="button" onclick="this.parentElement.remove()" style="color:red; border:none; background:none; font-size:1.2rem;">&times;</button>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline" style="padding: 5px; font-size:0.8rem; margin-top:5px;" onclick="addTierRow('main-tiers-list-edit-{{ product.product_id }}', 'main')"><i class="fas fa-plus"></i> Добавить оптовую цену</button>
</div>
{% endif %}
<div class="variants-container" id="variants-container-edit-{{ product.product_id }}">
<div style="display:flex; justify-content:space-between; align-items:center;">
<label style="font-weight:600; font-size:0.9rem;"><i class="fas fa-tags"></i> Варианты товара</label>
<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>
</div>
<div id="variants-list-edit-{{ product.product_id }}">
{% for variant in product.variants %}
<div class="variant-row">
<div style="display:flex; flex-direction:column; gap:5px; margin-right: 5px;">
<button type="button" class="btn btn-outline" style="padding: 2px 5px;" onclick="moveVariant(this, 'up')"><i class="fas fa-arrow-up"></i></button>
<button type="button" class="btn btn-outline" style="padding: 2px 5px;" onclick="moveVariant(this, 'down')"><i class="fas fa-arrow-down"></i></button>
</div>
<div style="flex:1; display:flex; flex-wrap:wrap; gap:10px;">
<div class="form-group">
<label>Наличие</label>
<select name="variant_is_available[]">
<option value="1" {% if variant.is_available != False %}selected{% endif %}>Да</option>
<option value="0" {% if variant.is_available == False %}selected{% endif %}>Нет</option>
</select>
</div>
<div class="form-group">
<label>Название</label>
<input type="text" name="variant_name[]" value="{{ variant.name }}" placeholder="Цвет, размер" required>
</div>
{% if settings.business_type != 'retail' %}
<div class="form-group">
<label>В уп. (шт)</label>
<input type="number" name="variant_pieces_per_box[]" value="{{ variant.pieces_per_box }}" placeholder="Шт">
</div>
{% endif %}
{% if settings.use_barcodes and sys_mode not in['external', 'light_external'] %}
<div class="form-group">
<label>Штрих-код</label>
<div style="display:flex; gap:5px;">
<input type="text" name="variant_barcode[]" value="{{ variant.barcode }}" placeholder="Код">
<button type="button" class="btn btn-outline" style="padding: 12px;" onclick="startScanner(val => this.previousElementSibling.value = val)"><i class="fas fa-barcode"></i></button>
</div>
</div>
{% endif %}
<div class="form-group var-price-input" {% if not product.has_variant_prices %}style="display:none;"{% endif %}>
<label>Цена</label>
<input type="number" name="variant_price[]" value="{{ variant.price }}" placeholder="Цена за ед." step="0.01" {% if product.has_variant_prices %}required{% endif %}>
</div>
{% if settings.business_type == 'mixed' %}
<div class="form-group var-price-input" {% if not product.has_variant_prices %}style="display:none;"{% endif %}>
<label>Цена уп.</label>
<input type="number" name="variant_box_price[]" value="{{ variant.box_price }}" placeholder="Опционально" step="0.01">
</div>
{% endif %}
{% if settings.track_inventory and sys_mode not in ['external', 'light_external'] %}
<div class="form-group">
<label>Остаток</label>
<input type="number" name="variant_stock[]" value="{{ variant.stock }}" placeholder="Остаток">
</div>
{% endif %}
{% if settings.business_type == 'wholesale' %}
<div class="variant-tiers-container var-price-input" {% if not product.has_variant_prices %}style="display:none;"{% endif %} style="width:100%; margin-top:5px; padding:10px; background:#fafafa; border:1px solid #ddd; border-radius:6px;">
<label style="font-size:0.8rem; font-weight:bold;">Оптовые цены варианта</label>
<div id="var-tiers-list-edit-{{ product.product_id }}-{{ loop.index0 }}" class="var-tier-list">
{% for tier in variant.wholesale_tiers %}
<div style="display:flex; gap:5px; margin-top:5px;">
<input type="number" name="variant_{{ loop.index0 }}_tier_qty[]" value="{{ tier.qty }}" placeholder="От (шт)" required style="width:80px;">
<input type="number" name="variant_{{ loop.index0 }}_tier_price[]" value="{{ tier.price }}" placeholder="Цена" step="0.01" required>
<button type="button" onclick="this.parentElement.remove()" style="color:red; border:none; background:none; font-size:1.2rem;">&times;</button>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline btn-add-var-tier" style="padding: 4px; font-size:0.75rem; margin-top:5px;" onclick="addTierRow('var-tiers-list-edit-{{ product.product_id }}-{{ loop.index0 }}', 'variant', {{ loop.index0 }})"><i class="fas fa-plus"></i> Добавить оптовую цену</button>
</div>
{% endif %}
</div>
<button type="button" class="remove-variant-btn" onclick="const row = this.closest('.variant-row'); const listId = row.parentNode.id; row.remove(); updateMainStockVisibility('edit-prod-{{ product.product_id }}'); updateVariantIndices(listId);"><i class="fas fa-times-circle"></i></button>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline" style="padding: 5px 10px; font-size:0.85rem;" onclick="addVariantRow('variants-list-edit-{{ product.product_id }}')"><i class="fas fa-plus"></i> Добавить вариант</button>
</div>
<div class="form-group" style="width: 100%;">
<label>Описание товара</label>
<textarea name="description">{{ product.description }}</textarea>
</div>
<div class="file-input-wrapper">
<label style="font-size: 0.85rem; font-weight: 600; color: #636e72; display:block; margin-bottom:5px;">Фотографии товара (до 10 шт)</label>
{% if product.photos and product.photos|length > 0 %}
<div class="current-photos" style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom:10px;">
{% for ph in product.photos %}
<div class="photo-preview" id="photo-{{ product.product_id }}-{{ loop.index }}" style="position:relative;">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ ph }}" style="width:60px;height:60px;object-fit:cover;border-radius:8px;">
<button type="button" onclick="removePhoto('{{ product.product_id }}', '{{ ph }}', 'photo-{{ product.product_id }}-{{ loop.index }}')" style="position:absolute;top:-5px;right:-5px;background:red;color:white;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:bold;">&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
<div id="removed-photos-{{ product.product_id }}"></div>
<label style="font-size: 0.85rem; font-weight: 600; color: #636e72; display:block; margin-bottom:5px; margin-top:10px;">Добавить новые фото</label>
<input type="file" name="photos" accept="image/*" multiple max="10">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;"><i class="fas fa-save"></i> Сохранить изменения</button>
</form>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="modal-overlay" id="scannerModal" style="z-index:9999; display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
<div style="background:#fff; padding:20px; border-radius:12px; width:100%; max-width:400px; text-align:center;">
<h3 style="margin-top:0;">Сканирование</h3>
<div id="reader" style="width:100%; min-height:300px; margin-bottom:15px;"></div>
<button class="btn btn-danger" onclick="stopScanner()">Отмена</button>
</div>
</div>
<script>
const trackInventory = {{ 'true' if settings.track_inventory and sys_mode not in ['external', 'light_external'] else 'false' }};
const useBarcodes = {{ 'true' if settings.use_barcodes and sys_mode not in ['external', 'light_external'] else 'false' }};
const businessType = '{{ settings.business_type }}';
function showLoading(form) {
const btn = form.querySelector('button[type="submit"]');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Загрузка...';
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.7';
}
function toggleCategory(id) {
const content = document.getElementById(id);
const icon = document.getElementById('icon-' + id);
if(content.classList.contains('active')) {
content.classList.remove('active');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
} else {
content.classList.add('active');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
}
function toggleAddProduct(id) {
const form = document.getElementById(id);
form.classList.toggle('active');
}
function toggleEditProduct(id) {
const form = document.getElementById(id);
form.classList.toggle('active');
if(form.classList.contains('active')){
const cb = form.querySelector('input[name="has_variant_prices"]');
toggleVariantPrices(cb, id);
updateMainStockVisibility(id);
}
}
function removePhoto(pid, filename, elId) {
const container = document.getElementById(`removed-photos-${pid}`);
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'remove_photos[]';
input.value = filename;
container.appendChild(input);
document.getElementById(elId).style.display = 'none';
}
function addTierRow(containerId, type, varIndex = 0) {
const container = document.getElementById(containerId);
const div = document.createElement('div');
div.style.display = 'flex'; div.style.gap = '5px'; div.style.marginTop = '5px';
let namePrefix = type === 'main' ? 'main' : `variant_${varIndex}`;
div.innerHTML = `
<input type="number" name="${namePrefix}_tier_qty[]" placeholder="От (шт)" required style="width:80px;">
<input type="number" name="${namePrefix}_tier_price[]" placeholder="Цена" step="0.01" required>
<button type="button" onclick="this.parentElement.remove()" style="color:red; border:none; background:none; font-size:1.2rem;">&times;</button>
`;
container.appendChild(div);
}
function updateVariantIndices(listId) {
const list = document.getElementById(listId);
if (!list) return;
const rows = list.querySelectorAll(':scope > .variant-row');
rows.forEach((row, index) => {
row.querySelectorAll('input[name*="_tier_qty[]"]').forEach(inp => {
inp.name = `variant_${index}_tier_qty[]`;
});
row.querySelectorAll('input[name*="_tier_price[]"]').forEach(inp => {
inp.name = `variant_${index}_tier_price[]`;
});
const btn = row.querySelector('.btn-add-var-tier');
if (btn) {
const tierList = row.querySelector('.var-tier-list');
if(tierList) {
tierList.id = `var-tiers-list-${listId}-${index}`;
btn.setAttribute('onclick', `addTierRow('${tierList.id}', 'variant', ${index})`);
}
}
});
}
function moveVariant(btn, direction) {
const row = btn.closest('.variant-row');
const list = row.parentNode;
if (direction === 'up' && row.previousElementSibling) {
list.insertBefore(row, row.previousElementSibling);
} else if (direction === 'down' && row.nextElementSibling) {
list.insertBefore(row.nextElementSibling, row);
}
updateVariantIndices(list.id);
}
function addVariantRow(containerId) {
const container = document.getElementById(containerId);
const formBlock = container.closest('form');
const formId = formBlock.parentElement.id;
const hasVariantPrices = formBlock.querySelector('input[name="has_variant_prices"]').checked;
const div = document.createElement('div');
div.className = 'variant-row';
let displayStyle = hasVariantPrices ? '' : 'style="display:none;"';
let reqAttr = hasVariantPrices ? 'required' : '';
let html = `
<div style="display:flex; flex-direction:column; gap:5px; margin-right: 5px;">
<button type="button" class="btn btn-outline" style="padding: 2px 5px;" onclick="moveVariant(this, 'up')"><i class="fas fa-arrow-up"></i></button>
<button type="button" class="btn btn-outline" style="padding: 2px 5px;" onclick="moveVariant(this, 'down')"><i class="fas fa-arrow-down"></i></button>
</div>
<div style="flex:1; display:flex; flex-wrap:wrap; gap:10px;">
<div class="form-group">
<label>Наличие</label>
<select name="variant_is_available[]">
<option value="1" selected>Да</option>
<option value="0">Нет</option>
</select>
</div>
<div class="form-group">
<label>Название</label>
<input type="text" name="variant_name[]" placeholder="Цвет, размер" required>
</div>
`;
if (businessType !== 'retail') {
html += `
<div class="form-group">
<label>В уп. (шт)</label>
<input type="number" name="variant_pieces_per_box[]" placeholder="Шт">
</div>`;
}
if (useBarcodes) {
html += `
<div class="form-group">
<label>Штрих-код</label>
<div style="display:flex; gap:5px;">
<input type="text" name="variant_barcode[]" placeholder="Код">
<button type="button" class="btn btn-outline" style="padding: 12px;" onclick="startScanner(val => this.previousElementSibling.value = val)"><i class="fas fa-barcode"></i></button>
</div>
</div>`;
}
html += `
<div class="form-group var-price-input" ${displayStyle}>
<label>Цена</label>
<input type="number" name="variant_price[]" placeholder="Цена за ед." step="0.01" ${reqAttr}>
</div>
`;
if (businessType === 'mixed') {
html += `
<div class="form-group var-price-input" ${displayStyle}>
<label>Цена уп.</label>
<input type="number" name="variant_box_price[]" placeholder="Опционально" step="0.01">
</div>`;
}
if (trackInventory) {
html += `
<div class="form-group">
<label>Остаток</label>
<input type="number" name="variant_stock[]" placeholder="Остаток">
</div>`;
}
if (businessType === 'wholesale') {
html += `
<div class="variant-tiers-container var-price-input" ${displayStyle} style="width:100%; margin-top:5px; padding:10px; background:#fafafa; border:1px solid #ddd; border-radius:6px;">
<label style="font-size:0.8rem; font-weight:bold;">Оптовые цены варианта</label>
<div class="var-tier-list"></div>
<button type="button" class="btn btn-outline btn-add-var-tier" style="padding: 4px; font-size:0.75rem; margin-top:5px;"><i class="fas fa-plus"></i> Добавить оптовую цену</button>
</div>`;
}
html += `</div><button type="button" class="remove-variant-btn" onclick="const row = this.closest('.variant-row'); const listId = row.parentNode.id; row.remove(); updateMainStockVisibility('${formId}'); updateVariantIndices(listId);"><i class="fas fa-times-circle"></i></button>`;
div.innerHTML = html;
container.appendChild(div);
updateVariantIndices(containerId);
updateMainStockVisibility(formId);
}
function toggleVariantPrices(cb, formId) {
const form = document.getElementById(formId);
const mainPriceContainer = form.querySelector('.main-price-container');
const mainPriceInput = form.querySelector('.main-price-input');
const mainBoxPriceContainer = form.querySelector('.main-box-price-container');
const mainTiersContainer = form.querySelector('.main-tiers-container');
const varPriceInputs = form.querySelectorAll('.var-price-input');
if (cb.checked) {
if(mainPriceContainer) mainPriceContainer.style.display = 'none';
if(mainPriceInput) mainPriceInput.removeAttribute('required');
if(mainBoxPriceContainer) mainBoxPriceContainer.style.display = 'none';
if(mainTiersContainer) mainTiersContainer.style.display = 'none';
varPriceInputs.forEach(input => {
input.style.display = 'flex';
const inp = input.querySelector('input[name="variant_price[]"]');
if(inp) inp.setAttribute('required', 'required');
});
} else {
if(mainPriceContainer) mainPriceContainer.style.display = 'flex';
if(mainPriceInput) mainPriceInput.setAttribute('required', 'required');
if(mainBoxPriceContainer) mainBoxPriceContainer.style.display = 'flex';
if(mainTiersContainer) mainTiersContainer.style.display = 'block';
varPriceInputs.forEach(input => {
input.style.display = 'none';
const inp = input.querySelector('input[name="variant_price[]"]');
if(inp) inp.removeAttribute('required');
});
}
}
function updateMainStockVisibility(formId) {
const form = document.getElementById(formId);
const variants = form.querySelectorAll('.variant-row');
if(trackInventory) {
const mainStock = form.querySelector('.main-stock-container');
if(mainStock) {
if(variants.length > 0) mainStock.style.display = 'none';
else mainStock.style.display = 'flex';
}
}
if(useBarcodes) {
const mainBc = form.querySelector('.main-barcode-container');
if(mainBc) {
if(variants.length > 0) mainBc.style.display = 'none';
else mainBc.style.display = 'flex';
}
}
}
function filterAdmin() {
const query = document.getElementById('adminSearch').value.toLowerCase();
const categories = document.querySelectorAll('.category-block');
categories.forEach(cat => {
const headerText = cat.querySelector('.category-header span').innerText;
if(headerText.includes('Настройка магазина') ||
headerText.includes('Онлайн заказы') ||
headerText.includes('Пользователи') ||
headerText.includes('Персонал')) return;
const catName = cat.querySelector('.cat-title-text').innerText.toLowerCase();
const products = cat.querySelectorAll('.product-item');
let catMatch = catName.includes(query);
let hasVisibleProduct = false;
products.forEach(prod => {
const prodName = prod.querySelector('.product-name').innerText.toLowerCase();
const prodId = prod.getAttribute('data-pid').toLowerCase();
if (prodName.includes(query) || prodId.includes(query) || catMatch) {
prod.style.display = 'flex';
hasVisibleProduct = true;
} else {
prod.style.display = 'none';
}
});
if (catMatch || hasVisibleProduct) {
cat.style.display = 'block';
if (query && hasVisibleProduct) {
cat.querySelector('.category-content').classList.add('active');
cat.querySelector('.fas.fa-chevron-down, .fas.fa-chevron-up').className = 'fas fa-chevron-up';
}
} else {
cat.style.display = 'none';
}
if (!query) {
cat.querySelector('.category-content').classList.remove('active');
cat.querySelector('.fas.fa-chevron-up, .fas.fa-chevron-down').className = 'fas fa-chevron-down';
}
});
}
function filterStaff() {
const query = document.getElementById('staffSearch').value.toLowerCase();
const items = document.querySelectorAll('.staff-item');
items.forEach(item => {
const text = item.querySelector('.staff-name-text').innerText.toLowerCase();
if(text.includes(query)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
}
function copyToClipboard(text) {
let textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
alert('Ссылка скопирована!');
} catch (err) {
alert('Не удалось скопировать ссылку');
}
document.body.removeChild(textArea);
}
function startScanner(callback) {
document.getElementById('scannerModal').style.display = 'flex';
const html5QrCode = new Html5Qrcode("reader");
const config = { fps: 10, qrbox: { width: 250, height: 250 } };
html5QrCode.start({ facingMode: "environment" }, config, (text) => {
html5QrCode.stop().then(() => {
document.getElementById('scannerModal').style.display = 'none';
callback(text);
});
}).catch(err => {
console.log(err);
alert('Не удалось запустить камеру');
document.getElementById('scannerModal').style.display = 'none';
});
window.currentScanner = html5QrCode;
}
function stopScanner() {
if(window.currentScanner) {
window.currentScanner.stop().catch(()=>{});
}
document.getElementById('scannerModal').style.display = 'none';
}
document.querySelectorAll('.add-product-wrapper').forEach(wrapper => {
const cb = wrapper.querySelector('input[name="has_variant_prices"]');
if(cb) toggleVariantPrices(cb, wrapper.id);
updateMainStockVisibility(wrapper.id);
});
</script>
</body>
</html>
'''
REPORTS_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчеты</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f6f9; --surface: #ffffff; --text: #2d3436; --border: #e0e6ed; --primary: #0984e3; --success: #00b894; --warning: #f39c12; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg); color: var(--text); padding: 20px; margin: 0; }
.container { max-width: 1000px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: center; background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
.header h1 { margin: 0; font-size: 1.5rem; }
.btn { padding: 10px 15px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; background: var(--primary); }
.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); }
.filter-bar input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; outline: none; font-family: inherit; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; flex-wrap: wrap; }
.tab { padding: 10px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 8px; transition: background 0.2s; }
.tab:hover { background: #e9ecef; }
.tab.active { background: var(--primary); color: #fff; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }
.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; }
.stat-card .title { font-size: 0.9rem; color: #636e72; font-weight: 600; }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.stat-card.icon { font-size: 2rem; color: var(--primary); opacity: 0.2; position: absolute; right: 20px; bottom: 20px; }
.stat-card-inner { position: relative; }
.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; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
th { font-weight: 600; color: #636e72; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-chart-line"></i> Расширенные Отчеты</h1>
<a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> Назад в панель</a>
</div>
<div class="filter-bar">
<span style="font-weight: 500;">Период:</span>
<input type="date" id="dateStart" onchange="updateReports()">
<span>—</span>
<input type="date" id="dateEnd" onchange="updateReports()">
<button class="btn" style="background:var(--success);" onclick="setMonthDates()">Текущий месяц</button>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('general')">Общий отчет</div>
<div class="tab" onclick="switchTab('daily')">По дням</div>
<div class="tab" onclick="switchTab('category')">По категориям</div>
<div class="tab" onclick="switchTab('staff')">По сотрудникам</div>
</div>
<div id="general" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-inner">
<div class="title">Общая выручка</div>
<div class="value" id="totalRevenue">0</div>
<i class="fas fa-money-bill-wave icon"></i>
</div>
</div>
<div class="stat-card">
<div class="stat-card-inner">
<div class="title">Кол-во заказов</div>
<div class="value" id="totalOrders">0</div>
<i class="fas fa-shopping-cart icon"></i>
</div>
</div>
<div class="stat-card">
<div class="stat-card-inner">
<div class="title">Средний чек (AOV)</div>
<div class="value" id="avgOrderValue">0</div>
<i class="fas fa-receipt icon"></i>
</div>
</div>
<div class="stat-card">
<div class="stat-card-inner">
<div class="title">Продано товаров (шт)</div>
<div class="value" id="totalItemsSold">0</div>
<i class="fas fa-box icon"></i>
</div>
</div>
<div class="stat-card">
<div class="stat-card-inner">
<div class="title">Возвраты (сумма)</div>
<div class="value" id="totalReturns" style="color:var(--danger);">0</div>
<i class="fas fa-undo icon"></i>
</div>
</div>
</div>
<div class="table-container">
<h3>Топ продаваемых товаров</h3>
<table>
<thead>
<tr>
<th>Товар</th>
<th>Кол-во (шт)</th>
<th>Сумма ({{ currency_code }})</th>
</tr>
</thead>
<tbody id="topProductsTable"></tbody>
</table>
</div>
</div>
<div id="daily" class="tab-content">
<div class="table-container">
<h3>Продажи по дням</h3>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Кол-во заказов</th>
<th>Выручка ({{ currency_code }})</th>
</tr>
</thead>
<tbody id="dailySalesTable"></tbody>
</table>
</div>
</div>
<div id="category" class="tab-content">
<div class="table-container">
<h3>Продажи по категориям</h3>
<table>
<thead>
<tr>
<th>Категория</th>
<th>Кол-во (шт)</th>
<th>Выручка ({{ currency_code }})</th>
</tr>
</thead>
<tbody id="categorySalesTable"></tbody>
</table>
</div>
</div>
<div id="staff" class="tab-content">
<div class="table-container">
<h3>Выручка по сотрудникам</h3>
<table>
<thead>
<tr>
<th>Сотрудник</th>
<th>Кол-во заказов</th>
<th>Выручка ({{ currency_code }})</th>
</tr>
</thead>
<tbody id="staffSalesTable"></tbody>
</table>
</div>
</div>
</div>
<script>
const allOrders = {{ orders_json|safe }};
function switchTab(tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
function setMonthDates() {
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const offset = firstDay.getTimezoneOffset() * 60000;
const localFirst = new Date(firstDay.getTime() - offset).toISOString().split('T')[0];
const localToday = new Date(today.getTime() - offset).toISOString().split('T')[0];
document.getElementById('dateStart').value = localFirst;
document.getElementById('dateEnd').value = localToday;
updateReports();
}
function updateReports() {
const startDate = document.getElementById('dateStart').value;
const endDate = document.getElementById('dateEnd').value;
let filteredOrders = allOrders.filter(o => o.status === 'confirmed' || o.status === 'pos' || o.status === 'returned');
if (startDate) {
const sDate = new Date(startDate);
filteredOrders = filteredOrders.filter(o => new Date(o.created_at.split(' ')[0]) >= sDate);
}
if (endDate) {
const eDate = new Date(endDate);
filteredOrders = filteredOrders.filter(o => new Date(o.created_at.split(' ')[0]) <= eDate);
}
let totalRev = 0;
let totalRet = 0;
let totalItems = 0;
let ordersCount = 0;
let staffSales = {};
let productSales = {};
let dailySales = {};
let categorySales = {};
filteredOrders.forEach(o => {
if(o.status === 'returned') {
totalRet += o.total_price;
} else {
totalRev += o.total_price;
ordersCount++;
const staff = o.staff_name || 'Онлайн (Без сотрудника)';
if(!staffSales[staff]) staffSales[staff] = { orders: 0, sum: 0 };
staffSales[staff].orders += 1;
staffSales[staff].sum += o.total_price;
const dateStr = o.created_at.split(' ')[0];
if(!dailySales[dateStr]) dailySales[dateStr] = { orders: 0, sum: 0 };
dailySales[dateStr].orders += 1;
dailySales[dateStr].sum += o.total_price;
o.cart.forEach(item => {
if(item.quantity > 0) {
totalItems += item.quantity;
let pName = item.name;
if(item.variant_name) pName += ` (${item.variant_name})`;
if(!productSales[pName]) productSales[pName] = { qty: 0, sum: 0 };
productSales[pName].qty += item.quantity;
let itemPrice = parseFloat(item.calculated_price) || parseFloat(item.price);
productSales[pName].sum += (itemPrice * item.quantity);
let catName = item.category || 'Без категории';
if(!categorySales[catName]) categorySales[catName] = { qty: 0, sum: 0 };
categorySales[catName].qty += item.quantity;
categorySales[catName].sum += (itemPrice * item.quantity);
}
});
}
});
document.getElementById('totalRevenue').innerText = totalRev.toLocaleString() + ' {{ currency_code }}';
document.getElementById('totalOrders').innerText = ordersCount;
document.getElementById('totalReturns').innerText = totalRet.toLocaleString() + ' {{ currency_code }}';
document.getElementById('totalItemsSold').innerText = totalItems.toLocaleString();
let aov = ordersCount > 0 ? (totalRev / ordersCount) : 0;
document.getElementById('avgOrderValue').innerText = Math.round(aov).toLocaleString() + ' {{ currency_code }}';
renderTopProducts(productSales);
renderStaffTable(staffSales);
renderDailyTable(dailySales);
renderCategoryTable(categorySales);
}
function renderTopProducts(data) {
const tbody = document.getElementById('topProductsTable');
tbody.innerHTML = '';
const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum).slice(0, 15);
if(sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
return;
}
sorted.forEach(p => {
tbody.innerHTML += `
<tr>
<td style="font-weight:500;">${p}</td>
<td>${data[p].qty}</td>
<td>${Math.round(data[p].sum).toLocaleString()}</td>
</tr>
`;
});
}
function renderStaffTable(data) {
const tbody = document.getElementById('staffSalesTable');
tbody.innerHTML = '';
const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum);
if(sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
return;
}
sorted.forEach(s => {
tbody.innerHTML += `
<tr>
<td style="font-weight:500;">${s}</td>
<td>${data[s].orders}</td>
<td>${Math.round(data[s].sum).toLocaleString()}</td>
</tr>
`;
});
}
function renderDailyTable(data) {
const tbody = document.getElementById('dailySalesTable');
tbody.innerHTML = '';
const sorted = Object.keys(data).sort((a,b) => new Date(b) - new Date(a));
if(sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
return;
}
sorted.forEach(d => {
tbody.innerHTML += `
<tr>
<td style="font-weight:500;">${d}</td>
<td>${data[d].orders}</td>
<td>${Math.round(data[d].sum).toLocaleString()}</td>
</tr>
`;
});
}
function renderCategoryTable(data) {
const tbody = document.getElementById('categorySalesTable');
tbody.innerHTML = '';
const sorted = Object.keys(data).sort((a,b) => data[b].sum - data[a].sum);
if(sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Нет данных</td></tr>';
return;
}
sorted.forEach(c => {
tbody.innerHTML += `
<tr>
<td style="font-weight:500;">${c}</td>
<td>${data[c].qty}</td>
<td>${Math.round(data[c].sum).toLocaleString()}</td>
</tr>
`;
});
}
setMonthDates();
</script>
</body>
</html>
'''
INVENTORY_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Учет остатков</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f6f9; --surface: #ffffff; --text: #2d3436; --border: #e0e6ed; --primary: #27ae60; --info: #0984e3; --danger: #ff7675; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg); color: var(--text); padding: 20px; margin: 0; }
.container { max-width: 1200px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: center; background: var(--surface); padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; flex-wrap: wrap; gap: 15px; }
.header h1 { margin: 0; font-size: 1.5rem; }
.btn { padding: 10px 15px; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; background: var(--info); }
.search-bar { background: var(--surface); padding: 15px 20px; border-radius: 12px; display: flex; align-items: center; margin-bottom: 20px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); position: relative; }
.search-bar i { position: absolute; left: 35px; color: #636e72; }
.search-bar input { width: 100%; padding: 10px 10px 10px 40px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-size: 1rem; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; }
.tab { padding: 10px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 8px; transition: background 0.2s; display:flex; align-items:center; gap:8px; }
.tab:hover { background: #e9ecef; }
.tab.active { background: var(--primary); color: #fff; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.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; }
table { width: 100%; border-collapse: collapse; min-width: 800px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { font-weight: 600; color: #636e72; background: #fafafa; }
.action-cell { display: flex; gap: 5px; align-items: center; }
.action-cell input[type="number"] { width: 70px; padding: 6px; border: 1px solid var(--border); border-radius: 6px; text-align: center; }
.action-cell input[type="text"] { width: 120px; padding: 6px; border: 1px solid var(--border); border-radius: 6px; }
.btn-sm { padding: 6px 10px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; color: white; }
.btn-add { background: var(--primary); }
.btn-sub { background: var(--danger); }
.badge-type-add { background: #d4edda; color: #155724; padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
.badge-type-sub { background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
.badge-danger { background: var(--danger); color: white; padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-boxes"></i> Учет остатков</h1>
<a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> Назад в панель</a>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('current')">Текущие остатки</div>
<div class="tab" onclick="switchTab('low')">Заканчивающиеся <span class="badge-danger">{{ low_stock_items|length }}</span></div>
<div class="tab" onclick="switchTab('history')">История операций</div>
</div>
<div id="current" class="tab-content active">
<div class="search-bar">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="Поиск товара или ID..." oninput="filterTable()">
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Товар</th>
<th>Категория</th>
<th>Остаток</th>
<th>Действие (Приход / Списание)</th>
</tr>
</thead>
<tbody id="inventoryTable">
{% for p in products %}
{% if not p.variants %}
<tr class="inv-row" data-pid="{{ p.product_id }}">
<td class="prod-name">
<strong>{{ p.name }}</strong><br>
<span style="font-size:0.75rem; color:#b2bec3;">ID: {{ p.product_id }}</span>
</td>
<td>{{ p.category }}</td>
<td><strong>{{ p.stock if p.stock != "" else "0" }}</strong></td>
<td>
<div class="action-cell">
<input type="number" id="qty_{{ p.product_id }}_-1" placeholder="Кол-во" min="1">
<input type="text" id="comment_{{ p.product_id }}_-1" placeholder="Комментарий">
<button class="btn-sm btn-add" onclick="updateStock('{{ p.product_id }}', -1, true)">+ Приход</button>
<button class="btn-sm btn-sub" onclick="updateStock('{{ p.product_id }}', -1, false)">- Списание</button>
</div>
</td>
</tr>
{% else %}
{% for v in p.variants %}
<tr class="inv-row" data-pid="{{ p.product_id }}">
<td class="prod-name">
<strong>{{ p.name }}</strong> <span style="color:#636e72;">({{ v.name }})</span><br>
<span style="font-size:0.75rem; color:#b2bec3;">ID: {{ p.product_id }}</span>
</td>
<td>{{ p.category }}</td>
<td><strong>{{ v.stock if v.stock != "" else "0" }}</strong></td>
<td>
<div class="action-cell">
<input type="number" id="qty_{{ p.product_id }}_{{ loop.index0 }}" placeholder="Кол-во" min="1">
<input type="text" id="comment_{{ p.product_id }}_{{ loop.index0 }}" placeholder="Комментарий">
<button class="btn-sm btn-add" onclick="updateStock('{{ p.product_id }}', {{ loop.index0 }}, true)">+ Приход</button>
<button class="btn-sm btn-sub" onclick="updateStock('{{ p.product_id }}', {{ loop.index0 }}, false)">- Списание</button>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div id="low" class="tab-content">
<div class="table-container">
<table>
<thead>
<tr>
<th>Товар</th>
<th>Вариант</th>
<th>Категория</th>
<th>Остаток</th>
</tr>
</thead>
<tbody>
{% for item in low_stock_items %}
<tr>
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.variant }}</td>
<td>{{ item.category }}</td>
<td style="color:var(--danger); font-weight:bold;">{{ item.stock }}</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;">Нет заканчивающихся товаров</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div id="history" class="tab-content">
<div class="table-container">
<table>
<thead>
<tr>
<th>Дата</th>
<th>Товар</th>
<th>Тип</th>
<th>Кол-во</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody>
{% for h in history|reverse %}
<tr>
<td>{{ h.date }}</td>
<td>{{ h.product }} {% if h.variant %}({{ h.variant }}){% endif %}</td>
<td>
{% if h.change > 0 %}
<span class="badge-type-add">Приход</span>
{% else %}
<span class="badge-type-sub">Списание</span>
{% endif %}
</td>
<td><strong>{{ h.change|abs }}</strong></td>
<td>{{ h.comment }}</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align:center;">История пуста</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
const envId = '{{ env_id }}';
function switchTab(tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
function filterTable() {
const query = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('.inv-row');
rows.forEach(row => {
const text = row.querySelector('.prod-name').innerText.toLowerCase();
const pid = row.getAttribute('data-pid').toLowerCase();
if(text.includes(query) || pid.includes(query)) row.style.display = 'table-row';
else row.style.display = 'none';
});
}
function updateStock(productId, variantIdx, isAdd) {
const qtyInput = document.getElementById(`qty_${productId}_${variantIdx}`);
const commentInput = document.getElementById(`comment_${productId}_${variantIdx}`);
const qty = parseInt(qtyInput.value);
const comment = commentInput.value;
if(isNaN(qty) || qty <= 0) {
alert('Введите корректное количество');
return;
}
fetch(`/${envId}/api/inventory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: productId,
variant_idx: variantIdx,
qty: qty,
is_add: isAdd,
comment: comment
})
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert('Ошибка обновления остатков');
}
});
}
</script>
</body>
</html>
'''
@app.route('/')
def index():
return render_template_string(LANDING_PAGE_TEMPLATE)
@app.route('/admhosto', methods=['GET'])
def admhosto():
data = load_data()
environments_data =[]
for env_id, env_data in data.items():
if env_id == 'default_env':
continue
settings = env_data.get('settings', {})
org_name = settings.get("organization_name", f"Shop {env_id}")
environments_data.append({
"id": env_id,
"org_name": org_name,
"pwd_enabled": settings.get("admin_password_enabled", False),
"password": settings.get("admin_password", ""),
"system_mode": settings.get("system_mode", "both")
})
environments_data.sort(key=lambda x: x['id'])
return render_template_string(ADMHOSTO_TEMPLATE, environments=environments_data)
@app.route('/admhosto/create', methods=['POST'])
def create_environment():
all_data = load_data()
while True:
new_id = ''.join(random.choices(string.digits, k=6))
if new_id not in all_data:
break
all_data[new_id] = {
'products': [], 'categories':[], 'category_photos': {}, 'orders': {}, 'staff': [], 'catalog_users': [], 'inventory_history':[],
'settings': {
"organization_name": f"Shop {new_id}",
"admin_password_enabled": False,
"admin_password": "",
"logo_url": DEFAULT_LOGO_URL,
"whatsapp_number": DEFAULT_WHATSAPP_NUMBER,
"invoice_contacts": "",
"currency": "T",
"track_inventory": False,
"use_barcodes": False,
"business_type": "mixed",
"system_mode": "both",
"hide_stock_online": False,
"closed_catalog_enabled": False,
"theme": "light",
"customer_fields": {'name': True, 'phone': True, 'city': True, 'address': False, 'zip': False},
"socials": {
'wa': {'enabled': True, 'url': ''},
'ig': {'enabled': True, 'url': ''},
'tg': {'enabled': True, 'url': ''}
}
}
}
save_data(all_data)
flash(f'Новая среда с ID {new_id} успешно создана.', 'success')
return redirect(url_for('admhosto'))
@app.route('/admhosto/update_pwd/<env_id>', methods=['POST'])
def update_env_pwd(env_id):
all_data = load_data()
if env_id in all_data:
pwd_enabled = 'pwd_enabled' in request.form
password = request.form.get('password', '').strip()
all_data[env_id]['settings']['admin_password_enabled'] = pwd_enabled
all_data[env_id]['settings']['admin_password'] = password
save_data(all_data)
flash(f'Пароль для среды {env_id} обновлен.', 'success')
else:
flash(f'Среда {env_id} не найдена.', 'error')
return redirect(url_for('admhosto'))
@app.route('/admhosto/update_mode/<env_id>', methods=['POST'])
def update_env_mode(env_id):
all_data = load_data()
if env_id in all_data:
mode = request.form.get('system_mode', 'both')
all_data[env_id]['settings']['system_mode'] = mode
if mode in ['external', 'light_external']:
all_data[env_id]['settings']['track_inventory'] = False
all_data[env_id]['settings']['use_barcodes'] = False
all_data[env_id]['settings']['closed_catalog_enabled'] = False
save_data(all_data)
flash(f'Режим среды {env_id} обновлен.', 'success')
return redirect(url_for('admhosto'))
@app.route('/admhosto/clear_history/<env_id>', methods=['POST'])
def clear_history(env_id):
if request.form.get('pwd') == 'admin':
all_data = load_data()
if env_id in all_data:
all_data[env_id]['orders'] = {}
all_data[env_id]['inventory_history'] =[]
save_data(all_data)
flash(f'История среды {env_id} успешно очищена.', 'success')
else:
flash('Неверный пароль для очистки истории.', 'error')
return redirect(url_for('admhosto'))
@app.route('/admhosto/delete/<env_id>', methods=['POST'])
def delete_environment(env_id):
all_data = load_data()
if env_id in all_data:
del all_data[env_id]
save_data(all_data)
flash(f'Среда {env_id} была удалена.', 'success')
else:
flash(f'Среда {env_id} не найдена.', 'error')
return redirect(url_for('admhosto'))
@app.route('/<env_id>/login', methods=['GET', 'POST'])
def admin_login(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if not settings.get('admin_password_enabled'):
return redirect(url_for('admin', env_id=env_id))
if request.method == 'POST':
pwd = request.form.get('password', '')
if pwd == settings.get('admin_password', ''):
session.permanent = True
session[f'admin_auth_{env_id}'] = True
return redirect(url_for('admin', env_id=env_id))
else:
flash('Неверный пароль', 'error')
return render_template_string(LOGIN_TEMPLATE, env_id=env_id)
@app.route('/<env_id>/logout')
def admin_logout(env_id):
session.pop(f'admin_auth_{env_id}', None)
return redirect(url_for('admin_login', env_id=env_id))
@app.route('/<env_id>/catalog', methods=['GET', 'POST'])
def catalog(env_id):
data = get_env_data(env_id)
all_products = data.get('products',[])
categories = data.get('categories',[])
category_photos = data.get('category_photos', {})
settings = data.get('settings', {})
mode = request.args.get('mode', 'online')
staff_id = request.args.get('staff_id', '')
if settings.get('system_mode', 'both') == 'internal' and mode != 'pos':
return "Каталог недоступен в режиме внутреннего учета", 403
if settings.get('closed_catalog_enabled') and mode != 'pos':
if not session.get(f'catalog_auth_{env_id}'):
if request.method == 'POST':
pwd = request.form.get('password', '').strip()
catalog_users = data.get('catalog_users', [])
valid = any(u.get('password') == pwd for u in catalog_users)
if valid:
session.permanent = True
session[f'catalog_auth_{env_id}'] = True
return redirect(url_for('catalog', env_id=env_id, mode=mode, staff_id=staff_id))
else:
flash('Неверный пароль', 'error')
return render_template_string(CATALOG_LOGIN_TEMPLATE, env_id=env_id)
return render_template_string(
CATALOG_TEMPLATE,
products_json=json.dumps(all_products),
categories_json=json.dumps(categories),
category_photos_json=json.dumps(category_photos),
repo_id=REPO_ID,
currency_code=settings.get('currency', 'T'),
settings=settings,
env_id=env_id,
mode=mode,
staff_id=staff_id
)
@app.route('/<env_id>/api/staff_orders/<staff_id>')
def get_staff_orders(env_id, staff_id):
data = get_env_data(env_id)
orders = data.get('orders', {})
date_filter = request.args.get('date')
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')]
if date_filter:
filtered =[]
for o in staff_orders:
if o.get('created_at', '').startswith(date_filter):
filtered.append(o)
staff_orders = filtered
staff_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return jsonify(staff_orders[:50])
@app.route('/<env_id>/api/assembly/<order_id>', methods=['POST'])
def update_assembly(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if not order:
return jsonify({"success": False, "error": "Order not found"}), 404
req = request.json
c_key = req.get('c_key')
qty = int(req.get('qty', 0))
if 'assembled' not in order:
order['assembled'] = {}
order['assembled'][c_key] = qty
save_env_data(env_id, data)
return jsonify({"success": True})
@app.route('/<env_id>/api/finish_assembly/<order_id>', methods=['POST'])
def finish_assembly(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if not order:
return jsonify({"success": False}), 404
assembled_data = order.get('assembled', {})
new_cart = []
track_inv = data['settings'].get('track_inventory', False) and data['settings'].get('system_mode', 'both') not in ['external', 'light_external']
is_stock_deducted = order.get('status') in ['confirmed', 'pos']
for item in order.get('cart',[]):
c_key = item.get('c_key')
original_qty = item.get('quantity', 0)
assembled_qty = int(assembled_data.get(c_key, 0))
if assembled_qty < original_qty and is_stock_deducted and track_inv:
diff = original_qty - assembled_qty
restore_stock(c_key, item.get('product_id'), item.get('variant_idx', -1), diff, data['products'])
if assembled_qty > 0:
item['quantity'] = assembled_qty
new_cart.append(item)
order['cart'] = new_cart
update_order_totals(order, data['settings'].get('business_type', 'mixed'))
save_env_data(env_id, data)
return jsonify({"success": True})
@app.route('/<env_id>/process_return/<order_id>', methods=['POST'])
def process_return(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if not order:
return jsonify({"success": False, "error": "Order not found"}), 404
req_data = request.get_json()
returns = req_data.get('returns', {})
track_inv = data['settings'].get('track_inventory', False) and data['settings'].get('system_mode', 'both') not in['external', 'light_external']
for c_key, ret_qty in returns.items():
ret_qty = int(ret_qty)
if ret_qty <= 0: continue
for item in order['cart']:
if item.get('c_key') == c_key:
if ret_qty > item['quantity']:
ret_qty = item['quantity']
item['quantity'] -= ret_qty
if track_inv:
restore_stock(c_key, item.get('product_id'), item.get('variant_idx', -1), ret_qty, data['products'])
break
update_order_totals(order, data['settings'].get('business_type', 'mixed'))
if order['total_price'] <= 0:
order['status'] = 'returned'
save_env_data(env_id, data)
return jsonify({"success": True})
@app.route('/<env_id>/create_order', methods=['POST'])
def create_order(env_id):
order_data = request.get_json()
if not order_data or 'cart' not in order_data:
return jsonify({"error": "Bad request"}), 400
data = get_env_data(env_id)
cart_items = order_data['cart']
mode = order_data.get('mode', 'online')
staff_id = order_data.get('staff_id', '')
global_discount = float(order_data.get('global_discount', 0))
if global_discount < 0: global_discount = 0
staff_name = ''
staff_whatsapp = ''
if staff_id:
for s in data.get('staff', []):
if s['id'] == staff_id:
staff_name = s['name']
staff_whatsapp = s['whatsapp']
break
order_status = 'pos' if mode == 'pos' else 'pending'
customer_name = order_data.get('customer_name', '')
customer_phone = order_data.get('customer_phone', '')
customer_city = order_data.get('customer_city', '')
customer_address = order_data.get('customer_address', '')
customer_zip = order_data.get('customer_zip', '')
customer_whatsapp = order_data.get('customer_whatsapp', '')
product_dict = {p['product_id']: p.get('category', 'Без категории') for p in data.get('products', [])}
processed_cart =[]
for item in cart_items:
processed_cart.append({
"c_key": item.get('c_key'),
"product_id": item.get('product_id'),
"name": item['name'],
"price": float(item['cart_price']),
"cart_box_price": float(item.get('cart_box_price', 0)),
"quantity": int(item['quantity']),
"pieces_per_box": int(item.get('pieces_per_box', 1)) if str(item.get('pieces_per_box', 1)).strip() != "" else 1,
"variant_name": item.get('variant_name', ''),
"variant_idx": item.get('variant_idx', -1),
"discount": float(item.get('discount', 0)),
"wholesale_tiers": item.get('wholesale_tiers', []),
"category": product_dict.get(item.get('product_id'), 'Без категории'),
"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+"
})
order_id = f"SA-{datetime.now().strftime('%Y%m%d')}-{str(len(data.get('orders', {}))+1).zfill(3)}"
new_order = {
"id": order_id,
"created_at": get_almaty_time(),
"cart": processed_cart,
"status": order_status,
"staff_id": staff_id,
"staff_name": staff_name,
"staff_whatsapp": staff_whatsapp,
"customer_name": customer_name,
"customer_phone": customer_phone,
"customer_city": customer_city,
"customer_address": customer_address,
"customer_zip": customer_zip,
"customer_whatsapp": customer_whatsapp,
"global_discount": global_discount,
"assembled": {}
}
update_order_totals(new_order, data['settings'].get('business_type', 'mixed'))
if order_status == 'pos' and data['settings'].get('track_inventory', False) and data['settings'].get('system_mode', 'both') not in ['external', 'light_external']:
deduct_stock(processed_cart, data['products'])
data['orders'][order_id] = new_order
save_env_data(env_id, data)
return jsonify({"order_id": order_id}), 201
@app.route('/<env_id>/order/<order_id>')
def view_order(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
settings = data.get('settings', {})
if not order:
return "Order not found", 404
return render_template_string(
ORDER_TEMPLATE,
order=order,
settings=settings,
currency_code=settings.get('currency', 'T'),
env_id=env_id
)
@app.route('/<env_id>/assembly/<order_id>')
def view_assembly(env_id, order_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('system_mode', 'both') == 'light_external':
return redirect(url_for('admin', env_id=env_id))
order = data.get('orders', {}).get(order_id)
if not order:
return "Order not found", 404
return render_template_string(
ASSEMBLY_TEMPLATE,
order=order,
env_id=env_id
)
@app.route('/<env_id>/edit_order/<order_id>', methods=['POST'])
def edit_order(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if not order:
return jsonify({"success": False, "error": "Order not found"}), 404
if order.get('status') != 'pending':
return jsonify({"success": False, "error": "Can only edit pending orders"}), 400
req_data = request.get_json()
c_key = req_data.get('c_key')
change = req_data.get('change', 0)
exact_qty = req_data.get('exact_qty')
remove = req_data.get('remove', False)
for item in order['cart']:
if item.get('c_key') == c_key:
if remove:
order['cart'].remove(item)
else:
if exact_qty is not None:
item['quantity'] = int(exact_qty)
else:
item['quantity'] += change
if item['quantity'] <= 0:
order['cart'].remove(item)
break
update_order_totals(order, data['settings'].get('business_type', 'mixed'))
save_env_data(env_id, data)
return jsonify({"success": True, "total_price": order['total_price']})
@app.route('/<env_id>/apply_discount/<order_id>', methods=['POST'])
def apply_discount(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if not order:
return redirect(url_for('admin', env_id=env_id))
if order.get('status') != 'pending':
flash('Скидку можно применить только к ожидающим заказам.', 'error')
return redirect(url_for('admin', env_id=env_id))
global_discount = request.form.get('global_discount', 0)
try:
order['global_discount'] = float(global_discount)
except ValueError:
order['global_discount'] = 0
for item in order.get('cart',[]):
item_disc = request.form.get(f"item_discount_{item['c_key']}", 0)
try:
item['discount'] = float(item_disc)
except ValueError:
item['discount'] = 0
update_order_totals(order, data['settings'].get('business_type', 'mixed'))
save_env_data(env_id, data)
flash('Скидка успешно применена.', 'success')
return redirect(url_for('admin', env_id=env_id))
@app.route('/<env_id>/order_action/<order_id>', methods=['POST'])
def order_action(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if not order:
return redirect(url_for('admin', env_id=env_id))
action = request.form.get('action')
if action == 'confirm' and order.get('status') == 'pending':
order['status'] = 'confirmed'
if data['settings'].get('track_inventory', False) and data['settings'].get('system_mode', 'both') not in ['external', 'light_external']:
deduct_stock(order['cart'], data['products'])
elif action == 'delete':
del data['orders'][order_id]
save_env_data(env_id, data)
return redirect(url_for('admin', env_id=env_id))
@app.route('/<env_id>/history')
def history(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
orders_list = list(data.get('orders', {}).values())
orders_list.sort(key=lambda x: x.get('created_at', ''), reverse=True)
sys_mode = settings.get('system_mode', 'both')
if sys_mode in ['external', 'light_external']:
page_title = 'История заказов'
elif sys_mode == 'internal':
page_title = 'История накладных'
else:
page_title = 'История заказов и накладных'
return render_template_string(
HISTORY_TEMPLATE,
env_id=env_id,
sys_mode=sys_mode,
currency_code=settings.get('currency', 'T'),
orders_json=json.dumps(orders_list),
page_title=page_title
)
@app.route('/<env_id>/assembly_list')
def assembly_list(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
sys_mode = settings.get('system_mode', 'both')
if sys_mode == 'light_external':
return redirect(url_for('admin', env_id=env_id))
all_orders = list(data.get('orders', {}).values())
unassembled =[o for o in all_orders if not is_order_fully_assembled(o)]
unassembled.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return render_template_string(
HISTORY_TEMPLATE,
env_id=env_id,
sys_mode=sys_mode,
currency_code=settings.get('currency', 'T'),
orders_json=json.dumps(unassembled),
page_title='Сборка (Несобранные заказы)'
)
@app.route('/<env_id>/reports')
def reports(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
if settings.get('system_mode', 'both') == 'light_external':
return redirect(url_for('admin', env_id=env_id))
orders_list = list(data.get('orders', {}).values())
return render_template_string(
REPORTS_TEMPLATE,
env_id=env_id,
currency_code=settings.get('currency', 'T'),
orders_json=json.dumps(orders_list)
)
@app.route('/<env_id>/inventory')
def inventory(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
if settings.get('system_mode', 'both') in ['external', 'light_external']:
return redirect(url_for('admin', env_id=env_id))
low_stock_items = get_low_stock_items(data.get('products',[]))
return render_template_string(
INVENTORY_TEMPLATE,
env_id=env_id,
products=data.get('products', []),
history=data.get('inventory_history',[]),
low_stock_items=low_stock_items)
@app.route('/<env_id>/api/inventory', methods=['POST'])
def api_inventory(env_id):
data = get_env_data(env_id)
req = request.json
pid = req.get('product_id')
vidx = int(req.get('variant_idx', -1))
qty = int(req.get('qty', 0))
is_add = req.get('is_add', True)
comment = req.get('comment', '')
for p in data['products']:
if p['product_id'] == pid:
name = p['name']
v_name = ""
if vidx != -1 and vidx < len(p.get('variants',[])):
v_name = p['variants'][vidx]['name']
curr = p['variants'][vidx].get('stock', 0)
curr = int(curr) if str(curr).lstrip('-').strip() != "" and curr is not None else 0
p['variants'][vidx]['stock'] = curr + qty if is_add else curr - qty
else:
curr = p.get('stock', 0)
curr = int(curr) if str(curr).lstrip('-').strip() != "" and curr is not None else 0
p['stock'] = curr + qty if is_add else curr - qty
data['inventory_history'].append({
"date": get_almaty_time(),
"product": name,
"variant": v_name,
"change": qty if is_add else -qty,
"comment": comment
})
save_env_data(env_id, data)
return jsonify({"success": True})
return jsonify({"success": False})
@app.route('/<env_id>/admin', methods=['GET', 'POST'])
def admin(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
if settings.get('admin_password_enabled') and not session.get(f'admin_auth_{env_id}'):
return redirect(url_for('admin_login', env_id=env_id))
products = data.get('products',[])
categories = data.get('categories',[])
category_photos = data.get('category_photos', {})
staff = data.get('staff',[])
catalog_users = data.get('catalog_users', [])
orders = data.get('orders', {})
pending_orders =[o for o in orders.values() if o.get('status') == 'pending']
pending_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
unassembled_count = len([o for o in orders.values() if not is_order_fully_assembled(o)])
low_stock_count = len(get_low_stock_items(products))
if request.method == 'POST':
action = request.form.get('action')
if action == 'add_staff':
staff_name = request.form.get('staff_name', '').strip()
staff_wa = request.form.get('staff_whatsapp', '').strip()
if staff_name and staff_wa:
staff.append({'id': uuid4().hex, 'name': staff_name, 'whatsapp': staff_wa})
data['staff'] = staff
save_env_data(env_id, data)
elif action == 'delete_staff':
sid = request.form.get('staff_id')
data['staff'] = [s for s in staff if s['id'] != sid]
save_env_data(env_id, data)
elif action == 'add_catalog_user':
user_name = request.form.get('user_name', '').strip()
if user_name:
pwd = ''.join(random.choices(string.digits, k=6))
catalog_users.append({'id': uuid4().hex, 'name': user_name, 'password': pwd})
data['catalog_users'] = catalog_users
save_env_data(env_id, data)
elif action == 'delete_catalog_user':
uid = request.form.get('user_id')
data['catalog_users'] = [u for u in catalog_users if u['id'] != uid]
save_env_data(env_id, data)
elif action == 'clear_history':
data['orders'] = {}
data['inventory_history'] =[]
save_env_data(env_id, data)
flash('История продаж и заказов успешно очищена.', 'success')
elif action == 'update_settings':
settings['organization_name'] = request.form.get('organization_name', '').strip()
settings['theme'] = request.form.get('theme', 'light')
settings['business_type'] = request.form.get('business_type', 'mixed')
settings['whatsapp_number'] = request.form.get('whatsapp_number', '').strip()
settings['invoice_contacts'] = request.form.get('invoice_contacts', '').strip()
settings['currency'] = request.form.get('currency', 'T')
if settings.get('system_mode', 'both') in ['external', 'light_external']:
settings['track_inventory'] = False
settings['use_barcodes'] = False
settings['hide_stock_online'] = False
settings['closed_catalog_enabled'] = 'closed_catalog_enabled' in request.form
else:
settings['track_inventory'] = 'track_inventory' in request.form
settings['use_barcodes'] = 'use_barcodes' in request.form
settings['hide_stock_online'] = 'hide_stock_online' in request.form
settings['closed_catalog_enabled'] = 'closed_catalog_enabled' in request.form
settings['admin_password_enabled'] = 'admin_password_enabled' in request.form
settings['admin_password'] = request.form.get('admin_password', '').strip()
settings['customer_fields'] = {
'name': 'cf_name' in request.form,
'phone': 'cf_phone' in request.form,
'city': 'cf_city' in request.form,
'address': 'cf_address' in request.form,
'zip': 'cf_zip' in request.form
}
logo_file = request.files.get('logo')
if logo_file and logo_file.filename and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
ext = os.path.splitext(logo_file.filename)[1].lower()
if ext in['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg']:
logo_filename = f"logo_{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, logo_filename)
logo_file.save(temp_path)
try:
api = HfApi()
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"logos/{logo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
settings['logo_url'] = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/logos/{logo_filename}"
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
settings['socials']['wa']['enabled'] = 'wa_enabled' in request.form
settings['socials']['wa']['url'] = request.form.get('wa_url', '').strip()
settings['socials']['ig']['enabled'] = 'ig_enabled' in request.form
settings['socials']['ig']['url'] = request.form.get('ig_url', '').strip()
settings['socials']['tg']['enabled'] = 'tg_enabled' in request.form
settings['socials']['tg']['url'] = request.form.get('tg_url', '').strip()
data['settings'] = settings
save_env_data(env_id, data)
elif action == 'add_category':
cat_name = request.form.get('category_name', '').strip()
if cat_name and cat_name not in categories:
categories.append(cat_name)
data['categories'] = categories
photo_file = request.files.get('category_photo')
if photo_file and photo_file.filename:
filename = process_and_upload_image(photo_file, 'category_photos', size=(512, 512))
if filename:
data.setdefault('category_photos', {})[cat_name] = filename
save_env_data(env_id, data)
elif action == 'edit_category':
old_cat = request.form.get('old_category_name', '')
new_cat = request.form.get('new_category_name', '').strip()
if old_cat and new_cat and old_cat in categories:
if old_cat != new_cat and new_cat not in categories:
idx = categories.index(old_cat)
categories[idx] = new_cat
for p in products:
if p.get('category') == old_cat:
p['category'] = new_cat
if old_cat in data.get('category_photos', {}):
data.setdefault('category_photos', {})[new_cat] = data['category_photos'].pop(old_cat)
cat_to_update = new_cat if (old_cat != new_cat and new_cat not in categories) else old_cat
if old_cat == new_cat or (old_cat != new_cat and new_cat not in categories):
photo_file = request.files.get('category_photo')
if photo_file and photo_file.filename:
filename = process_and_upload_image(photo_file, 'category_photos', size=(512, 512))
if filename:
data.setdefault('category_photos', {})[cat_to_update] = filename
data['categories'] = categories
data['products'] = products
save_env_data(env_id, data)
elif action == 'move_category':
cat_name = request.form.get('category_name')
direction = request.form.get('direction')
if cat_name in categories:
idx = categories.index(cat_name)
if direction == 'up' and idx > 0:
categories[idx], categories[idx-1] = categories[idx-1], categories[idx]
elif direction == 'down' and idx < len(categories) - 1:
categories[idx], categories[idx+1] = categories[idx+1], categories[idx]
data['categories'] = categories
save_env_data(env_id, data)
elif action == 'delete_category':
cat_name = request.form.get('category_name')
if cat_name in categories:
categories.remove(cat_name)
data['products'] =[p for p in products if p.get('category') != cat_name]
data['categories'] = categories
if cat_name in data.get('category_photos', {}):
del data['category_photos'][cat_name]
save_env_data(env_id, data)
elif action == 'add_product':
name = request.form.get('name', '').strip()
barcode = request.form.get('barcode', '').strip()
is_available = request.form.get('is_available', '1') == '1'
price_str = request.form.get('price', '')
price = float(price_str) if price_str else ""
ppb_str = request.form.get('pieces_per_box', '')
pieces_per_box = int(ppb_str) if ppb_str else ""
bp_str = request.form.get('box_price', '')
box_price = float(bp_str) if bp_str else ""
moq_str = request.form.get('min_order', '')
min_order = int(moq_str) if moq_str else ""
stock_str = request.form.get('stock', '')
main_stock = int(stock_str) if stock_str else ""
description = request.form.get('description', '').strip()
category = request.form.get('category')
has_variant_prices = 'has_variant_prices' in request.form
main_wholesale_tiers = []
if not has_variant_prices:
mt_qtys = request.form.getlist('main_tier_qty[]')
mt_prices = request.form.getlist('main_tier_price[]')
for q, p_val in zip(mt_qtys, mt_prices):
if q and p_val:
main_wholesale_tiers.append({'qty': int(q), 'price': float(p_val)})
variant_names = request.form.getlist('variant_name[]')
variant_barcodes = request.form.getlist('variant_barcode[]')
variant_prices = request.form.getlist('variant_price[]')
variant_box_prices = request.form.getlist('variant_box_price[]')
variant_stocks = request.form.getlist('variant_stock[]')
variant_ppbs = request.form.getlist('variant_pieces_per_box[]')
variant_is_availables = request.form.getlist('variant_is_available[]')
variants = []
for i in range(len(variant_names)):
v_name = variant_names[i].strip()
if v_name:
v_price = price
v_box_price = box_price
v_is_avail = True
if i < len(variant_is_availables):
v_is_avail = variant_is_availables[i] == '1'
v_wholesale_tiers = []
if has_variant_prices:
if i < len(variant_prices) and variant_prices[i]:
v_price = float(variant_prices[i])
if i < len(variant_box_prices) and variant_box_prices[i]:
v_box_price = float(variant_box_prices[i])
vt_qtys = request.form.getlist(f'variant_{i}_tier_qty[]')
vt_prices = request.form.getlist(f'variant_{i}_tier_price[]')
for q, p_val in zip(vt_qtys, vt_prices):
if q and p_val:
v_wholesale_tiers.append({'qty': int(q), 'price': float(p_val)})
v_stock = ""
if i < len(variant_stocks) and variant_stocks[i]:
v_stock = int(variant_stocks[i])
v_barcode = ""
if i < len(variant_barcodes) and variant_barcodes[i]:
v_barcode = variant_barcodes[i].strip()
v_ppb = pieces_per_box
if i < len(variant_ppbs) and variant_ppbs[i]:
v_ppb = int(variant_ppbs[i])
variants.append({
"name": v_name,
"barcode": v_barcode,
"price": v_price,
"box_price": v_box_price,
"stock": v_stock,
"pieces_per_box": v_ppb,
"is_available": v_is_avail,
"wholesale_tiers": v_wholesale_tiers
})
uploaded_photos = request.files.getlist('photos')[:10]
photos_list =[]
if uploaded_photos and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in uploaded_photos:
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in['.jpg', '.jpeg', '.png', '.webp', '.gif']:
continue
photo_filename = f"{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
try:
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
photos_list.append(photo_filename)
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
new_product = {
'product_id': uuid4().hex,
'name': name,
'barcode': barcode,
'price': price,
'pieces_per_box': pieces_per_box,
'box_price': box_price,
'min_order': min_order,
'stock': main_stock,
'description': description,
'category': category,
'photos': photos_list,
'variants': variants,
'has_variant_prices': has_variant_prices,
'is_available': is_available,
'wholesale_tiers': main_wholesale_tiers
}
products.append(new_product)
data['products'] = products
save_env_data(env_id, data)
elif action == 'edit_product':
pid = request.form.get('product_id')
name = request.form.get('name', '').strip()
barcode = request.form.get('barcode', '').strip()
is_available = request.form.get('is_available', '1') == '1'
price_str = request.form.get('price', '')
price = float(price_str) if price_str else ""
ppb_str = request.form.get('pieces_per_box', '')
pieces_per_box = int(ppb_str) if ppb_str else ""
bp_str = request.form.get('box_price', '')
box_price = float(bp_str) if bp_str else ""
moq_str = request.form.get('min_order', '')
min_order = int(moq_str) if moq_str else ""
stock_str = request.form.get('stock', '')
main_stock = int(stock_str) if stock_str else ""
description = request.form.get('description', '').strip()
has_variant_prices = 'has_variant_prices' in request.form
main_wholesale_tiers = []
if not has_variant_prices:
mt_qtys = request.form.getlist('main_tier_qty[]')
mt_prices = request.form.getlist('main_tier_price[]')
for q, p_val in zip(mt_qtys, mt_prices):
if q and p_val:
main_wholesale_tiers.append({'qty': int(q), 'price': float(p_val)})
remove_photos = request.form.getlist('remove_photos[]')
variant_names = request.form.getlist('variant_name[]')
variant_barcodes = request.form.getlist('variant_barcode[]')
variant_prices = request.form.getlist('variant_price[]')
variant_box_prices = request.form.getlist('variant_box_price[]')
variant_stocks = request.form.getlist('variant_stock[]')
variant_ppbs = request.form.getlist('variant_pieces_per_box[]')
variant_is_availables = request.form.getlist('variant_is_available[]')
variants = []
for i in range(len(variant_names)):
v_name = variant_names[i].strip()
if v_name:
v_price = price
v_box_price = box_price
v_is_avail = True
if i < len(variant_is_availables):
v_is_avail = variant_is_availables[i] == '1'
v_wholesale_tiers = []
if has_variant_prices:
if i < len(variant_prices) and variant_prices[i]:
v_price = float(variant_prices[i])
if i < len(variant_box_prices) and variant_box_prices[i]:
v_box_price = float(variant_box_prices[i])
vt_qtys = request.form.getlist(f'variant_{i}_tier_qty[]')
vt_prices = request.form.getlist(f'variant_{i}_tier_price[]')
for q, p_val in zip(vt_qtys, vt_prices):
if q and p_val:
v_wholesale_tiers.append({'qty': int(q), 'price': float(p_val)})
v_stock = ""
if i < len(variant_stocks) and variant_stocks[i]:
v_stock = int(variant_stocks[i])
v_barcode = ""
if i < len(variant_barcodes) and variant_barcodes[i]:
v_barcode = variant_barcodes[i].strip()
v_ppb = pieces_per_box
if i < len(variant_ppbs) and variant_ppbs[i]:
v_ppb = int(variant_ppbs[i])
variants.append({
"name": v_name,
"barcode": v_barcode,
"price": v_price,
"box_price": v_box_price,
"stock": v_stock,
"pieces_per_box": v_ppb,
"is_available": v_is_avail,
"wholesale_tiers": v_wholesale_tiers
})
uploaded_photos = request.files.getlist('photos')[:10]
photos_list =[]
if uploaded_photos and uploaded_photos[0].filename and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in uploaded_photos:
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in['.jpg', '.jpeg', '.png', '.webp', '.gif']:
continue
photo_filename = f"{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
try:
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
photos_list.append(photo_filename)
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
for p in products:
if p.get('product_id') == pid:
p['name'] = name
p['barcode'] = barcode
p['price'] = price
p['pieces_per_box'] = pieces_per_box
p['box_price'] = box_price
p['min_order'] = min_order
p['stock'] = main_stock
p['description'] = description
p['variants'] = variants
p['has_variant_prices'] = has_variant_prices
p['is_available'] = is_available
p['wholesale_tiers'] = main_wholesale_tiers
existing_photos = p.get('photos', [])
existing_photos =[ph for ph in existing_photos if ph not in remove_photos]
p['photos'] = existing_photos + photos_list
break
data['products'] = products
save_env_data(env_id, data)
elif action == 'move_product':
pid = request.form.get('product_id')
direction = request.form.get('direction')
idx = -1
for i, p in enumerate(products):
if p.get('product_id') == pid:
idx = i
break
if idx != -1:
cat = products[idx].get('category')
if direction == 'up':
swap_idx = -1
for i in range(idx - 1, -1, -1):
if products[i].get('category') == cat:
swap_idx = i
break
if swap_idx != -1:
products[idx], products[swap_idx] = products[swap_idx], products[idx]
elif direction == 'down':
swap_idx = -1
for i in range(idx + 1, len(products)):
if products[i].get('category') == cat:
swap_idx = i
break
if swap_idx != -1:
products[idx], products[swap_idx] = products[swap_idx], products[idx]
data['products'] = products
save_env_data(env_id, data)
elif action == 'delete_product':
pid = request.form.get('product_id')
data['products'] =[p for p in products if p.get('product_id') != pid]
save_env_data(env_id, data)
return redirect(url_for('admin', env_id=env_id))
return render_template_string(
ADMIN_TEMPLATE,
products=products,
categories=categories,
category_photos=category_photos,
repo_id=REPO_ID,
currency_code=settings.get('currency', 'T'),
env_id=env_id,
settings=settings,
staff=staff,
catalog_users=catalog_users,
pending_orders=pending_orders,
unassembled_count=unassembled_count,
low_stock_count=low_stock_count
)
@app.route('/<env_id>/force_upload', methods=['POST'])
def force_upload(env_id):
upload_db_to_hf()
return redirect(url_for('admin', env_id=env_id))
@app.route('/<env_id>/force_download', methods=['POST'])
def force_download(env_id):
download_db_from_hf()
return redirect(url_for('admin', env_id=env_id))
if __name__ == '__main__':
download_db_from_hf()
load_data()
if HF_TOKEN_WRITE:
threading.Thread(target=periodic_backup, daemon=True).start()
port = int(os.environ.get('PORT', 7860))
app.run(host='0.0.0.0', port=port)