| 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">«</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">»</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;">×</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;">×</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;">×</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;">×</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) |
|
|
|
|