| from flask import Flask, render_template_string, request, redirect, url_for |
| import json |
| import os |
| import threading |
| import time |
| import uuid |
| from datetime import datetime |
| import zoneinfo |
| from huggingface_hub import HfApi, hf_hub_download |
|
|
| app = Flask(__name__) |
| DATA_FILE = 'data_fabrics.json' |
| REPO_ID = "Kgshop/vahid" |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
| INITIALIZED = False |
|
|
| def get_almaty_time(): |
| return datetime.now(zoneinfo.ZoneInfo("Asia/Almaty")).strftime('%d.%m.%Y %H:%M:%S') |
|
|
| def load_data(): |
| global INITIALIZED |
| if not INITIALIZED: |
| try: |
| hf_hub_download( |
| repo_id=REPO_ID, |
| filename=DATA_FILE, |
| repo_type="dataset", |
| token=HF_TOKEN_READ, |
| local_dir=".", |
| local_dir_use_symlinks=False |
| ) |
| except Exception: |
| pass |
| INITIALIZED = True |
|
|
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: |
| data = json.load(file) |
| if not isinstance(data, dict): |
| data = {'products': [], 'history': []} |
| if 'settings' not in data: |
| data['settings'] = { |
| 'warehouses': [{'id': 'w_main', 'name': 'Основной склад'}], |
| 'shops': [{'id': 's_main', 'name': 'Основной магазин'}], |
| 'employees': [] |
| } |
| if 'products' not in data: |
| data['products'] = [] |
| if 'history' not in data: |
| data['history'] = [] |
| |
| for p in data['products']: |
| if 'price_retail' not in p: |
| p['price_retail'] = p.get('price', 0) |
| if 'price_wholesale' not in p: |
| p['price_wholesale'] = p.get('price', 0) |
| if 'variants' not in p: |
| p['variants'] = [] |
| if 'photo' not in p: |
| p['photo'] = '' |
| if 'box_qty' not in p: |
| p['box_qty'] = 1 |
| if 'stocks' not in p: |
| p['stocks'] = {} |
| if 'colors' in p: |
| for c in p['colors']: |
| for loc, amt in c.get('stocks', {}).items(): |
| p['stocks'][loc] = p['stocks'].get(loc, 0) + amt |
| del p['colors'] |
| return data |
| except Exception: |
| return { |
| 'settings': { |
| 'warehouses': [{'id': 'w_main', 'name': 'Основной склад'}], |
| 'shops': [{'id': 's_main', 'name': 'Основной магазин'}], |
| 'employees': [] |
| }, |
| 'products': [], |
| 'history': [] |
| } |
|
|
| def save_data(data): |
| try: |
| with open(DATA_FILE, 'w', encoding='utf-8') as file: |
| json.dump(data, file, ensure_ascii=False, indent=4) |
| threading.Thread(target=upload_db_to_hf, daemon=True).start() |
| except Exception: |
| pass |
|
|
| def upload_db_to_hf(): |
| try: |
| api = HfApi() |
| api.upload_file( |
| path_or_fileobj=DATA_FILE, |
| path_in_repo=DATA_FILE, |
| repo_id=REPO_ID, |
| repo_type="dataset", |
| token=HF_TOKEN_WRITE, |
| commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| except Exception: |
| pass |
|
|
| def periodic_backup(): |
| while True: |
| upload_db_to_hf() |
| time.sleep(800) |
|
|
| def add_history(data, action, product, amount, price, from_loc, to_loc, emp_name): |
| data['history'].insert(0, { |
| 'time': get_almaty_time(), |
| 'action': action, |
| 'product': product, |
| 'amount': amount, |
| 'price': price, |
| 'from_loc_name': from_loc, |
| 'to_loc_name': to_loc, |
| 'emp_name': emp_name |
| }) |
| data['history'] = data['history'][:1000] |
|
|
| def get_loc_name(data, loc_id): |
| for w in data['settings']['warehouses']: |
| if w['id'] == loc_id: return w['name'] |
| for s in data['settings']['shops']: |
| if s['id'] == loc_id: return s['name'] |
| return loc_id |
|
|
| def handle_post(req, data, role, emp): |
| action = req.form.get('action') |
| emp_name = emp['name'] if emp else 'Админ' |
| |
| if action == 'add' and role == 'admin': |
| loc_id = req.form.get('loc_id') |
| try: |
| price_retail = int(req.form.get('price_retail', 0)) |
| except ValueError: |
| price_retail = 0 |
| try: |
| price_wholesale = int(req.form.get('price_wholesale', 0)) |
| except ValueError: |
| price_wholesale = 0 |
| |
| photo = req.form.get('photo', '') |
| try: |
| rolls = int(req.form.get('rolls', 0)) |
| except ValueError: |
| rolls = 0 |
| try: |
| box_qty = int(req.form.get('box_qty', 1)) |
| except ValueError: |
| box_qty = 1 |
| |
| v_names = req.form.getlist('v_name[]') |
| v_prices = req.form.getlist('v_price[]') |
| variants = [] |
| for n, pr in zip(v_names, v_prices): |
| if n.strip(): |
| try: |
| variants.append({'name': n.strip(), 'price': int(pr)}) |
| except ValueError: |
| pass |
| |
| data['products'].insert(0, { |
| 'id': str(time.time()), |
| 'name': req.form['name'], |
| 'description': req.form['description'], |
| 'price_retail': price_retail, |
| 'price_wholesale': price_wholesale, |
| 'variants': variants, |
| 'photo': photo, |
| 'box_qty': box_qty, |
| 'stocks': {loc_id: rolls} |
| }) |
|
|
| elif action == 'edit' and role == 'admin': |
| idx = int(req.form['index']) |
| if 0 <= idx < len(data['products']): |
| p = data['products'][idx] |
| p['name'] = req.form.get('name', p['name']) |
| p['description'] = req.form.get('description', p.get('description', '')) |
| try: |
| p['price_retail'] = int(req.form.get('price_retail', p.get('price_retail', 0))) |
| except ValueError: |
| pass |
| try: |
| p['price_wholesale'] = int(req.form.get('price_wholesale', p.get('price_wholesale', 0))) |
| except ValueError: |
| pass |
| try: |
| p['box_qty'] = int(req.form.get('box_qty', p.get('box_qty', 1))) |
| except ValueError: |
| pass |
| |
| v_names = req.form.getlist('v_name[]') |
| v_prices = req.form.getlist('v_price[]') |
| variants = [] |
| for n, pr in zip(v_names, v_prices): |
| if n.strip(): |
| try: |
| variants.append({'name': n.strip(), 'price': int(pr)}) |
| except ValueError: |
| pass |
| p['variants'] = variants |
| |
| photo = req.form.get('photo', '') |
| if photo: |
| p['photo'] = photo |
| |
| elif action == 'delete' and role == 'admin': |
| idx = int(req.form['index']) |
| if 0 <= idx < len(data['products']): |
| del_name = data['products'][idx]['name'] |
| data['products'].pop(idx) |
| add_history(data, 'Удаление', del_name, 0, 0, '', '', emp_name) |
| |
| elif action == 'income' and role == 'admin': |
| idx = int(req.form['product_index']) |
| amt = int(req.form['rolls']) |
| loc_id = req.form['loc_id'] |
| p = data['products'][idx] |
| p['stocks'][loc_id] = p.get('stocks', {}).get(loc_id, 0) + amt |
| add_history(data, 'Приход', p['name'], amt, p.get('price_wholesale', 0), '', get_loc_name(data, loc_id), emp_name) |
| |
| elif action == 'transfer' and role == 'admin': |
| idx = int(req.form['pIndex']) |
| amt = int(req.form['amt']) |
| from_loc = req.form['from_loc'] |
| to_loc = req.form['to_loc'] |
| p = data['products'][idx] |
| av = p.get('stocks', {}).get(from_loc, 0) |
| if amt > av: amt = av |
| if amt > 0: |
| p['stocks'][from_loc] -= amt |
| p['stocks'][to_loc] = p['stocks'].get(to_loc, 0) + amt |
| from_name = get_loc_name(data, from_loc) |
| to_name = get_loc_name(data, to_loc) |
| add_history(data, 'Перемещение', p['name'], amt, 0, from_name, to_name, emp_name) |
|
|
| elif action == 'checkout': |
| cart_data = req.form.get('cart', '[]') |
| cart = json.loads(cart_data) |
| for item in cart: |
| idx = item.get('pIndex') |
| amt = item.get('amt') |
| loc = item.get('loc') |
| pName = item.get('pName') |
| itemPrice = item.get('price', 0) |
| |
| if 0 <= idx < len(data['products']): |
| p = data['products'][idx] |
| av = p.get('stocks', {}).get(loc, 0) |
| if amt > av: amt = av |
| if amt > 0: |
| p['stocks'][loc] -= amt |
| loc_name = get_loc_name(data, loc) |
| add_history(data, 'Продажа', pName, amt, itemPrice, loc_name, '', emp_name) |
|
|
| elif action == 'add_loc' and role == 'admin': |
| l_type = req.form['l_type'] |
| l_name = req.form['l_name'] |
| l_id = f"loc_{int(time.time()*1000)}" |
| data['settings'][l_type].append({'id': l_id, 'name': l_name}) |
| |
| elif action == 'del_loc' and role == 'admin': |
| l_type = req.form['l_type'] |
| l_id = req.form['l_id'] |
| data['settings'][l_type] = [x for x in data['settings'][l_type] if x['id'] != l_id] |
| |
| elif action == 'add_emp' and role == 'admin': |
| e_name = req.form['e_name'] |
| e_shop = req.form['e_shop'] |
| e_id = str(uuid.uuid4())[:8] |
| data['settings']['employees'].append({'id': e_id, 'name': e_name, 'token': e_id, 'shop_id': e_shop}) |
| |
| elif action == 'del_emp' and role == 'admin': |
| e_id = req.form['e_id'] |
| data['settings']['employees'] = [x for x in data['settings']['employees'] if x['id'] != e_id] |
|
|
| MAIN_HTML = ''' |
| <!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"> |
| <title>Учет товаров</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --bg: #f3f4f6; |
| --surface: #ffffff; |
| --primary: #4f46e5; |
| --success: #10b981; |
| --danger: #ef4444; |
| --text-main: #111827; |
| --text-muted: #6b7280; |
| --border: #e5e7eb; |
| } |
| * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Inter', sans-serif; -webkit-tap-highlight-color: transparent; } |
| body { background: var(--bg); color: var(--text-main); padding: 16px; padding-bottom: 80px; } |
| .container { max-width: 1200px; margin: 0 auto; } |
| h1 { text-align: center; font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } |
| |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| |
| .top-actions { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } |
| .btn { flex: 1; padding: 12px; border: none; border-radius: 12px; font-size: 0.95rem; font-weight: 600; color: white; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 8px; transition: transform 0.1s, opacity 0.2s; } |
| .btn:active { transform: scale(0.98); } |
| .btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } |
| .btn-primary { background: var(--primary); } |
| .btn-success { background: var(--success); } |
| .btn-danger { background: var(--danger); } |
| |
| .main-nav { display: flex; gap: 8px; margin-bottom: 16px; overflow-x: auto; padding-bottom: 4px; } |
| .nav-btn { flex: 0 0 auto; padding: 10px 16px; border-radius: 8px; border: none; background: #e5e7eb; color: var(--text-muted); font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: background 0.2s, color 0.2s; } |
| .nav-btn.active { background: var(--primary); color: white; } |
| |
| .loc-nav { display: flex; gap: 8px; margin-bottom: 16px; overflow-x: auto; padding-bottom: 4px; } |
| .loc-btn { flex: 0 0 auto; padding: 8px 14px; border-radius: 20px; border: 1px solid var(--border); background: var(--surface); color: var(--text-main); font-weight: 500; font-size: 0.85rem; cursor: pointer; transition: all 0.2s; } |
| .loc-btn.active { background: var(--text-main); color: white; border-color: var(--text-main); } |
| .loc-btn.has-search-stock { border-color: var(--success) !important; color: var(--success) !important; background: #ecfdf5 !important; font-weight: 700; } |
| .loc-btn.has-search-stock.active { background: var(--success) !important; color: white !important; } |
| |
| .search-box { width: 100%; padding: 14px; border: 1px solid var(--border); border-radius: 12px; outline: none; font-size: 1rem; margin-bottom: 16px; transition: border-color 0.2s; } |
| .search-box:focus { border-color: var(--primary); } |
| |
| #catalog { display: flex; flex-direction: column; gap: 10px; } |
| |
| .product { background: var(--surface); border-radius: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); display: flex; flex-direction: column; overflow: hidden; padding: 10px; gap: 8px; } |
| .product-main { display: flex; flex-direction: row; align-items: center; gap: 12px; } |
| .product-img-wrapper { width: 50px; height: 50px; flex-shrink: 0; background: #f9fafb; display: flex; align-items: center; justify-content: center; border-radius: 8px; overflow: hidden; cursor: zoom-in; } |
| .product-img { width: 100%; height: 100%; object-fit: cover; } |
| .product-info { display: flex; flex-direction: column; flex: 1; justify-content: center; min-width: 0; } |
| .product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.2; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| .product-price { font-size: 0.85rem; font-weight: 700; color: var(--primary); } |
| .product-stock { font-size: 0.75rem; color: var(--text-muted); } |
| .product-actions { display: flex; flex-direction: column; gap: 6px; width: 100%; margin-top: 4px; } |
| |
| .qty-input { width: 60px; padding: 6px; border: 1px solid var(--border); border-radius: 6px; text-align: center; font-weight: 600; outline: none; font-size: 0.85rem; transition: border-color 0.2s; } |
| .qty-input:focus { border-color: var(--primary); } |
| |
| .history-item { background: var(--surface); border-radius: 12px; padding: 16px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } |
| .history-header { display: flex; justify-content: space-between; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 8px; } |
| .history-body { display: flex; justify-content: space-between; align-items: center; } |
| .history-prod { font-weight: 600; } |
| .history-badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 600; } |
| .badge-green { background: #d1fae5; color: #065f46; } |
| .badge-orange { background: #ffedd5; color: #9a3412; } |
| |
| .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(17, 24, 39, 0.6); z-index: 1000; justify-content: center; align-items: flex-end; } |
| .modal-overlay.active { display: flex; } |
| .modal-content { background: var(--surface); padding: 24px; border-radius: 20px 20px 0 0; width: 100%; max-width: 600px; max-height: 90vh; overflow-y: auto; position: relative; } |
| |
| .close-modal { position: absolute; top: 20px; right: 20px; width: 32px; height: 32px; background: var(--bg); border-radius: 16px; display: flex; justify-content: center; align-items: center; cursor: pointer; border: none; font-size: 18px; } |
| .form-control { width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.95rem; outline: none; margin-bottom: 10px; transition: border-color 0.2s; } |
| .form-control:focus { border-color: var(--primary); } |
| |
| .settings-grid { display: grid; gap: 16px; grid-template-columns: 1fr; } |
| .settings-card { background: var(--surface); border-radius: 12px; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } |
| .settings-list-item { display: flex; flex-direction: column; padding: 12px 0; border-bottom: 1px solid var(--border); gap: 12px; } |
| .settings-list-item:last-child { border-bottom: none; } |
| .settings-list-item-header { display: flex; justify-content: space-between; align-items: center; } |
| .settings-link-box { display: flex; gap: 8px; align-items: center; width: 100%; } |
| .settings-link-input { flex: 1; padding: 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.85rem; background: var(--bg); outline: none; color: var(--text-main); } |
| |
| .cart-float { display: none; position: fixed; bottom: 24px; right: 24px; background: var(--text-main); color: white; padding: 14px 24px; border-radius: 30px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); cursor: pointer; z-index: 100; font-weight: 600; align-items: center; gap: 8px; transition: transform 0.2s; } |
| .cart-float:active { transform: scale(0.95); } |
| |
| .invoice-table { width: 100%; border-collapse: collapse; margin-bottom: 16px; } |
| .invoice-table th, .invoice-table td { padding: 10px; border-bottom: 1px solid var(--border); text-align: left; font-size:0.9rem; } |
| .invoice-total { font-size: 1.2rem; font-weight: 700; text-align: right; } |
| |
| @media (min-width: 768px) { |
| body { padding: 24px; } |
| .top-actions { max-width: 400px; } |
| .settings-grid { grid-template-columns: repeat(3, 1fr); gap: 24px; } |
| .modal-overlay { align-items: center; } |
| .modal-content { border-radius: 20px; max-width: 500px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="loader" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.7); z-index:9999; justify-content:center; align-items:center;"> |
| <div style="border: 4px solid #f3f3f3; border-top: 4px solid var(--primary); border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite;"></div> |
| </div> |
| |
| <div class="container"> |
| <h1 id="main_title">Учет товаров</h1> |
| |
| {% if role == 'admin' %} |
| <div class="top-actions"> |
| <button class="btn btn-primary" onclick="openModal('addModal')">Новый товар</button> |
| <button class="btn btn-success" onclick="openModal('incomeModal')">Приход</button> |
| </div> |
| <div class="main-nav"> |
| <button id="navbtn_catalog" class="nav-btn active" onclick="switchNav('catalog')">Каталог</button> |
| <button id="navbtn_reports" class="nav-btn" onclick="switchNav('reports')">Отчеты</button> |
| <button id="navbtn_settings" class="nav-btn" onclick="switchNav('settings')">Настройки</button> |
| </div> |
| {% elif role == 'employee' %} |
| <div class="main-nav"> |
| <button id="navbtn_catalog" class="nav-btn active" onclick="switchNav('catalog')">Каталог</button> |
| </div> |
| {% endif %} |
| |
| <div id="section_catalog"> |
| <div class="loc-nav" id="loc_nav_container"></div> |
| <input type="text" id="search" class="search-box" placeholder="Поиск товара..." oninput="onSearchInput()"> |
| <div id="catalog"></div> |
| </div> |
| |
| <div id="section_reports" style="display:none;"> |
| <div style="display:flex; flex-wrap:wrap; gap:12px; margin-bottom:12px;"> |
| <input type="date" id="hist_from" class="form-control" style="flex:1; margin:0;" onchange="renderHistory()"> |
| <input type="date" id="hist_to" class="form-control" style="flex:1; margin:0;" onchange="renderHistory()"> |
| </div> |
| <div style="display:flex; flex-wrap:wrap; gap:12px; margin-bottom:12px;"> |
| <select id="hist_emp" class="form-control" style="flex:1; margin:0;" onchange="renderHistory()"></select> |
| <select id="hist_action_type" class="form-control" style="flex:1; margin:0;" onchange="renderHistory()"> |
| <option value="all">Все операции</option> |
| <option value="Продажа">Все продажи</option> |
| <option value="Продажа_retail">Продажи (Розница)</option> |
| <option value="Продажа_wholesale">Продажи (Опт)</option> |
| <option value="Приход">Приходы</option> |
| <option value="Перемещение">Перемещения</option> |
| <option value="Удаление">Удаления</option> |
| </select> |
| </div> |
| <h3 id="total_sales" style="margin-bottom:16px; color:var(--primary);"></h3> |
| <div id="history_container"></div> |
| </div> |
| |
| <div id="section_settings" style="display:none;"> |
| <div class="settings-grid"> |
| <div class="settings-card"> |
| <h3>Публичные каталоги</h3> |
| <div class="settings-list-item"> |
| <div class="settings-list-item-header"><span style="font-weight:600;">Розничный каталог</span></div> |
| <div class="settings-link-box"> |
| <input type="text" class="settings-link-input" value="{{ request.host_url }}catalog/retail" readonly onclick="this.select()"> |
| <button type="button" onclick="navigator.clipboard.writeText('{{ request.host_url }}catalog/retail');alert('Ссылка скопирована')" class="btn btn-primary" style="padding:8px 12px; font-size:0.85rem; flex:0 0 auto;">Копировать</button> |
| </div> |
| </div> |
| <div class="settings-list-item"> |
| <div class="settings-list-item-header"><span style="font-weight:600;">Оптовый каталог</span></div> |
| <div class="settings-link-box"> |
| <input type="text" class="settings-link-input" value="{{ request.host_url }}catalog/wholesale" readonly onclick="this.select()"> |
| <button type="button" onclick="navigator.clipboard.writeText('{{ request.host_url }}catalog/wholesale');alert('Ссылка скопирована')" class="btn btn-primary" style="padding:8px 12px; font-size:0.85rem; flex:0 0 auto;">Копировать</button> |
| </div> |
| </div> |
| </div> |
| |
| <div class="settings-card"> |
| <h3>Склады</h3> |
| <div id="set_warehouses"></div> |
| <form method="POST" onsubmit="return showLoader(this)" style="display:flex; gap:8px; margin-top:12px;"> |
| <input type="hidden" name="action" value="add_loc"> |
| <input type="hidden" name="l_type" value="warehouses"> |
| <input type="text" name="l_name" class="form-control" style="margin:0;" placeholder="Название" required> |
| <button type="submit" class="btn btn-primary" style="flex:0 0 auto; padding:0 20px;">+</button> |
| </form> |
| </div> |
| <div class="settings-card"> |
| <h3>Магазины</h3> |
| <div id="set_shops"></div> |
| <form method="POST" onsubmit="return showLoader(this)" style="display:flex; gap:8px; margin-top:12px;"> |
| <input type="hidden" name="action" value="add_loc"> |
| <input type="hidden" name="l_type" value="shops"> |
| <input type="text" name="l_name" class="form-control" style="margin:0;" placeholder="Название" required> |
| <button type="submit" class="btn btn-primary" style="flex:0 0 auto; padding:0 20px;">+</button> |
| </form> |
| </div> |
| <div class="settings-card"> |
| <h3>Сотрудники</h3> |
| <div id="set_emps"></div> |
| <form method="POST" onsubmit="return showLoader(this)" style="display:flex; flex-direction:column; gap:8px; margin-top:12px;"> |
| <input type="hidden" name="action" value="add_emp"> |
| <input type="text" name="e_name" class="form-control" style="margin:0;" placeholder="Имя сотрудника" required> |
| <select name="e_shop" id="emp_shop_select" class="form-control" style="margin:0;" required></select> |
| <button type="submit" class="btn btn-primary" style="padding:10px;">Добавить сотрудника</button> |
| </form> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <div class="cart-float" id="cart_btn" onclick="openCart()"> |
| Корзина (<span id="cart_count">0</span>) |
| </div> |
| |
| <div id="imageZoomModal" class="modal-overlay" onclick="closeModal('imageZoomModal')" style="z-index:2000; align-items:center;"> |
| <img id="zoomedImage" style="max-width:90%; max-height:90%; object-fit:contain; border-radius:8px;"> |
| </div> |
| |
| <div id="addModal" class="modal-overlay"> |
| <div class="modal-content"> |
| <button class="close-modal" onclick="closeModal('addModal')">×</button> |
| <h2>Добавить товар</h2> |
| <form method="POST" style="margin-top:16px;" onsubmit="return showLoader(this)"> |
| <input type="hidden" name="action" value="add"> |
| <input type="text" name="name" class="form-control" placeholder="Название" required> |
| <textarea name="description" class="form-control" placeholder="Описание"></textarea> |
| |
| <div style="display:flex; gap:8px; margin-bottom:12px;"> |
| <input type="number" name="price_retail" class="form-control" style="margin-bottom:0;" placeholder="Розничная цена (₸)" required> |
| <input type="number" name="price_wholesale" class="form-control" style="margin-bottom:0;" placeholder="Оптовая цена (₸)" required> |
| </div> |
| |
| <label style="display:block; margin-bottom:8px; font-weight:600; font-size:0.9rem;">Варианты товара (не обязательно)</label> |
| <div id="add_variants_container"></div> |
| <button type="button" class="btn btn-success" style="padding:8px; font-size:0.85rem; margin-bottom:12px; width:100%; background:#eab308;" onclick="addVariantField('add_variants_container')">+ Добавить вариант</button> |
| |
| <input type="number" name="box_qty" class="form-control" placeholder="Штук в коробке" value="1" required> |
| |
| <label style="display:block; margin-bottom:8px; font-size:0.9rem; color:var(--text-muted);">Фото товара (не обязательно)</label> |
| <input type="file" accept="image/*" class="form-control" onchange="processImage(this, 'photo_b64', 'photo_preview')"> |
| <input type="hidden" name="photo" id="photo_b64"> |
| <img id="photo_preview" style="display:none; width:100%; height:200px; object-fit:cover; border-radius:12px; margin-bottom:16px;"> |
| |
| <select name="loc_id" id="add_loc_select" class="form-control" required></select> |
| <input type="number" name="rolls" class="form-control" placeholder="Количество при создании" value="0" required> |
| <button type="submit" class="btn btn-primary" style="width:100%;">Сохранить</button> |
| </form> |
| </div> |
| </div> |
| |
| <div id="editModal" class="modal-overlay"> |
| <div class="modal-content"> |
| <button class="close-modal" onclick="closeModal('editModal')">×</button> |
| <h2>Изменить товар</h2> |
| <form method="POST" style="margin-top:16px;" onsubmit="return showLoader(this)"> |
| <input type="hidden" name="action" value="edit"> |
| <input type="hidden" name="index" id="edit_index"> |
| <input type="text" name="name" id="edit_name" class="form-control" placeholder="Название" required> |
| <textarea name="description" id="edit_desc" class="form-control" placeholder="Описание"></textarea> |
| |
| <div style="display:flex; gap:8px; margin-bottom:12px;"> |
| <input type="number" name="price_retail" id="edit_price_retail" class="form-control" style="margin-bottom:0;" placeholder="Розничная цена (₸)" required> |
| <input type="number" name="price_wholesale" id="edit_price_wholesale" class="form-control" style="margin-bottom:0;" placeholder="Оптовая цена (₸)" required> |
| </div> |
| |
| <label style="display:block; margin-bottom:8px; font-weight:600; font-size:0.9rem;">Варианты товара (не обязательно)</label> |
| <div id="edit_variants_container"></div> |
| <button type="button" class="btn btn-success" style="padding:8px; font-size:0.85rem; margin-bottom:12px; width:100%; background:#eab308;" onclick="addVariantField('edit_variants_container')">+ Добавить вариант</button> |
| |
| <input type="number" name="box_qty" id="edit_box_qty" class="form-control" placeholder="Штук в коробке" required> |
| |
| <label style="display:block; margin-bottom:8px; font-size:0.9rem; color:var(--text-muted);">Новое фото (не обязательно)</label> |
| <input type="file" accept="image/*" class="form-control" onchange="processImage(this, 'edit_photo_b64', 'edit_photo_preview')"> |
| <input type="hidden" name="photo" id="edit_photo_b64"> |
| <img id="edit_photo_preview" style="display:none; width:100%; height:200px; object-fit:cover; border-radius:12px; margin-bottom:16px;"> |
| |
| <button type="submit" class="btn btn-success" style="width:100%; background:var(--primary);">Сохранить изменения</button> |
| </form> |
| </div> |
| </div> |
| |
| <div id="incomeModal" class="modal-overlay"> |
| <div class="modal-content"> |
| <button class="close-modal" onclick="closeModal('incomeModal')">×</button> |
| <h2>Приход</h2> |
| <form method="POST" style="margin-top:16px;" onsubmit="return showLoader(this)"> |
| <input type="hidden" name="action" value="income"> |
| <select name="loc_id" id="inc_loc_select" class="form-control" required></select> |
| <select name="product_index" id="inc_product" class="form-control" required></select> |
| <input type="number" name="rolls" class="form-control" placeholder="Количество" min="1" required> |
| <button type="submit" class="btn btn-success" style="width:100%;">Принять</button> |
| </form> |
| </div> |
| </div> |
| |
| <div id="transferModal" class="modal-overlay"> |
| <div class="modal-content"> |
| <button class="close-modal" onclick="closeModal('transferModal')">×</button> |
| <h2>Перемещение</h2> |
| <form method="POST" style="margin-top:16px;" onsubmit="return showLoader(this)"> |
| <input type="hidden" name="action" value="transfer"> |
| <input type="hidden" name="pIndex" id="trans_pIndex"> |
| <input type="hidden" name="from_loc" id="trans_from"> |
| <p id="trans_info" style="margin-bottom:16px; font-weight:600;"></p> |
| <select name="to_loc" id="trans_dest_select" class="form-control" required></select> |
| <input type="number" name="amt" id="trans_amt" class="form-control" placeholder="Количество" min="1" required> |
| <button type="submit" class="btn btn-primary" style="width:100%;">Переместить</button> |
| </form> |
| </div> |
| </div> |
| |
| <div id="cartModal" class="modal-overlay"> |
| <div class="modal-content"> |
| <button class="close-modal" onclick="closeModal('cartModal')">×</button> |
| <h2>Корзина</h2> |
| <div id="cart_items" style="margin-bottom:16px; margin-top:16px;"></div> |
| <button id="checkout_btn" class="btn btn-success" style="width:100%;" onclick="submitCart()">Оформить накладную</button> |
| </div> |
| </div> |
| |
| <div id="invoiceModal" class="modal-overlay"> |
| <div class="modal-content"> |
| <button class="close-modal" onclick="closeInvoice()">×</button> |
| <h2 style="text-align:center; margin-bottom:16px;">Накладная</h2> |
| <div id="invoice_content"></div> |
| <button class="btn btn-primary" style="width:100%; margin-top:20px;" onclick="closeInvoice()">Готово</button> |
| </div> |
| </div> |
| |
| <form id="delForm" method="POST" style="display:none;" onsubmit="return showLoader(this)"> |
| <input type="hidden" name="action" value="delete"> |
| <input type="hidden" name="index" id="del_index"> |
| </form> |
| |
| <script> |
| const data = {{ data|tojson }}; |
| const role = "{{ role }}"; |
| const price_type = "{{ price_type }}"; |
| const hostUrl = "{{ request.host_url }}"; |
| const currentEmp = {{ emp|tojson }}; |
| let activeLoc = 'all'; |
| let cart = []; |
| let searchTimeout; |
| |
| function showLoader(formElement) { |
| if (formElement && formElement.dataset.submitted === 'true') { |
| return false; |
| } |
| if (formElement) formElement.dataset.submitted = 'true'; |
| document.getElementById('loader').style.display = 'flex'; |
| return true; |
| } |
| |
| function init() { |
| if (role === 'admin' || role === 'employee') { |
| document.getElementById('main_title').innerText = role === 'admin' ? 'Админ панель' : 'Панель сотрудника'; |
| } else { |
| document.getElementById('main_title').innerText = price_type === 'wholesale' ? 'Оптовый каталог' : 'Розничный каталог'; |
| } |
| buildLocNav(); |
| if (role === 'admin') { |
| buildSettings(); |
| buildIncProducts(); |
| buildLocSelects(); |
| buildEmpSelect(); |
| initHistoryDates(); |
| } |
| renderCatalog(); |
| } |
| |
| function switchNav(nav) { |
| ['catalog', 'reports', 'settings'].forEach(id => { |
| const el = document.getElementById('section_' + id); |
| if(el) el.style.display = (id === nav) ? 'block' : 'none'; |
| }); |
| document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); |
| document.getElementById('navbtn_' + nav).classList.add('active'); |
| if (nav === 'reports') renderHistory(); |
| } |
| |
| function buildLocNav() { |
| const cont = document.getElementById('loc_nav_container'); |
| if(!cont) return; |
| let html = `<button class="loc-btn active" id="locbtn_all" onclick="setLoc('all')">Все локации</button>`; |
| |
| data.settings.warehouses.forEach(w => { |
| html += `<button class="loc-btn" id="locbtn_${w.id}" onclick="setLoc('${w.id}')">${w.name}</button>`; |
| }); |
| |
| let allowedShops = data.settings.shops; |
| if (role === 'employee' && currentEmp && currentEmp.shop_id) { |
| allowedShops = data.settings.shops.filter(s => s.id === currentEmp.shop_id); |
| } |
| |
| allowedShops.forEach(s => { |
| html += `<button class="loc-btn" id="locbtn_${s.id}" onclick="setLoc('${s.id}')">${s.name}</button>`; |
| }); |
| cont.innerHTML = html; |
| } |
| |
| function setLoc(locId) { |
| activeLoc = locId; |
| document.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active')); |
| document.getElementById('locbtn_' + locId).classList.add('active'); |
| renderCatalog(); |
| } |
| |
| function getLocName(locId) { |
| let locs = [...data.settings.warehouses, ...data.settings.shops]; |
| let found = locs.find(l => l.id === locId); |
| return found ? found.name : locId; |
| } |
| |
| function zoomImage(src) { |
| if (!src) return; |
| document.getElementById('zoomedImage').src = src; |
| openModal('imageZoomModal'); |
| } |
| |
| function onSearchInput() { |
| clearTimeout(searchTimeout); |
| searchTimeout = setTimeout(renderCatalog, 200); |
| } |
| |
| function renderCatalog() { |
| const q = document.getElementById('search').value.toLowerCase().trim(); |
| const cat = document.getElementById('catalog'); |
| let htmlArr = []; |
| let locationsWithStock = new Set(); |
| |
| data.products.forEach((p, pIdx) => { |
| let match = p.name.toLowerCase().includes(q) || (p.description && p.description.toLowerCase().includes(q)); |
| if (!match) return; |
| |
| if (q !== '') { |
| for (let loc in p.stocks) { |
| if (p.stocks[loc] > 0) { |
| locationsWithStock.add(loc); |
| } |
| } |
| } |
| |
| let stock = 0; |
| if (activeLoc === 'all') { |
| if (role === 'employee' && currentEmp && currentEmp.shop_id) { |
| let validLocs = data.settings.warehouses.map(w => w.id); |
| validLocs.push(currentEmp.shop_id); |
| for (let loc in p.stocks) { |
| if (validLocs.includes(loc)) { |
| stock += p.stocks[loc]; |
| } |
| } |
| } else { |
| stock = Object.values(p.stocks || {}).reduce((a,b)=>a+b, 0); |
| } |
| } else { |
| stock = (p.stocks || {})[activeLoc] || 0; |
| } |
| |
| if (activeLoc !== 'all' && stock <= 0) return; |
| |
| let imgHtml = p.photo ? `<img src="${p.photo}" class="product-img">` : `<span style="font-size:0.6rem; color:#9ca3af;">Нет фото</span>`; |
| let imgClick = p.photo ? `onclick="zoomImage('${p.photo}')"` : ``; |
| |
| let priceHtml = ''; |
| if (role !== 'public') { |
| priceHtml = ` |
| <div style="display:flex; gap:6px; align-items:center; font-size:0.8rem; margin:4px 0; flex-wrap:wrap;"> |
| Розн: <input type="number" id="price_ret_${pIdx}" value="${p.price_retail}" style="width:65px; padding:2px 4px; border:1px solid var(--border); border-radius:4px; font-weight:600;" onclick="this.select()"> ₸ |
| | Опт: <input type="number" id="price_whl_${pIdx}" value="${p.price_wholesale}" style="width:65px; padding:2px 4px; border:1px solid var(--border); border-radius:4px; font-weight:600;" onclick="this.select()"> ₸ |
| </div> |
| `; |
| } else { |
| priceHtml = ` |
| <div class="product-price" style="margin:4px 0;"> |
| ${price_type === 'both' ? `Розница: ${p.price_retail} ₸ | Опт: ${p.price_wholesale} ₸` : (price_type === 'retail' ? `${p.price_retail} ₸` : `${p.price_wholesale} ₸`)} |
| </div> |
| `; |
| } |
| |
| let actionsHtml = ''; |
| if (role !== 'public') { |
| if (activeLoc !== 'all') { |
| if (stock > 0) { |
| let varHtml = ''; |
| if (p.variants && p.variants.length > 0) { |
| varHtml = `<select id="var_${pIdx}" class="form-control" style="padding:6px; font-size:0.8rem; margin-bottom:4px; height:auto;"> |
| <option value="">-- Без варианта --</option> |
| ${p.variants.map((v, i) => `<option value="${i}">${v.name} (+${v.price} ₸)</option>`).join('')} |
| </select>`; |
| } |
| |
| actionsHtml += ` |
| <div class="product-actions"> |
| ${varHtml} |
| <div style="display:flex; gap:6px; align-items:center; width:100%;"> |
| <input type="number" id="amt_${pIdx}" class="qty-input" value="1" min="1" max="${stock}"> |
| <button class="btn btn-success" style="padding:8px 6px; font-size:0.75rem; flex:1;" onclick="addToCart(${pIdx}, false, 'retail')">В корз (Розн)</button> |
| <button class="btn btn-primary" style="padding:8px 6px; font-size:0.75rem; flex:1;" onclick="addToCart(${pIdx}, false, 'wholesale')">В корз (Опт)</button> |
| </div> |
| <div style="display:flex; gap:6px; width:100%; margin-top:2px;"> |
| <button class="btn btn-success" style="padding:8px 6px; font-size:0.75rem; flex:1; background:#059669;" onclick="addToCart(${pIdx}, true, 'retail')">Коробка (Розн)</button> |
| <button class="btn btn-primary" style="padding:8px 6px; font-size:0.75rem; flex:1; background:#4338ca;" onclick="addToCart(${pIdx}, true, 'wholesale')">Коробка (Опт)</button> |
| </div> |
| `; |
| if (role === 'admin') { |
| actionsHtml += `<div style="display:flex; gap:6px; margin-top:2px; width:100%;"> |
| <button class="btn btn-primary" style="padding:8px 6px; flex:1; font-size:0.75rem;" onclick="openTransfer(${pIdx}, ${stock})">Переместить</button> |
| <button class="btn btn-success" style="padding:8px 6px; flex:1; font-size:0.75rem; background:#eab308;" onclick="openEdit(${pIdx})">Изменить</button> |
| <button class="btn btn-danger" style="padding:8px 6px; flex:1; font-size:0.75rem;" onclick="delProd(${pIdx})">Удалить</button> |
| </div>`; |
| } |
| actionsHtml += `</div>`; |
| } |
| } else { |
| actionsHtml += `<div class="product-actions"> |
| <div style="font-size:0.8rem; color:var(--text-muted); text-align:center; margin:8px 0;">Выберите локацию для действий</div>`; |
| if (role === 'admin') { |
| actionsHtml += `<div style="display:flex; gap:6px; width:100%;"> |
| <button class="btn btn-success" style="padding:8px; flex:1; font-size:0.75rem; background:#eab308;" onclick="openEdit(${pIdx})">Изменить</button> |
| <button class="btn btn-danger" style="padding:8px; flex:1; font-size:0.75rem;" onclick="delProd(${pIdx})">Удалить</button> |
| </div>`; |
| } |
| actionsHtml += `</div>`; |
| } |
| } |
| |
| let html = `<div class="product"> |
| <div class="product-main"> |
| <div class="product-img-wrapper" ${imgClick}>${imgHtml}</div> |
| <div class="product-info"> |
| <div class="product-title">${p.name}</div> |
| ${p.description ? `<div style="font-size:0.75rem; color:var(--text-muted);">${p.description}</div>` : ''} |
| ${priceHtml} |
| <div class="product-stock">Наличие: ${stock} шт | В коробке: ${p.box_qty || 1} шт</div> |
| </div> |
| </div> |
| ${actionsHtml} |
| </div>`; |
| htmlArr.push(html); |
| }); |
| cat.innerHTML = htmlArr.join(''); |
| |
| document.querySelectorAll('.loc-btn').forEach(btn => { |
| if (btn.id === 'locbtn_all') return; |
| let locId = btn.id.replace('locbtn_', ''); |
| if (q !== '' && locationsWithStock.has(locId)) { |
| btn.classList.add('has-search-stock'); |
| } else { |
| btn.classList.remove('has-search-stock'); |
| } |
| }); |
| } |
| |
| function addToCart(pIdx, isBox, pType) { |
| let p = data.products[pIdx]; |
| let amt = isBox ? parseInt(p.box_qty || 1) : parseInt(document.getElementById('amt_'+pIdx).value); |
| let stock = activeLoc === 'all' ? Object.values(p.stocks || {}).reduce((a,b)=>a+b, 0) : (p.stocks[activeLoc] || 0); |
| |
| if(amt <= 0 || isNaN(amt)) return alert('Неверное количество'); |
| if(activeLoc !== 'all' && amt > stock) return alert('Превышен остаток'); |
| |
| let basePrice = pType === 'wholesale' ? p.price_wholesale : p.price_retail; |
| if (role !== 'public') { |
| if (pType === 'wholesale') { |
| let customPriceInput = document.getElementById('price_whl_' + pIdx); |
| if (customPriceInput) { |
| basePrice = parseInt(customPriceInput.value) || basePrice; |
| } |
| } else { |
| let customPriceInput = document.getElementById('price_ret_' + pIdx); |
| if (customPriceInput) { |
| basePrice = parseInt(customPriceInput.value) || basePrice; |
| } |
| } |
| } |
| |
| let varSelect = document.getElementById('var_'+pIdx); |
| let vName = ''; |
| let vPrice = 0; |
| if (varSelect && varSelect.value !== '') { |
| let vIdx = parseInt(varSelect.value); |
| let variant = p.variants[vIdx]; |
| if(variant) { |
| vName = ' (' + variant.name + ')'; |
| vPrice = variant.price; |
| } |
| } |
| |
| let finalPrice = basePrice + vPrice; |
| let finalName = p.name + vName + (pType === 'wholesale' ? ' [Опт]' : ' [Розн]'); |
| |
| let existing = cart.find(i => i.pIndex === pIdx && i.loc === activeLoc && i.pName === finalName && i.price === finalPrice); |
| let cartTotalForProduct = cart.filter(i => i.pIndex === pIdx && i.loc === activeLoc).reduce((s, i) => s + i.amt, 0); |
| |
| if(activeLoc !== 'all' && (cartTotalForProduct + amt > stock)) return alert('Превышен остаток в корзине'); |
| |
| if(existing) { |
| existing.amt += amt; |
| } else { |
| cart.push({pIndex: pIdx, pName: finalName, price: finalPrice, amt: amt, loc: activeLoc}); |
| } |
| updateCartUI(); |
| } |
| |
| function updateCartUI() { |
| let totalItems = cart.reduce((sum, item) => sum + item.amt, 0); |
| const btn = document.getElementById('cart_btn'); |
| document.getElementById('cart_count').innerText = totalItems; |
| btn.style.display = totalItems > 0 ? 'flex' : 'none'; |
| } |
| |
| function openCart() { |
| let html = ''; |
| let total = 0; |
| cart.forEach((c, i) => { |
| let sum = c.amt * c.price; |
| total += sum; |
| html += `<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border); padding:10px 0;"> |
| <div> |
| <div style="font-weight:600;">${c.pName}</div> |
| <div style="font-size:0.85rem; color:var(--text-muted);">${c.amt} шт x ${c.price} ₸</div> |
| </div> |
| <div style="display:flex; align-items:center; gap:12px;"> |
| <span style="font-weight:700;">${sum} ₸</span> |
| <button style="border:none; background:var(--danger); color:white; border-radius:6px; padding:6px 10px; cursor:pointer; font-weight:700;" onclick="removeFromCart(${i})">×</button> |
| </div> |
| </div>`; |
| }); |
| html += `<div style="text-align:right; font-size:1.2rem; font-weight:700; margin-top:16px;">Итого: ${total} ₸</div>`; |
| document.getElementById('cart_items').innerHTML = html; |
| openModal('cartModal'); |
| } |
| |
| function removeFromCart(idx) { |
| cart.splice(idx, 1); |
| updateCartUI(); |
| if(cart.length === 0) { |
| closeModal('cartModal'); |
| } else { |
| openCart(); |
| } |
| } |
| |
| async function submitCart() { |
| if (cart.length === 0) return; |
| const btn = document.getElementById('checkout_btn'); |
| if (btn.disabled) return; |
| btn.disabled = true; |
| document.getElementById('loader').style.display = 'flex'; |
| |
| let fd = new FormData(); |
| fd.append('action', 'checkout'); |
| fd.append('cart', JSON.stringify(cart)); |
| |
| try { |
| await fetch(window.location.href, {method: 'POST', body: fd}); |
| |
| let dateStr = new Date().toLocaleString('ru-RU'); |
| let html = `<p style="color:var(--text-muted); margin-bottom:16px;">Дата: ${dateStr}</p> |
| <table class="invoice-table"> |
| <tr><th>Товар</th><th>Кол.</th><th>Сумма</th></tr>`; |
| let total = 0; |
| cart.forEach(c => { |
| let sum = c.amt * c.price; |
| total += sum; |
| html += `<tr><td>${c.pName}</td><td>${c.amt}</td><td>${sum} ₸</td></tr>`; |
| }); |
| html += `</table><div class="invoice-total">Итого к оплате: ${total} ₸</div>`; |
| |
| document.getElementById('invoice_content').innerHTML = html; |
| closeModal('cartModal'); |
| openModal('invoiceModal'); |
| cart = []; |
| updateCartUI(); |
| } catch (e) { |
| alert('Ошибка при оформлении'); |
| } finally { |
| document.getElementById('loader').style.display = 'none'; |
| btn.disabled = false; |
| } |
| } |
| |
| function closeInvoice() { |
| window.location.reload(); |
| } |
| |
| function openTransfer(pIdx, maxStock) { |
| if(maxStock <= 0) return alert('Нет в наличии'); |
| document.getElementById('trans_pIndex').value = pIdx; |
| document.getElementById('trans_from').value = activeLoc; |
| document.getElementById('trans_info').innerText = data.products[pIdx].name + ' (Доступно: ' + maxStock + ' шт)'; |
| document.getElementById('trans_amt').max = maxStock; |
| |
| const dest = document.getElementById('trans_dest_select'); |
| dest.innerHTML = ''; |
| [...data.settings.warehouses, ...data.settings.shops].forEach(l => { |
| if (l.id !== activeLoc) { |
| dest.innerHTML += `<option value="${l.id}">${l.name}</option>`; |
| } |
| }); |
| openModal('transferModal'); |
| } |
| |
| function addVariantField(containerId, name='', price='') { |
| const cont = document.getElementById(containerId); |
| const div = document.createElement('div'); |
| div.style = "display:flex; gap:8px; margin-bottom:8px; align-items:center;"; |
| div.innerHTML = ` |
| <input type="text" name="v_name[]" class="form-control" style="margin:0; flex:1;" placeholder="Название (напр. XXL)" value="${name}" required> |
| <input type="number" name="v_price[]" class="form-control" style="margin:0; width:100px;" placeholder="Цена (₸)" value="${price}" required> |
| <button type="button" style="border:none; background:var(--danger); color:white; border-radius:8px; padding:10px; cursor:pointer;" onclick="this.parentElement.remove()">×</button> |
| `; |
| cont.appendChild(div); |
| } |
| |
| function openEdit(pIdx) { |
| let p = data.products[pIdx]; |
| document.getElementById('edit_index').value = pIdx; |
| document.getElementById('edit_name').value = p.name; |
| document.getElementById('edit_desc').value = p.description || ''; |
| document.getElementById('edit_price_retail').value = p.price_retail || p.price || 0; |
| document.getElementById('edit_price_wholesale').value = p.price_wholesale || p.price || 0; |
| document.getElementById('edit_box_qty').value = p.box_qty || 1; |
| document.getElementById('edit_photo_b64').value = ''; |
| |
| document.getElementById('edit_variants_container').innerHTML = ''; |
| if (p.variants) { |
| p.variants.forEach(v => { |
| addVariantField('edit_variants_container', v.name, v.price); |
| }); |
| } |
| |
| let preview = document.getElementById('edit_photo_preview'); |
| if(p.photo) { |
| preview.src = p.photo; |
| preview.style.display = 'block'; |
| } else { |
| preview.src = ''; |
| preview.style.display = 'none'; |
| } |
| openModal('editModal'); |
| } |
| |
| function processImage(input, b64Id, previewId) { |
| if (input.files && input.files[0]) { |
| const reader = new FileReader(); |
| reader.onload = function(e) { |
| const img = new Image(); |
| img.onload = function() { |
| const canvas = document.createElement('canvas'); |
| let w = img.width, h = img.height; |
| const max = 600; |
| if (w > max || h > max) { |
| if (w > h) { h = Math.round(h * max / w); w = max; } |
| else { w = Math.round(w * max / h); h = max; } |
| } |
| canvas.width = w; canvas.height = h; |
| const ctx = canvas.getContext('2d'); |
| ctx.drawImage(img, 0, 0, w, h); |
| const b64 = canvas.toDataURL('image/jpeg', 0.6); |
| document.getElementById(b64Id).value = b64; |
| const p = document.getElementById(previewId); |
| p.src = b64; p.style.display = 'block'; |
| } |
| img.src = e.target.result; |
| } |
| reader.readAsDataURL(input.files[0]); |
| } |
| } |
| |
| function delProd(idx) { |
| if(confirm('Удалить товар?')) { |
| document.getElementById('del_index').value = idx; |
| const form = document.getElementById('delForm'); |
| if (showLoader(form)) { |
| form.submit(); |
| } |
| } |
| } |
| |
| function openModal(id) { document.getElementById(id).classList.add('active'); } |
| function closeModal(id) { document.getElementById(id).classList.remove('active'); } |
| |
| function buildSettings() { |
| const wCont = document.getElementById('set_warehouses'); |
| wCont.innerHTML = data.settings.warehouses.map(w => `<div class="settings-list-item"><div class="settings-list-item-header"><span style="font-weight:600;">${w.name}</span><form method="POST" onsubmit="return showLoader(this)" style="margin:0;"><input type="hidden" name="action" value="del_loc"><input type="hidden" name="l_type" value="warehouses"><input type="hidden" name="l_id" value="${w.id}"><button type="submit" class="btn btn-danger" style="padding:6px 12px; font-size:0.85rem;">Удалить</button></form></div></div>`).join(''); |
| |
| const sCont = document.getElementById('set_shops'); |
| sCont.innerHTML = data.settings.shops.map(s => `<div class="settings-list-item"><div class="settings-list-item-header"><span style="font-weight:600;">${s.name}</span><form method="POST" onsubmit="return showLoader(this)" style="margin:0;"><input type="hidden" name="action" value="del_loc"><input type="hidden" name="l_type" value="shops"><input type="hidden" name="l_id" value="${s.id}"><button type="submit" class="btn btn-danger" style="padding:6px 12px; font-size:0.85rem;">Удалить</button></form></div></div>`).join(''); |
| |
| const eCont = document.getElementById('set_emps'); |
| eCont.innerHTML = data.settings.employees.map(e => { |
| let shop = data.settings.shops.find(s => s.id === e.shop_id); |
| let shopName = shop ? shop.name : 'Не привязан'; |
| return ` |
| <div class="settings-list-item"> |
| <div class="settings-list-item-header"> |
| <span style="font-weight:600;">${e.name} <small style="color:var(--text-muted)">(${shopName})</small></span> |
| <form method="POST" onsubmit="return showLoader(this)" style="margin:0;"> |
| <input type="hidden" name="action" value="del_emp"> |
| <input type="hidden" name="e_id" value="${e.id}"> |
| <button type="submit" class="btn btn-danger" style="padding:6px 12px; font-size:0.85rem;">Удалить</button> |
| </form> |
| </div> |
| <div class="settings-link-box"> |
| <input type="text" class="settings-link-input" value="${hostUrl}emp/${e.token}" readonly onclick="this.select()"> |
| <button type="button" onclick="navigator.clipboard.writeText('${hostUrl}emp/${e.token}');alert('Ссылка скопирована')" class="btn btn-primary" style="padding:8px 12px; font-size:0.85rem; flex:0 0 auto;">Копировать</button> |
| </div> |
| </div> |
| `}).join(''); |
| } |
| |
| function buildLocSelects() { |
| const locs = [...data.settings.warehouses, ...data.settings.shops]; |
| const opts = locs.map(l => `<option value="${l.id}">${l.name}</option>`).join(''); |
| document.getElementById('add_loc_select').innerHTML = opts; |
| document.getElementById('inc_loc_select').innerHTML = opts; |
| |
| const shopOpts = data.settings.shops.map(s => `<option value="${s.id}">${s.name}</option>`).join(''); |
| document.getElementById('emp_shop_select').innerHTML = '<option value="">-- Выберите магазин --</option>' + shopOpts; |
| } |
| |
| function buildIncProducts() { |
| const sel = document.getElementById('inc_product'); |
| sel.innerHTML = '<option value="">-- Товар --</option>' + data.products.map((p,i) => `<option value="${i}">${p.name}</option>`).join(''); |
| } |
| |
| function buildEmpSelect() { |
| const sel = document.getElementById('hist_emp'); |
| sel.innerHTML = '<option value="all">Все сотрудники</option><option value="Админ">Админ</option>' + data.settings.employees.map(e => `<option value="${e.name}">${e.name}</option>`).join(''); |
| } |
| |
| function initHistoryDates() { |
| const d = new Date(); |
| const first = new Date(d.getFullYear(), d.getMonth(), 1); |
| document.getElementById('hist_from').value = first.toISOString().split('T')[0]; |
| document.getElementById('hist_to').value = d.toISOString().split('T')[0]; |
| } |
| |
| function renderHistory() { |
| const hc = document.getElementById('history_container'); |
| let htmlArr = []; |
| |
| const fDate = new Date(document.getElementById('hist_from').value + "T00:00:00" || 0); |
| const tDate = new Date(document.getElementById('hist_to').value + "T23:59:59" || "2099-01-01T23:59:59"); |
| const eFilter = document.getElementById('hist_emp').value; |
| const actFilter = document.getElementById('hist_action_type').value; |
| |
| let total = 0; |
| |
| data.history.forEach(h => { |
| const pts = h.time.split(' ')[0].split('.'); |
| const hd = new Date(`${pts[2]}-${pts[1]}-${pts[0]}T${h.time.split(' ')[1]}`); |
| |
| let passAction = true; |
| if (actFilter !== 'all') { |
| if (actFilter === 'Продажа') { |
| passAction = h.action === 'Продажа'; |
| } else if (actFilter === 'Продажа_retail') { |
| passAction = h.action === 'Продажа' && h.product.includes('[Розн]'); |
| } else if (actFilter === 'Продажа_wholesale') { |
| passAction = h.action === 'Продажа' && h.product.includes('[Опт]'); |
| } else { |
| passAction = h.action === actFilter; |
| } |
| } |
| |
| if (hd >= fDate && hd <= tDate && (eFilter === 'all' || h.emp_name === eFilter) && passAction) { |
| if (h.action === 'Продажа') total += (h.amount * h.price); |
| |
| let bCls = h.action.includes('Приход') ? 'badge-green' : 'badge-orange'; |
| let locInfo = h.action === 'Перемещение' ? `${h.from_loc_name} ➔ ${h.to_loc_name}` : (h.from_loc_name || h.to_loc_name); |
| |
| htmlArr.push(`<div class="history-item"> |
| <div class="history-header"> |
| <span>${h.time} | ${h.emp_name}</span> |
| <span class="history-badge ${bCls}">${h.action}</span> |
| </div> |
| <div class="history-body"> |
| <span class="history-prod">${h.product}<br><small style="color:var(--text-muted)">${locInfo}</small></span> |
| <span style="font-weight:700; text-align:right;">${h.amount} шт ${h.price > 0 ? `<br><small style="color:var(--primary)">${h.amount * h.price} ₸</small>` : ''}</span> |
| </div> |
| </div>`); |
| } |
| }); |
| hc.innerHTML = htmlArr.join(''); |
| document.getElementById('total_sales').innerText = `Итого продаж: ${total} ₸`; |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def public_index(): |
| return redirect(url_for('public_retail')) |
|
|
| @app.route('/catalog/retail') |
| def public_retail(): |
| data = load_data() |
| return render_template_string(MAIN_HTML, data=data, role='public', emp=None, price_type='retail') |
|
|
| @app.route('/catalog/wholesale') |
| def public_wholesale(): |
| data = load_data() |
| return render_template_string(MAIN_HTML, data=data, role='public', emp=None, price_type='wholesale') |
|
|
| @app.route('/admin', methods=['GET', 'POST']) |
| def admin_panel(): |
| data = load_data() |
| if request.method == 'POST': |
| handle_post(request, data, 'admin', None) |
| save_data(data) |
| return redirect(url_for('admin_panel')) |
| return render_template_string(MAIN_HTML, data=data, role='admin', emp=None, price_type='both') |
|
|
| @app.route('/emp/<token>', methods=['GET', 'POST']) |
| def employee_panel(token): |
| data = load_data() |
| emp = next((e for e in data['settings']['employees'] if e.get('token') == token), None) |
| if not emp: |
| return "Неверная ссылка", 403 |
| if request.method == 'POST': |
| handle_post(request, data, 'employee', emp) |
| save_data(data) |
| return redirect(f'/emp/{token}') |
| return render_template_string(MAIN_HTML, data=data, role='employee', emp=emp, price_type='both') |
|
|
| if __name__ == '__main__': |
| load_data() |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| app.run(debug=True, host='0.0.0.0', port=7860) |