mails / app.py
Kgshop's picture
Update app.py
03c192d verified
Raw
History Blame Contribute Delete
427 kB
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
import math
from PIL import Image, ImageOps
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, session, Response
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()
TRANSLATIONS = {
'ru': {},
'kk': {
'Каталог': 'Каталог',
'Поиск товаров...': 'Тауарларды іздеу...',
'Поиск': 'Іздеу',
'Ничего не найдено': 'Ештеңе табылмады',
'В этой категории пока нет товаров': 'Бұл санатта әзірге тауарлар жоқ',
'В упаковке:': 'Қаптамада:',
'В коробке:': 'Қорапта:',
'Мин. заказ:': 'Мин. тапсырыс:',
'Нет в наличии': 'Қолжетімсіз',
'Остаток:': 'Қалдық:',
'От': 'Бастап',
'шт': 'дана',
'кор.': 'қор.',
'уп.': 'қап.',
'+ Упаковка': '+ Қаптама',
'+ Коробка': '+ Қорап',
'Сумма заказа:': 'Тапсырыс сомасы:',
'Корзина': 'Себет',
'Ваш заказ': 'Сіздің тапсырысыңыз',
'Имя клиента (необязательно)': 'Клиенттің аты (міндетті емес)',
'Ваше Имя': 'Сіздің атыңыз',
'Номер телефона': 'Телефон нөмірі',
'Город': 'Қала',
'Адрес доставки': 'Жеткізу мекенжайы',
'Индекс': 'Индекс',
'Оформить заказ': 'Тапсырыс беру',
'Оформление...': 'Рәсімдеу...',
'Админ-панель': 'Әкімші тақтасы',
'Отчеты': 'Есептер',
'Остатки': 'Қалдықтар',
'История накладных и заказов': 'Жүкқұжаттар мен тапсырыстар тарихы',
'Сборка': 'Құрастыру',
'В каталог': 'Каталогқа',
'Выход': 'Шығу',
'Сохранить на сервер': 'Серверге сақтау',
'Скачать с сервера': 'Серверден жүктеу',
'Онлайн заказы': 'Онлайн тапсырыстар',
'Пользователи (Закрытый каталог)': 'Пайдаланушылар (Жабық каталог)',
'Персонал': 'Қызметкерлер',
'Настройка магазина': 'Дүкен параметрлері',
'Управление категориями': 'Санаттарды басқару',
'Добавить товар': 'Тауар қосу',
'Название товара': 'Тауар атауы',
'Наличие': 'Болуы',
'Штрих-код': 'Штрих-код',
'Цена за ед.': 'Бір данасының бағасы',
'В уп. (шт)': 'Қаптамада (дана)',
'В кор. (шт)': 'Қорапта (дана)',
'Цена за упаковку': 'Қаптама бағасы',
'Мин. заказ (шт)': 'Мин. тапсырыс (дана)',
'Остаток на складе': 'Қоймадағы қалдық',
'Описание товара': 'Тауар сипаттамасы',
'Фотографии товара (до 10 шт)': 'Тауар фотосуреттері (10 дейін)',
'Сохранить товар': 'Тауарды сақтау',
'Да': 'Иә',
'Нет': 'Жоқ',
'Варианты товара': 'Тауар нұсқалары',
'Язык интерфейса': 'Интерфейс тілі',
'Учет коробок': 'Қораптар есебі',
'Включить учет больших коробок': 'Үлкен қораптар есебін қосу',
'Общая скидка на чек (сумма)': 'Чектегі жалпы жеңілдік (сома)',
'WhatsApp клиента (напр. +77001234567) необязательно': 'Клиенттің WhatsApp (мыс. +77001234567) міндетті емес',
'Режим кассы: Быстрое оформление': 'Касса режимі: Тез рәсімдеу',
'Сохраненные кассы': 'Сақталған кассалар',
'Новая касса': 'Жаңа касса',
'Свой товар': 'Өз тауарыңыз'
},
'uz': {
'Каталог': 'Katalog',
'Поиск товаров...': 'Tovarlarni qidirish...',
'Поиск': 'Qidirish',
'Ничего не найдено': 'Hech narsa topilmadi',
'В этой категории пока нет товаров': 'Ushbu toifada hozircha tovarlar yoʻq',
'В упаковке:': 'Oʻramda:',
'В коробке:': 'Qutida:',
'Мин. заказ:': 'Min. buyurtma:',
'Нет в наличии': 'Mavjud emas',
'Остаток:': 'Qoldiq:',
'От': 'Dan',
'шт': 'dona',
'кор.': 'quti',
'уп.': 'oʻram',
'+ Упаковка': '+ Oʻram',
'+ Коробка': '+ Quti',
'Сумма заказа:': 'Buyurtma summasi:',
'Корзина': 'Savat',
'Ваш заказ': 'Sizning buyurtmangiz',
'Имя клиента (необязательно)': 'Mijoz ismi (majburiy emas)',
'Ваше Имя': 'Ismingiz',
'Номер телефона': 'Telefon raqami',
'Город': 'Shahar',
'Адрес доставки': 'Yetkazib berish manzili',
'Индекс': 'Indeks',
'Оформить заказ': 'Buyurtma berish',
'Оформление...': 'Rasmiylashtirilmoqda...',
'Админ-панель': 'Admin panel',
'Отчеты': 'Hisobotlar',
'Остатки': 'Qoldiqlar',
'История накладных и заказов': 'Yukxat va buyurtmalar tarixi',
'Сборка': 'Yigʻish',
'В каталог': 'Katalogga',
'Выход': 'Chiqish',
'Сохранить на сервер': 'Serverga saqlash',
'Скачать с сервера': 'Serverdan yuklab olish',
'Онлайн заказы': 'Onlayn buyurtmalar',
'Пользователи (Закрытый каталог)': 'Foydalanuvchilar (Yopiq katalog)',
'Персонал': 'Xodimlar',
'Настройка магазина': 'Do\'kon sozlamalari',
'Управление категориями': 'Toifalarni boshqarish',
'Добавить товар': 'Tovar qo\'shish',
'Название товара': 'Tovar nomi',
'Наличие': 'Mavjudligi',
'Штрих-код': 'Shtrix-kod',
'Цена за ед.': 'Dona narxi',
'В уп. (шт)': 'O\'ramda (dona)',
'В кор. (шт)': 'Qutida (dona)',
'Цена за упаковку': 'O\'ram narxi',
'Мин. заказ (шт)': 'Min. buyurtma (dona)',
'Остаток на складе': 'Ombordagi qoldiq',
'Описание товара': 'Tovar tavsifi',
'Фотографии товара (до 10 шт)': 'Tovar rasmlari (10 tagacha)',
'Сохранить товар': 'Tovarni saqlash',
'Да': 'Ha',
'Нет': 'Yo\'q',
'Варианты товара': 'Tovar variantlari',
'Язык интерфейса': 'Interfeys tili',
'Учет коробок': 'Qutilar hisobi',
'Включить учет больших коробок': 'Katta qutilar hisobini yoqish',
'Общая скидка на чек (сумма)': 'Chek uchun umumiy chegirma (summa)',
'WhatsApp клиента (напр. +77001234567) необязательно': 'Mijoz WhatsApp (mas. +77001234567) majburiy emas',
'Режим кассы: Быстрое оформление': 'Kassa rejimi: Tez rasmiylashtirish',
'Сохраненные кассы': 'Saqlangan kassalar',
'Новая касса': 'Yangi kassa',
'Свой товар': 'O\'z tovaringiz'
}
}
def get_t(lang='ru'):
def t(text):
if not isinstance(text, str): return text
return TRANSLATIONS.get(lang, {}).get(text, text)
return t
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':[],
'purchase_orders': [],
'debt_history': [],
'settings': {
'organization_name': 'Default Shop',
'admin_password_enabled': False,
'admin_password': '',
'logo_url': DEFAULT_LOGO_URL,
'qr_code_1_url': '',
'qr_code_2_url': '',
'catalog_subtitle': '',
'whatsapp_number': DEFAULT_WHATSAPP_NUMBER,
'order_messenger': 'wa',
'order_contact': DEFAULT_WHATSAPP_NUMBER,
'invoice_contacts': '',
'currency': '₸',
'track_inventory': False,
'use_barcodes': False,
'use_master_boxes': False,
'margin_tracking': False,
'language': 'ru',
'business_type': 'mixed',
'system_mode': 'both',
'hide_stock_online': False,
'closed_catalog_enabled': False,
'commission_enabled': False,
'commission_percent': 0.0,
'theme': 'light',
'is_deleted': False,
'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'},
'kaspi': {'enabled': False, 'url': ''}
}
}
}
}
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 'purchase_orders' not in env_data: env_data['purchase_orders'] = []; changed = True
if 'debt_history' not in env_data: env_data['debt_history'] = []; changed = True
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 'qr_code_1_url' not in settings: settings['qr_code_1_url'] = ''; changed = True
if 'qr_code_2_url' not in settings: settings['qr_code_2_url'] = ''; changed = True
if 'catalog_subtitle' not in settings: settings['catalog_subtitle'] = ''; changed = True
if 'whatsapp_number' not in settings: settings['whatsapp_number'] = DEFAULT_WHATSAPP_NUMBER; changed = True
if 'order_messenger' not in settings: settings['order_messenger'] = 'wa'; changed = True
if 'order_contact' not in settings: settings['order_contact'] = settings.get('whatsapp_number', DEFAULT_WHATSAPP_NUMBER); changed = True
if 'invoice_contacts' not in settings: settings['invoice_contacts'] = ''; changed = True
if 'is_deleted' not in settings: settings['is_deleted'] = False; changed = True
if 'currency' not in settings: settings['currency'] = '₸'; changed = True
elif settings['currency'] == 'T': settings['currency'] = '₸'; 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 'use_master_boxes' not in settings: settings['use_master_boxes'] = False; changed = True
if 'margin_tracking' not in settings: settings['margin_tracking'] = False; changed = True
if 'language' not in settings: settings['language'] = 'ru'; 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 'commission_enabled' not in settings: settings['commission_enabled'] = False; changed = True
if 'commission_percent' not in settings: settings['commission_percent'] = 0.0; 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'},
'kaspi': {'enabled': False, 'url': ''}
}
changed = True
else:
if 'kaspi' not in settings['socials']:
settings['socials']['kaspi'] = {'enabled': False, 'url': ''}
changed = True
for product in env_data['products']:
if 'created_at' not in product: product['created_at'] = '2000-01-01 00:00:00'; changed = True
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 'pieces_per_master_box' not in product: product['pieces_per_master_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 'cost_price' not in product: product['cost_price'] = ""; 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 'cost_price' not in v: v['cost_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 'pieces_per_master_box' not in v: v['pieces_per_master_box'] = product.get('pieces_per_master_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
if 'photo' not in v: v['photo'] = ""; 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
if 'commission_amount' not in order: order['commission_amount'] = 0; changed = True
if 'is_debt' not in order: order['is_debt'] = False; changed = True
if 'paid_amount' not in order: order['paid_amount'] = order.get('total_price', 0); changed = True
if 'debt_amount' not in order: order['debt_amount'] = 0; changed = True
for item in order.get('cart', []):
if 'discount' not in item: item['discount'] = 0; changed = True
if 'cost_price' not in item: item['cost_price'] = 0; changed = True
if 'category' not in item: item['category'] = 'Без категории'; changed = True
if 'wholesale_tiers' not in item: item['wholesale_tiers'] = []; changed = True
if 'pieces_per_master_box' not in item: item['pieces_per_master_box'] = 1; 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':[],
'purchase_orders': [],
'debt_history': [],
'settings': {
'organization_name': f'Shop {env_id}',
'admin_password_enabled': False,
'admin_password': '',
'logo_url': DEFAULT_LOGO_URL,
'qr_code_1_url': '',
'qr_code_2_url': '',
'catalog_subtitle': '',
'whatsapp_number': DEFAULT_WHATSAPP_NUMBER if 'DEFAULT_WHATSAPP_NUMBER' in globals() else "+77470623684",
'order_messenger': 'wa',
'order_contact': DEFAULT_WHATSAPP_NUMBER if 'DEFAULT_WHATSAPP_NUMBER' in globals() else "+77470623684",
'invoice_contacts': '',
'currency': '₸',
'track_inventory': False,
'use_barcodes': False,
'use_master_boxes': False,
'margin_tracking': False,
'language': 'ru',
'business_type': 'mixed',
'system_mode': 'both',
'hide_stock_online': False,
'closed_catalog_enabled': False,
'commission_enabled': False,
'commission_percent': 0.0,
'theme': 'light',
'is_deleted': False,
'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': ''},
'kaspi': {'enabled': False, '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)
@app.before_request
def check_deleted_env():
if request.endpoint and request.view_args and 'env_id' in request.view_args:
env_id = request.view_args['env_id']
if env_id in ['admhosto', 'default_env']:
return
all_data = load_data()
if env_id in all_data and all_data[env_id].get('settings', {}).get('is_deleted', False):
return "<h1 style='text-align:center; margin-top:20vh; font-family:sans-serif;'>Среда отключена</h1>", 403
def update_order_totals(order, settings):
business_type = settings.get('business_type', 'mixed')
commission_enabled = settings.get('commission_enabled', False)
commission_percent = float(settings.get('commission_percent', 0.0))
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)
if commission_enabled and commission_percent > 0:
commission_amount = total * (commission_percent / 100.0)
order['commission_amount'] = round(commission_amount, 2)
total += commission_amount
else:
order['commission_amount'] = 0
order['total_price'] = round(total, 2)
if order.get('is_debt', False):
order['debt_amount'] = round(order['total_price'] - float(order.get('paid_amount', 0)), 2)
if order['debt_amount'] <= 0:
order['is_debt'] = False
order['debt_amount'] = 0
order['paid_amount'] = order['total_price']
else:
order['paid_amount'] = order['total_price']
order['debt_amount'] = 0
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:
if item.get('is_custom', False):
continue
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: 16px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 16px; }
button { width: 100%; padding: 16px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 16px; transition: background 0.3s; min-height: 44px; touch-action: manipulation; }
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: 16px; margin-bottom: 20px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; font-size: 16px; text-align: center; letter-spacing: 2px; }
button { width: 100%; padding: 16px; background-color: #48D1CC; color: #003C43; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 16px; transition: background 0.3s; min-height: 44px; touch-action: manipulation; }
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; margin: 0; }
.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: 12px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; font-size: 16px; font-family: 'Montserrat', sans-serif; }
.button { padding: 12px 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; min-height: 44px; touch-action: manipulation; }
.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: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; }
.env-pwd select { padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-family: inherit; font-size: 16px; }
.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">
<h2>Активные среды</h2>
<ul class="env-list">
{% for env in active_envs %}
<li class="env-item active-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: 8px 12px; font-size: 0.9rem;">Сохранить</button>
</form>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="section" style="margin-top: 40px; padding-top: 20px; border-top: 2px dashed #ccc;">
<h2 style="color: #e17055;">Архив сред</h2>
<ul class="env-list">
{% for env in archived_envs %}
<li class="env-item archive-env-item">
<div class="env-header">
<span class="env-id" style="color: #636e72;">{{ env.org_name }} (ID: {{ env.id }})</span>
<div class="env-actions">
<form method="POST" action="/admhosto/restore/{{ env.id }}" style="display:inline;">
<button type="submit" class="button" style="background:#27ae60;"><i class="fas fa-undo"></i> Восстановить</button>
</form>
<form method="POST" action="/admhosto/hard_delete/{{ 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>
</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 5px 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 input { width: 100%; padding: 15px 10px; border: none; background: transparent; outline: none; font-size: 16px; color: var(--text); }
.stories-wrapper { display: flex; gap: 15px; padding: 15px 20px; background: var(--surface); margin-bottom: 1px; border-bottom: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
.stories-wrapper::-webkit-scrollbar { display: none; }
.story-item { display: inline-flex; flex-direction: column; align-items: center; width: 72px; cursor: pointer; text-decoration: none; }
.story-img-wrap { width: 64px; height: 64px; border-radius: 50%; padding: 3px; background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%); margin-bottom: 6px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
.story-img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; border: 2px solid var(--surface); background: var(--bg); }
.story-title { font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%; text-align: center; color: var(--text); font-weight: 500; }
.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; cursor: pointer; transition: color 0.3s ease; }
.product-desc.expanded { -webkit-line-clamp: unset; color: var(--text); }
.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: 36px; height: 36px; font-size: 1.1rem; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; transition: background 0.2s; touch-action: manipulation; }
.quantity-control button:active { background: var(--border); }
.quantity-control button:disabled { color: #555; cursor: not-allowed; }
.quantity-control input { width: 44px; height: 36px; border: none; text-align: center; background: transparent; font-weight: 600; font-size: 16px; 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 12px; height: 36px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; touch-action: manipulation; }
.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: 14px 28px; border: none; border-radius: 12px; font-weight: 600; font-size: 16px; cursor: pointer; box-shadow: 0 4px 12px rgba(26,26,26,0.2); transition: transform 0.2s; touch-action: manipulation; }
.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: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--text); touch-action: manipulation; }
.customer-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 25px; }
.customer-form input { padding: 16px; border: 1px solid var(--border); border-radius: 12px; font-size: 16px; background: var(--bg); color: var(--text); outline: none; transition: border-color 0.2s; box-sizing: border-box; width: 100%; }
.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: 40px; height: 40px; font-size: 1.1rem; cursor: pointer; color: var(--primary); touch-action: manipulation; }
.cart-item-controls button:active { background: var(--border); }
.cart-item-controls input { width: 45px; text-align: center; font-weight: 600; font-size: 16px; 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.2rem; cursor: pointer; padding: 10px; touch-action: manipulation; }
.confirm-btn { background: var(--accent); color: #fff; width: 100%; padding: 16px; border: none; border-radius: 14px; font-size: 16px; font-weight: 700; cursor: pointer; box-shadow: 0 4px 15px rgba(37,211,102,0.3); min-height: 50px; touch-action: manipulation; }
.confirm-btn:disabled { background: #999; cursor: not-allowed; box-shadow: none; }
.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: 44px; height: 44px; 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; }
.gallery-text { position: absolute; bottom: 80px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 1.1rem; background: rgba(0,0,0,0.6); padding: 6px 16px; border-radius: 20px; text-align: center; z-index: 301; font-weight: 600; display: none; }
.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: 52px; height: 52px; 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-kaspi { background: #f14635; }
.btn-float-returns { background: #e17055; }
.btn-float-history { background: #0984e3; }
.multi-cart-btn {
background: #8e44ad;
position: relative;
}
.cart-badge-count {
position: absolute;
top: -5px;
right: -5px;
background: #ff7675;
color: white;
font-size: 0.8rem;
font-weight: bold;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.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: 60px; padding: 8px; border: 1px solid #ccc; border-radius: 6px; text-align: center; font-size: 16px; }
.process-return-btn { background: #e17055; color: white; border: none; padding: 12px 15px; border-radius: 8px; cursor: pointer; margin-top: 10px; width: 100%; font-weight: 600; min-height: 44px; }
.history-btn { background: #0984e3; color: white; border: none; padding: 12px 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; min-height: 44px; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 8px; padding: 20px; flex-wrap: wrap; margin-bottom: 20px; }
.pagination button { background: var(--surface); border: 1px solid var(--border); color: var(--text); width: 44px; height: 44px; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; justify-content: center; align-items: center; font-size: 16px; touch-action: manipulation; }
.pagination button.active { background: var(--primary); color: #fff; border-color: var(--primary); }
.pagination button:active { transform: scale(0.95); }
.pagination span { color: var(--text-muted); }
{% if settings.theme in ['dark', 'magma', 'ocean', 'forest', 'cyberpunk'] %}
.category-item, .product-card, .header, .search-bar, .cart-bar, .modal-content, .stories-wrapper { 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">{{ t('Режим кассы: Быстрое оформление') }}</div>
{% endif %}
<div class="top-logo-container" style="flex-direction: column;">
<img src="{{ settings.logo_url }}" class="top-logo" alt="Логотип">
{% if settings.catalog_subtitle %}
<div style="margin-top: 10px; font-weight: 600; font-size: 0.95rem; color: var(--text);">{{ settings.catalog_subtitle }}</div>
{% endif %}
</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">
<input type="text" id="searchInput" placeholder="{{ t('Поиск товаров...') }}" onkeydown="if(event.key==='Enter') filterCategories()">
<button class="btn" style="background:var(--primary); color:#fff; border:none; padding:10px 15px; border-radius:8px; cursor:pointer;" onclick="filterCategories()"><i class="fas fa-search"></i></button>
{% if settings.use_barcodes %}
<i class="fas fa-barcode" style="cursor:pointer; padding: 10px; color: var(--primary); margin-left: 5px; font-size: 1.2rem;" onclick="startScanner(val => { document.getElementById('searchInput').value = val; filterCategories(); })"></i>
{% endif %}
{% if mode == 'pos' %}
<button class="btn" style="background:#8e44ad; color:#fff; border:none; padding:10px 15px; border-radius:8px; cursor:pointer; margin-left:5px;" onclick="openCustomProductModal()" title="{{ t('Свой товар') }}"><i class="fas fa-plus"></i></button>
{% endif %}
</div>
</div>
<div class="stories-wrapper" id="storiesWrapper" style="display: none;"></div>
<div class="categories-container" id="categoriesContainer"></div>
<div class="products-container" id="productsContainer"></div>
<div class="floating-socials">
{% if mode == 'pos' %}
<a href="#" class="social-btn multi-cart-btn" onclick="openMultiCartModal()">
<i class="fas fa-cash-register"></i>
<div class="cart-badge-count" id="cartCountBadge">1</div>
</a>
{% if 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 %}
{% 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 %}
{% if settings.currency == '₸' and settings.socials.kaspi.enabled and settings.socials.kaspi.url %}
{% for link in settings.socials.kaspi.url.split() %}
{% if link.strip() %}
<a href="{{ link.strip() }}" class="social-btn btn-float-kaspi" target="_blank"><i class="fas fa-shopping-bag"></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;">{{ t('Сумма заказа:') }}</span>
<span class="cart-total"><span id="cartTotalSum">0</span> {{ currency_code }}</span>
</div>
<button class="checkout-btn" onclick="openCartModal()">{{ t('Корзина') }} <i class="fas fa-shopping-bag" style="margin-left:5px;"></i></button>
</div>
<div class="modal-overlay" id="multiCartModal" onclick="if(event.target === this) closeMultiCartModal()">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('Сохраненные кассы') }}</h2>
<button class="modal-close" onclick="closeMultiCartModal()"><i class="fas fa-times"></i></button>
</div>
<div id="multiCartList" style="display:flex; flex-direction:column; gap:10px; margin-bottom: 20px;"></div>
<button class="confirm-btn" style="background:var(--info);" onclick="createNewCart()">+ {{ t('Новая касса') }}</button>
</div>
</div>
<div class="modal-overlay" id="customProductModal" onclick="if(event.target === this) closeCustomProductModal()">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('Свой товар') }}</h2>
<button class="modal-close" onclick="closeCustomProductModal()"><i class="fas fa-times"></i></button>
</div>
<div class="customer-form">
<input type="text" id="cpName" placeholder="Название товара">
<input type="number" id="cpQty" placeholder="Количество" min="1">
<input type="number" id="cpPrice" placeholder="Цена за ед." min="0" step="0.01">
<label style="font-size: 0.9rem; margin-top: 10px;">Фото (опционально)</label>
<input type="file" id="cpPhoto" accept="image/*">
</div>
<button class="confirm-btn" id="cpSubmitBtn" onclick="addCustomProduct()">Добавить в накладную</button>
</div>
</div>
<div class="modal-overlay" id="cartModal" onclick="if(event.target === this) closeCartModal()">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('Ваш заказ') }}</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;">{{ t('Общая скидка на чек (сумма)') }}</label>
<input type="number" id="globalDiscountVal" value="0" min="0" onchange="updateCartUI()" style="width: 100%; border:none; background:var(--surface); padding:12px; border-radius:8px; font-weight:600; font-size:16px; outline:none; color: var(--text);">
</div>
<input type="text" id="custNamePos" placeholder="{{ t('Имя клиента (необязательно)') }}">
<input type="text" id="custWhatsapp" placeholder="{{ t('WhatsApp клиента (напр. +77001234567) необязательно') }}">
<div style="margin-top: 15px; background: var(--bg); padding: 15px; border-radius: 12px; border: 1px solid var(--border);">
<label style="display:flex; align-items:center; gap:10px; font-weight:600; cursor:pointer;">
<input type="checkbox" id="isDebtPos" onchange="toggleDebtFields()" style="width:auto; height: 20px;"> Оформить в долг
</label>
<div id="debtFields" style="display:none; margin-top:10px;">
<label style="font-size: 0.9rem; font-weight: 600; display:block; margin-bottom:5px;">Оплачено сейчас ({{ currency_code }}):</label>
<input type="number" id="paidAmountPos" min="0" value="0" onchange="validateDebtAmount()" style="width: 100%; border:none; background:var(--surface); padding:12px; border-radius:8px; font-weight:600; font-size:16px; outline:none; color: var(--text);">
<div style="font-size:0.85rem; color:#e17055; margin-top:5px;" id="debtRemainder">В долг: 0 {{ currency_code }}</div>
</div>
</div>
{% else %}
{% if settings.customer_fields.name %} <input type="text" id="custName" placeholder="{{ t('Ваше Имя') }}" required> {% endif %}
{% if settings.customer_fields.phone %} <input type="text" id="custPhone" placeholder="{{ t('Номер телефона') }}" required> {% endif %}
{% if settings.customer_fields.city %} <input type="text" id="custCity" placeholder="{{ t('Город') }}" required> {% endif %}
{% if settings.customer_fields.address %} <input type="text" id="custAddress" placeholder="{{ t('Адрес доставки') }}" required> {% endif %}
{% if settings.customer_fields.zip %} <input type="text" id="custZip" placeholder="{{ t('Индекс') }}" required> {% endif %}
{% endif %}
</div>
<button class="confirm-btn" onclick="submitOrder()">{{ t('Оформить заказ') }}</button>
</div>
</div>
<div class="modal-overlay" id="returnsModal" onclick="if(event.target === this) closeReturnsModal()">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('Мои продажи (Возврат)') }}</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> {{ t('Загрузка...') }}</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>{{ t('Мои накладные') }}</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: 12px; border: 1px solid var(--border); border-radius: 8px; flex: 1; background: var(--bg); color: var(--text); font-size:16px;" onchange="loadStaffHistory()">
</div>
<div id="staffHistoryContent" class="returns-list">
<div style="text-align:center; padding:20px;"><i class="fas fa-spinner fa-spin"></i> {{ t('Загрузка...') }}</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">
<div class="gallery-text" id="galleryText"></div>
<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:12px 20px; border-radius:8px; font-weight:bold; cursor:pointer; width:100%; font-size:16px;" 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 useMasterBoxes = {{ 'true' if settings.use_master_boxes else 'false' }};
const businessType = '{{ settings.business_type }}';
const cFields = {{ settings.customer_fields|tojson }};
const commissionEnabled = {{ 'true' if settings.commission_enabled else 'false' }};
const commissionPercent = {{ settings.commission_percent | default(0) }};
let cartsStore = { active: 'cart_' + Date.now(), sessions: {} };
let cart = {};
let currentGalleryPhotos =[];
let currentGalleryIndex = 0;
let cartTotalCurrent = 0;
let currentPage = 1;
const itemsPerPage = 20;
let currentView = 'categories';
let currentCategory = null;
let currentSearchQuery = '';
function initCart() {
const lsKey = `metastore_multi_${envId}_${mode}`;
try {
const saved = localStorage.getItem(lsKey);
if (saved) { cartsStore = JSON.parse(saved); }
} catch(e){}
if (!cartsStore.active || !cartsStore.sessions) {
cartsStore = { active: 'cart_' + Date.now(), sessions: {} };
}
if (Object.keys(cartsStore.sessions).length === 0) {
cartsStore.sessions[cartsStore.active] = {};
}
if (!cartsStore.sessions[cartsStore.active]) {
cartsStore.active = Object.keys(cartsStore.sessions)[0];
}
cart = cartsStore.sessions[cartsStore.active];
if(document.getElementById('cartCountBadge')) {
document.getElementById('cartCountBadge').innerText = Object.keys(cartsStore.sessions).length;
}
}
function saveCartsState() {
const lsKey = `metastore_multi_${envId}_${mode}`;
try { localStorage.setItem(lsKey, JSON.stringify(cartsStore)); } catch(e){}
if(document.getElementById('cartCountBadge')) {
document.getElementById('cartCountBadge').innerText = Object.keys(cartsStore.sessions).length;
}
}
function openMultiCartModal() {
renderMultiCartList();
const modal = document.getElementById('multiCartModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
}
function closeMultiCartModal() {
const modal = document.getElementById('multiCartModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function renderMultiCartList() {
const list = document.getElementById('multiCartList');
list.innerHTML = '';
for (let cId in cartsStore.sessions) {
const c = cartsStore.sessions[cId];
const itemsCount = Object.keys(c).length;
let total = 0;
for (let k in c) total += calculateItemPrice(c[k]);
const isActive = cId === cartsStore.active;
list.innerHTML += `
<div style="display:flex; justify-content:space-between; align-items:center; padding:15px; background:var(--bg); border:1px solid ${isActive?'var(--primary)':'var(--border)'}; border-radius:12px;">
<div onclick="switchCart('${cId}')" style="flex:1; cursor:pointer;">
<div style="font-weight:600;">${isActive ? '✅ ' : ''}Касса ${cId.split('_')[1].substring(0,6)}</div>
<div style="font-size:0.85rem; color:var(--text-muted);">Позиций: ${itemsCount} | Сумма: ${Math.round(total*100)/100}</div>
</div>
<button onclick="deleteCart('${cId}')" style="background:none; border:none; color:var(--danger); font-size:1.2rem; cursor:pointer; padding:10px;"><i class="fas fa-trash-alt"></i></button>
</div>
`;
}
}
function createNewCart() {
const newId = 'cart_' + Date.now();
cartsStore.sessions[newId] = {};
switchCart(newId);
}
function switchCart(cId) {
if (cartsStore.sessions[cId]) {
cartsStore.active = cId;
cart = cartsStore.sessions[cId];
saveCartsState();
updateCartUI();
if (currentView === 'products') showProducts(currentCategory, currentPage);
else if (currentView === 'search') filterCategories(currentPage);
closeMultiCartModal();
}
}
function deleteCart(cId) {
if (Object.keys(cartsStore.sessions).length <= 1) {
cartsStore.sessions[cId] = {};
} else {
delete cartsStore.sessions[cId];
if (cartsStore.active === cId) {
cartsStore.active = Object.keys(cartsStore.sessions)[0];
}
}
cart = cartsStore.sessions[cartsStore.active];
saveCartsState();
renderMultiCartList();
updateCartUI();
if (currentView === 'products') showProducts(currentCategory, currentPage);
else if (currentView === 'search') filterCategories(currentPage);
}
function renderStories() {
const now = new Date();
const newArrivals = products.filter(p => {
if (!p.created_at) return false;
const parts = p.created_at.split(/[- :]/);
if(parts.length !== 6) return false;
const pDate = new Date(Date.UTC(parts[0], parts[1]-1, parts[2], parts[3], parts[4], parts[5]));
const diffDays = (now - pDate) / (1000 * 3600 * 24);
return diffDays >= 0 && diffDays <= 2;
});
const wrapper = document.getElementById('storiesWrapper');
if(newArrivals.length > 0) {
wrapper.style.display = 'flex';
wrapper.innerHTML = '';
newArrivals.forEach(p => {
let photoUrl = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+';
if(p.photos && p.photos.length > 0) {
photoUrl = `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.photos[0]}`;
} else if(p.variants && p.variants.length > 0 && p.variants[0].photo) {
photoUrl = `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.variants[0].photo}`;
}
wrapper.innerHTML += `
<div class="story-item" onclick="openStory('${p.name.replace(/'/g, "\\'")}')">
<div class="story-img-wrap">
<img src="${photoUrl}" class="story-img" loading="lazy">
</div>
<div class="story-title">${p.name}</div>
</div>
`;
});
} else {
wrapper.style.display = 'none';
}
}
function openStory(name) {
document.getElementById('searchInput').value = name;
filterCategories(1);
}
function init() {
initCart();
renderStories();
renderCategories(1);
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 renderPagination(totalPages, currentPage, funcName) {
let pagContainer = document.getElementById('paginationContainer');
if (!pagContainer) {
pagContainer = document.createElement('div');
pagContainer.id = 'paginationContainer';
pagContainer.className = 'pagination';
const cartBar = document.getElementById('cartBar');
document.body.insertBefore(pagContainer, cartBar);
}
if (totalPages <= 1) {
pagContainer.innerHTML = '';
pagContainer.style.display = 'none';
return;
}
pagContainer.style.display = 'flex';
let html = '';
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, currentPage + 2);
if (currentPage > 1) {
html += `<button onclick="handlePagination('${funcName}', ${currentPage - 1})"><i class="fas fa-chevron-left"></i></button>`;
}
if (startPage > 1) {
html += `<button onclick="handlePagination('${funcName}', 1)">1</button>`;
if (startPage > 2) html += `<span>...</span>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `<button class="${i === currentPage ? 'active' : ''}" onclick="handlePagination('${funcName}', ${i})">${i}</button>`;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) html += `<span>...</span>`;
html += `<button onclick="handlePagination('${funcName}', ${totalPages})">${totalPages}</button>`;
}
if (currentPage < totalPages) {
html += `<button onclick="handlePagination('${funcName}', ${currentPage + 1})"><i class="fas fa-chevron-right"></i></button>`;
}
pagContainer.innerHTML = html;
}
function handlePagination(funcName, page) {
window.scrollTo({top: 0, behavior: 'smooth'});
if (funcName === 'renderCategories') renderCategories(page);
else if (funcName === 'showProducts') showProducts(currentCategory, page);
else if (funcName === 'filterCategories') filterCategories(page);
}
function renderCategories(page = 1) {
currentPage = page;
currentView = 'categories';
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 = '{{ t("Каталог") }}';
const stories = document.getElementById('storiesWrapper');
if(stories && stories.innerHTML.trim() !== '') stories.style.display = 'flex';
container.innerHTML = '';
const totalPages = Math.ceil(categoriesList.length / itemsPerPage);
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const paginated = categoriesList.slice(start, end);
paginated.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);" loading="lazy">`;
}
const div = document.createElement('div');
div.className = 'category-item';
div.onclick = () => showProducts(cat, 1);
div.innerHTML = `
${iconHtml}
<span class="name">${cat}</span>
<span class="count">${count} {{ t('шт') }}</span>
`;
container.appendChild(div);
});
renderPagination(totalPages, page, 'renderCategories');
}
function showCategories() {
document.getElementById('searchInput').value = '';
renderCategories(1);
}
function filterCategories(page = 1) {
const query = document.getElementById('searchInput').value.toLowerCase();
if (!query) {
renderCategories(1);
return;
}
currentPage = page;
currentView = 'search';
currentSearchQuery = query;
document.getElementById('categoriesContainer').style.display = 'none';
document.getElementById('storiesWrapper').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'flex';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = '{{ t("Поиск") }}';
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);">{{ t("Ничего не найдено") }}</div>';
renderPagination(0, 1, 'filterCategories');
} else {
const totalPages = Math.ceil(matchedProducts.length / itemsPerPage);
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const paginated = matchedProducts.slice(start, end);
paginated.forEach(p => renderProductCard(p, container));
renderPagination(totalPages, page, 'filterCategories');
}
}
function formatQtyText(qty, ppb, ppmb) {
if (businessType === 'retail') {
return `${qty} {{ t('шт') }}`;
}
ppb = parseInt(ppb) || 1;
ppmb = parseInt(ppmb) || 1;
if (useMasterBoxes && ppmb > 1 && qty >= ppmb) {
let masterBoxes = Math.floor(qty / ppmb);
let remAfterMaster = qty % ppmb;
let packs = ppb > 1 ? Math.floor(remAfterMaster / ppb) : 0;
let remainder = ppb > 1 ? remAfterMaster % ppb : remAfterMaster;
let res = `${masterBoxes} {{ t('кор.') }}`;
if (packs > 0) res += ` ${packs} {{ t('уп.') }}`;
if (remainder > 0) res += ` ${remainder} {{ t('шт') }}`;
return res;
} else if (ppb > 1 && qty >= ppb) {
let boxes = Math.floor(qty / ppb);
let remainder = qty % ppb;
return `${boxes} {{ t('уп.') }}` + (remainder > 0 ? ` ${remainder} {{ t('шт') }}` : '');
}
return `${qty} {{ t('шт') }}`;
}
function renderProductCard(p, container) {
let allPhotos = [];
if (p.photos && p.photos.length > 0) {
p.photos.forEach(ph => allPhotos.push({ file: ph, variant: '' }));
}
if (p.variants && p.variants.length > 0) {
p.variants.forEach(v => {
if (v.photo) {
allPhotos.push({ file: v.photo, variant: v.name });
}
});
}
const ppb = parseInt(p.pieces_per_box) || 1;
const ppmb = parseInt(p.pieces_per_master_box) || 1;
const hasPhotos = allPhotos.length > 0;
const photoUrl = hasPhotos
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${allPhotos[0].file}`
: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNhMGEwYTAiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0Ij7QndC10YIg0YTQvtGC0L48L3RleHQ+PC9zdmc+';
const photoIndicator = hasPhotos && allPhotos.length > 1 ? `<div class="photo-count"><i class="fas fa-images"></i> ${allPhotos.length}</div>` : '';
const imgClick = hasPhotos ? `onclick="openGallery('${p.product_id}')"` : '';
const descHtml = p.description ? `<div class="product-desc" onclick="this.classList.toggle('expanded')">${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">{{ t('В упаковке:') }} ${ppb} {{ t('шт') }}</div>`;
if (useMasterBoxes && ppmb > 1) boxInfoHtml += `<div class="product-box-info" style="color:#0984e3;">{{ t('В коробке:') }} ${ppmb} {{ t('шт') }}</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;">{{ t('Мин. заказ:') }} ${minO} {{ t('шт') }}</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">{{ t('Остаток:') }} ${v.stock} {{ t('шт') }}</div>` : '';
if (!vAvailable) {
vStockHtml = `<div class="variant-stock" style="color:#e17055; font-weight:bold;">{{ t('Нет в наличии') }}</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 vPpmb = parseInt(v.pieces_per_master_box) || ppmb;
let priceText = `${vPrice} ${currency}`;
if (businessType === 'mixed' && vBoxPrice && vPpb > 1) {
priceText += `<br><span style="font-size:0.8rem; color:var(--text-muted);">{{ t('Цена за упаковку') }}: ${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('От') }} ${t.qty} {{ t('шт') }}: ${t.price} ${currency}<br>`;
});
priceText += `</div>`;
}
}
let addBoxBtnVariant = '';
if (businessType !== 'retail' && vPpb > 1) {
addBoxBtnVariant += `<button class="box-btn" style="height:36px; margin-right:5px;" onclick="updateCart('${p.product_id}', ${vPpb}, null, false, '${cKey}', ${moq})" ${vDisabledAttr}>{{ t('+ Упаковка') }}</button>`;
}
if (useMasterBoxes && businessType !== 'retail' && vPpmb > 1) {
addBoxBtnVariant += `<button class="box-btn" style="background:#0984e3; height:36px; margin-right:5px;" onclick="updateCart('${p.product_id}', ${vPpmb}, null, false, '${cKey}', ${moq})" ${vDisabledAttr}>{{ t('+ Коробка') }}</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.9rem;"></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.9rem;"></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;">{{ t('Остаток:') }} ${p.stock} {{ t('шт') }}</div>` : '';
if (!isMainAvailable) {
mStockHtml = `<div style="font-size:0.8rem; color:#e17055; margin-top:4px; font-weight:bold;">{{ t('Нет в наличии') }}</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}>{{ t('+ Упаковка') }}</button>`;
}
if (useMasterBoxes && businessType !== 'retail' && ppmb > 1) {
addBoxBtn += `<button class="box-btn" style="background:#0984e3; margin-left:5px;" onclick="updateCart('${p.product_id}', ${ppmb}, null, false, null, ${moq})" ${mainDisabledAttr}>{{ t('+ Коробка') }}</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);">{{ t('Цена за упаковку') }}: ${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('От') }} ${t.qty} {{ t('шт') }}: ${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.9rem;"></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.9rem;"></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" loading="lazy">
${photoIndicator}
</div>
<div class="product-info">
<div class="product-title">${p.name}</div>
${descHtml}
${boxInfoHtml}
</div>
</div>
${variantsHtml}
${mainControlsHtml}
`;
container.appendChild(div);
}
function showProducts(category, page = 1) {
currentPage = page;
currentView = 'products';
currentCategory = category;
document.getElementById('categoriesContainer').style.display = 'none';
document.getElementById('storiesWrapper').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);">{{ t("В этой категории пока нет товаров") }}</div>';
renderPagination(0, 1, 'showProducts');
} else {
const totalPages = Math.ceil(catProducts.length / itemsPerPage);
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const paginated = catProducts.slice(start, end);
paginated.forEach(p => renderProductCard(p, container));
renderPagination(totalPages, page, 'showProducts');
}
}
function updateCart(productId, change, exactValue = null, fromCartModal = false, cartKeyOverride = null, moq = 1) {
let cKey = cartKeyOverride !== null ? cartKeyOverride : productId;
if (cart[cKey] && cart[cKey].is_custom) {
let currentQty = cart[cKey].quantity;
let newQty = exactValue !== null ? exactValue : currentQty + change;
if (newQty <= 0) {
delete cart[cKey];
} else {
cart[cKey].quantity = newQty;
}
updateCartUI();
return;
}
const p = products.find(x => x.product_id === productId);
if (!p) return;
if (p.is_available === false && change > 0) return;
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;
let pPpmb = parseInt(p.pieces_per_master_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;
}
if(p.variants[varIdx].pieces_per_master_box) {
pPpmb = parseInt(p.variants[varIdx].pieces_per_master_box) || pPpmb;
}
} 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, pieces_per_master_box: pPpmb, 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;
if (commissionEnabled && commissionPercent > 0) {
total += total * (commissionPercent / 100.0);
}
cartTotalCurrent = total;
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();
}
validateDebtAmount();
saveCartsState();
}
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 ppmb = parseInt(item.pieces_per_master_box) || 1;
const formattedQty = formatQtyText(item.quantity, ppb, ppmb);
const pId = item.product_id;
let moq = (businessType === 'wholesale' && parseInt(item.min_order) > 0) ? parseInt(item.min_order) : 1;
if(item.is_custom) moq = 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.9rem;"></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.9rem;"></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>
`;
}
if (commissionEnabled && commissionPercent > 0) {
list.innerHTML += `<div style="text-align: right; font-size: 0.85rem; color: #0984e3; margin-top: 10px; font-weight: 600;">Включая комиссию ${commissionPercent}%</div>`;
}
}
function toggleDebtFields() {
const isDebt = document.getElementById('isDebtPos').checked;
const fields = document.getElementById('debtFields');
if (isDebt) {
fields.style.display = 'block';
document.getElementById('paidAmountPos').value = Math.round(cartTotalCurrent * 100) / 100;
validateDebtAmount();
} else {
fields.style.display = 'none';
}
}
function validateDebtAmount() {
if(document.getElementById('isDebtPos') && document.getElementById('isDebtPos').checked) {
let paid = parseFloat(document.getElementById('paidAmountPos').value) || 0;
if(paid < 0) { paid = 0; document.getElementById('paidAmountPos').value = 0; }
if(paid > cartTotalCurrent) { paid = cartTotalCurrent; document.getElementById('paidAmountPos').value = cartTotalCurrent; }
let debt = cartTotalCurrent - paid;
document.getElementById('debtRemainder').innerText = `В долг: ${Math.round(debt * 100) / 100} ${currency}`;
}
}
function openCartModal() {
renderCartModalItems();
const modal = document.getElementById('cartModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
if(document.getElementById('isDebtPos')) {
document.getElementById('isDebtPos').checked = false;
toggleDebtFields();
}
}
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() : '';
const isDebtEl = document.getElementById('isDebtPos');
if(isDebtEl && isDebtEl.checked) {
orderData.is_debt = true;
orderData.paid_amount = parseFloat(document.getElementById('paidAmountPos').value) || 0;
} else {
orderData.is_debt = false;
}
} 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> {{ t("Оформление...") }}';
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) {
delete cartsStore.sessions[cartsStore.active];
if (Object.keys(cartsStore.sessions).length === 0) {
cartsStore.active = 'cart_' + Date.now();
cartsStore.sessions[cartsStore.active] = {};
} else {
cartsStore.active = Object.keys(cartsStore.sessions)[0];
}
saveCartsState();
window.location.href = `/${envId}/order/${data.order_id}?mode=${mode}&staff_id=${staffId}`;
}
})
.catch(() => {
btn.innerHTML = '{{ t("Оформить заказ") }}';
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} {{ t('шт') }}</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">{{ t("Сборка накладной") }}</a>
</div>
`;
});
});
}
function openGallery(productId) {
const product = products.find(p => p.product_id === productId);
if (!product) return;
let allPhotos = [];
if (product.photos && product.photos.length > 0) {
product.photos.forEach(ph => allPhotos.push({ file: ph, text: '' }));
}
if (product.variants && product.variants.length > 0) {
product.variants.forEach(v => {
if (v.photo) {
allPhotos.push({ file: v.photo, text: v.name });
}
});
}
if (allPhotos.length === 0) return;
currentGalleryPhotos = allPhotos.map(p => ({
src: `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${p.file}`,
text: p.text
}));
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].src;
const textEl = document.getElementById('galleryText');
const text = currentGalleryPhotos[currentGalleryIndex].text;
if (text) {
textEl.innerText = text;
textEl.style.display = 'block';
} else {
textEl.style.display = 'none';
}
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';
}
function openCustomProductModal() {
document.getElementById('cpName').value = '';
document.getElementById('cpQty').value = '';
document.getElementById('cpPrice').value = '';
document.getElementById('cpPhoto').value = '';
const modal = document.getElementById('customProductModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
}
function closeCustomProductModal() {
const modal = document.getElementById('customProductModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function addCustomProduct() {
const name = document.getElementById('cpName').value.trim();
const qty = parseInt(document.getElementById('cpQty').value);
const price = parseFloat(document.getElementById('cpPrice').value);
const photoInput = document.getElementById('cpPhoto');
if(!name || isNaN(qty) || qty <= 0 || isNaN(price) || price < 0) {
alert('Заполните все обязательные поля корректно.');
return;
}
const btn = document.getElementById('cpSubmitBtn');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Добавление...';
btn.disabled = true;
const processAdd = (photoFilename) => {
let cKey = 'custom_' + Date.now();
cart[cKey] = {
c_key: cKey,
product_id: cKey,
name: name,
cart_price: price,
cart_box_price: 0,
quantity: qty,
pieces_per_box: 1,
pieces_per_master_box: 1,
variant_name: '',
variant_idx: -1,
discount: 0,
wholesale_tiers: [],
is_custom: true,
photo_custom: photoFilename || ''
};
updateCartUI();
closeCustomProductModal();
btn.innerHTML = 'Добавить в накладную';
btn.disabled = false;
};
if(photoInput.files.length > 0) {
let formData = new FormData();
formData.append('photo', photoInput.files[0]);
fetch(`/${envId}/api/upload_custom_photo`, {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
processAdd(data.filename || '');
})
.catch(() => {
processAdd('');
});
} else {
processAdd('');
}
}
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; margin-bottom: 20px; }
.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: 100%; }
th, td { border-bottom: 1px solid var(--border); padding: 16px; text-align: center; font-size: 1.05rem; }
th { background: #fafafa; font-weight: 700; color: #636e72; text-transform: uppercase; font-size: 0.9rem; letter-spacing: 0.5px; }
.num-col { display: table-cell; }
.img-cell img { width: 60px; height: 60px; object-fit: cover; border-radius: 8px; border: 1px solid #eee; cursor: zoom-in; }
.total-row { background: #fafafa; font-weight: 800; }
.total-row td { font-size: 1.2rem; 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: 44px; height: 44px; font-size: 1.2rem; cursor: pointer; color: var(--primary); transition: background 0.2s; touch-action: manipulation; }
.cart-item-controls button:active { background: #e0e0e0; }
.cart-item-controls input { width: 50px; text-align: center; font-weight: 600; font-size: 16px; border: none; background: transparent; color: var(--primary); outline: none; padding: 5px; }
.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: 1.05rem; 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; min-height: 44px; touch-action: manipulation; }
.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); }
.qr-codes { display: flex; justify-content: center; gap: 20px; margin-top: 20px; }
.qr-codes img { max-width: 150px; height: auto; border: 1px solid #ccc; border-radius: 8px; padding: 5px; background: #fff; }
body.invoice-mode .receipt-box { display: none; }
body.receipt-mode .invoice-box { display: none; }
body.receipt-mode .receipt-box {
display: block; width: 100%; max-width: 350px; margin: 0 auto;
background: #fff; padding: 20px; font-family: 'Courier New', Courier, monospace;
font-size: 12px; color: #000; box-shadow: 0 4px 20px rgba(0,0,0,0.05);
border-radius: 12px; margin-bottom: 20px;
}
body.receipt-mode .receipt-box .dashed-line { border-top: 1px dashed #000; margin: 5px 0; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2000; justify-content: center; align-items: center; flex-direction: column; }
@media print {
.action-bar, .screen-only { display: none !important; }
.print-only { display: block !important; }
body { padding: 0 !important; background: #fff !important; display: block !important; }
body.invoice-mode .invoice-box { box-shadow: none; padding: 0; max-width: 100%; border-radius: 0; display: block; margin: 0; }
body.invoice-mode .table-responsive { border: none; overflow: visible; }
body.invoice-mode table { min-width: 100%; }
body.invoice-mode th, body.invoice-mode td { border: 1px solid #000; }
body.receipt-mode .receipt-box { width: 80mm; max-width: 80mm; margin: 0; box-shadow: none; padding: 0; border-radius: 0; }
}
@media (max-width: 600px) {
.header h1 { font-size: 1.4rem; }
.info-row { font-size: 0.9rem; }
.invoice-box { padding: 15px 10px; }
.btn { font-size: 0.9rem; flex-direction: column; padding: 10px; gap: 4px; }
.btn i { font-size: 1.2rem; }
th, td { padding: 8px 6px; font-size: 0.9rem; }
.img-cell img { width: 45px; height: 45px; }
.cart-item-controls input { width: 40px; font-size: 16px; }
.cart-item-controls button { width: 36px; height: 36px; font-size: 1rem; }
.num-col { display: none; }
}
</style>
</head>
<body class="invoice-mode">
<div id="loadingOverlay"><i class="fas fa-spinner fa-spin"></i></div>
{% set total_qty = order.cart | selectattr('quantity', '>', 0) | sum(attribute='quantity') %}
<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; display: flex; flex-direction: column; align-items: flex-end;">
<div>Статус:
{% 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>
{% if order.is_debt %}
<div style="margin-top: 5px; color: #e17055;">В долг: {{ order.debt_amount }} {{ currency_code }}</div>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table>
<thead>
<tr>
<th class="num-col" style="width: 40px;">№</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 ppmb = item.pieces_per_master_box|default(1)|int %}
{% set assembled = order.assembled.get(item.c_key, 0) if order.assembled else 0 %}
{% if item.quantity > 0 %}
<tr>
<td class="num-col">{{ loop.index }}</td>
<td style="text-align: left; font-weight: 500;">
{{ item.name }}
<div style="font-size: 0.85rem; color: #b2bec3; margin-top: 4px;">Категория: {{ item.category }}</div>
{% if item.variant_name %}
<div style="font-size: 0.95rem; color: #636e72;">Вариант: {{ item.variant_name }}</div>
{% endif %}
<div style="font-size: 0.9rem; color: {% if assembled == item.quantity %}#00b894{% else %}#0984e3{% endif %}; margin-top: 6px; font-weight:600;">Собрано: {{ assembled }} / {{ item.quantity }}</div>
{% if item.discount and item.discount > 0 %}
<div style="font-size: 0.9rem; color: #e17055; margin-top: 4px;">Скидка: -{{ item.discount }} {{ currency_code }} за ед.</div>
{% endif %}
</td>
<td class="img-cell"><img src="{{ item.photo_url }}" alt="img" onclick="openZoom(this.src)"></td>
<td style="text-align: center;">
<div class="screen-only">
<div style="display:flex; flex-direction:column; align-items:center; gap:6px;">
{% 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.9rem;"></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.9rem;"></i></button>
</div>
<button onclick="updateItem('{{ item.c_key }}', 0, true)" style="color: #ff7675; background: none; border: none; font-size: 1.3rem; cursor: pointer; padding: 10px; touch-action: manipulation;"><i class="fas fa-trash-alt"></i></button>
</div>
{% endif %}
<div style="font-size: 0.95rem; color: #00b894; font-weight: 600;">
{% if settings.use_master_boxes and ppmb > 1 and item.quantity >= ppmb %}
{% set master = item.quantity // ppmb %}
{% set rem_master = item.quantity % ppmb %}
{% set packs = (rem_master // ppb) if ppb > 1 else 0 %}
{% set rem = (rem_master % ppb) if ppb > 1 else rem_master %}
{{ master }} {{ t('кор.') }}{% if packs > 0 %} {{ packs }} {{ t('уп.') }}{% endif %}{% if rem > 0 %} {{ rem }} {{ t('шт') }}{% endif %}
{% elif settings.business_type != 'retail' and ppb > 1 and (item.quantity // ppb) > 0 %}
{% set packs = item.quantity // ppb %}
{% set rem = item.quantity % ppb %}
{{ packs }} {{ t('уп.') }}{% if rem > 0 %} {{ rem }} {{ t('шт') }}{% endif %}
{% else %}
{{ item.quantity }} {{ t('шт') }}
{% endif %}
</div>
</div>
</div>
<div class="print-only" style="font-weight: bold;">
{% if settings.use_master_boxes and ppmb > 1 and item.quantity >= ppmb %}
{% set master = item.quantity // ppmb %}
{% set rem_master = item.quantity % ppmb %}
{% set packs = (rem_master // ppb) if ppb > 1 else 0 %}
{% set rem = (rem_master % ppb) if ppb > 1 else rem_master %}
{{ master }} {{ t('кор.') }}{% if packs > 0 %} {{ packs }} {{ t('уп.') }}{% endif %}{% if rem > 0 %} {{ rem }} {{ t('шт') }}{% endif %}
{% elif settings.business_type != 'retail' and ppb > 1 and (item.quantity // ppb) > 0 %}
{% set packs = item.quantity // ppb %}
{% set rem = item.quantity % ppb %}
{{ packs }} {{ t('уп.') }}{% if rem > 0 %} {{ rem }} {{ t('шт') }}{% endif %}
{% else %}
{{ item.quantity }} {{ t('шт') }}
{% 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="6" style="text-align: right; padding-right: 20px;">
Общее кол-во: {{ total_qty }} шт<br>
{% if order.global_discount > 0 %}
<div style="color:#e17055; font-size:1rem; margin-bottom:5px;">Применена общая скидка: -{{ order.global_discount }} {{ currency_code }}</div>
{% endif %}
{% if settings.commission_enabled and order.commission_amount and order.commission_amount > 0 %}
<div style="color:#0984e3; font-size:1rem; margin-bottom:5px;">Комиссия ({{ settings.commission_percent }}%): +{{ order.commission_amount }} {{ currency_code }}</div>
{% endif %}
Итого: {{ order.total_price }} {{ currency_code }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="qr-codes">
{% if settings.qr_code_1_url %}
<img src="{{ settings.qr_code_1_url }}" alt="QR Code 1">
{% endif %}
{% if settings.qr_code_2_url %}
<img src="{{ settings.qr_code_2_url }}" alt="QR Code 2">
{% endif %}
</div>
</div>
<div class="receipt-box">
<div style="text-align: center;">
<div style="font-weight: bold; font-size: 16px;">{{ settings.organization_name }}</div>
{% if settings.invoice_contacts %}
<div style="font-size: 12px;">
{% for contact in settings.invoice_contacts.split(',') %}
{% if contact.strip() %}
<div>{{ contact.strip() }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="dashed-line"></div>
<div style="font-weight: bold; font-size: 14px;">ТОВАРНЫЙ ЧЕК № {{ order.id }}</div>
<div style="font-size: 12px;">{{ order.created_at }}</div>
<div class="dashed-line"></div>
</div>
<div style="font-size: 12px; margin-bottom: 5px;">
{% if order.status != 'pos' and order.status != 'returned' %}
{% if order.customer_name %}<div>Покупатель: {{ order.customer_name }}</div>{% endif %}
{% if order.customer_phone %}<div>Тел: {{ order.customer_phone }}</div>{% endif %}
{% else %}
<div>Покупатель: {{ order.customer_name if order.customer_name else 'Розничный покупатель' }}</div>
{% if order.customer_whatsapp %}<div>WA: {{ order.customer_whatsapp }}</div>{% endif %}
{% endif %}
{% if order.staff_name %}<div>Кассир: {{ order.staff_name }}</div>{% endif %}
</div>
<div class="dashed-line"></div>
<table style="width: 100%; font-size: 12px; text-align: left; border-collapse: collapse;">
<thead>
<tr>
<th style="padding-bottom: 5px; border:none; text-align: left;">Товар</th>
<th style="text-align: center; padding-bottom: 5px; border:none;">Кол</th>
<th style="text-align: right; padding-bottom: 5px; border:none;">Сумма</th>
</tr>
</thead>
<tbody>
{% for item in order.cart %}
{% if item.quantity > 0 %}
<tr>
<td style="padding: 3px 5px 3px 0; border:none; text-align: left;">
{{ item.name }}{% if item.variant_name %} ({{ item.variant_name }}){% endif %}<br>
<span style="font-size: 10px; color: #555;">{{ item.calculated_price | round(2) }} {{ currency_code }}</span>
</td>
<td style="text-align: center; padding: 3px 0; vertical-align: top; border:none;">{{ item.quantity }}</td>
<td style="text-align: right; padding: 3px 0; vertical-align: top; border:none;">{{ (item.calculated_price * item.quantity) | round(2) }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<div class="dashed-line"></div>
<div style="display: flex; justify-content: space-between; font-size: 12px; margin: 3px 0;">
<span>Итог ({{ total_qty }} шт):</span>
<span>{{ order.total_price }} {{ currency_code }}</span>
</div>
{% if order.global_discount > 0 %}
<div style="display: flex; justify-content: space-between; font-size: 12px; margin: 3px 0;">
<span>Скидка:</span>
<span>-{{ order.global_discount }} {{ currency_code }}</span>
</div>
{% endif %}
{% if settings.commission_enabled and order.commission_amount and order.commission_amount > 0 %}
<div style="display: flex; justify-content: space-between; font-size: 12px; margin: 3px 0;">
<span>Комиссия:</span>
<span>+{{ order.commission_amount }} {{ currency_code }}</span>
</div>
{% endif %}
<div class="dashed-line"></div>
<div style="display: flex; justify-content: space-between; font-weight: bold; font-size: 16px; margin: 10px 0;">
<span>К ОПЛАТЕ:</span>
<span>{{ order.total_price }} {{ currency_code }}</span>
</div>
{% if order.is_debt %}
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-top: 5px;">
<span>Оплачено:</span>
<span>{{ order.paid_amount }} {{ currency_code }}</span>
</div>
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 5px; color: #d63031;">
<span>В ДОЛГ:</span>
<span>{{ order.debt_amount }} {{ currency_code }}</span>
</div>
<div class="dashed-line"></div>
{% endif %}
<div style="text-align: center; margin-top: 10px;">
{% if settings.qr_code_1_url %}
<img src="{{ settings.qr_code_1_url }}" style="max-width: 100px; display: inline-block; margin: 5px;">
{% endif %}
{% if settings.qr_code_2_url %}
<img src="{{ settings.qr_code_2_url }}" style="max-width: 100px; display: inline-block; margin: 5px;">
{% endif %}
</div>
<div style="text-align: center; margin-top: 15px; font-size: 12px; font-weight: bold;">*** СПАСИБО ЗА ПОКУПКУ! ***</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" style="background:#dfe6e9; color:#2d3436; flex: 0 0 auto; padding: 15px 20px;" onclick="toggleView()" id="viewToggleBtn" title="Переключить вид"><i class="fas fa-receipt"></i></button>
<button class="btn btn-print" onclick="window.print()"><i class="fas fa-print"></i> Печать</button>
{% set messenger = settings.order_messenger|default('wa') %}
{% if order.status == 'pos' %}
<button class="btn btn-wa" onclick="sendOrder()"><i class="fab fa-whatsapp" style="font-size: 1.2rem;"></i> WhatsApp</button>
{% else %}
{% if messenger == 'tg' %}
<button class="btn" style="background:#0088cc;" onclick="sendOrder()"><i class="fab fa-telegram" style="font-size: 1.2rem;"></i> Telegram</button>
{% else %}
<button class="btn btn-wa" onclick="sendOrder()"><i class="fab fa-whatsapp" style="font-size: 1.2rem;"></i> WhatsApp</button>
{% endif %}
{% endif %}
</div>
</div>
<div class="modal-overlay" id="zoomModal" onclick="if(event.target===this) closeZoom()">
<div style="position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center; flex-direction:column;">
<button onclick="closeZoom()" style="position:absolute; top:20px; right:20px; z-index:1001; font-size:2rem; background:rgba(0,0,0,0.5); border:none; color:white; width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; touch-action:manipulation; cursor:pointer;"><i class="fas fa-times"></i></button>
<div style="overflow:auto; width:100%; height:100%; display:flex; align-items:center; justify-content:center;" id="zoomWrapper">
<img id="zoomImg" src="" style="max-width:90%; max-height:90%; object-fit:contain; transition: transform 0.2s; transform: scale(1); border-radius:8px;">
</div>
<div style="position:absolute; bottom:40px; display:flex; gap:20px; z-index:1001; background:rgba(0,0,0,0.5); padding:10px 20px; border-radius:20px;">
<button onclick="zoomImg(-0.25)" style="background:none; border:none; color:white; font-size:1.5rem; padding:10px; cursor:pointer; touch-action:manipulation;"><i class="fas fa-search-minus"></i></button>
<button onclick="zoomImg(0.25)" style="background:none; border:none; color:white; font-size:1.5rem; padding:10px; cursor:pointer; touch-action:manipulation;"><i class="fas fa-search-plus"></i></button>
</div>
</div>
</div>
<script>
const envId = '{{ env_id }}';
function sendOrder() {
let msg = `Здравствуйте! ${'{{ order.status }}' === 'pos' ? 'Ваша накладная' : 'Мой заказ'} №{{ order.id }}\nСсылка: ${window.location.href.split('?')[0]}`;
let isPos = '{{ order.status }}' === 'pos';
let targetContact = '';
let messengerType = 'wa';
if (isPos) {
targetContact = '{{ order.customer_whatsapp }}';
messengerType = 'wa';
} else {
let staffWa = '{{ order.staff_whatsapp }}';
if (staffWa) {
targetContact = staffWa;
messengerType = 'wa';
} else {
targetContact = '{{ settings.order_contact|default(settings.whatsapp_number) }}';
messengerType = '{{ settings.order_messenger|default("wa") }}';
}
}
targetContact = targetContact.trim();
if (messengerType === 'tg') {
if (targetContact.startsWith('@')) targetContact = targetContact.substring(1);
else targetContact = targetContact.replace(/[^0-9a-zA-Z_]/g, '');
window.open(`https://t.me/${targetContact}?text=${encodeURIComponent(msg)}`, '_blank');
} else {
targetContact = targetContact.replace(/[^0-9+]/g, '');
window.open(`https://api.whatsapp.com/send?phone=${encodeURIComponent(targetContact)}&text=${encodeURIComponent(msg)}`, '_blank');
}
}
function toggleView() {
const body = document.body;
const btnIcon = document.querySelector('#viewToggleBtn i');
if (body.classList.contains('invoice-mode')) {
body.classList.remove('invoice-mode');
body.classList.add('receipt-mode');
btnIcon.className = 'fas fa-file-invoice';
} else {
body.classList.remove('receipt-mode');
body.classList.add('invoice-mode');
btnIcon.className = 'fas fa-receipt';
}
}
{% 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 %}
let cScale = 1;
function openZoom(src) {
document.getElementById('zoomImg').src = src;
cScale = 1;
document.getElementById('zoomImg').style.transform = `scale(${cScale})`;
document.getElementById('zoomModal').style.display = 'flex';
}
function closeZoom() {
document.getElementById('zoomModal').style.display = 'none';
}
function zoomImg(delta) {
event.stopPropagation();
cScale += delta;
if(cScale < 0.5) cScale = 0.5;
if(cScale > 4) cScale = 4;
document.getElementById('zoomImg').style.transform = `scale(${cScale})`;
}
</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: 12px 20px; border-radius: 8px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; font-size: 16px; min-height: 44px; touch-action: manipulation; }
.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: 1.1rem; }
.item-variant { font-size: 0.95rem; color: #636e72; margin-top: 2px; }
.item-target { font-size: 1rem; 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: 50px; height: 50px; font-size: 1.4rem; cursor: pointer; color: var(--primary); transition: background 0.2s; touch-action: manipulation; }
.assembly-controls button:active { background: #e0e0e0; }
.assembly-controls input { width: 60px; text-align: center; font-weight: 700; font-size: 16px; border: none; background: transparent; color: var(--primary); outline: none; padding: 5px; }
.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.9rem; padding: 4px 10px; 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; padding: 10px;"><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" loading="lazy">
<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: 12px 18px; 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); min-height: 44px; touch-action: manipulation; }
.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: 12px 15px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-family: inherit; font-size: 16px; }
.search-input { flex: 1; min-width: 200px; border-radius: 8px 0 0 8px !important; border-right: none !important; }
.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: 6px 10px; border-radius: 12px; font-size: 0.85rem; 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: 8px; flex-wrap: wrap; }
.action-btns a { padding: 10px 14px; border-radius: 6px; font-size: 0.95rem; color: #fff; text-decoration: none; font-weight: bold; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; touch-action: manipulation; }
.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">
<div style="display:flex; flex:1; min-width: 300px;">
<input type="text" id="searchBox" class="search-input" placeholder="Поиск по номеру, клиенту или сотруднику..." onkeydown="if(event.key==='Enter') filterOrders()">
<button class="btn" style="border-radius: 0 8px 8px 0; padding: 0 20px;" onclick="filterOrders()"><i class="fas fa-search"></i></button>
</div>
<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>
<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="7" 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>` : '';
let debtText = o.is_debt ? `<span style="color:#e17055; font-weight:bold;">${o.debt_amount}</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>${debtText}</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>{{ t('Админ-панель') }}</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: 14px 20px; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 16px; transition: opacity 0.2s; min-height: 44px; touch-action: manipulation; }
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--info); }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); }
.btn-warning { background: var(--warning); }
.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, input[type="password"], input[type="file"] { width: 100%; padding: 16px 20px; border: 1px solid var(--border); border-radius: 12px; font-size: 16px; outline: none; transition: border-color 0.2s; background: #fafafa; box-sizing: border-box; }
input[type="text"]:focus, input[type="number"]:focus, input[type="url"]:focus, select:focus, textarea:focus, input[type="password"]:focus { border-color: var(--info); background: #fff; }
input[type="file"] { border: 1px dashed #ccc; cursor: pointer; }
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; display: flex; box-shadow: 0 4px 15px rgba(0,0,0,0.03); border-radius: 12px; overflow: hidden; background: var(--surface); }
.search-bar-admin input { flex: 1; border: none; padding: 18px 20px; font-size: 16px; outline: none; border-radius: 0; background: transparent; }
.search-bar-admin button { border-radius: 0; padding: 0 30px; margin: 0; border: none; }
.category-block { border: 1px solid var(--border); margin-bottom: 15px; border-radius: 12px; overflow: hidden; background: #fff; content-visibility: auto; contain-intrinsic-size: auto 500px; }
.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; font-size: 1.1rem; min-height: 50px; }
.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: 1.05rem; word-break: break-word; }
.product-desc { font-size: 0.9rem; color: #636e72; margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-meta { font-size: 0.85rem; color: #b2bec3; margin-top: 4px; }
.product-actions { display: flex; gap: 5px; flex-wrap: wrap; }
.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); font-size: 1.05rem; min-height: 50px; display: flex; align-items: center; justify-content: center; }
.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: 8px; flex: 1; min-width: 150px; }
.form-group label { font-size: 1rem; font-weight: 600; color: #636e72; margin-bottom: 2px; }
.form-row { display: flex; gap: 15px; flex-wrap: wrap; }
.file-input-wrapper { position: relative; width: 100%; }
.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: 150px; font-weight: 600; font-size: 1.05rem; }
.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: 12px; 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; font-size: 1.05rem; }
.variants-container { background: #f4f6f9; padding: 15px; border-radius: 12px; 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: 15px; border-radius: 10px; border: 1px solid var(--border); }
.remove-variant-btn { color: var(--danger); background: none; border: none; font-size: 1.4rem; cursor: pointer; padding: 12px 5px; flex: 0 0 auto; touch-action: manipulation; }
.order-item { background: #fff; padding: 15px; border-radius: 12px; border: 1px solid var(--border); margin-bottom: 10px; }
.order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 10px; font-size: 1.1rem; }
.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: 12px; border: 1px solid var(--border); margin-bottom: 10px; }
.badge { background: var(--danger); color: white; padding: 4px 10px; border-radius: 12px; font-size: 0.9rem; margin-left: 5px; }
.pagination { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 15px; justify-content: center; }
.pagination .btn { padding: 12px 16px; font-size: 16px; border-radius: 8px; }
@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 { width: 100%; justify-content: flex-end; }
.form-row { flex-direction: column; gap: 10px; }
.form-group { width: 100%; flex: 1 1 100%; }
.variant-row { flex-direction: column; align-items: stretch; }
.remove-variant-btn { width: 100%; text-align: right; padding: 5px; }
}
</style>
</head>
<body>
{% macro render_pagination(current, total, param_name, extra_args) %}
{% if total > 1 %}
<div class="pagination" style="margin-bottom: 15px;">
{% if current > 1 %}
<a href="?{{ param_name }}={{ current - 1 }}{{ extra_args|safe }}" class="btn btn-outline">&laquo;</a>
{% endif %}
{% set p_start = current - 2 if current - 2 > 1 else 1 %}
{% set p_end = current + 2 if current + 2 < total else total %}
{% if p_start > 1 %}
<a href="?{{ param_name }}=1{{ extra_args|safe }}" class="btn btn-outline">1</a>
{% if p_start > 2 %}
<span style="padding: 10px; color: #636e72;">...</span>
{% endif %}
{% endif %}
{% for p in range(p_start, p_end + 1) %}
<a href="?{{ param_name }}={{ p }}{{ extra_args|safe }}" class="btn {% if p == current %}btn-primary{% else %}btn-outline{% endif %}">{{ p }}</a>
{% endfor %}
{% if p_end < total %}
{% if p_end < total - 1 %}
<span style="padding: 10px; color: #636e72;">...</span>
{% endif %}
<a href="?{{ param_name }}={{ total }}{{ extra_args|safe }}" class="btn btn-outline">{{ total }}</a>
{% endif %}
{% if current < total %}
<a href="?{{ param_name }}={{ current + 1 }}{{ extra_args|safe }}" class="btn btn-outline">&raquo;</a>
{% endif %}
</div>
{% endif %}
{% endmacro %}
<div class="container">
{% set sys_mode = settings.system_mode|default('both') %}
<div class="header-panel">
<h1><i class="fas fa-cog"></i> {{ t('Админ-панель') }} ({{ env_id }})</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap; justify-content:center;">
{% if sys_mode != 'light_external' %}
<a href="/{{ env_id }}/reports" class="btn btn-primary" style="background:#8e44ad;"><i class="fas fa-chart-line"></i> {{ t('Отчеты') }}</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> {{ t('Остатки') }}
{% if low_stock_count > 0 %}<span class="badge" style="background:#e17055;">{{ low_stock_count }}</span>{% endif %}
</a>
{% if sys_mode == 'both' %}
<a href="/{{ env_id }}/purchases" class="btn btn-primary" style="background:#f1c40f; color:#2d3436;">
<i class="fas fa-truck-loading"></i> Заказы поставщикам
</a>
{% endif %}
<a href="/{{ env_id }}/debts" class="btn btn-primary" style="background:#c0392b;">
<i class="fas fa-hand-holding-usd"></i> Долги
</a>
{% endif %}
{% if sys_mode == 'both' %}
<a href="/{{ env_id }}/history" class="btn btn-primary" style="background:#34495e;"><i class="fas fa-history"></i> {{ t('История накладных и заказов') }}</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> {{ t('История заказов') }}</a>
{% else %}
<a href="/{{ env_id }}/history" class="btn btn-primary" style="background:#34495e;"><i class="fas fa-history"></i> {{ t('История накладных') }}</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> {{ t('Сборка') }}
{% 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> {{ t('В каталог') }}</a>
{% endif %}
{% if settings.admin_password_enabled %}
<a href="/{{ env_id }}/logout" class="btn btn-danger"><i class="fas fa-sign-out-alt"></i> {{ t('Выход') }}</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> {{ t('Сохранить на сервер') }}</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> {{ t('Скачать с сервера') }}</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> {{ t('Онлайн заказы') }} {% 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: 1.05rem; 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: 8px 12px; font-size: 16px;"><i class="fas fa-eye"></i> Накладная</a>
<button type="button" class="btn btn-warning" style="padding: 8px 12px; font-size: 16px;" 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; display:inline-block;">
<input type="hidden" name="action" value="confirm">
<button type="submit" class="btn btn-success" style="padding: 8px 12px; font-size: 16px;"><i class="fas fa-check"></i> Подтвердить</button>
</form>
<form method="POST" action="/{{ env_id }}/order_action/{{ order.id }}" style="margin:0; display:inline-block;" onsubmit="return confirm('Удалить заказ?');">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-danger" style="padding: 8px 12px; font-size: 16px;"><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:25px; border-radius:16px; 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; font-size: 1.05rem;">{{ t('Общая скидка на чек (сумма)') }} ({{ 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:1.05rem; 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: 120px;">
</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; font-size: 1.1rem;">Нет новых онлайн заказов</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> {{ t('Пользователи (Закрытый каталог)') }}</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 style="flex:1;">
<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; font-size: 1.05rem;">{{ u.name }}</span>
<span style="background: #e8daef; padding: 8px 15px; border-radius: 8px; font-family: monospace; font-size: 1.2rem; 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"><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> {{ t('Персонал') }}</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 style="flex:1;">
<input type="text" name="staff_whatsapp" placeholder="WhatsApp (напр. +77001234567)" required style="flex:1;">
<button type="submit" class="btn btn-info"><i class="fas fa-plus"></i> Добавить</button>
</form>
<div class="search-bar-admin" style="margin-bottom:15px; display:flex;">
<input type="text" id="staffSearch" placeholder="Поиск сотрудника..." onkeydown="if(event.key==='Enter') debounceFilterStaff()" style="flex:1;">
<button type="button" class="btn btn-primary" onclick="filterStaff()"><i class="fas fa-search"></i></button>
</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; font-size:1.05rem;" 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"><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.9rem;" 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.9rem;" 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="card" style="margin-bottom: 20px;">
<h2><i class="fas fa-database"></i> Импорт и Экспорт товаров</h2>
<div style="display:flex; gap:15px; flex-wrap:wrap;">
<div style="flex:1; min-width:250px; padding:20px; background:#fafafa; border:1px solid var(--border); border-radius:12px;">
<h3>Экспорт</h3>
<p style="font-size:1rem; color:#636e72;">Скачать текущую базу товаров в формате JSON.</p>
<a href="/{{ env_id }}/export_products" class="btn btn-primary" target="_blank"><i class="fas fa-download"></i> Скачать JSON</a>
</div>
<div style="flex:1; min-width:250px; padding:20px; background:#fafafa; border:1px solid var(--border); border-radius:12px;">
<h3>Импорт</h3>
<p style="font-size:1rem; color:#636e72;">Загрузите JSON файл с товарами. <a href="#" onclick="alert('Формат JSON:\\n[\\n {\\n \x22name\x22: \x22Название\x22,\\n \x22price\x22: 1000,\\n \x22category\x22: \x22Категория\x22,\\n \x22stock\x22: 10,\\n \x22barcode\x22: \x2212345\x22\\n }\\n]')">Показать образец</a></p>
<form method="POST" enctype="multipart/form-data" style="display:flex; flex-direction:column; gap:10px;" onsubmit="showLoading(this)">
<input type="hidden" name="action" value="import_products">
<input type="file" name="import_file" accept=".json" required style="background: #fff;">
<select name="import_mode">
<option value="append">Добавить к текущим (обновить по ID)</option>
<option value="replace">Удалить старые и заменить</option>
</select>
<button type="submit" class="btn btn-warning"><i class="fas fa-upload"></i> Загрузить</button>
</form>
</div>
</div>
</div>
<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> {{ t('Настройка магазина') }}</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>{{ t('Язык интерфейса') }}:</label>
<select name="language">
<option value="ru" {% if settings.language == 'ru' %}selected{% endif %}>Русский</option>
<option value="kk" {% if settings.language == 'kk' %}selected{% endif %}>Қазақша</option>
<option value="uz" {% if settings.language == 'uz' %}selected{% endif %}>O'zbekcha</option>
</select>
</div>
<div class="settings-row">
<label>Название магазина:</label>
<input type="text" name="organization_name" value="{{ settings.organization_name }}" required>
</div>
<div class="settings-row">
<label>Подпись под логотипом в каталоге:</label>
<input type="text" name="catalog_subtitle" value="{{ settings.catalog_subtitle }}" placeholder="Например: Добро пожаловать!">
</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="₸" {% if settings.currency == '₸' %}selected{% endif %}>Тенге (₸)</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>
<option value="$" {% if settings.currency == '$' %}selected{% endif %}>Доллар США ($)</option>
</select>
</div>
<div class="settings-row">
<label>Мессенджер для заказов:</label>
<select name="order_messenger">
<option value="wa" {% if settings.order_messenger == 'wa' %}selected{% endif %}>WhatsApp</option>
<option value="tg" {% if settings.order_messenger == 'tg' %}selected{% endif %}>Telegram</option>
</select>
</div>
<div class="settings-row">
<label>Контакт для заказов (Номер или @username):</label>
<input type="text" name="order_contact" value="{{ settings.order_contact|default(settings.whatsapp_number) }}" placeholder="+77001234567 или @username" 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.9rem; color: #636e72;">Текущий логотип: <img src="{{ settings.logo_url }}" loading="lazy" style="height:40px; vertical-align:middle; border:1px solid #ccc; border-radius:6px; margin-left:10px;"></div>
<div class="settings-row">
<label>QR код 1 (загрузить):</label>
<input type="file" name="qr_code_1" accept="image/*">
</div>
{% if settings.qr_code_1_url %}
<div style="text-align: right; font-size: 0.9rem; color: #636e72;">Текущий QR 1: <img src="{{ settings.qr_code_1_url }}" loading="lazy" style="height:40px; vertical-align:middle; border:1px solid #ccc; border-radius:6px; margin-left:10px;"></div>
{% endif %}
<div class="settings-row">
<label>QR код 2 (загрузить):</label>
<input type="file" name="qr_code_2" accept="image/*">
</div>
{% if settings.qr_code_2_url %}
<div style="text-align: right; font-size: 0.9rem; color: #636e72;">Текущий QR 2: <img src="{{ settings.qr_code_2_url }}" loading="lazy" style="height:40px; vertical-align:middle; border:1px solid #ccc; border-radius:6px; margin-left:10px;"></div>
{% endif %}
<div class="social-settings">
<div style="font-weight: 600; margin-bottom: 5px; font-size: 1.1rem;">Поля для клиента (Оформление заказа):</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 != 'external' %}
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; font-size: 1.1rem;">Учет товара:</div>
<label style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" name="use_master_boxes" style="width:auto;" {% if settings.use_master_boxes %}checked{% endif %}>
{{ t('Включить учет больших коробок') }}
</label>
{% endif %}
{% if sys_mode not in ['external', 'light_external'] %}
<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="margin_tracking" {% if settings.margin_tracking %}checked{% endif %}> Включить учет маржи (себестоимость товара)</label>
<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; font-size: 1.1rem;">Доступ к каталогу:</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; font-size: 1.1rem;">Комиссия на заказы:</div>
<div class="social-item" style="margin-bottom: 10px; flex-wrap: wrap;">
<label style="width: auto; margin-right: 15px;"><input type="checkbox" name="commission_enabled" {% if settings.commission_enabled %}checked{% endif %}> Добавить комиссию</label>
<input type="number" step="0.01" name="commission_percent" value="{{ settings.commission_percent }}" placeholder="Процент (например, 0.95)" style="flex: 1; min-width: 150px; padding: 12px; font-size: 16px;">
</div>
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; margin-top: 10px; font-size: 1.1rem;">Безопасность (Админ-панель):</div>
<div class="social-item" style="margin-bottom: 10px; flex-wrap: wrap;">
<label style="width: auto; margin-right: 15px;"><input type="checkbox" name="admin_password_enabled" {% if settings.admin_password_enabled %}checked{% endif %}> Пароль на вход</label>
<input type="password" name="admin_password" value="{{ settings.admin_password }}" placeholder="Установите пароль" style="flex: 1; min-width: 150px; padding: 12px; font-size: 16px;">
</div>
<div style="font-weight: 600; margin-bottom: 5px; border-top: 1px solid var(--border); padding-top: 15px; margin-top: 10px; font-size: 1.1rem;">Социальные сети (каждая ссылка с новой строки или через пробел):</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.4rem;"></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.4rem;"></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.4rem;"></i> Telegram</label>
<textarea name="tg_url" placeholder="https://t.me/..." style="flex:1; min-height:60px;">{{ settings.socials.tg.url }}</textarea>
</div>
<div class="social-item" style="align-items: flex-start;">
<label style="margin-top: 10px;"><input type="checkbox" name="kaspi_enabled" {% if settings.socials.kaspi.enabled %}checked{% endif %}> <i class="fas fa-shopping-bag" style="color:#f14635; font-size:1.4rem;"></i> Kaspi Магазин</label>
<textarea name="kaspi_url" placeholder="https://kaspi.kz/..." style="flex:1; min-height:60px;">{{ settings.socials.kaspi.url }}</textarea>
</div>
</div>
<button type="submit" class="btn btn-success" style="align-self: flex-end; width: 100%; justify-content: center; padding: 16px; margin-top: 10px;"><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); font-size: 1.1rem;">Сброс данных:</div>
<form method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить ВСЮ историю продаж и заказов? Это действие необратимо!');">
<input type="hidden" name="action" value="clear_history">
<button type="submit" class="btn btn-danger" style="width: 100%; justify-content: center; padding: 16px;"><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" style="padding: 16px; font-size: 16px;">
<input type="file" name="category_photo" accept="image/*" title="Фото категории" style="padding: 12px; font-size: 16px;">
<button type="submit" class="btn btn-dark" style="min-height: 50px;"><i class="fas fa-plus"></i> Добавить</button>
</form>
</div>
{% set search_q = request.args.get('q', '').lower() %}
{% set cat_page = request.args.get('page', 1)|int %}
{% set per_page = 10 %}
<div class="search-bar-admin">
<form method="GET" style="display: flex; width: 100%; position: relative; margin:0;">
<input type="text" name="q" value="{{ request.args.get('q', '') }}" placeholder="Поиск по товарам (ID, Название, Штрихкод)..." style="width: 100%;">
<button type="submit" class="btn btn-primary" style="border-radius: 0 12px 12px 0;"><i class="fas fa-search"></i></button>
</form>
</div>
{% set filtered_cats = [] %}
{% if search_q %}
{% for c in categories %}
{% set ns_cat = namespace(match=false) %}
{% if search_q in c|lower %}
{% set ns_cat.match = true %}
{% else %}
{% for p in products %}
{% if p.category == c %}
{% if search_q in p.name|lower or search_q in p.product_id|lower or (p.barcode and search_q in p.barcode|lower) %}
{% set ns_cat.match = true %}
{% else %}
{% for v in p.variants %}
{% if v.barcode and search_q in v.barcode|lower %}
{% set ns_cat.match = true %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% if ns_cat.match %}
{% set _ = filtered_cats.append(c) %}
{% endif %}
{% endfor %}
{% else %}
{% set filtered_cats = categories %}
{% endif %}
{% set total_cats = filtered_cats|length %}
{% set total_pages = (total_cats / per_page)|round(0, 'ceil')|int %}
{% if total_pages == 0 %}{% set total_pages = 1 %}{% endif %}
{% set start_idx = (cat_page - 1) * per_page %}
{% set end_idx = start_idx + per_page %}
{% set paged_cats = filtered_cats[start_idx:end_idx] %}
{% for category in paged_cats %}
<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: 10px 14px;"><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: 10px 14px;"><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:5px; padding: 10px 14px;"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
</div>
<div class="category-content {% if search_q %}active{% endif %}" id="cat-{{ loop.index }}">
<form method="POST" enctype="multipart/form-data" style="margin-bottom: 15px; padding: 20px; background: #fff; border-bottom: 1px solid var(--border); display:flex; gap:15px; 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: 250px;">
<input type="text" name="new_category_name" value="{{ category }}" required>
<div>
<label style="font-size:0.95rem; font-weight:600; color:#636e72;">Фото категории (опционально, 512x512):</label>
<input type="file" name="category_photo" accept="image/*" style="padding: 10px;">
</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) }}" loading="lazy" style="width:80px;height:80px;object-fit:cover;border-radius:12px;border:1px solid var(--border);">
</div>
{% endif %}
<button type="submit" class="btn btn-warning" style="align-self: flex-start; min-height: 44px;"><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: 1.1rem; color: #636e72; margin-bottom: 10px;">Новый товар в категории "{{ 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" style="padding: 16px 20px; font-size: 16px; border-radius: 12px; border: 1px solid var(--border); background: #fafafa;">
<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: 16px;" 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.margin_tracking and sys_mode == 'both' %}
<div class="form-group main-cost-price-container">
<label style="color:#e67e22;">Себестоимость</label>
<input type="number" name="cost_price" placeholder="Себестоимость" step="0.01" class="main-cost-price-input">
</div>
{% endif %}
{% if settings.business_type != 'retail' %}
<div class="form-group">
<label>В уп. (шт)</label>
<input type="number" name="pieces_per_box" placeholder="Шт в уп." min="1" required>
</div>
{% if settings.use_master_boxes %}
<div class="form-group">
<label>В кор. (шт)</label>
<input type="number" name="pieces_per_master_box" placeholder="Шт в кор." min="1">
</div>
{% endif %}
{% 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: 10px; font-size:16px; margin-top:5px; min-height: 44px;" 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:1.05rem;"><i class="fas fa-tags"></i> Варианты товара</label>
<label style="font-size:16px; cursor:pointer;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'add-prod-{{ loop.index }}')" style="width:auto; display:inline-block; margin-right:5px; height: 18px;"> Разные цены</label>
</div>
<div id="variants-list-add-{{ loop.index }}"></div>
<button type="button" class="btn btn-outline" style="padding: 10px 15px; font-size:16px; min-height: 44px;" 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: 1rem; font-weight: 600; color: #636e72; display:block; margin-bottom:8px;">Фотографии товара (до 10 шт)</label>
<input type="file" name="photos" accept="image/*" multiple max="10" style="padding: 12px; font-size: 16px;">
</div>
<button type="submit" class="btn btn-success" style="width: 100%; justify-content: center; padding: 18px; font-size: 1.15rem; min-height: 50px;"><i class="fas fa-check"></i> Сохранить товар</button>
</form>
</div>
{% set ns = namespace(count=0) %}
{% for product in products %}
{% if product.category == category %}
{% set ns_prod = namespace(match=false) %}
{% if not search_q %}
{% set ns_prod.match = true %}
{% else %}
{% if search_q in product.name|lower or search_q in product.product_id|lower or search_q in category|lower or (product.barcode and search_q in product.barcode|lower) %}
{% set ns_prod.match = true %}
{% else %}
{% for v in product.variants %}
{% if v.barcode and search_q in v.barcode|lower %}
{% set ns_prod.match = true %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if ns_prod.match %}
{% if ns.count < 50 or search_q %}
<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] }}" loading="lazy" 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.85rem; 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 %}
{% if settings.margin_tracking and sys_mode == 'both' and product.cost_price %}
<span style="color:#e67e22;">| Себестоимость: {{ product.cost_price }} {{ currency_code }}</span>
{% endif %}
{% endif %}
{% if settings.business_type != 'retail' %}
• В уп: {{ product.pieces_per_box|default(1) }} шт
{% if settings.use_master_boxes and product.pieces_per_master_box %}
• В кор: {{ product.pieces_per_master_box }} шт
{% endif %}
{% 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; justify-content: flex-end;">
<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: 10px 14px;"><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: 10px 14px; margin-right:5px;"><i class="fas fa-arrow-down"></i></button>
</form>
<button class="btn btn-warning" style="padding: 10px 14px;" 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" style="padding: 10px 14px;"><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 }}">
<div style="font-weight: 600; font-size: 1.1rem; color: #636e72; margin-bottom: 10px;">Редактирование товара</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="new_category" required style="padding: 16px 20px; font-size: 16px; border-radius: 12px; border: 1px solid var(--border); background: #fafafa;">
{% for cat in categories %}
<option value="{{ cat }}" {% if product.category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1;">
<label>Наличие</label>
<select name="is_available" style="padding: 16px 20px; font-size: 16px; border-radius: 12px; border: 1px solid var(--border); background: #fafafa;">
<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: 16px;" 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.margin_tracking and sys_mode == 'both' %}
<div class="form-group main-cost-price-container" {% if product.has_variant_prices %}style="display:none;"{% endif %}>
<label style="color:#e67e22;">Себестоимость</label>
<input type="number" name="cost_price" value="{{ product.cost_price }}" step="0.01" class="main-cost-price-input">
</div>
{% endif %}
{% 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>
{% if settings.use_master_boxes %}
<div class="form-group">
<label>В кор. (шт)</label>
<input type="number" name="pieces_per_master_box" value="{{ product.pieces_per_master_box }}" min="1">
</div>
{% endif %}
{% 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:10px; margin-top:10px;">
<input type="number" name="main_tier_qty[]" value="{{ tier.qty }}" placeholder="От (шт)" required style="width:120px;">
<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.6rem; cursor:pointer;">&times;</button>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline" style="padding: 10px; font-size:16px; margin-top:10px; min-height: 44px;" 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:1.05rem;"><i class="fas fa-tags"></i> Варианты товара</label>
<label style="font-size:16px; cursor:pointer;"><input type="checkbox" name="has_variant_prices" onchange="toggleVariantPrices(this, 'edit-prod-{{ product.product_id }}')" {% if product.has_variant_prices %}checked{% endif %} style="width:auto; display:inline-block; margin-right:5px; height: 18px;"> Разные цены</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: 8px;" onclick="moveVariant(this, 'up')"><i class="fas fa-arrow-up"></i></button>
<button type="button" class="btn btn-outline" style="padding: 8px;" onclick="moveVariant(this, 'down')"><i class="fas fa-arrow-down"></i></button>
</div>
<div style="flex:1; display:flex; flex-wrap:wrap; gap:15px;">
<div class="form-group">
<label>Наличие</label>
<select name="variant_is_available_{{ loop.index0 }}" style="padding: 16px 20px; font-size: 16px; border-radius: 12px; border: 1px solid var(--border); background: #fafafa;">
<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_{{ loop.index0 }}" value="{{ variant.name }}" placeholder="Цвет, размер" required>
</div>
<div class="form-group" style="min-width: 200px;">
<label>Фото варианта</label>
{% if variant.photo %}
<div style="display:flex; gap:10px; align-items:center; margin-bottom:10px;">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ variant.photo }}" loading="lazy" style="width:60px; height:60px; object-fit:cover; border-radius:8px; border:1px solid #ccc;">
<label style="font-size:0.95rem; display:flex; align-items:center; gap:5px; margin:0;"><input type="checkbox" name="variant_remove_photo_{{ loop.index0 }}" value="1" style="width:auto; margin:0;"> Удалить</label>
<input type="hidden" name="variant_existing_photo_{{ loop.index0 }}" value="{{ variant.photo }}">
</div>
{% endif %}
<input type="file" name="variant_photo_{{ loop.index0 }}" accept="image/*" style="padding:10px; font-size: 16px;">
</div>
{% if settings.business_type != 'retail' %}
<div class="form-group">
<label>В уп. (шт)</label>
<input type="number" name="variant_pieces_per_box_{{ loop.index0 }}" value="{{ variant.pieces_per_box }}" placeholder="Шт">
</div>
{% if settings.use_master_boxes %}
<div class="form-group">
<label>В кор. (шт)</label>
<input type="number" name="variant_pieces_per_master_box_{{ loop.index0 }}" value="{{ variant.pieces_per_master_box }}" placeholder="Шт">
</div>
{% endif %}
{% 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_{{ loop.index0 }}" value="{{ variant.barcode }}" placeholder="Код">
<button type="button" class="btn btn-outline" style="padding: 16px;" 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_{{ loop.index0 }}" value="{{ variant.price }}" placeholder="Цена за ед." step="0.01" {% if product.has_variant_prices %}required{% endif %}>
</div>
{% if settings.margin_tracking and sys_mode == 'both' %}
<div class="form-group var-price-input" {% if not product.has_variant_prices %}style="display:none;"{% endif %}>
<label style="color:#e67e22;">Себестоим.</label>
<input type="number" name="variant_cost_price_{{ loop.index0 }}" value="{{ variant.cost_price }}" placeholder="Себестоимость" step="0.01">
</div>
{% endif %}
{% 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_{{ loop.index0 }}" 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_{{ loop.index0 }}" 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:10px; padding:15px; background:#fafafa; border:1px solid #ddd; border-radius:12px;">
<label style="font-size:1rem; 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:10px; margin-top:10px;">
<input type="number" name="variant_{{ loop.index0 }}_tier_qty[]" value="{{ tier.qty }}" placeholder="От (шт)" required style="width:120px;">
<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.6rem; cursor:pointer;">&times;</button>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline btn-add-var-tier" style="padding: 10px 14px; font-size:16px; margin-top:10px; min-height: 44px;" onclick="addTierRow('var-tiers-list-edit-{{ product.product_id }}-{{ loop.index0 }}', 'variant', {{ loop.index0 }})"><i class="fas fa-plus"></i> Добавить оптовую цену</button>
</div>
{% endif %}
</div>
<input type="hidden" name="variant_indices[]" value="{{ loop.index0 }}">
<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 }}');"><i class="fas fa-times-circle"></i></button>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-outline" style="padding: 10px 15px; font-size:16px; min-height: 44px;" 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: 1rem; font-weight: 600; color: #636e72; display:block; margin-bottom:8px;">Фотографии товара (до 10 шт)</label>
{% if product.photos and product.photos|length > 0 %}
<div class="current-photos" style="display:flex; gap:15px; flex-wrap:wrap; margin-bottom:15px;">
{% 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 }}" loading="lazy" style="width:80px;height:80px;object-fit:cover;border-radius:12px;">
<button type="button" onclick="removePhoto('{{ product.product_id }}', '{{ ph }}', 'photo-{{ product.product_id }}-{{ loop.index }}')" style="position:absolute;top:-8px;right:-8px;background:red;color:white;border:none;border-radius:50%;width:30px;height:30px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:bold; touch-action: manipulation;">&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
<div id="removed-photos-{{ product.product_id }}"></div>
<label style="font-size: 1rem; font-weight: 600; color: #636e72; display:block; margin-bottom:8px; margin-top:10px;">Добавить новые фото</label>
<input type="file" name="photos" accept="image/*" multiple max="10" style="padding: 12px; font-size: 16px;">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center; padding: 18px; font-size: 1.15rem; min-height: 50px;"><i class="fas fa-save"></i> Сохранить изменения</button>
</form>
</div>
</div>
{% endif %}
{% set ns.count = ns.count + 1 %}
{% endif %}
{% endif %}
{% endfor %}
{% if ns.count > 50 and not search_q %}
<div style="padding: 20px; text-align: center; color: #636e72; background: #fafafa; border-top: 1px solid var(--border); font-weight: 600; font-size: 1.05rem;">
Показано 50 из {{ ns.count }} товаров. Воспользуйтесь поиском, чтобы найти остальные.
</div>
{% endif %}
</div>
</div>
{% endfor %}
{{ render_pagination(cat_page, total_pages, 'page', '&q=' ~ search_q) }}
</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:25px; border-radius:16px; width:100%; max-width:450px; text-align:center;">
<h3 style="margin-top:0; font-size: 1.3rem;">Сканирование</h3>
<div id="reader" style="width:100%; min-height:300px; margin-bottom:20px;"></div>
<button class="btn btn-danger" onclick="stopScanner()" style="width: 100%; padding: 15px; font-size: 1.1rem; min-height: 50px;">Отмена</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 useMasterBoxes = {{ 'true' if settings.use_master_boxes and sys_mode != 'external' else 'false' }};
const marginTracking = {{ 'true' if settings.margin_tracking and sys_mode == 'both' else 'false' }};
const businessType = '{{ settings.business_type }}';
let globalVariantCounter = 1000;
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"]');
if(cb) {
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 = '10px'; div.style.marginTop = '10px';
let namePrefix = type === 'main' ? 'main' : `variant_${varIndex}`;
div.innerHTML = `
<input type="number" name="${namePrefix}_tier_qty[]" placeholder="От (шт)" required style="width:120px;">
<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.6rem; cursor:pointer; touch-action: manipulation;">&times;</button>
`;
container.appendChild(div);
}
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);
}
}
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' : '';
globalVariantCounter++;
const vId = globalVariantCounter;
let html = `
<div style="display:flex; flex-direction:column; gap:5px; margin-right: 5px;">
<button type="button" class="btn btn-outline" style="padding: 8px;" onclick="moveVariant(this, 'up')"><i class="fas fa-arrow-up"></i></button>
<button type="button" class="btn btn-outline" style="padding: 8px;" onclick="moveVariant(this, 'down')"><i class="fas fa-arrow-down"></i></button>
</div>
<div style="flex:1; display:flex; flex-wrap:wrap; gap:15px;">
<div class="form-group">
<label>Наличие</label>
<select name="variant_is_available_${vId}" style="padding: 16px 20px; font-size: 16px; border-radius: 12px; border: 1px solid var(--border); background: #fafafa;">
<option value="1" selected>Да</option>
<option value="0">Нет</option>
</select>
</div>
<div class="form-group">
<label>Название</label>
<input type="text" name="variant_name_${vId}" placeholder="Цвет, размер" required>
</div>
<div class="form-group" style="min-width: 200px;">
<label>Фото варианта</label>
<input type="file" name="variant_photo_${vId}" accept="image/*" style="padding:10px; font-size: 16px;">
</div>
`;
if (businessType !== 'retail') {
html += `
<div class="form-group">
<label>В уп. (шт)</label>
<input type="number" name="variant_pieces_per_box_${vId}" placeholder="Шт">
</div>`;
if (useMasterBoxes) {
html += `
<div class="form-group">
<label>В кор. (шт)</label>
<input type="number" name="variant_pieces_per_master_box_${vId}" placeholder="Шт">
</div>`;
}
}
if (useBarcodes) {
html += `
<div class="form-group">
<label>Штрих-код</label>
<div style="display:flex; gap:5px;">
<input type="text" name="variant_barcode_${vId}" placeholder="Код">
<button type="button" class="btn btn-outline" style="padding: 16px;" 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_${vId}" placeholder="Цена за ед." step="0.01" ${reqAttr}>
</div>
`;
if (marginTracking) {
html += `
<div class="form-group var-price-input" ${displayStyle}>
<label style="color:#e67e22;">Себестоим.</label>
<input type="number" name="variant_cost_price_${vId}" placeholder="Себестоимость" step="0.01">
</div>
`;
}
if (businessType === 'mixed') {
html += `
<div class="form-group var-price-input" ${displayStyle}>
<label>Цена уп.</label>
<input type="number" name="variant_box_price_${vId}" placeholder="Опционально" step="0.01">
</div>`;
}
if (trackInventory) {
html += `
<div class="form-group">
<label>Остаток</label>
<input type="number" name="variant_stock_${vId}" placeholder="Остаток">
</div>`;
}
if (businessType === 'wholesale') {
html += `
<div class="variant-tiers-container var-price-input" ${displayStyle} style="width:100%; margin-top:10px; padding:15px; background:#fafafa; border:1px solid #ddd; border-radius:12px;">
<label style="font-size:1rem; font-weight:bold;">Оптовые цены варианта</label>
<div class="var-tier-list" id="var-tiers-list-add-${vId}"></div>
<button type="button" class="btn btn-outline btn-add-var-tier" style="padding: 10px 14px; font-size:16px; margin-top:10px; min-height: 44px;" onclick="addTierRow('var-tiers-list-add-${vId}', 'variant', ${vId})"><i class="fas fa-plus"></i> Добавить оптовую цену</button>
</div>`;
}
html += `</div>
<input type="hidden" name="variant_indices[]" value="${vId}">
<button type="button" class="remove-variant-btn" onclick="const row = this.closest('.variant-row'); row.remove(); updateMainStockVisibility('${formId}');"><i class="fas fa-times-circle"></i></button></div>`;
div.innerHTML = html;
container.appendChild(div);
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 mainCostPriceContainer = form.querySelector('.main-cost-price-container');
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(mainCostPriceContainer) mainCostPriceContainer.style.display = 'none';
if(mainBoxPriceContainer) mainBoxPriceContainer.style.display = 'none';
if(mainTiersContainer) mainTiersContainer.style.display = 'none';
varPriceInputs.forEach(input => {
input.style.display = 'flex';
const inps = input.querySelectorAll('input[name^="variant_price_"]');
inps.forEach(inp => inp.setAttribute('required', 'required'));
});
} else {
if(mainPriceContainer) mainPriceContainer.style.display = 'flex';
if(mainPriceInput) mainPriceInput.setAttribute('required', 'required');
if(mainCostPriceContainer) mainCostPriceContainer.style.display = 'flex';
if(mainBoxPriceContainer) mainBoxPriceContainer.style.display = 'flex';
if(mainTiersContainer) mainTiersContainer.style.display = 'block';
varPriceInputs.forEach(input => {
input.style.display = 'none';
const inps = input.querySelectorAll('input[name^="variant_price_"]');
inps.forEach(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';
}
}
}
let staffTimer;
function debounceFilterStaff() {
clearTimeout(staffTimer);
staffTimer = setTimeout(filterStaff, 300);
}
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: 12px 18px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; background: var(--primary); font-size: 16px; min-height: 44px; touch-action: manipulation; }
.filter-bar { background: var(--surface); padding: 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: 16px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-family: inherit; font-size: 16px; width: 100%; box-sizing: border-box; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; flex-wrap: wrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tab { padding: 12px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 10px; transition: background 0.2s; font-size: 16px; white-space: nowrap; }
.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; position: relative; overflow: hidden; }
.stat-card .title { font-size: 1rem; color: #636e72; font-weight: 600; z-index: 2; }
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: var(--text); z-index: 2; }
.stat-card.icon { font-size: 3rem; color: var(--primary); opacity: 0.1; position: absolute; right: 10px; bottom: -5px; z-index: 1; }
.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: 500px; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid var(--border); font-size: 16px; }
th { font-weight: 600; color: #636e72; background: #fafafa; }
@media (max-width: 600px) {
.header { flex-direction: column; align-items: stretch; text-align: center; }
.filter-bar { flex-direction: column; align-items: stretch; }
.stats-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.stat-card { padding: 15px; }
.stat-card .value { font-size: 1.5rem; }
}
</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; font-size: 16px;">Период:</span>
<div style="display:flex; gap:10px; flex:1;">
<input type="date" id="dateStart" onchange="updateReports()" style="flex:1;">
<span style="display:flex; align-items:center;">—</span>
<input type="date" id="dateEnd" onchange="updateReports()" style="flex:1;">
</div>
<button class="btn" style="background:var(--success); width: 100%; justify-content: center;" 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>
{% if settings.margin_tracking and settings.system_mode == 'both' %}
<div class="tab" onclick="switchTab('margin')">Маржа и Прибыль</div>
{% endif %}
</div>
<div id="general" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="title">Общая выручка</div>
<div class="value" id="totalRevenue">0</div>
<i class="fas fa-money-bill-wave icon"></i>
</div>
<div class="stat-card">
<div class="title">Кол-во заказов</div>
<div class="value" id="totalOrders">0</div>
<i class="fas fa-shopping-cart icon"></i>
</div>
<div class="stat-card">
<div class="title">Средний чек (AOV)</div>
<div class="value" id="avgOrderValue">0</div>
<i class="fas fa-receipt icon"></i>
</div>
<div class="stat-card">
<div class="title">Продано товаров (шт)</div>
<div class="value" id="totalItemsSold">0</div>
<i class="fas fa-box icon"></i>
</div>
<div class="stat-card">
<div class="title">Возвраты (сумма)</div>
<div class="value" id="totalReturns" style="color:var(--danger);">0</div>
<i class="fas fa-undo icon"></i>
</div>
{% if settings.commission_enabled %}
<div class="stat-card">
<div class="title">Сумма комиссий ({{ settings.commission_percent }}%)</div>
<div class="value" id="totalCommissions" style="color:var(--info);">0</div>
<i class="fas fa-percentage icon"></i>
</div>
{% endif %}
</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>
{% if settings.margin_tracking and settings.system_mode == 'both' %}
<div id="margin" class="tab-content">
<div class="stats-grid">
<div class="stat-card">
<div class="title">Чистая прибыль</div>
<div class="value" id="totalProfit" style="color:var(--success);">0</div>
<i class="fas fa-coins icon"></i>
</div>
<div class="stat-card">
<div class="title">Себестоимость проданного</div>
<div class="value" id="totalCost" style="color:var(--warning);">0</div>
<i class="fas fa-boxes icon"></i>
</div>
<div class="stat-card">
<div class="title">Средняя маржинальность</div>
<div class="value" id="avgMargin">0%</div>
<i class="fas fa-chart-pie icon"></i>
</div>
</div>
<div class="table-container">
<h3>Маржинальность по товарам</h3>
<table>
<thead>
<tr>
<th>Товар</th>
<th>Кол-во (шт)</th>
<th>Выручка</th>
<th>Себест.</th>
<th>Прибыль</th>
<th>Маржа %</th>
</tr>
</thead>
<tbody id="marginTableBody"></tbody>
</table>
</div>
</div>
{% endif %}
</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 totalCommissions = 0;
let totalCost = 0;
let staffSales = {};
let productSales = {};
let dailySales = {};
let categorySales = {};
let marginSales = {};
filteredOrders.forEach(o => {
if(o.status === 'returned') {
totalRet += o.total_price;
} else {
totalRev += o.total_price;
ordersCount++;
if (o.commission_amount) {
totalCommissions += o.commission_amount;
}
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})`;
let itemPrice = parseFloat(item.calculated_price) || parseFloat(item.price);
let itemRev = itemPrice * item.quantity;
let itemCost = parseFloat(item.cost_price) || 0;
let itemTotalCost = itemCost * item.quantity;
totalCost += itemTotalCost;
if(!productSales[pName]) productSales[pName] = { qty: 0, sum: 0 };
productSales[pName].qty += item.quantity;
productSales[pName].sum += itemRev;
let catName = item.category || 'Без категории';
if(!categorySales[catName]) categorySales[catName] = { qty: 0, sum: 0 };
categorySales[catName].qty += item.quantity;
categorySales[catName].sum += itemRev;
if(!marginSales[pName]) marginSales[pName] = { qty: 0, rev: 0, cost: 0, profit: 0 };
marginSales[pName].qty += item.quantity;
marginSales[pName].rev += itemRev;
marginSales[pName].cost += itemTotalCost;
marginSales[pName].profit += (itemRev - itemTotalCost);
}
});
}
});
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 tcEl = document.getElementById('totalCommissions');
if (tcEl) {
tcEl.innerText = totalCommissions.toLocaleString() + ' {{ currency_code }}';
}
let aov = ordersCount > 0 ? (totalRev / ordersCount) : 0;
document.getElementById('avgOrderValue').innerText = Math.round(aov).toLocaleString() + ' {{ currency_code }}';
let tpEl = document.getElementById('totalProfit');
if(tpEl) {
let totalProfit = totalRev - totalCost - totalCommissions;
let avgMargin = totalRev > 0 ? (totalProfit / totalRev) * 100 : 0;
tpEl.innerText = Math.round(totalProfit).toLocaleString() + ' {{ currency_code }}';
document.getElementById('totalCost').innerText = Math.round(totalCost).toLocaleString() + ' {{ currency_code }}';
document.getElementById('avgMargin').innerText = avgMargin.toFixed(1) + '%';
renderMarginTable(marginSales);
}
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>
`;
});
}
function renderMarginTable(data) {
const tbody = document.getElementById('marginTableBody');
if(!tbody) return;
tbody.innerHTML = '';
const sorted = Object.keys(data).sort((a,b) => data[b].profit - data[a].profit);
if(sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;">Нет данных</td></tr>';
return;
}
sorted.forEach(p => {
let marginPct = data[p].rev > 0 ? (data[p].profit / data[p].rev) * 100 : 0;
tbody.innerHTML += `
<tr>
<td style="font-weight:500;">${p}</td>
<td>${data[p].qty}</td>
<td>${Math.round(data[p].rev).toLocaleString()}</td>
<td>${Math.round(data[p].cost).toLocaleString()}</td>
<td style="color:var(--success); font-weight:bold;">${Math.round(data[p].profit).toLocaleString()}</td>
<td>${marginPct.toFixed(1)}%</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; --warning: #f39c12; --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: 12px 18px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: var(--info); font-size: 16px; min-height: 44px; touch-action: manipulation; }
.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 input { width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-size: 16px; border-right: none; border-top-right-radius: 0; border-bottom-right-radius: 0; box-sizing: border-box; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; flex-wrap: wrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.tab { padding: 12px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 10px; transition: background 0.2s; display:flex; align-items:center; gap:8px; font-size: 16px; white-space: nowrap; }
.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: 600px; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; font-size: 16px; }
th { font-weight: 600; color: #636e72; background: #fafafa; }
.inv-row { content-visibility: auto; contain-intrinsic-size: auto 60px; }
.action-cell { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; }
.action-cell input[type="number"] { width: 80px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; text-align: center; font-size: 16px; box-sizing: border-box; }
.action-cell input[type="text"] { width: 150px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 16px; box-sizing: border-box; }
.btn-sm { padding: 10px 14px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; color: white; font-size: 16px; touch-action: manipulation; }
.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.9rem; font-weight: 600; }
.badge-type-sub { background: #f8d7da; color: #721c24; padding: 4px 8px; border-radius: 12px; font-size: 0.9rem; font-weight: 600; }
.badge-danger { background: var(--danger); color: white; padding: 2px 8px; border-radius: 12px; font-size: 0.9rem; }
.badge-info { background: var(--info); color: white; padding: 2px 8px; border-radius: 12px; font-size: 0.9rem; }
@media (max-width: 600px) {
.header { flex-direction: column; align-items: stretch; text-align: center; }
.action-cell { flex-direction: column; align-items: stretch; }
.action-cell input[type="number"], .action-cell input[type="text"] { width: 100%; }
.btn-sm { width: 100%; }
}
</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('purchases')">Заказы <span class="badge-info">{{ active_purchases|length }}</span></div>
<div class="tab" onclick="switchTab('history')">История операций</div>
</div>
<div id="current" class="tab-content active">
<div class="search-bar" style="padding: 10px 20px;">
<div style="display:flex; flex:1;">
<input type="text" id="searchInput" placeholder="Поиск товара или ID..." onkeydown="if(event.key==='Enter') filterTable()" onkeyup="debounceFilterTable()">
<button class="btn" style="border-radius: 0 8px 8px 0; padding: 0 20px;" onclick="filterTable()"><i class="fas fa-search"></i></button>
</div>
</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.85rem; 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.85rem; 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="purchases" class="tab-content">
<div class="table-container">
<table>
<thead>
<tr>
<th>Дата и время</th>
<th>Товар</th>
<th>Ожидается (шт)</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for po in active_purchases %}
<tr>
<td>{{ po.date }}</td>
<td><strong>{{ po.product_name }}</strong> {% if po.variant_name %}<br><span style="color:#636e72;">{{ po.variant_name }}</span>{% endif %}</td>
<td><strong style="color:var(--info); font-size:1.2rem;">{{ po.qty }}</strong></td>
<td>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<button class="btn-sm btn-add" onclick="receivePurchase('{{ po.id }}', {{ po.qty }})"><i class="fas fa-check"></i> Пришел полный</button>
<div style="display:flex; align-items:center; gap:5px; border-left: 1px solid var(--border); padding-left: 15px; margin-left: 5px;">
<input type="number" id="part_qty_{{ po.id }}" placeholder="Кол-во" style="width:90px; padding:10px; border:1px solid var(--border); border-radius:8px; text-align:center; font-size:16px;">
<button class="btn-sm" style="background:var(--warning); color:#fff;" onclick="receivePartialPurchase('{{ po.id }}')">Частично</button>
</div>
</div>
</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');
}
let invTimer;
function debounceFilterTable() {
clearTimeout(invTimer);
invTimer = setTimeout(filterTable, 300);
}
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('Ошибка обновления остатков');
}
});
}
function receivePurchase(poId, qty) {
if(!confirm('Подтвердить полное поступление?')) return;
processReceive(poId, qty);
}
function receivePartialPurchase(poId) {
const input = document.getElementById('part_qty_' + poId);
const qty = parseInt(input.value);
if(isNaN(qty) || qty <= 0) {
alert('Укажите корректное количество');
return;
}
processReceive(poId, qty);
}
function processReceive(poId, qty) {
fetch(`/${envId}/api/receive_purchase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ po_id: poId, qty: qty })
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert('Ошибка при поступлении товара');
}
});
}
</script>
</body>
</html>
'''
DEBTS_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: #c0392b; --info: #0984e3; --success: #27ae60; }
body { font-family: 'Montserrat', sans-serif; background-color: var(--bg); color: var(--text); padding: 20px; margin: 0; }
.container { max-width: 1100px; 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; color: var(--primary); }
.btn { padding: 12px 18px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; background: var(--info); font-size: 16px; min-height: 44px; justify-content: center; }
.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: 600px; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid var(--border); font-size: 16px; }
th { font-weight: 600; color: #636e72; background: #fafafa; }
.payment-form { display: flex; gap: 10px; flex-wrap: wrap; }
.payment-form input { padding: 12px; border: 1px solid var(--border); border-radius: 8px; width: 140px; outline: none; font-size: 16px; box-sizing: border-box; }
.payment-form button { background: var(--success); color: white; border: none; padding: 12px 15px; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 16px; min-height: 44px; }
.tabs { display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 2px solid var(--border); padding-bottom: 10px; }
.tab { padding: 12px 20px; cursor: pointer; font-weight: 600; color: #636e72; border-radius: 10px; transition: background 0.2s; font-size: 16px; white-space: nowrap; }
.tab:hover { background: #e9ecef; }
.tab.active { background: var(--primary); color: #fff; }
.tab-content { display: none; }
.tab-content.active { display: block; }
@media (max-width: 600px) {
.header { flex-direction: column; align-items: stretch; text-align: center; }
.payment-form { flex-direction: column; align-items: stretch; width: 100%; }
.payment-form input { width: 100%; }
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-hand-holding-usd"></i> Учет долгов</h1>
<a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> Назад в панель</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div style="padding: 20px; margin-bottom: 20px; border-radius: 12px; background: #d4edda; color: #155724; font-size: 1.1rem; font-weight: 600;">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="tabs">
<div class="tab active" onclick="switchTab('active')">Активные долги</div>
<div class="tab" onclick="switchTab('history')">История долгов</div>
</div>
<div id="active" class="tab-content active">
<div class="table-container">
<table>
<thead>
<tr>
<th>Накладная</th>
<th>Дата</th>
<th>Клиент</th>
<th>Общая сумма</th>
<th>Оплачено</th>
<th style="color:var(--primary);">Долг</th>
<th>Внести оплату</th>
</tr>
</thead>
<tbody>
{% for o in debts %}
<tr>
<td><a href="/{{ env_id }}/order/{{ o.id }}" target="_blank" style="color:var(--info); font-weight:bold; text-decoration:none;">{{ o.id }}</a></td>
<td>{{ o.created_at.split(' ')[0] }}</td>
<td>{{ o.customer_name if o.customer_name else 'Касса' }}<br><span style="font-size:0.9rem; color:#636e72;">{{ o.customer_whatsapp }}</span></td>
<td>{{ o.total_price }} {{ currency }}</td>
<td style="color:var(--success); font-weight:bold;">{{ o.paid_amount }} {{ currency }}</td>
<td style="color:var(--primary); font-weight:bold; font-size:1.2rem;">{{ o.debt_amount }} {{ currency }}</td>
<td>
<form class="payment-form" action="/{{ env_id }}/pay_debt/{{ o.id }}" method="POST">
<input type="number" name="amount" step="0.01" max="{{ o.debt_amount }}" required placeholder="Сумма ({{ currency }})">
<button type="submit"><i class="fas fa-check"></i> Оплатить</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="text-align: center; padding: 40px; color: #636e72; font-size: 1.1rem;">Нет активных долгов</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 debt_history %}
<tr>
<td>{{ h.date }}</td>
<td>{{ h.customer }}</td>
<td><a href="/{{ env_id }}/order/{{ h.order_id }}" target="_blank" style="color:var(--info); font-weight:bold; text-decoration:none;">{{ h.order_id }}</a></td>
<td>{{ h.action }}</td>
<td style="font-weight:bold; color: {% if h.action == 'Оплата долга' %}var(--success){% else %}var(--primary){% endif %};">
{{ h.amount }} {{ currency }}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="text-align: center; padding: 40px; color: #636e72; font-size: 1.1rem;">История пуста</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
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');
}
</script>
</body>
</html>
'''
PURCHASES_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: #f1c40f; --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; color: #2d3436; }
.btn { padding: 12px 18px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; color: #fff; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; background: var(--info); font-size: 16px; min-height: 44px; touch-action: manipulation; }
.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 input { width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: 8px; outline: none; font-size: 16px; border-right: none; border-top-right-radius: 0; border-bottom-right-radius: 0; box-sizing: border-box; }
.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: 600px; }
th, td { padding: 15px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; font-size: 16px; }
th { font-weight: 600; color: #636e72; background: #fafafa; }
.btn-order { background: var(--primary); color: #2d3436; font-weight: 600; padding: 10px 15px; border-radius: 8px; border: none; cursor: pointer; font-size: 16px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; touch-action: manipulation; }
.modal-overlay { 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; z-index: 1000; padding: 15px; box-sizing: border-box; }
.modal-content { background: #fff; padding: 25px; border-radius: 16px; width: 100%; max-width: 450px; text-align: center; }
.modal-content h3 { margin-top: 0; font-size: 1.3rem; }
.modal-content input { width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 20px; font-size: 16px; text-align: center; box-sizing: border-box; }
.modal-actions { display: flex; gap: 15px; justify-content: center; flex-wrap: wrap; }
.modal-actions button { flex: 1; padding: 16px; font-size: 16px; min-width: 120px; }
.inv-row { content-visibility: auto; contain-intrinsic-size: auto 60px; }
@media (max-width: 600px) {
.header { flex-direction: column; align-items: stretch; text-align: center; }
.header .btn { width: 100%; }
.btn-order { width: 100%; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-truck-loading" style="color:var(--primary);"></i> Заказы поставщикам</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<a href="/{{ env_id }}/inventory" class="btn" style="background:#27ae60;"><i class="fas fa-boxes"></i> В остатки</a>
<a href="/{{ env_id }}/admin" class="btn"><i class="fas fa-arrow-left"></i> В панель</a>
</div>
</div>
<div class="search-bar" style="padding: 10px 20px;">
<div style="display:flex; flex:1;">
<input type="text" id="searchInput" placeholder="Поиск товара или ID..." onkeydown="if(event.key==='Enter') filterTable()" onkeyup="debounceFilterTable()">
<button class="btn" style="border-radius: 0 8px 8px 0; padding: 0 20px;" onclick="filterTable()"><i class="fas fa-search"></i></button>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Товар</th>
<th>Категория</th>
<th>Текущий остаток</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% 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.85rem; color:#b2bec3;">ID: {{ p.product_id }}</span>
</td>
<td>{{ p.category }}</td>
<td><strong>{{ p.stock if p.stock != "" else "0" }}</strong></td>
<td>
<button class="btn-order" onclick="openOrderModal('{{ p.product_id }}', -1, '{{ p.name|replace("'", "\\'") }}')"><i class="fas fa-plus"></i> Сделать заказ</button>
</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.85rem; color:#b2bec3;">ID: {{ p.product_id }}</span>
</td>
<td>{{ p.category }}</td>
<td><strong>{{ v.stock if v.stock != "" else "0" }}</strong></td>
<td>
<button class="btn-order" onclick="openOrderModal('{{ p.product_id }}', {{ loop.index0 }}, '{{ p.name|replace("'", "\\'") }} ({{ v.name|replace("'", "\\'") }})')"><i class="fas fa-plus"></i> Сделать заказ</button>
</td>
</tr>
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal-overlay" id="orderModal">
<div class="modal-content">
<h3>Заказ товара</h3>
<p id="orderProductName" style="font-weight:600; color:#636e72; margin-bottom:15px; font-size:1.1rem;"></p>
<input type="number" id="orderQty" placeholder="Введите количество" min="1">
<div class="modal-actions">
<button class="btn btn-order" style="background:#27ae60; color:#fff;" onclick="submitOrder()">Подтвердить</button>
<button class="btn btn-danger" style="background:#ff7675;" onclick="closeOrderModal()">Отмена</button>
</div>
</div>
</div>
<script>
const envId = '{{ env_id }}';
let currentOrder = null;
let searchTimer;
function debounceFilterTable() {
clearTimeout(searchTimer);
searchTimer = setTimeout(filterTable, 300);
}
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 openOrderModal(pid, vidx, name) {
currentOrder = { product_id: pid, variant_idx: vidx };
document.getElementById('orderProductName').innerText = name;
document.getElementById('orderQty').value = '';
document.getElementById('orderModal').style.display = 'flex';
}
function closeOrderModal() {
document.getElementById('orderModal').style.display = 'none';
currentOrder = null;
}
function submitOrder() {
if(!currentOrder) return;
const qty = parseInt(document.getElementById('orderQty').value);
if(isNaN(qty) || qty <= 0) {
alert('Укажите корректное количество');
return;
}
fetch(`/${envId}/api/purchase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: currentOrder.product_id,
variant_idx: currentOrder.variant_idx,
qty: qty
})
})
.then(r => r.json())
.then(data => {
if(data.success) {
alert('Заказ успешно создан! Перейдите во вкладку "Заказы" в "Остатках".');
closeOrderModal();
} 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()
active_envs = []
archived_envs = []
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}")
env_info = {
"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")
}
if settings.get('is_deleted', False):
archived_envs.append(env_info)
else:
active_envs.append(env_info)
active_envs.sort(key=lambda x: x['id'])
archived_envs.sort(key=lambda x: x['id'])
return render_template_string(ADMHOSTO_TEMPLATE, active_envs=active_envs, archived_envs=archived_envs)
@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':[], 'purchase_orders': [], 'debt_history': [],
'settings': {
"organization_name": f"Shop {new_id}",
"admin_password_enabled": False,
"admin_password": "",
"logo_url": DEFAULT_LOGO_URL,
"qr_code_1_url": "",
"qr_code_2_url": "",
"catalog_subtitle": "",
"whatsapp_number": DEFAULT_WHATSAPP_NUMBER,
"order_messenger": "wa",
"order_contact": DEFAULT_WHATSAPP_NUMBER,
"invoice_contacts": "",
"currency": "₸",
"track_inventory": False,
"use_barcodes": False,
"use_master_boxes": False,
"margin_tracking": False,
"language": "ru",
"business_type": "mixed",
"system_mode": "both",
"hide_stock_online": False,
"closed_catalog_enabled": False,
"commission_enabled": False,
"commission_percent": 0.0,
"theme": "light",
"is_deleted": False,
"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': ''},
'kaspi': {'enabled': False, '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'] =[]
all_data[env_id]['debt_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:
all_data[env_id]['settings']['is_deleted'] = True
save_data(all_data)
flash(f'Среда {env_id} была отключена (в архиве).', 'success')
else:
flash(f'Среда {env_id} не найдена.', 'error')
return redirect(url_for('admhosto'))
@app.route('/admhosto/restore/<env_id>', methods=['POST'])
def restore_environment(env_id):
all_data = load_data()
if env_id in all_data:
all_data[env_id]['settings']['is_deleted'] = False
save_data(all_data)
flash(f'Среда {env_id} восстановлена.', 'success')
return redirect(url_for('admhosto'))
@app.route('/admhosto/hard_delete/<env_id>', methods=['POST'])
def hard_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')
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', {})
t = get_t(settings.get('language', 'ru'))
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', '₸'),
settings=settings,
env_id=env_id,
mode=mode,
staff_id=staff_id,
t=t
)
@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/upload_custom_photo', methods=['POST'])
def upload_custom_photo(env_id):
file = request.files.get('photo')
if file and file.filename:
filename = process_and_upload_image(file, 'photos', size=(512, 512))
if filename:
return jsonify({'success': True, 'filename': filename})
return jsonify({'success': False})
@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 and not item.get('is_custom'):
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'])
if order.get('is_debt'):
order['debt_amount'] = round(order['total_price'] - float(order.get('paid_amount', 0)), 2)
if order['debt_amount'] <= 0:
order['is_debt'] = False
order['debt_amount'] = 0
order['paid_amount'] = order['total_price']
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 and not item.get('is_custom'):
restore_stock(c_key, item.get('product_id'), item.get('variant_idx', -1), ret_qty, data['products'])
break
update_order_totals(order, data['settings'])
if order['total_price'] <= 0:
order['status'] = 'returned'
order['is_debt'] = False
order['debt_amount'] = 0
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 for p in data.get('products', [])}
processed_cart =[]
for item in cart_items:
if item.get('is_custom'):
photo_url = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
if item.get('photo_custom'):
photo_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo_custom']}"
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": 0,
"cost_price": 0,
"quantity": int(item['quantity']),
"pieces_per_box": 1,
"pieces_per_master_box": 1,
"variant_name": '',
"variant_idx": -1,
"discount": float(item.get('discount', 0)),
"wholesale_tiers": [],
"category": 'Свой товар',
"photo_url": photo_url,
"is_custom": True
})
continue
p_info = product_dict.get(item.get('product_id'), {})
cat = p_info.get('category', 'Без категории')
photo_url = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMHcwIi8+PC9zdmc+"
vidx = item.get('variant_idx', -1)
cost_price = p_info.get('cost_price', 0)
if vidx != -1 and p_info.get('variants') and len(p_info['variants']) > vidx:
v_photo = p_info['variants'][vidx].get('photo')
if v_photo:
photo_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{v_photo}"
elif item.get('photos'):
photo_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}"
cost_price = p_info['variants'][vidx].get('cost_price', cost_price)
elif item.get('photos'):
photo_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photos'][0]}"
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)),
"cost_price": float(cost_price) if str(cost_price).strip() else 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,
"pieces_per_master_box": int(item.get('pieces_per_master_box', 1)) if str(item.get('pieces_per_master_box', 1)).strip() != "" else 1,
"variant_name": item.get('variant_name', ''),
"variant_idx": vidx,
"discount": float(item.get('discount', 0)),
"wholesale_tiers": item.get('wholesale_tiers', []),
"category": cat,
"photo_url": photo_url,
"is_custom": False
})
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": {},
"commission_amount": 0,
"is_debt": order_data.get('is_debt', False),
"paid_amount": float(order_data.get('paid_amount', 0)),
"debt_amount": 0
}
update_order_totals(new_order, data['settings'])
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'])
if new_order['is_debt'] and new_order['debt_amount'] > 0:
if 'debt_history' not in data:
data['debt_history'] = []
data['debt_history'].append({
'id': uuid4().hex,
'date': get_almaty_time(),
'order_id': order_id,
'customer': customer_name or 'Касса',
'action': 'Возникновение долга',
'amount': new_order['debt_amount']
})
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', {})
t = get_t(settings.get('language', 'ru'))
if not order:
return "Order not found", 404
return render_template_string(
ORDER_TEMPLATE,
order=order,
settings=settings,
currency_code=settings.get('currency', '₸'),
env_id=env_id,
t=t
)
@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'])
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'])
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>/pay_debt/<order_id>', methods=['POST'])
def pay_debt(env_id, order_id):
data = get_env_data(env_id)
order = data.get('orders', {}).get(order_id)
if order and order.get('is_debt'):
amount = float(request.form.get('amount', 0))
if amount > 0:
order['paid_amount'] = round(float(order.get('paid_amount', 0)) + amount, 2)
order['debt_amount'] = round(float(order.get('total_price', 0)) - order['paid_amount'], 2)
if order['debt_amount'] <= 0:
order['is_debt'] = False
order['debt_amount'] = 0
order['paid_amount'] = order.get('total_price', 0)
if 'debt_history' not in data:
data['debt_history'] = []
data['debt_history'].append({
'id': uuid4().hex,
'date': get_almaty_time(),
'order_id': order_id,
'customer': order.get('customer_name') or 'Касса',
'action': 'Оплата долга',
'amount': amount
})
save_env_data(env_id, data)
flash(f'Оплата в размере {amount} успешно внесена для заказа №{order_id}', 'success')
return redirect(url_for('debts', env_id=env_id))
@app.route('/<env_id>/debts')
def debts(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))
all_orders = list(data.get('orders', {}).values())
debt_orders = [o for o in all_orders if o.get('is_debt', False) and float(o.get('debt_amount', 0)) > 0]
debt_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
history_list = data.get('debt_history', [])
history_list = sorted(history_list, key=lambda x: x.get('date', ''), reverse=True)
return render_template_string(
DEBTS_TEMPLATE,
env_id=env_id,
debts=debt_orders,
debt_history=history_list,
currency=settings.get('currency', '₸')
)
@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', '₸'),
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', '₸'),
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,
settings=settings,
currency_code=settings.get('currency', '₸'),
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',[]))
active_purchases = data.get('purchase_orders', [])
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,
active_purchases=active_purchases)
@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>/purchases')
def purchases(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') != 'both':
return redirect(url_for('admin', env_id=env_id))
return render_template_string(PURCHASES_TEMPLATE, env_id=env_id, products=data.get('products', []))
@app.route('/<env_id>/api/purchase', methods=['POST'])
def create_purchase(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))
if qty <= 0:
return jsonify({"success": False})
name = ""
v_name = ""
for p in data['products']:
if p['product_id'] == pid:
name = p['name']
if vidx != -1 and vidx < len(p.get('variants', [])):
v_name = p['variants'][vidx]['name']
break
po_id = uuid4().hex
data['purchase_orders'].append({
'id': po_id,
'date': get_almaty_time(),
'product_id': pid,
'variant_idx': vidx,
'product_name': name,
'variant_name': v_name,
'qty': qty
})
save_env_data(env_id, data)
return jsonify({"success": True})
@app.route('/<env_id>/api/receive_purchase', methods=['POST'])
def receive_purchase(env_id):
data = get_env_data(env_id)
req = request.json
po_id = req.get('po_id')
qty_received = int(req.get('qty', 0))
if qty_received <= 0:
return jsonify({"success": False})
po = next((x for x in data['purchase_orders'] if x['id'] == po_id), None)
if not po:
return jsonify({"success": False})
pid = po['product_id']
vidx = po['variant_idx']
for p in data['products']:
if p['product_id'] == pid:
if vidx != -1 and vidx < len(p.get('variants', [])):
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_received
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_received
data['inventory_history'].append({
"date": get_almaty_time(),
"product": p['name'],
"variant": po['variant_name'],
"change": qty_received,
"comment": "Поступление от поставщика"
})
break
if qty_received >= po['qty']:
data['purchase_orders'] = [x for x in data['purchase_orders'] if x['id'] != po_id]
else:
po['qty'] -= qty_received
save_env_data(env_id, data)
return jsonify({"success": True})
@app.route('/<env_id>/export_products', methods=['GET'])
def export_products(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', [])
export_data = []
for p in products:
export_data.append({
"product_id": p.get("product_id"),
"name": p.get("name", ""),
"price": p.get("price", ""),
"cost_price": p.get("cost_price", ""),
"category": p.get("category", ""),
"stock": p.get("stock", ""),
"barcode": p.get("barcode", ""),
"description": p.get("description", ""),
"is_available": p.get("is_available", True),
"pieces_per_box": p.get("pieces_per_box", ""),
"pieces_per_master_box": p.get("pieces_per_master_box", ""),
"box_price": p.get("box_price", ""),
"min_order": p.get("min_order", ""),
"has_variant_prices": p.get("has_variant_prices", False),
"variants": p.get("variants", [])
})
json_str = json.dumps(export_data, ensure_ascii=False, indent=2)
return Response(
json_str,
mimetype='application/json',
headers={'Content-disposition': f'attachment; filename=products_{env_id}.json'}
)
@app.route('/<env_id>/admin', methods=['GET', 'POST'])
def admin(env_id):
data = get_env_data(env_id)
settings = data.get('settings', {})
t = get_t(settings.get('language', 'ru'))
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'] =[]
data['debt_history'] = []
save_env_data(env_id, data)
flash('История продаж и заказов успешно очищена.', 'success')
elif action == 'import_products':
file = request.files.get('import_file')
mode = request.form.get('import_mode', 'append')
if file and file.filename.endswith('.json'):
try:
imported_products = json.load(file)
if not isinstance(imported_products, list):
raise ValueError("JSON должен содержать список товаров")
if mode == 'replace':
data['products'] = []
data['categories'] = []
for p in imported_products:
if not p.get('name') or not p.get('category'):
continue
cat_name = str(p['category']).strip()
if cat_name not in data['categories']:
data['categories'].append(cat_name)
existing_p = None
if p.get('product_id'):
existing_p = next((x for x in data['products'] if x['product_id'] == p['product_id']), None)
if existing_p and mode == 'append':
existing_p.update({
'name': str(p.get('name', '')),
'price': p.get('price', ''),
'cost_price': p.get('cost_price', ''),
'category': cat_name,
'stock': p.get('stock', ''),
'barcode': str(p.get('barcode', '')),
'description': str(p.get('description', '')),
'is_available': p.get('is_available', True),
'pieces_per_box': p.get('pieces_per_box', ''),
'pieces_per_master_box': p.get('pieces_per_master_box', ''),
'box_price': p.get('box_price', ''),
'min_order': p.get('min_order', ''),
'has_variant_prices': p.get('has_variant_prices', False),
'variants': p.get('variants', [])
})
else:
new_p = {
'product_id': p.get('product_id') or uuid4().hex,
'created_at': get_almaty_time(),
'name': str(p.get('name', '')),
'price': p.get('price', ''),
'cost_price': p.get('cost_price', ''),
'category': cat_name,
'stock': p.get('stock', ''),
'barcode': str(p.get('barcode', '')),
'description': str(p.get('description', '')),
'is_available': p.get('is_available', True),
'pieces_per_box': p.get('pieces_per_box', ''),
'pieces_per_master_box': p.get('pieces_per_master_box', ''),
'box_price': p.get('box_price', ''),
'min_order': p.get('min_order', ''),
'has_variant_prices': p.get('has_variant_prices', False),
'variants': p.get('variants', []),
'photos': p.get('photos', []),
'wholesale_tiers': p.get('wholesale_tiers', [])
}
data['products'].append(new_p)
save_env_data(env_id, data)
flash('Импорт товаров успешно завершен!', 'success')
except Exception as e:
flash(f'Ошибка импорта: {str(e)}', 'error')
else:
flash('Пожалуйста, загрузите валидный JSON файл.', 'error')
elif action == 'update_settings':
settings['language'] = request.form.get('language', 'ru')
settings['organization_name'] = request.form.get('organization_name', '').strip()
settings['catalog_subtitle'] = request.form.get('catalog_subtitle', '').strip()
settings['theme'] = request.form.get('theme', 'light')
settings['business_type'] = request.form.get('business_type', 'mixed')
settings['whatsapp_number'] = request.form.get('order_contact', '').strip()
settings['order_messenger'] = request.form.get('order_messenger', 'wa')
settings['order_contact'] = request.form.get('order_contact', '').strip()
settings['invoice_contacts'] = request.form.get('invoice_contacts', '').strip()
settings['currency'] = request.form.get('currency', '₸')
sys_m = settings.get('system_mode', 'both')
if sys_m == 'external':
settings['track_inventory'] = False
settings['use_barcodes'] = False
settings['use_master_boxes'] = False
settings['margin_tracking'] = False
settings['hide_stock_online'] = False
settings['closed_catalog_enabled'] = 'closed_catalog_enabled' in request.form
elif sys_m == 'light_external':
settings['track_inventory'] = False
settings['use_barcodes'] = False
settings['use_master_boxes'] = 'use_master_boxes' in request.form
settings['margin_tracking'] = 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['use_master_boxes'] = 'use_master_boxes' in request.form
settings['margin_tracking'] = 'margin_tracking' 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['commission_enabled'] = 'commission_enabled' in request.form
try:
settings['commission_percent'] = float(request.form.get('commission_percent', 0.0))
except ValueError:
settings['commission_percent'] = 0.0
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
}
def process_image_setting(field_name, dict_key, folder="logos"):
img_file = request.files.get(field_name)
if img_file and img_file.filename and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
ext = os.path.splitext(img_file.filename)[1].lower()
if ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg']:
img_filename = f"{field_name}_{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, img_filename)
img_file.save(temp_path)
try:
api = HfApi()
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"{folder}/{img_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
settings[dict_key] = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{folder}/{img_filename}"
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
process_image_setting('logo', 'logo_url')
process_image_setting('qr_code_1', 'qr_code_1_url')
process_image_setting('qr_code_2', 'qr_code_2_url')
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()
settings['socials']['kaspi']['enabled'] = 'kaspi_enabled' in request.form
settings['socials']['kaspi']['url'] = request.form.get('kaspi_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 ""
cp_str = request.form.get('cost_price', '')
cost_price = float(cp_str) if cp_str else ""
ppb_str = request.form.get('pieces_per_box', '')
pieces_per_box = int(ppb_str) if ppb_str else ""
ppmb_str = request.form.get('pieces_per_master_box', '')
pieces_per_master_box = int(ppmb_str) if ppmb_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)})
var_indices = request.form.getlist('variant_indices[]')
variants = []
for idx in var_indices:
v_name = request.form.get(f'variant_name_{idx}', '').strip()
if v_name:
v_price = price
v_cost_price = cost_price
v_box_price = box_price
v_is_avail = request.form.get(f'variant_is_available_{idx}', '1') == '1'
v_wholesale_tiers = []
if has_variant_prices:
vp = request.form.get(f'variant_price_{idx}')
if vp: v_price = float(vp)
vcp = request.form.get(f'variant_cost_price_{idx}')
if vcp: v_cost_price = float(vcp)
vbp = request.form.get(f'variant_box_price_{idx}')
if vbp: v_box_price = float(vbp)
vt_qtys = request.form.getlist(f'variant_{idx}_tier_qty[]')
vt_prices = request.form.getlist(f'variant_{idx}_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 = request.form.get(f'variant_stock_{idx}', '')
if v_stock: v_stock = int(v_stock)
v_barcode = request.form.get(f'variant_barcode_{idx}', '').strip()
v_ppb = pieces_per_box
vppb_val = request.form.get(f'variant_pieces_per_box_{idx}')
if vppb_val: v_ppb = int(vppb_val)
v_ppmb = pieces_per_master_box
vppmb_val = request.form.get(f'variant_pieces_per_master_box_{idx}')
if vppmb_val: v_ppmb = int(vppmb_val)
v_photo = ""
photo_file = request.files.get(f'variant_photo_{idx}')
if photo_file and photo_file.filename:
filename = process_and_upload_image(photo_file, 'photos', size=(512, 512))
if filename:
v_photo = filename
variants.append({
"name": v_name,
"barcode": v_barcode,
"price": v_price,
"cost_price": v_cost_price,
"box_price": v_box_price,
"stock": v_stock,
"pieces_per_box": v_ppb,
"pieces_per_master_box": v_ppmb,
"is_available": v_is_avail,
"wholesale_tiers": v_wholesale_tiers,
"photo": v_photo
})
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,
'created_at': get_almaty_time(),
'name': name,
'barcode': barcode,
'price': price,
'cost_price': cost_price,
'pieces_per_box': pieces_per_box,
'pieces_per_master_box': pieces_per_master_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')
new_cat = request.form.get('new_category', '').strip()
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 ""
cp_str = request.form.get('cost_price', '')
cost_price = float(cp_str) if cp_str else ""
ppb_str = request.form.get('pieces_per_box', '')
pieces_per_box = int(ppb_str) if ppb_str else ""
ppmb_str = request.form.get('pieces_per_master_box', '')
pieces_per_master_box = int(ppmb_str) if ppmb_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[]')
var_indices = request.form.getlist('variant_indices[]')
variants = []
for idx in var_indices:
v_name = request.form.get(f'variant_name_{idx}', '').strip()
if v_name:
v_price = price
v_cost_price = cost_price
v_box_price = box_price
v_is_avail = request.form.get(f'variant_is_available_{idx}', '1') == '1'
v_wholesale_tiers = []
if has_variant_prices:
vp = request.form.get(f'variant_price_{idx}')
if vp: v_price = float(vp)
vcp = request.form.get(f'variant_cost_price_{idx}')
if vcp: v_cost_price = float(vcp)
vbp = request.form.get(f'variant_box_price_{idx}')
if vbp: v_box_price = float(vbp)
vt_qtys = request.form.getlist(f'variant_{idx}_tier_qty[]')
vt_prices = request.form.getlist(f'variant_{idx}_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 = request.form.get(f'variant_stock_{idx}', '')
if v_stock: v_stock = int(v_stock)
v_barcode = request.form.get(f'variant_barcode_{idx}', '').strip()
v_ppb = pieces_per_box
vppb_val = request.form.get(f'variant_pieces_per_box_{idx}')
if vppb_val: v_ppb = int(vppb_val)
v_ppmb = pieces_per_master_box
vppmb_val = request.form.get(f'variant_pieces_per_master_box_{idx}')
if vppmb_val: v_ppmb = int(vppmb_val)
v_photo = request.form.get(f'variant_existing_photo_{idx}', '')
if request.form.get(f'variant_remove_photo_{idx}') == '1':
v_photo = ''
photo_file = request.files.get(f'variant_photo_{idx}')
if photo_file and photo_file.filename:
filename = process_and_upload_image(photo_file, 'photos', size=(512, 512))
if filename:
v_photo = filename
variants.append({
"name": v_name,
"barcode": v_barcode,
"price": v_price,
"cost_price": v_cost_price,
"box_price": v_box_price,
"stock": v_stock,
"pieces_per_box": v_ppb,
"pieces_per_master_box": v_ppmb,
"is_available": v_is_avail,
"wholesale_tiers": v_wholesale_tiers,
"photo": v_photo
})
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
if new_cat and new_cat in categories:
p['category'] = new_cat
p['barcode'] = barcode
p['price'] = price
p['cost_price'] = cost_price
p['pieces_per_box'] = pieces_per_box
p['pieces_per_master_box'] = pieces_per_master_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', '₸'),
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,
t=t
)
@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)