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