Optom / app.py
Kgshop's picture
Update app.py
b284a41 verified
Raw
History Blame Contribute Delete
66.2 kB
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)