uchet / app.py
Kgshop's picture
Update app.py
1ad671d verified
Raw
History Blame Contribute Delete
44.1 kB
from flask import Flask, render_template_string, request, redirect, url_for
import json
import os
import logging
import threading
import time
from datetime import datetime
import zoneinfo
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError
app = Flask(__name__)
DATA_FILE = 'data_fabrics.json'
REPO_ID = "Kgshop/Konka"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
logging.basicConfig(level=logging.ERROR)
def get_almaty_time():
return datetime.now(zoneinfo.ZoneInfo("Asia/Almaty")).strftime('%d.%m.%Y %H:%M:%S')
def load_data():
try:
download_db_from_hf()
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
data = {'products': [], 'history': []}
if 'products' not in data:
data['products'] = []
if 'history' not in data:
data['history'] = []
for p in data['products']:
if 'category' in p: del p['category']
if 'price' in p: del p['price']
if 'wholesale_price' in p: del p['wholesale_price']
if 'min_wholesale' in p: del p['min_wholesale']
if 'discount' in p: del p['discount']
if 'photos' in p: del p['photos']
for c in p.get('colors', []):
wr = c.get('warehouse_rolls', c.get('rolls', 0))
c['warehouse_rolls'] = wr
c['shop_rolls'] = c.get('shop_rolls', 0)
if 'rolls' in c: del c['rolls']
return data
except Exception:
return {'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)
upload_db_to_hf()
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 download_db_from_hf():
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
def periodic_backup():
while True:
upload_db_to_hf()
time.sleep(800)
@app.route('/')
def catalog():
data = load_data()
catalog_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;
--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: 40px; }
.container { max-width: 600px; margin: 0 auto; }
h1 { text-align: center; color: var(--text-main); font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; letter-spacing: -0.02em; }
.tabs-wrapper { background: #e5e7eb; border-radius: 12px; padding: 4px; display: flex; margin-bottom: 20px; }
.tab-btn { flex: 1; border: none; background: transparent; padding: 12px; border-radius: 8px; font-size: 0.95rem; font-weight: 600; color: var(--text-muted); cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.tab-btn.active { background: var(--surface); color: var(--text-main); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.search-box { width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: 12px; outline: none; font-size: 1rem; margin-bottom: 24px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); transition: all 0.3s ease; }
.search-box:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1); }
.product { background: var(--surface); border-radius: 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03); margin-bottom: 16px; overflow: hidden; transform: translateY(0); transition: transform 0.3s ease, box-shadow 0.3s ease; }
.product-header { padding: 20px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; background: var(--surface); transition: background 0.3s ease; }
.product-header:active { background: #f9fafb; }
.product-title { font-size: 1.1rem; font-weight: 600; color: var(--text-main); }
.chevron { width: 20px; height: 20px; fill: var(--text-muted); transition: transform 0.3s ease; }
.product.active .chevron { transform: rotate(180deg); }
.product-body-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.product.active .product-body-wrapper { grid-template-rows: 1fr; }
.product-content { overflow: hidden; }
.product-content-inner { padding: 0 20px 20px 20px; border-top: 1px solid var(--border); margin-top: 1px; }
.desc { font-size: 0.9rem; color: var(--text-muted); margin: 16px 0; line-height: 1.5; }
.color-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--border); }
.color-row:last-child { border-bottom: none; padding-bottom: 0; }
.color-name { font-weight: 500; font-size: 0.95rem; }
.qty-badge { background: var(--primary); color: white; padding: 4px 12px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; }
.qty-empty { background: var(--border); color: var(--text-muted); }
</style>
</head>
<body>
<div class="container">
<h1>Каталог Тканей</h1>
<div class="tabs-wrapper">
<button class="tab-btn active" id="tab_warehouse" onclick="setTab('warehouse')">Склад</button>
<button class="tab-btn" id="tab_shop" onclick="setTab('shop')">Магазин</button>
</div>
<input type="text" id="search" class="search-box" placeholder="Поиск по названию или цвету..." oninput="render()">
<div id="catalog"></div>
</div>
<script>
const products = {{ products|tojson }};
let activeTab = 'warehouse';
function setTab(tab) {
activeTab = tab;
document.getElementById('tab_warehouse').className = tab === 'warehouse' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tab_shop').className = tab === 'shop' ? 'tab-btn active' : 'tab-btn';
render();
}
function render() {
const q = document.getElementById('search').value.toLowerCase();
const catalog = document.getElementById('catalog');
catalog.innerHTML = '';
products.forEach((p, pIndex) => {
let match = p.name.toLowerCase().includes(q);
if (!match && p.colors) {
match = p.colors.some(c => c.name.toLowerCase().includes(q));
}
if (!match) return;
let html = `<div class="product">
<div class="product-header" onclick="toggle(this)">
<div class="product-title">${p.name}</div>
<svg class="chevron" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</div>
<div class="product-body-wrapper">
<div class="product-content">
<div class="product-content-inner">
${p.description ? `<div class="desc">${p.description}</div>` : '<div style="height:16px;"></div>'}
<div class="colors-list">`;
if(p.colors && p.colors.length > 0) {
p.colors.forEach(c => {
let qty = activeTab === 'warehouse' ? c.warehouse_rolls : c.shop_rolls;
let badgeClass = qty > 0 ? 'qty-badge' : 'qty-badge qty-empty';
html += `<div class="color-row">
<span class="color-name">${c.name}</span>
<span class="${badgeClass}">${qty} шт</span>
</div>`;
});
} else {
html += `<div class="desc">Нет информации по цветам</div>`;
}
html += `</div></div></div></div></div>`;
catalog.insertAdjacentHTML('beforeend', html);
});
}
function toggle(el) {
el.closest('.product').classList.toggle('active');
}
render();
</script>
</body>
</html>
'''
return render_template_string(catalog_html, products=data['products'])
@app.route('/admin', methods=['GET', 'POST'])
def admin():
data = load_data()
products = data.get('products', [])
history = data.get('history', [])
if request.method == 'POST':
action = request.form.get('action')
if action == 'add':
color_names = request.form.getlist('color_names')
color_rolls = request.form.getlist('color_rolls')
colors_list = []
for name, rolls in zip(color_names, color_rolls):
name = name.strip()
if name:
try: r = int(rolls)
except ValueError: r = 0
colors_list.append({"name": name, "warehouse_rolls": r, "shop_rolls": 0})
new_product = {
'name': request.form['name'],
'description': request.form['description'],
'colors': colors_list
}
products.append(new_product)
elif action == 'income':
idx = int(request.form['product_index'])
color_name = request.form['color_name']
if color_name == '__new__':
color_name = request.form['new_color_input'].strip()
rolls_to_add = int(request.form['rolls'])
found = False
for c in products[idx].get('colors', []):
if c['name'] == color_name:
c['warehouse_rolls'] = c.get('warehouse_rolls', 0) + rolls_to_add
found = True
break
if not found:
if 'colors' not in products[idx]:
products[idx]['colors'] = []
products[idx]['colors'].append({"name": color_name, "warehouse_rolls": rolls_to_add, "shop_rolls": 0})
history.insert(0, {
'time': get_almaty_time(),
'action': 'Приход',
'product': products[idx]['name'],
'color': color_name,
'amount': rolls_to_add
})
elif action == 'delete':
index = int(request.form['index'])
if 0 <= index < len(products):
del_name = products[index]['name']
products.pop(index)
history.insert(0, {
'time': get_almaty_time(),
'action': 'Удаление',
'product': del_name,
'color': '-',
'amount': 0
})
elif action == 'process_color':
p_idx = int(request.form.get('pIndex', -1))
c_name = request.form.get('cName')
amt = int(request.form.get('amt', 0))
act_type = request.form.get('actType')
if 0 <= p_idx < len(products) and amt > 0:
for c in products[p_idx].get('colors', []):
if c['name'] == c_name:
if act_type in ['sell_warehouse', 'to_shop']:
av = c.get('warehouse_rolls', 0)
if amt > av: amt = av
elif act_type in ['sell_shop', 'to_warehouse']:
av = c.get('shop_rolls', 0)
if amt > av: amt = av
if amt <= 0:
break
if act_type == 'sell_warehouse':
c['warehouse_rolls'] -= amt
elif act_type == 'to_shop':
c['warehouse_rolls'] -= amt
c['shop_rolls'] = c.get('shop_rolls', 0) + amt
elif act_type == 'sell_shop':
c['shop_rolls'] -= amt
elif act_type == 'to_warehouse':
c['shop_rolls'] -= amt
c['warehouse_rolls'] = c.get('warehouse_rolls', 0) + amt
action_map = {
'sell_warehouse': 'Продажа (Склад)',
'to_shop': 'В магазин',
'sell_shop': 'Продажа (Магазин)',
'to_warehouse': 'На склад'
}
history.insert(0, {
'time': get_almaty_time(),
'action': action_map.get(act_type, act_type),
'product': products[p_idx]['name'],
'color': c_name,
'amount': amt
})
break
history = history[:1000]
save_data({'products': products, 'history': history})
return redirect(url_for('admin'))
admin_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;
--primary-hover: #4338ca;
--success: #10b981;
--success-hover: #059669;
--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: 40px; }
.container { max-width: 600px; margin: 0 auto; }
h1 { text-align: center; color: var(--text-main); font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; letter-spacing: -0.02em; }
.top-actions { display: flex; gap: 12px; margin-bottom: 24px; }
.btn { flex: 1; padding: 14px; border: none; border-radius: 12px; font-size: 0.95rem; font-weight: 600; color: white; cursor: pointer; transition: all 0.3s ease; display: flex; justify-content: center; align-items: center; gap: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.btn:active { transform: scale(0.98); }
.btn-primary { background: var(--primary); }
.btn-success { background: var(--success); }
.tabs-wrapper { background: #e5e7eb; border-radius: 12px; padding: 4px; display: flex; margin-bottom: 20px; }
.tab-btn { flex: 1; border: none; background: transparent; padding: 12px; border-radius: 8px; font-size: 0.9rem; font-weight: 600; color: var(--text-muted); cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.tab-btn.active { background: var(--surface); color: var(--text-main); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.search-box { width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: 12px; outline: none; font-size: 1rem; margin-bottom: 24px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); transition: all 0.3s ease; }
.search-box:focus { border-color: var(--primary); box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1); }
.product { background: var(--surface); border-radius: 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); margin-bottom: 16px; overflow: hidden; transition: box-shadow 0.3s ease; }
.product-header { padding: 20px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; background: var(--surface); }
.product-title { font-size: 1.1rem; font-weight: 600; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.delete-btn { width: 32px; height: 32px; border-radius: 8px; background: #fee2e2; color: var(--danger); display: flex; justify-content: center; align-items: center; font-size: 1.2rem; font-weight: bold; border: none; cursor: pointer; transition: all 0.2s; }
.delete-btn:active { transform: scale(0.9); }
.chevron { width: 20px; height: 20px; fill: var(--text-muted); transition: transform 0.3s ease; }
.product.active .chevron { transform: rotate(180deg); }
.product-body-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.product.active .product-body-wrapper { grid-template-rows: 1fr; }
.product-content { overflow: hidden; }
.product-content-inner { padding: 0 20px 20px 20px; border-top: 1px solid var(--border); margin-top: 1px; }
.desc { font-size: 0.9rem; color: var(--text-muted); margin: 16px 0; }
.color-action-row { display: flex; align-items: center; gap: 12px; padding: 16px 0; border-bottom: 1px solid var(--border); }
.color-action-row:last-child { border-bottom: none; padding-bottom: 0; }
.color-info { flex: 1; }
.color-name { font-weight: 600; font-size: 0.95rem; margin-bottom: 4px; }
.color-qty { font-size: 0.8rem; color: var(--text-muted); }
.color-input { width: 70px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; text-align: center; font-weight: 600; outline: none; }
.color-input:focus { border-color: var(--primary); }
.arrow-btn { background: var(--text-main); color: white; border: none; width: 42px; height: 42px; border-radius: 8px; cursor: pointer; display: flex; justify-content: center; align-items: center; font-size: 1.1rem; transition: background 0.2s; }
.arrow-btn:active { transform: scale(0.95); }
.history-container { display: none; overflow-y: auto; max-height: calc(100vh - 200px); padding-right: 4px; }
.history-item { background: var(--surface); border-radius: 12px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 8px; }
.history-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; color: var(--text-muted); }
.history-body { display: flex; justify-content: space-between; align-items: center; }
.history-prod { font-weight: 600; font-size: 0.95rem; }
.history-badge { padding: 4px 10px; border-radius: 6px; font-size: 0.8rem; font-weight: 600; }
.badge-green { background: #d1fae5; color: #065f46; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.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); backdrop-filter: blur(4px); z-index: 1000; justify-content: center; align-items: flex-end; opacity: 0; transition: opacity 0.3s ease; }
.modal-overlay.active { opacity: 1; }
.modal-content { background: var(--surface); padding: 24px; border-radius: 20px 20px 0 0; width: 100%; max-width: 600px; max-height: 90vh; overflow-y: auto; transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; }
.modal-overlay.active .modal-content { transform: translateY(0); }
@media (min-width: 600px) {
.modal-overlay { align-items: center; }
.modal-content { border-radius: 20px; transform: scale(0.95); opacity: 0; }
.modal-overlay.active .modal-content { transform: scale(1); opacity: 1; }
}
.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; font-size: 1.2rem; cursor: pointer; border: none; color: var(--text-muted); }
.modal-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; padding-right: 40px; }
.form-group { margin-bottom: 16px; }
.form-control { width: 100%; padding: 14px; border: 1px solid var(--border); border-radius: 12px; font-size: 1rem; outline: none; transition: border-color 0.2s; }
.form-control:focus { border-color: var(--primary); }
textarea.form-control { resize: vertical; min-height: 80px; }
.add-color-row { display: flex; gap: 12px; margin-bottom: 12px; }
.add-color-row input { margin-bottom: 0; }
.remove-row-btn { background: #fee2e2; color: var(--danger); border: none; width: 48px; border-radius: 12px; cursor: pointer; font-weight: bold; font-size: 1.2rem; display: flex; justify-content: center; align-items: center; }
.action-grid { display: grid; grid-template-columns: 1fr; gap: 12px; margin-top: 20px; }
.action-btn { padding: 16px; border: none; border-radius: 12px; font-size: 1rem; font-weight: 600; color: white; cursor: pointer; transition: transform 0.2s; }
.action-btn:active { transform: scale(0.98); }
.btn-sell { background: #f59e0b; }
.btn-move { background: #3b82f6; }
</style>
</head>
<body>
<div class="container">
<h1>Учет тканей - Админ</h1>
<div class="top-actions">
<button class="btn btn-primary" onclick="openModal('addModal')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
Новый товар
</button>
<button class="btn btn-success" onclick="openModal('incomeModal')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
Приход
</button>
</div>
<div class="tabs-wrapper">
<button class="tab-btn active" id="tab_warehouse" onclick="setTab('warehouse')">Склад</button>
<button class="tab-btn" id="tab_shop" onclick="setTab('shop')">Магазин</button>
<button class="tab-btn" id="tab_history" onclick="setTab('history')">История</button>
</div>
<input type="text" id="search" class="search-box" placeholder="Поиск по названию или цвету..." oninput="render()">
<div id="history_filters" style="display:none; gap: 12px; margin-bottom: 24px;">
<div style="flex: 1;">
<label style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 4px; display: block;">От</label>
<input type="date" id="hist_from" class="form-control" onchange="renderHistory()">
</div>
<div style="flex: 1;">
<label style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 4px; display: block;">До</label>
<input type="date" id="hist_to" class="form-control" onchange="renderHistory()">
</div>
</div>
<div id="catalog"></div>
<div id="history_container" class="history-container"></div>
</div>
<div id="addModal" class="modal-overlay">
<div class="modal-content">
<button class="close-modal" onclick="closeModal('addModal')">×</button>
<h2 class="modal-title">Добавить товар</h2>
<form method="POST">
<input type="hidden" name="action" value="add">
<div class="form-group">
<input type="text" name="name" class="form-control" placeholder="Название ткани" required>
</div>
<div class="form-group">
<textarea name="description" class="form-control" placeholder="Описание товара"></textarea>
</div>
<h3 style="font-size: 1rem; margin: 20px 0 12px;">Цвета (Остаток на складе)</h3>
<div id="colors_container">
<div class="add-color-row">
<input type="text" name="color_names" class="form-control" placeholder="Цвет" required>
<input type="number" name="color_rolls" class="form-control" style="width: 100px;" placeholder="Кол." value="0" min="0" required>
</div>
</div>
<button type="button" onclick="addColorRow()" style="width: 100%; padding: 12px; background: #f3f4f6; border: 1px dashed #cbd5e1; border-radius: 12px; color: #475569; font-weight: 600; cursor: pointer; margin-bottom: 24px;">+ Добавить еще цвет</button>
<button type="submit" class="btn btn-primary" style="width: 100%;">Сохранить товар</button>
</form>
</div>
</div>
<div id="incomeModal" class="modal-overlay">
<div class="modal-content">
<button class="close-modal" onclick="closeModal('incomeModal')">×</button>
<h2 class="modal-title">Сделать приход</h2>
<form method="POST">
<input type="hidden" name="action" value="income">
<div class="form-group">
<input type="text" id="income_search" class="form-control" placeholder="Поиск товара..." oninput="filterIncomeProducts()">
</div>
<div class="form-group">
<select name="product_index" id="income_product" class="form-control" onchange="updateIncomeColors()" required>
<option value="">-- Выберите товар --</option>
</select>
</div>
<div class="form-group">
<select name="color_name" id="income_color" class="form-control" onchange="checkNewColor(this.value)" required>
<option value="">Сначала выберите товар</option>
</select>
</div>
<div class="form-group">
<input type="text" name="new_color_input" id="new_color_input" class="form-control" style="display: none;" placeholder="Название нового цвета">
</div>
<div class="form-group">
<input type="number" name="rolls" class="form-control" placeholder="Количество" min="1" required>
</div>
<button type="submit" class="btn btn-success" style="width: 100%;">Подтвердить приход</button>
</form>
</div>
</div>
<div id="actionModal" class="modal-overlay">
<div class="modal-content" style="max-width: 400px;">
<button class="close-modal" onclick="closeModal('actionModal')">×</button>
<h2 class="modal-title" id="actionTitle" style="text-align: center; padding: 0;">Выберите действие</h2>
<p id="actionSub" style="text-align: center; color: var(--text-muted); margin-bottom: 10px;"></p>
<div class="action-grid">
<button id="btn_sell" class="action-btn btn-sell"></button>
<button id="btn_transfer" class="action-btn btn-move"></button>
</div>
</div>
</div>
<form id="delForm" method="POST" style="display: none;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="index" id="del_index">
</form>
<form id="processForm" method="POST" style="display: none;">
<input type="hidden" name="action" value="process_color">
<input type="hidden" name="pIndex" id="process_pIndex">
<input type="hidden" name="cName" id="process_cName">
<input type="hidden" name="amt" id="process_amt">
<input type="hidden" name="actType" id="process_actType">
</form>
<script>
let activeTab = 'warehouse';
const products = {{ products|tojson }};
const historyData = {{ history|tojson }};
function setTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('tab_' + tab).classList.add('active');
if(tab === 'history') {
document.getElementById('catalog').style.display = 'none';
document.getElementById('search').style.display = 'none';
document.getElementById('history_filters').style.display = 'flex';
document.getElementById('history_container').style.display = 'block';
renderHistory();
} else {
document.getElementById('catalog').style.display = 'block';
document.getElementById('search').style.display = 'block';
document.getElementById('history_filters').style.display = 'none';
document.getElementById('history_container').style.display = 'none';
render();
}
}
function render() {
const q = document.getElementById('search').value.toLowerCase();
const catalog = document.getElementById('catalog');
catalog.innerHTML = '';
products.forEach((p, pIndex) => {
let match = p.name.toLowerCase().includes(q);
if (!match && p.colors) {
match = p.colors.some(c => c.name.toLowerCase().includes(q));
}
if (!match) return;
let html = `<div class="product">
<div class="product-header" onclick="toggle(this)">
<div class="product-title">${p.name}</div>
<div class="header-actions">
<button class="delete-btn" onclick="deleteProduct(event, ${pIndex})">×</button>
<svg class="chevron" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</div>
</div>
<div class="product-body-wrapper">
<div class="product-content">
<div class="product-content-inner">
${p.description ? `<div class="desc">${p.description}</div>` : '<div style="height:16px;"></div>'}`;
if(p.colors && p.colors.length > 0) {
p.colors.forEach((c, cIndex) => {
let qty = activeTab === 'warehouse' ? c.warehouse_rolls : c.shop_rolls;
html += `<div class="color-action-row">
<div class="color-info">
<div class="color-name">${c.name}</div>
<div class="color-qty">В наличии: ${qty} шт</div>
</div>
<input type="number" id="amt_${pIndex}_${cIndex}" class="color-input" value="1" min="1" max="${qty}">
<button class="arrow-btn" onclick="openActionModal(event, ${pIndex}, '${c.name}', ${cIndex})">➔</button>
</div>`;
});
} else {
html += `<div class="desc">Нет цветов</div>`;
}
html += `</div></div></div></div>`;
catalog.insertAdjacentHTML('beforeend', html);
});
}
function parseHistoryDate(dStr) {
const parts = dStr.split(' ');
const dmy = parts[0].split('.');
return new Date(`${dmy[2]}-${dmy[1]}-${dmy[0]}T${parts[1]}`);
}
function renderHistory() {
const hc = document.getElementById('history_container');
hc.innerHTML = '';
const fromVal = document.getElementById('hist_from').value;
const toVal = document.getElementById('hist_to').value;
let fromDate = fromVal ? new Date(fromVal + "T00:00:00") : new Date(0);
let toDate = toVal ? new Date(toVal + "T23:59:59") : new Date(8640000000000000);
let filteredHistory = historyData.filter(h => {
const hd = parseHistoryDate(h.time);
return hd >= fromDate && hd <= toDate;
});
if(filteredHistory.length === 0) {
hc.innerHTML = '<div style="text-align:center; padding: 40px 0; color: #6b7280;">История пуста за этот период</div>';
return;
}
filteredHistory.forEach(h => {
let badgeClass = 'history-badge ';
if (h.action.includes('Приход')) badgeClass += 'badge-green';
else if (h.action.includes('Склад') || h.action.includes('Магазин')) badgeClass += 'badge-orange';
else badgeClass += 'badge-blue';
let html = `
<div class="history-item">
<div class="history-header">
<span>${h.time}</span>
<span class="${badgeClass}">${h.action}</span>
</div>
<div class="history-body">
<span class="history-prod">${h.product} ${h.color !== '-' ? `(${h.color})` : ''}</span>
<span style="font-weight:700;">${h.amount > 0 ? h.amount + ' шт' : ''}</span>
</div>
</div>`;
hc.insertAdjacentHTML('beforeend', html);
});
}
function toggle(el) {
el.closest('.product').classList.toggle('active');
}
function deleteProduct(event, index) {
event.stopPropagation();
if(confirm('Удалить товар?')) {
document.getElementById('del_index').value = index;
document.getElementById('delForm').submit();
}
}
function openModal(id) {
const modal = document.getElementById(id);
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
if(id === 'incomeModal') {
document.getElementById('income_search').value = '';
populateIncomeProducts('');
}
}
function closeModal(id) {
const modal = document.getElementById(id);
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function openActionModal(event, pIndex, cName, cIndex) {
event.stopPropagation();
const inputEl = document.getElementById(`amt_${pIndex}_${cIndex}`);
const amt = parseInt(inputEl.value);
const maxAmt = parseInt(inputEl.max);
if(!amt || amt <= 0) return alert('Введите корректное количество');
if(amt > maxAmt) return alert('Ошибка: количество превышает остаток!');
document.getElementById('process_pIndex').value = pIndex;
document.getElementById('process_cName').value = cName;
document.getElementById('process_amt').value = amt;
document.getElementById('actionTitle').innerText = cName;
document.getElementById('actionSub').innerText = `Количество: ${amt} шт.`;
const btnSell = document.getElementById('btn_sell');
const btnTransfer = document.getElementById('btn_transfer');
if(activeTab === 'warehouse') {
btnSell.innerText = 'Продать со склада';
btnSell.onclick = () => submitProcess('sell_warehouse');
btnTransfer.innerText = 'В магазин';
btnTransfer.onclick = () => submitProcess('to_shop');
} else {
btnSell.innerText = 'Продать с магазина';
btnSell.onclick = () => submitProcess('sell_shop');
btnTransfer.innerText = 'На склад';
btnTransfer.onclick = () => submitProcess('to_warehouse');
}
openModal('actionModal');
}
function submitProcess(actType) {
document.getElementById('process_actType').value = actType;
document.getElementById('processForm').submit();
}
function addColorRow() {
const row = document.createElement('div');
row.className = 'add-color-row';
row.innerHTML = `
<input type="text" name="color_names" class="form-control" placeholder="Цвет" required>
<input type="number" name="color_rolls" class="form-control" style="width: 100px;" placeholder="Кол." value="0" min="0" required>
<button type="button" class="remove-row-btn" onclick="this.parentElement.remove()">×</button>
`;
document.getElementById('colors_container').appendChild(row);
}
function populateIncomeProducts(query = '') {
const select = document.getElementById('income_product');
select.innerHTML = '<option value="">-- Выберите товар --</option>';
const q = query.toLowerCase();
products.forEach((p, i) => {
if (p.name.toLowerCase().includes(q)) {
select.innerHTML += `<option value="${i}">${p.name}</option>`;
}
});
updateIncomeColors();
}
function filterIncomeProducts() {
const query = document.getElementById('income_search').value;
populateIncomeProducts(query);
}
function updateIncomeColors() {
const pIdx = document.getElementById('income_product').value;
const select = document.getElementById('income_color');
select.innerHTML = '<option value="">-- Выберите цвет --</option>';
if(pIdx !== '') {
const p = products[pIdx];
if(p.colors) {
p.colors.forEach(c => {
select.innerHTML += `<option value="${c.name}">${c.name}</option>`;
});
}
select.innerHTML += `<option value="__new__">+ Новый цвет</option>`;
}
checkNewColor(select.value);
}
function checkNewColor(val) {
const input = document.getElementById('new_color_input');
if(val === '__new__') {
input.style.display = 'block';
input.required = true;
} else {
input.style.display = 'none';
input.required = false;
input.value = '';
}
}
function initHistoryDates() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
document.getElementById('hist_from').value = firstDay.toISOString().split('T')[0];
document.getElementById('hist_to').value = now.toISOString().split('T')[0];
}
initHistoryDates();
render();
</script>
</body>
</html>
'''
return render_template_string(admin_html, products=products, history=history)
if __name__ == '__main__':
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
try:
load_data()
except Exception:
pass
app.run(debug=True, host='0.0.0.0', port=7860)