| 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) |