Sova / app.py
Kgshop's picture
Update app.py
d2f6e24 verified
Raw
History Blame Contribute Delete
119 kB
import os
import base64
import json
import threading
import time
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from flask import Flask, render_template_string, request, redirect, url_for, flash, jsonify, send_from_directory
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import requests
load_dotenv()
app = Flask(__name__)
app.secret_key = 'super_secret_key_store_app_123'
DATA_FILE = 'data.json'
SYNC_FILES = [DATA_FILE, 'logo.png']
REPO_ID = os.getenv("REPO_ID", "Kgshop/sova")
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
CURRENCY_CODE = 'сум'
tashkent_tz = timezone(timedelta(hours=5), name='Asia/Tashkent')
def get_current_time():
return datetime.now(tashkent_tz).strftime('%Y-%m-%d %H:%M:%S')
def get_current_date():
return datetime.now(tashkent_tz).strftime('%Y-%m-%d')
def download_db_from_hf(specific_file=None, retries=3, delay=5):
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
hf_hub_download(
repo_id=REPO_ID,
filename=file_name,
repo_type="dataset",
token=token_to_use,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True,
resume_download=False
)
success = True
break
except RepositoryNotFoundError:
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with open(file_name, 'w', encoding='utf-8') as f:
json.dump({'products': [], 'categories': [], 'orders': {}, 'employees': [], 'workdays': {}, 'fines': [], 'settings': {
'cafe_name': 'HongKong',
'wa_shift1': '+77470623684',
'wa_shift2': '+77470623684',
'active_shift': 1,
'logo_version': '1'
}}, f)
except Exception:
pass
success = False
break
except requests.exceptions.RequestException:
pass
except Exception:
pass
if attempt < retries:
time.sleep(delay)
if not success:
all_successful = False
return all_successful
def upload_db_to_hf(specific_file=None):
if not HF_TOKEN_WRITE:
return
try:
api = HfApi()
files_to_upload = [specific_file] if specific_file else SYNC_FILES
for file_name in files_to_upload:
if os.path.exists(file_name):
try:
api.upload_file(
path_or_fileobj=file_name,
path_in_repo=file_name,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Sync {file_name} {get_current_time()}"
)
except Exception:
pass
except Exception:
pass
def periodic_backup():
while True:
time.sleep(1800)
upload_db_to_hf()
def load_data():
default_data = {
'products': [],
'categories': [],
'orders': {},
'employees': [],
'workdays': {},
'fines': [],
'settings': {
'cafe_name': 'Sova',
'wa_shift1': '+77470623684',
'wa_shift2': '+77470623684',
'active_shift': 1,
'logo_version': '1'
}
}
data = default_data
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if not isinstance(data, dict):
raise FileNotFoundError
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'employees' not in data: data['employees'] = []
if 'workdays' not in data: data['workdays'] = {}
if 'fines' not in data: data['fines'] = []
if 'settings' not in data: data['settings'] = default_data['settings']
if 'logo_version' not in data['settings']: data['settings']['logo_version'] = '1'
except (FileNotFoundError, json.JSONDecodeError):
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'employees' not in data: data['employees'] = []
if 'workdays' not in data: data['workdays'] = {}
if 'fines' not in data: data['fines'] = []
if 'settings' not in data: data['settings'] = default_data['settings']
if 'logo_version' not in data['settings']: data['settings']['logo_version'] = '1'
except Exception:
data = default_data
else:
data = default_data
except Exception:
data = default_data
migrated_cats = []
for c in data.get('categories', []):
if isinstance(c, str):
migrated_cats.append({'name': c, 'icon': 'fas fa-utensils'})
else:
if 'icon' not in c: c['icon'] = 'fas fa-utensils'
migrated_cats.append(c)
data['categories'] = migrated_cats
for product in data['products']:
if 'product_id' not in product:
product['product_id'] = uuid4().hex
for emp in data['employees']:
if 'pin' not in emp: emp['pin'] = '0000'
if 'daily_rate' not in emp: emp['daily_rate'] = 230000
if 'target_amount' not in emp: emp['target_amount'] = 1500000
if 'bonus_percentage' not in emp: emp['bonus_percentage'] = 10
if not os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(default_data, f)
except Exception:
pass
return data
def save_data(data):
try:
if not isinstance(data, dict):
return
if 'products' not in data: data['products'] = []
if 'categories' not in data: data['categories'] = []
if 'orders' not in data: data['orders'] = {}
if 'employees' not in data: data['employees'] = []
if 'workdays' not in data: data['workdays'] = {}
if 'fines' not in data: data['fines'] = []
if 'settings' not in data: data['settings'] = {
'cafe_name': 'Sova',
'wa_shift1': '',
'wa_shift2': '',
'active_shift': 1,
'logo_version': '1'
}
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
upload_db_to_hf(specific_file=DATA_FILE)
except Exception:
pass
CATALOG_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>{{ settings.cafe_name }} | POS</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --primary: #D4AF37; --bg: #000000; --surface: #111111; --text: #ffffff; --text-muted: #888888; --border: #333333; --accent: #D4AF37; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-tap-highlight-color: transparent; }
body { background-color: var(--bg); color: var(--text); padding-bottom: calc(90px + env(safe-area-inset-bottom)); }
.top-bar { display: flex; justify-content: space-between; align-items: center; background: var(--surface); padding: 15px 20px; border-bottom: 1px solid var(--border); }
.top-bar-left { display: flex; align-items: center; gap: 15px; }
.top-logo { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; border: 2px solid var(--primary); background-color: #1a1a1a; }
.employee-select { background: #1a1a1a; color: var(--primary); border: 1px solid var(--primary); padding: 10px 15px; border-radius: 8px; font-size: 1rem; font-weight: bold; outline: none; }
.my-report-btn { background: #1a1a1a; color: var(--text); border: 1px solid var(--border); padding: 10px 15px; border-radius: 8px; font-size: 0.9rem; cursor: pointer; transition: background 0.2s; }
.my-report-btn:active { background: #333; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 15px 20px; background: var(--surface); box-shadow: 0 2px 10px rgba(0,0,0,0.5); position: sticky; top: 0; z-index: 100; }
.header h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.5px; color: var(--primary); }
.back-btn { display: none; font-size: 1.2rem; cursor: pointer; color: var(--text); margin-right: 15px; padding: 5px; }
.search-bar { padding: 15px 20px; background: var(--surface); border-bottom: 1px solid var(--border); }
.search-container { position: relative; display: flex; align-items: center; background: #1a1a1a; border-radius: 12px; padding: 0 15px; border: 1px solid var(--border); transition: all 0.2s; }
.search-container:focus-within { border-color: var(--primary); background: #222; }
.search-container i { color: var(--text-muted); font-size: 0.9rem; }
.search-bar input { width: 100%; padding: 12px 10px; border: none; background: transparent; outline: none; font-size: 0.95rem; color: var(--text); }
.categories-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; padding: 20px; }
.category-item { background: var(--surface); padding: 20px 15px; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; border: 1px solid var(--border); transition: transform 0.2s, border-color 0.2s; text-align: center; }
.category-item:active { transform: scale(0.96); border-color: var(--primary); }
.category-item span.name { font-size: 0.95rem; font-weight: 600; line-height: 1.3; color: var(--text); }
.category-item span.count { color: var(--text-muted); font-size: 0.8rem; background: #1a1a1a; padding: 4px 10px; border-radius: 20px; }
.products-container { display: none; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; padding: 20px; }
.product-card { background: var(--surface); border-radius: 16px; padding: 12px; display: flex; flex-direction: column; align-items: center; text-align: center; justify-content: space-between; border: 1px solid var(--border); width: 100%; }
.product-img-wrapper { position: relative; width: 100%; height: 130px; flex-shrink: 0; }
.product-img { width: 100%; height: 100%; border-radius: 12px; object-fit: cover; cursor: pointer; background: #1a1a1a; border: 1px solid var(--border); }
.photo-count { position: absolute; bottom: 5px; right: 5px; background: rgba(0,0,0,0.8); color: var(--primary); font-size: 0.7rem; padding: 2px 6px; border-radius: 10px; pointer-events: none; }
.product-info { flex-grow: 1; display: flex; flex-direction: column; justify-content: space-between; min-width: 0; padding: 10px 0 0 0; width: 100%; align-items: center; }
.product-title { font-size: 0.95rem; font-weight: 600; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text); }
.product-desc { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.product-bottom { display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 10px; flex-wrap: wrap; gap: 10px; width: 100%; }
.product-price { font-weight: 700; font-size: 1rem; color: var(--primary); }
.controls-wrapper { display: flex; gap: 8px; align-items: center; justify-content: center; width: 100%; }
.quantity-control { display: flex; align-items: center; background: #1a1a1a; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
.quantity-control button { border: none; background: transparent; width: 35px; height: 35px; font-size: 1.2rem; cursor: pointer; color: var(--primary); display: flex; align-items: center; justify-content: center; transition: background 0.2s; }
.quantity-control button:active { background: #333; }
.quantity-control input { width: 35px; height: 35px; border: none; text-align: center; background: transparent; font-weight: 600; font-size: 1rem; color: var(--text); outline: none; }
.quantity-control input[type="number"]::-webkit-inner-spin-button,
.quantity-control input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.quantity-control input[type="number"] { -moz-appearance: textfield; }
.cart-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: var(--surface); border-top: 1px solid var(--border); padding: 15px 20px calc(15px + env(safe-area-inset-bottom)); display: none; justify-content: space-between; align-items: center; z-index: 100; border-top-left-radius: 20px; border-top-right-radius: 20px; }
.cart-info { display: flex; flex-direction: column; }
.cart-total { font-size: 1.25rem; font-weight: 800; color: var(--primary); }
.checkout-btn { background: var(--primary); color: #000; padding: 15px 30px; border: none; border-radius: 12px; font-weight: 700; font-size: 1.1rem; cursor: pointer; transition: transform 0.2s; }
.checkout-btn:active { transform: scale(0.95); }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); z-index: 200; justify-content: center; align-items: center; opacity: 0; transition: opacity 0.3s; padding: 20px; }
.modal-overlay.active { opacity: 1; }
.modal-content { background: var(--surface); width: 100%; max-width: 600px; max-height: 90vh; border-radius: 24px; border: 1px solid var(--border); padding: 25px 20px; overflow-y: auto; display: flex; flex-direction: column; transform: scale(0.9); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1); }
.modal-overlay.active .modal-content { transform: scale(1); }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.modal-header h2 { font-size: 1.3rem; font-weight: 700; color: var(--primary); }
.modal-close { font-size: 1.5rem; cursor: pointer; border: none; background: #1a1a1a; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--text); }
.customer-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.customer-form input[type="text"] { padding: 16px; border: 1px solid var(--border); border-radius: 12px; font-size: 1rem; background: #1a1a1a; color: var(--text); outline: none; transition: border-color 0.2s; }
.customer-form input[type="text"]:focus { border-color: var(--primary); background: #222; }
.payment-methods { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 25px; }
.payment-method-label { display: flex; align-items: center; justify-content: center; background: #1a1a1a; border: 1px solid var(--border); border-radius: 12px; padding: 15px; cursor: pointer; transition: all 0.2s; font-weight: 600; font-size: 1rem; }
.payment-method-label input { display: none; }
.payment-method-label input:checked + span { color: var(--primary); }
.payment-method-label:has(input:checked) { border-color: var(--primary); background: #222; }
.cart-item-list { display: flex; flex-direction: column; gap: 15px; margin-bottom: 25px; }
.cart-item { display: flex; justify-content: space-between; align-items: center; background: #1a1a1a; border: 1px solid var(--border); padding: 15px; border-radius: 12px; flex-wrap: wrap; gap: 10px; }
.cart-item-name { flex: 1; min-width: 120px; font-size: 1rem; font-weight: 500; line-height: 1.3; color: var(--text); }
.cart-item-controls { display: flex; align-items: center; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); overflow: hidden; }
.cart-item-controls button { border: none; background: transparent; width: 35px; height: 35px; font-size: 1.1rem; cursor: pointer; color: var(--primary); }
.cart-item-controls button:active { background: #333; }
.cart-item-controls input { width: 40px; text-align: center; font-weight: 600; font-size: 1rem; border: none; background: transparent; color: var(--text); outline: none; }
.cart-item-controls input[type="number"]::-webkit-inner-spin-button,
.cart-item-controls input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.cart-item-controls input[type="number"] { -moz-appearance: textfield; }
.cart-item-price { font-weight: 700; color: var(--primary); min-width: 80px; text-align: right; font-size: 1.1rem; }
.cart-item-delete { color: #ff4757; background: none; border: none; font-size: 1.3rem; cursor: pointer; padding: 5px; }
.confirm-btn { background: var(--accent); color: #000; width: 100%; padding: 18px; border: none; border-radius: 14px; font-size: 1.2rem; font-weight: 700; cursor: pointer; }
.report-table { width: 100%; border-collapse: collapse; margin-top: 15px; color: var(--text); }
.report-table th, .report-table td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; }
.report-table th { color: var(--primary); }
@media (min-width: 768px) {
.categories-container { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.products-container { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.product-img-wrapper { height: 140px; }
.cart-bar { max-width: 100%; left: 0; transform: none; border-radius: 0; }
}
@media (min-width: 1024px) {
.categories-container { grid-template-columns: repeat(6, 1fr); }
.products-container { grid-template-columns: repeat(6, 1fr); }
.product-img-wrapper { height: 120px; }
}
</style>
</head>
<body>
<div class="top-bar">
<div class="top-bar-left">
<img src="/logo.png?v={{ settings.logo_version }}" class="top-logo" alt="Логотип" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjMWExYTFhIi8+PC9zdmc+'">
<select class="employee-select" id="employeeSelect" onchange="saveEmployee()">
<option value="">Выберите сотрудника</option>
{% for emp in employees %}
<option value="{{ emp.id }}">{{ emp.name }}</option>
{% endfor %}
</select>
</div>
<button class="my-report-btn" onclick="openMyReport()"><i class="fas fa-chart-bar"></i> Мои продажи</button>
</div>
<div class="header">
<div style="display: flex; align-items: center;">
<i class="fas fa-arrow-left back-btn" id="backBtn" onclick="showCategories()"></i>
<h1 id="pageTitle">Меню (В заведении)</h1>
</div>
</div>
<div class="search-bar" id="searchBar">
<div class="search-container">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="Поиск блюд..." oninput="filterCategories()">
</div>
</div>
<div class="categories-container" id="categoriesContainer"></div>
<div class="products-container" id="productsContainer"></div>
<div class="cart-bar" id="cartBar">
<div class="cart-info">
<span style="font-size: 0.9rem; color: var(--text-muted); font-weight: 500;">Сумма заказа:</span>
<span class="cart-total"><span id="cartTotalSum">0</span> {{ currency_code }}</span>
</div>
<button class="checkout-btn" onclick="openCartModal()">Открыть корзину <i class="fas fa-shopping-cart" style="margin-left:5px;"></i></button>
</div>
<div class="modal-overlay" id="cartModal" onclick="if(event.target === this) closeCartModal()">
<div class="modal-content">
<div class="modal-header">
<h2>Ваш заказ</h2>
<button class="modal-close" onclick="closeCartModal()"><i class="fas fa-times"></i></button>
</div>
<div class="cart-item-list" id="cartItemList"></div>
<div class="customer-form" id="dineInFields">
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; color:var(--text); font-size:1.1rem; font-weight:bold;">
<input type="checkbox" id="isTakeaway" onchange="toggleTakeaway()" style="width:20px; height:20px;">
На вынос
</label>
<input type="text" id="tableNum" placeholder="Номер столика" required>
</div>
<h3 style="margin-bottom: 10px; color: var(--text); font-size: 1rem;">Способ оплаты</h3>
<div class="payment-methods">
<label class="payment-method-label">
<input type="radio" name="payment_method" value="cash" checked>
<span><i class="fas fa-money-bill-wave"></i> Наличка</span>
</label>
<label class="payment-method-label">
<input type="radio" name="payment_method" value="card">
<span><i class="fas fa-credit-card"></i> Карточка</span>
</label>
<label class="payment-method-label">
<input type="radio" name="payment_method" value="click">
<span><i class="fas fa-mobile-alt"></i> Click</span>
</label>
<label class="payment-method-label">
<input type="radio" name="payment_method" value="payme">
<span><i class="fas fa-wallet"></i> Payme</span>
</label>
<label class="payment-method-label">
<input type="radio" name="payment_method" value="paynet">
<span><i class="fas fa-wallet"></i> Paynet</span>
</label>
<label class="payment-method-label">
<input type="radio" name="payment_method" value="qr">
<span><i class="fas fa-qrcode"></i> QR</span>
</label>
</div>
<button class="confirm-btn" onclick="submitOrder()">Оформить и Распечатать</button>
</div>
</div>
<div class="modal-overlay" id="reportModal" onclick="if(event.target === this) closeMyReport()">
<div class="modal-content">
<div class="modal-header">
<h2>Мои продажи</h2>
<button class="modal-close" onclick="closeMyReport()"><i class="fas fa-times"></i></button>
</div>
<div style="display:flex; gap:10px; margin-bottom:15px; align-items:flex-end; color:var(--text);">
<div style="flex:1;">
<label style="font-size:0.8rem; color:var(--text-muted);">С</label>
<input type="date" id="myRepStart" style="width:100%; padding:8px; background:#1a1a1a; color:#fff; border:1px solid var(--border); border-radius:8px; outline:none;">
</div>
<div style="flex:1;">
<label style="font-size:0.8rem; color:var(--text-muted);">По</label>
<input type="date" id="myRepEnd" style="width:100%; padding:8px; background:#1a1a1a; color:#fff; border:1px solid var(--border); border-radius:8px; outline:none;">
</div>
<button class="btn btn-primary" onclick="openMyReport(true)" style="padding:8px 15px; background:var(--primary); color:#000; border:none; border-radius:8px; font-weight:bold; cursor:pointer;"><i class="fas fa-search"></i></button>
</div>
<div id="reportContent" style="color: var(--text);">Загрузка...</div>
</div>
</div>
<script>
const products = {{ products_json|safe }};
const categoriesList = {{ categories_json|safe }};
const currency = '{{ currency_code }}';
let cart = {};
let currentEmployeeId = localStorage.getItem('employeeId') || '';
function getLocalDateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function initDates() {
const ds = getLocalDateStr(new Date());
document.getElementById('myRepStart').value = ds;
document.getElementById('myRepEnd').value = ds;
}
function init() {
if(currentEmployeeId) {
const sel = document.getElementById('employeeSelect');
if([...sel.options].some(o => o.value === currentEmployeeId)) {
sel.value = currentEmployeeId;
} else {
currentEmployeeId = '';
localStorage.removeItem('employeeId');
}
}
initDates();
renderCategories();
updateCartUI();
}
function saveEmployee() {
const sel = document.getElementById('employeeSelect');
const targetVal = sel.value;
if(!targetVal) {
currentEmployeeId = '';
localStorage.removeItem('employeeId');
return;
}
const pin = prompt("Введите 4-значный PIN код сотрудника:");
if(pin) {
fetch('/api/verify_pin', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({employee_id: targetVal, pin: pin})
})
.then(r => r.json())
.then(res => {
if(res.success) {
currentEmployeeId = targetVal;
localStorage.setItem('employeeId', targetVal);
alert("Успешный вход!");
} else {
alert(res.error || "Неверный PIN!");
sel.value = currentEmployeeId;
}
})
.catch(() => {
alert("Ошибка сервера!");
sel.value = currentEmployeeId;
});
} else {
sel.value = currentEmployeeId;
}
}
function renderCategories() {
const container = document.getElementById('categoriesContainer');
const prodContainer = document.getElementById('productsContainer');
prodContainer.style.display = 'none';
container.style.display = 'grid';
document.getElementById('backBtn').style.display = 'none';
document.getElementById('pageTitle').innerText = 'Меню (В заведении)';
container.innerHTML = '';
categoriesList.forEach(cat => {
const catProducts = products.filter(p => p.category === cat.name);
const count = catProducts.length;
const div = document.createElement('div');
div.className = 'category-item';
div.onclick = () => showProducts(cat.name);
div.innerHTML = `
<div style="background: #1a1a1a; width: 60px; height: 60px; border-radius: 12px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; margin-bottom: 5px;">
<i class="${cat.icon}" style="font-size: 1.8rem; color: var(--primary);"></i>
</div>
<span class="name">${cat.name}</span>
<span class="count">${count} шт</span>
`;
container.appendChild(div);
});
}
function showCategories() {
document.getElementById('searchInput').value = '';
renderCategories();
}
function filterCategories() {
const query = document.getElementById('searchInput').value.toLowerCase();
if (!query) {
renderCategories();
return;
}
document.getElementById('categoriesContainer').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'grid';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = 'Поиск';
container.innerHTML = '';
const matchedProducts = products.filter(p =>
p.name.toLowerCase().includes(query) ||
p.category.toLowerCase().includes(query)
);
if(matchedProducts.length === 0) {
container.style.display = 'block';
container.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">Ничего не найдено</div>';
} else {
matchedProducts.forEach(p => renderProductCard(p, container));
}
}
function renderProductCard(p, container) {
const qty = cart[p.product_id] ? cart[p.product_id].quantity : 0;
const hasPhotos = p.photos && p.photos.length > 0;
const photoUrl = hasPhotos
? `https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/${p.photos[0]}`
: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjMWExYTFhIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiMzMzMzMzMiIGZvbnQtZmFtaWx5PSJzYW5zLXNlcmlmIiBmb250LXNpemU9IjE0Ij7QndC10YIg0YTQvtGC0L48L3RleHQ+PC9zdmc+';
const descHtml = p.description ? `<div class="product-desc">${p.description}</div>` : '';
const div = document.createElement('div');
div.className = 'product-card';
div.style.cursor = 'pointer';
div.onclick = (e) => {
if(!e.target.closest('.controls-wrapper')) {
updateCart(p.product_id, 1);
}
};
div.innerHTML = `
<div class="product-img-wrapper">
<img src="${photoUrl}" class="product-img">
</div>
<div class="product-info">
<div style="width: 100%;">
<div class="product-title">${p.name}</div>
${descHtml}
</div>
<div class="product-bottom">
<div class="product-price">${p.price} ${currency}</div>
<div class="controls-wrapper">
<div class="quantity-control">
<button onclick="updateCart('${p.product_id}', -1)"><i class="fas fa-minus"></i></button>
<input type="number" id="qty-${p.product_id}" value="${qty}" onchange="manualUpdateCart('${p.product_id}', this.value)">
<button onclick="updateCart('${p.product_id}', 1)"><i class="fas fa-plus"></i></button>
</div>
</div>
</div>
</div>
`;
container.appendChild(div);
}
function showProducts(category) {
document.getElementById('categoriesContainer').style.display = 'none';
const container = document.getElementById('productsContainer');
container.style.display = 'grid';
document.getElementById('backBtn').style.display = 'block';
document.getElementById('pageTitle').innerText = category;
container.innerHTML = '';
const catProducts = products.filter(p => p.category === category);
if(catProducts.length === 0) {
container.style.display = 'block';
container.innerHTML = '<div style="text-align:center; padding: 40px; color: var(--text-muted);">В этой категории пока нет блюд</div>';
} else {
catProducts.forEach(p => renderProductCard(p, container));
}
}
function updateCart(productId, change, exactValue = null) {
const product = products.find(p => p.product_id === productId);
if (!product) return;
if (!cart[productId]) {
cart[productId] = { ...product, quantity: 0 };
}
if (exactValue !== null) {
cart[productId].quantity = exactValue;
} else {
cart[productId].quantity += change;
}
if (cart[productId].quantity <= 0) {
delete cart[productId];
const qtyInput = document.getElementById(`qty-${productId}`);
if (qtyInput) qtyInput.value = 0;
} else {
const qtyInput = document.getElementById(`qty-${productId}`);
if (qtyInput) qtyInput.value = cart[productId].quantity;
}
updateCartUI();
}
function manualUpdateCart(productId, val) {
let num = parseInt(val);
if (isNaN(num) || num < 0) num = 0;
updateCart(productId, 0, num);
}
function updateCartUI() {
let total = 0;
for (let id in cart) {
total += cart[id].price * cart[id].quantity;
}
const cartBar = document.getElementById('cartBar');
if (total > 0) {
cartBar.style.display = 'flex';
document.getElementById('cartTotalSum').innerText = total;
} else {
cartBar.style.display = 'none';
closeCartModal();
}
if (document.getElementById('cartModal').classList.contains('active')) {
renderCartModalItems();
}
}
function renderCartModalItems() {
const list = document.getElementById('cartItemList');
list.innerHTML = '';
for (let id in cart) {
const item = cart[id];
list.innerHTML += `
<div class="cart-item">
<div class="cart-item-name">
${item.name}
<div style="font-size: 0.85rem; color: var(--primary); margin-top:4px;">${item.quantity} шт.</div>
</div>
<div style="display:flex; align-items:center; gap: 15px;">
<div class="cart-item-controls">
<button onclick="updateCart('${id}', -1)"><i class="fas fa-minus"></i></button>
<input type="number" value="${item.quantity}" onchange="manualUpdateCart('${id}', this.value)">
<button onclick="updateCart('${id}', 1)"><i class="fas fa-plus"></i></button>
</div>
<button class="cart-item-delete" onclick="updateCart('${id}', 0, 0)"><i class="fas fa-trash-alt"></i></button>
</div>
<div class="cart-item-price">${item.price * item.quantity} ${currency}</div>
</div>
`;
}
}
function toggleTakeaway() {
const isT = document.getElementById('isTakeaway').checked;
const tNum = document.getElementById('tableNum');
if (isT) {
tNum.style.display = 'none';
tNum.value = 'На вынос';
} else {
tNum.style.display = 'block';
tNum.value = '';
}
}
function openCartModal() {
renderCartModalItems();
const modal = document.getElementById('cartModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
}
function closeCartModal() {
const modal = document.getElementById('cartModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
function submitOrder() {
const empSelect = document.getElementById('employeeSelect');
if(!empSelect.value) {
alert('Пожалуйста, выберите сотрудника в верхней панели!');
closeCartModal();
return;
}
const cartArray = Object.values(cart);
if(cartArray.length === 0) return;
let table = document.getElementById('tableNum').value.trim();
if(!table) {
alert('Пожалуйста, укажите номер столика или отметьте "На вынос"');
return;
}
const isTakeaway = document.getElementById('isTakeaway').checked;
const orderType = isTakeaway ? 'takeaway' : 'dine_in';
const paymentMethod = document.querySelector('input[name="payment_method"]:checked').value;
const empId = empSelect.value;
const empName = empSelect.options[empSelect.selectedIndex].text;
const btn = document.querySelector('.confirm-btn');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Оформление...';
btn.disabled = true;
fetch('/create_order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cart: cartArray,
order_type: orderType,
table_number: table,
payment_method: paymentMethod,
employee_id: empId,
employee_name: empName
})
})
.then(r => r.json())
.then(data => {
if(data.order_id) {
cart = {};
window.location.href = `/order/${data.order_id}`;
}
})
.catch(() => {
btn.innerHTML = 'Оформить и Распечатать';
btn.disabled = false;
alert('Произошла ошибка. Попробуйте еще раз.');
});
}
function openMyReport(fetchData = false) {
const empSelect = document.getElementById('employeeSelect');
if(!empSelect.value) {
alert('Пожалуйста, выберите сотрудника в верхней панели!');
return;
}
if (!fetchData) {
const modal = document.getElementById('reportModal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('active'), 10);
}
document.getElementById('reportContent').innerHTML = '<i class="fas fa-spinner fa-spin"></i> Загрузка...';
const sDate = document.getElementById('myRepStart').value;
const eDate = document.getElementById('myRepEnd').value;
fetch(`/api/employee_report?employee_id=${empSelect.value}&start_date=${sDate}&end_date=${eDate}`)
.then(r => r.json())
.then(data => {
let html = `
<div style="font-size: 1.1rem; margin-bottom: 15px;">Сотрудник: <b>${empSelect.options[empSelect.selectedIndex].text}</b></div>
<div style="font-size: 1.2rem; margin-bottom: 15px; color: var(--primary);">Общая сумма: <b>${data.total_sum} ${currency}</b></div>
<div style="margin-bottom: 10px;">Количество заказов: ${data.order_count}</div>
<table class="report-table">
<tr><th>Способ оплаты</th><th>Сумма</th></tr>
<tr><td>Наличка</td><td>${data.by_payment.cash || 0} ${currency}</td></tr>
<tr><td>Карточка</td><td>${data.by_payment.card || 0} ${currency}</td></tr>
<tr><td>Click</td><td>${data.by_payment.click || 0} ${currency}</td></tr>
<tr><td>Payme</td><td>${data.by_payment.payme || 0} ${currency}</td></tr>
<tr><td>Paynet</td><td>${data.by_payment.paynet || 0} ${currency}</td></tr>
<tr><td>QR</td><td>${data.by_payment.qr || 0} ${currency}</td></tr>
</table>
`;
html += `
<h3 style="margin-top:20px; font-size:1rem; color:var(--primary);">Позиции</h3>
<table class="report-table" style="font-size:0.9rem;">
<tr><th>Название</th><th>Кол-во</th><th>Сумма</th></tr>
`;
const productsKeys = Object.keys(data.by_product || {}).sort((a,b) => data.by_product[b].qty - data.by_product[a].qty);
if (productsKeys.length === 0) {
html += `<tr><td colspan="3">Нет данных</td></tr>`;
} else {
productsKeys.forEach(k => {
html += `<tr><td>${k}</td><td>${data.by_product[k].qty}</td><td>${data.by_product[k].sum} ${currency}</td></tr>`;
});
}
html += '</table>';
html += `
<h3 style="margin-top:20px; font-size:1rem; color:var(--primary);">По дням</h3>
<table class="report-table" style="font-size:0.9rem;">
<tr><th>Дата</th><th>Сумма</th></tr>
`;
const dates = Object.keys(data.by_date).sort();
if (dates.length === 0) {
html += `<tr><td colspan="2">Нет данных</td></tr>`;
} else {
dates.forEach(d => {
html += `<tr><td>${d}</td><td>${data.by_date[d]} ${currency}</td></tr>`;
});
}
html += '</table>';
document.getElementById('reportContent').innerHTML = html;
});
}
function closeMyReport() {
const modal = document.getElementById('reportModal');
modal.classList.remove('active');
setTimeout(() => modal.style.display = 'none', 300);
}
init();
</script>
</body>
</html>
'''
ORDER_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Чек №{{ order.id }}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --bg: #f4f4f4; --surface: #ffffff; --text: #000000; --border: #000000; --print: #1a1a1a; }
* { box-sizing: border-box; font-family: "Courier New", Courier, monospace; }
body { margin: 0; padding: 20px; background: var(--bg); display: flex; flex-direction: column; align-items: center; color: var(--text); }
.receipt-ticket { background: var(--surface); width: 100%; max-width: 320px; padding: 20px 15px; border: 1px solid #ddd; box-shadow: 0 4px 10px rgba(0,0,0,0.1); border-radius: 4px; color: #000; }
.logo-area { text-align: center; margin-bottom: 10px; }
.logo-area img { max-width: 80px; max-height: 80px; object-fit: contain; filter: grayscale(100%) contrast(1.2); }
.cafe-name { text-align: center; font-size: 1.2rem; font-weight: bold; margin-bottom: 10px; text-transform: uppercase; }
.divider { border-top: 1px dashed var(--border); margin: 10px 0; }
.header-info { margin-bottom: 10px; font-size: 0.85rem; line-height: 1.4; }
.header-info div { display: flex; justify-content: space-between; }
.item-list { width: 100%; margin-bottom: 10px; }
.item-row { display: flex; flex-direction: column; margin-bottom: 8px; font-size: 0.85rem; }
.item-name { font-weight: bold; margin-bottom: 2px; }
.item-calc { display: flex; justify-content: space-between; padding-left: 5px; }
.totals-area { font-size: 0.95rem; }
.totals-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
.totals-row.grand-total { font-size: 1.1rem; font-weight: bold; margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border); }
.thank-you { text-align: center; margin-top: 20px; font-weight: bold; font-size: 0.9rem; }
.action-bar { position: fixed; bottom: 0; left: 0; width: 100%; background: #fff; border-top: 1px solid #ddd; padding: 15px; display: flex; gap: 15px; z-index: 100; justify-content: center; box-shadow: 0 -2px 10px rgba(0,0,0,0.1); }
.btn { padding: 15px 25px; border-radius: 8px; border: none; font-size: 1rem; font-weight: bold; cursor: pointer; color: #fff; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: sans-serif; text-transform: uppercase; }
.btn-print { background: var(--print); }
.btn-home { background: #555; }
.payment-info { font-weight: bold; text-align: right; text-transform: uppercase; font-size: 0.85rem; }
@media print {
body { background: #fff; padding: 0; margin: 0; align-items: flex-start; justify-content: flex-start; }
.receipt-ticket { box-shadow: none; border: none; padding: 0; max-width: 100%; width: 300px; }
.action-bar { display: none !important; }
}
</style>
</head>
<body onload="window.print()">
<div class="receipt-ticket">
<div class="logo-area">
<img src="/logo.png?v={{ settings.logo_version }}" alt="Logo" onerror="this.style.display='none'">
</div>
<div class="cafe-name">{{ settings.cafe_name }}</div>
<div class="divider"></div>
<div class="header-info">
<div><span>Чек:</span> <span>{{ order.id }}</span></div>
<div><span>Дата:</span> <span>{{ order.created_at }}</span></div>
<div><span>Столик:</span> <span>{{ order.table_number }}</span></div>
<div><span>Кассир:</span> <span>{{ order.employee_name|default('Не указан') }}</span></div>
</div>
<div class="divider"></div>
<div class="item-list">
{% set raw_total = 0 %}
{% for item in order.cart %}
{% set item_sum = item.price * item.quantity %}
{% set raw_total = raw_total + item_sum %}
<div class="item-row">
<div class="item-name">{{ item.name }}</div>
<div class="item-calc">
<div>{{ item.quantity }} x {{ item.price }}</div>
<div>{{ item_sum }}</div>
</div>
</div>
{% endfor %}
</div>
<div class="divider"></div>
<div class="totals-area">
<div class="totals-row">
<span>Итого:</span>
<span>{{ raw_total }}</span>
</div>
{% set discount = order.discount|default(0)|float %}
{% if discount > 0 %}
<div class="totals-row">
<span>Скидка:</span>
<span>-{{ discount }}</span>
</div>
{% endif %}
<div class="totals-row grand-total">
<span>К ОПЛАТЕ:</span>
<span>{{ order.total_price }} {{ currency_code }}</span>
</div>
<div class="totals-row" style="margin-top: 10px;">
<span>Оплата:</span>
<span class="payment-info">
{% if order.payment_method == 'cash' %}Наличка
{% elif order.payment_method == 'card' %}Карточка
{% elif order.payment_method == 'click' %}Click
{% elif order.payment_method == 'payme' %}Payme
{% elif order.payment_method == 'paynet' %}Paynet
{% elif order.payment_method == 'qr' %}QR
{% else %}{{ order.payment_method }}{% endif %}
</span>
</div>
</div>
<div class="divider"></div>
<div class="thank-you">
СПАСИБО ЗА ВАШ ВИЗИТ!
</div>
</div>
<div class="action-bar">
<a href="/" class="btn btn-home"><i class="fas fa-arrow-left"></i> Назад в меню</a>
<button class="btn btn-print" onclick="window.print()"><i class="fas fa-print"></i> Печать чека</button>
</div>
</body>
</html>
'''
REPORTS_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Отчеты по продажам</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --primary: #D4AF37; --bg: #000000; --surface: #111111; --border: #333333; --danger: #ff4757; --success: #2ed573; --info: #D4AF37; --warning: #ffa502; --text: #ffffff; --text-muted: #888888; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: var(--text); }
.container { max-width: 1000px; margin: 0 auto; }
.header-panel { background: var(--surface); padding: 20px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--primary); }
.btn { padding: 12px 20px; border: none; border-radius: 10px; font-weight: 700; cursor: pointer; color: #000; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; font-size: 0.95rem; transition: opacity 0.2s; }
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--info); }
.btn-dark { background: #1a1a1a; color: var(--primary); border: 1px solid var(--border); }
.report-section { background: var(--surface); padding: 20px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 20px; }
.report-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-top: 20px; }
.report-card { background: #1a1a1a; padding: 15px; border-radius: 12px; border: 1px solid var(--border); }
.report-card h3 { color: var(--primary); margin-top: 0; margin-bottom: 15px; font-size: 1rem; border-bottom: 1px dashed var(--border); padding-bottom: 10px; }
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 150px; }
input[type="date"] { width: 100%; padding: 12px 15px; border: 1px solid var(--border); border-radius: 10px; font-size: 0.95rem; outline: none; transition: border-color 0.2s; background: #1a1a1a; color: var(--text); }
input[type="date"]:focus { border-color: var(--primary); background: #222; }
label { display: block; margin-bottom: 5px; color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
.rep-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.rep-table th, .rep-table td { padding: 8px 5px; border-bottom: 1px solid var(--border); text-align: left; }
.rep-table th { color: var(--text-muted); font-weight: normal; }
@media (max-width: 600px) {
.header-panel { flex-direction: column; align-items: stretch; text-align: center; }
.form-row { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="header-panel">
<h1><i class="fas fa-chart-line"></i> Отчеты по продажам</h1>
<a href="/admin" class="btn btn-dark"><i class="fas fa-arrow-left"></i> Назад в админку</a>
</div>
<div class="report-section">
<div class="form-row" style="margin-bottom: 20px; align-items: flex-end;">
<div>
<label>Дата начала</label>
<input type="date" id="repStart">
</div>
<div>
<label>Дата конца</label>
<input type="date" id="repEnd">
</div>
<button class="btn btn-primary" style="height: 44px;" onclick="generateReports()"><i class="fas fa-filter"></i> Показать</button>
</div>
<div style="font-size: 1.2rem; margin-bottom: 10px;">Общая выручка: <span id="repTotalSum" style="color:var(--success); font-weight:bold;">0</span></div>
<div class="report-grid">
<div class="report-card">
<h3>По категориям</h3>
<table class="rep-table" id="tableCat"></table>
</div>
<div class="report-card">
<h3>По позициям</h3>
<table class="rep-table" id="tablePos"></table>
</div>
<div class="report-card">
<h3>По сотрудникам</h3>
<table class="rep-table" id="tableEmp"></table>
</div>
<div class="report-card">
<h3>По способам оплаты</h3>
<table class="rep-table" id="tablePay"></table>
</div>
</div>
</div>
</div>
<script>
const allOrders = {{ orders_json|safe }};
const currencyCode = '{{ currency_code }}';
function getLocalDateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function initReports() {
const todayStr = getLocalDateStr(new Date());
document.getElementById('repStart').value = todayStr;
document.getElementById('repEnd').value = todayStr;
generateReports();
}
function generateReports() {
const start = document.getElementById('repStart').value;
const end = document.getElementById('repEnd').value;
let filtered = allOrders;
if(start) {
filtered = filtered.filter(o => o.created_at.split(' ')[0] >= start);
}
if(end) {
filtered = filtered.filter(o => o.created_at.split(' ')[0] <= end);
}
let totalSum = 0;
let byCat = {};
let byPos = {};
let byEmp = {};
let byPay = {};
filtered.forEach(o => {
totalSum += o.total_price;
const emp = o.employee_name || 'Не указан';
if(!byEmp[emp]) byEmp[emp] = {sum: 0, qty: 0};
byEmp[emp].sum += o.total_price;
byEmp[emp].qty += 1;
let pType = o.payment_method;
if(pType === 'cash') pType = 'Наличка';
else if(pType === 'card') pType = 'Карточка';
else if(pType === 'click') pType = 'Click';
else if(pType === 'payme') pType = 'Payme';
else if(pType === 'paynet') pType = 'Paynet';
else if(pType === 'qr') pType = 'QR';
if(!byPay[pType]) byPay[pType] = {sum: 0, qty: 0};
byPay[pType].sum += o.total_price;
byPay[pType].qty += 1;
o.cart.forEach(item => {
const itemSum = item.price * item.quantity;
const cat = item.category || 'Без категории';
if(!byCat[cat]) byCat[cat] = {sum: 0, qty: 0};
byCat[cat].sum += itemSum;
byCat[cat].qty += parseInt(item.quantity);
if(!byPos[item.name]) byPos[item.name] = {sum: 0, qty: 0};
byPos[item.name].sum += itemSum;
byPos[item.name].qty += parseInt(item.quantity);
});
});
document.getElementById('repTotalSum').innerText = totalSum + ' ' + currencyCode;
renderRepTable('tableCat', byCat, 'Шт');
renderRepTable('tablePos', byPos, 'Шт');
renderRepTable('tableEmp', byEmp, 'Заказов');
renderRepTable('tablePay', byPay, 'Заказов');
}
function renderRepTable(id, dataObj, qtyLabel) {
const el = document.getElementById(id);
el.innerHTML = `<tr><th>Название</th><th>${qtyLabel}</th><th>Сумма</th></tr>`;
let sorted = Object.keys(dataObj).map(k => ({name: k, ...dataObj[k]})).sort((a,b)=>b.sum - a.sum);
if(sorted.length === 0) {
el.innerHTML += '<tr><td colspan="3">Нет данных</td></tr>';
} else {
sorted.forEach(item => {
el.innerHTML += `<tr><td>${item.name}</td><td>${item.qty}</td><td>${item.sum} ${currencyCode}</td></tr>`;
});
}
}
window.onload = () => {
initReports();
};
</script>
</body>
</html>
'''
SALARY_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Отчет по ЗП и Штрафам</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --primary: #D4AF37; --bg: #000000; --surface: #111111; --border: #333333; --danger: #ff4757; --success: #2ed573; --info: #D4AF37; --warning: #ffa502; --text: #ffffff; --text-muted: #888888; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: var(--text); }
.container { max-width: 1000px; margin: 0 auto; }
.header-panel { background: var(--surface); padding: 20px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--primary); }
.btn { padding: 12px 20px; border: none; border-radius: 10px; font-weight: 700; cursor: pointer; color: #000; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; font-size: 0.95rem; transition: opacity 0.2s; }
.btn:active { opacity: 0.8; }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); }
.btn-dark { background: #1a1a1a; color: var(--primary); border: 1px solid var(--border); }
.report-section { background: var(--surface); padding: 20px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 20px; }
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 150px; }
input[type="date"], input[type="number"], input[type="text"], select { width: 100%; padding: 12px 15px; border: 1px solid var(--border); border-radius: 10px; font-size: 0.95rem; outline: none; transition: border-color 0.2s; background: #1a1a1a; color: var(--text); }
input[type="date"]:focus, input[type="number"]:focus, input[type="text"]:focus, select:focus { border-color: var(--primary); background: #222; }
label { display: block; margin-bottom: 5px; color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
.rep-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.rep-table th, .rep-table td { padding: 8px 5px; border-bottom: 1px solid var(--border); text-align: left; }
.rep-table th { color: var(--text-muted); font-weight: normal; }
@media (max-width: 600px) {
.header-panel { flex-direction: column; align-items: stretch; text-align: center; }
.form-row { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="header-panel">
<h1><i class="fas fa-money-bill-wave"></i> Отчет по ЗП</h1>
<a href="/admin" class="btn btn-dark"><i class="fas fa-arrow-left"></i> Назад в админку</a>
</div>
<div class="report-section">
<h2 style="margin-top:0; color:var(--primary); font-size:1.2rem; margin-bottom: 15px;">Управление штрафами</h2>
<form method="POST" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<input type="hidden" name="action" value="add_fine">
<div style="flex:1; min-width:150px;">
<label>Сотрудник</label>
<select name="employee_id" required>
{% for emp in employees %}
<option value="{{ emp.id }}">{{ emp.name }}</option>
{% endfor %}
</select>
</div>
<div style="flex:1; min-width:120px;">
<label>Дата</label>
<input type="date" name="date" required id="fineDate">
</div>
<div style="flex:1; min-width:120px;">
<label>Сумма</label>
<input type="number" name="amount" required step="1" min="0">
</div>
<div style="flex:2; min-width:150px;">
<label>Причина</label>
<input type="text" name="reason" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="height:44px; color:#fff;"><i class="fas fa-plus"></i> Добавить штраф</button>
</form>
<h3 style="margin-top:25px; margin-bottom:10px; font-size:1.1rem; color:var(--primary);">История штрафов</h3>
<div style="overflow-x:auto;">
<table class="rep-table">
<tr><th>Сотрудник</th><th>Дата</th><th>Причина</th><th>Сумма</th><th>Действия</th></tr>
{% for fine in fines %}
<tr>
<td>
{% for emp in employees %}
{% if emp.id == fine.employee_id %}{{ emp.name }}{% endif %}
{% endfor %}
</td>
<td>{{ fine.date }}</td>
<td>{{ fine.reason }}</td>
<td style="color:var(--danger); font-weight:bold;">-{{ fine.amount }} {{ currency_code }}</td>
<td>
<form method="POST" style="margin:0;" onsubmit="return confirm('Удалить штраф?');">
<input type="hidden" name="action" value="delete_fine">
<input type="hidden" name="fine_id" value="{{ fine.id }}">
<button type="submit" class="btn btn-dark" style="padding:5px 10px; border-color:var(--danger); color:var(--danger);"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
{% if not fines %}
<tr><td colspan="5" style="text-align:center; padding:15px; color:var(--text-muted);">Нет штрафов</td></tr>
{% endif %}
</table>
</div>
</div>
<div class="report-section">
<h2 style="margin-top:0; color:var(--primary); font-size:1.2rem; margin-bottom: 15px;">Генерация Зарплаты</h2>
<div class="form-row" style="margin-bottom: 20px; align-items: flex-end;">
<div>
<label>Дата начала</label>
<input type="date" id="salStart">
</div>
<div>
<label>Дата конца</label>
<input type="date" id="salEnd">
</div>
<button class="btn btn-success" style="height: 44px; color:#000;" onclick="generateSalaryReport()"><i class="fas fa-calculator"></i> Посчитать ЗП</button>
</div>
<div style="overflow-x:auto;">
<table class="rep-table" id="tableSalary"></table>
</div>
</div>
</div>
<script>
const allOrders = {{ orders_json|safe }};
const workdays = {{ workdays_json|safe }};
const employees = {{ employees_json|safe }};
const fines = {{ fines_json|safe }};
const currencyCode = '{{ currency_code }}';
function getLocalDateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function initSalaryReport() {
const today = new Date();
const todayStr = getLocalDateStr(today);
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
document.getElementById('salStart').value = getLocalDateStr(firstDay);
document.getElementById('salEnd').value = todayStr;
const fineDateInput = document.getElementById('fineDate');
if(fineDateInput) { fineDateInput.value = todayStr; }
generateSalaryReport();
}
function generateSalaryReport() {
const start = document.getElementById('salStart').value;
const end = document.getElementById('salEnd').value;
if (!start || !end) return;
let html = '<tr><th>Сотрудник</th><th>Дней отработано</th><th>Фикс. ЗП</th><th>Бонусы с продаж</th><th>Штрафы</th><th>Итого ЗП</th></tr>';
let empSales = {};
allOrders.forEach(o => {
const oDate = o.created_at.split(' ')[0];
if (oDate >= start && oDate <= end) {
if (!empSales[o.employee_id]) empSales[o.employee_id] = {};
if (!empSales[o.employee_id][oDate]) empSales[o.employee_id][oDate] = 0;
empSales[o.employee_id][oDate] += o.total_price;
}
});
employees.forEach(emp => {
let daysWorked = 0;
let fixedSal = 0;
let bonusSal = 0;
let totalFines = 0;
let currDate = new Date(start);
const endDate = new Date(end);
while(currDate <= endDate) {
const dateStr = getLocalDateStr(currDate);
const dailySales = (empSales[emp.id] && empSales[emp.id][dateStr]) ? empSales[emp.id][dateStr] : 0;
if (dailySales > 0) {
daysWorked++;
fixedSal += parseFloat(emp.daily_rate || 0);
const target = parseFloat(emp.target_amount || 0);
const pct = parseFloat(emp.bonus_percentage || 0);
if (pct > 0 && dailySales > target) {
bonusSal += (dailySales - target) * (pct / 100);
}
}
currDate.setDate(currDate.getDate() + 1);
}
fines.forEach(f => {
if (f.employee_id === emp.id && f.date >= start && f.date <= end) {
totalFines += parseFloat(f.amount);
}
});
const total = fixedSal + bonusSal - totalFines;
if (daysWorked > 0 || total > 0 || totalFines > 0) {
html += `<tr>
<td>${emp.name}</td>
<td>${daysWorked}</td>
<td>${fixedSal.toFixed(2)} ${currencyCode}</td>
<td>${bonusSal.toFixed(2)} ${currencyCode}</td>
<td style="color:var(--danger);">-${totalFines.toFixed(2)} ${currencyCode}</td>
<td style="font-weight:bold; color:var(--success);">${total.toFixed(2)} ${currencyCode}</td>
</tr>`;
}
});
if (html === '<tr><th>Сотрудник</th><th>Дней отработано</th><th>Фикс. ЗП</th><th>Бонусы с продаж</th><th>Штрафы</th><th>Итого ЗП</th></tr>') {
html += '<tr><td colspan="6">За выбранный период данных нет</td></tr>';
}
document.getElementById('tableSalary').innerHTML = html;
}
window.onload = () => {
initSalaryReport();
};
</script>
</body>
</html>
'''
ADMIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Админ-панель</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root { --primary: #D4AF37; --bg: #000000; --surface: #111111; --border: #333333; --danger: #ff4757; --success: #2ed573; --info: #D4AF37; --warning: #ffa502; --text: #ffffff; --text-muted: #888888; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { background: var(--bg); padding: max(20px, env(safe-area-inset-top)) 15px calc(20px + env(safe-area-inset-bottom)); margin: 0; color: var(--text); }
.container { max-width: 1000px; margin: 0 auto; }
.header-panel { background: var(--surface); padding: 20px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; }
.header-panel h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--primary); }
.btn { padding: 12px 20px; border: none; border-radius: 10px; font-weight: 700; cursor: pointer; color: #000; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; font-size: 0.95rem; transition: opacity 0.2s; }
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--info); }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); padding: 8px 15px; font-size: 0.85rem; color: #fff; }
.btn-warning { background: var(--warning); padding: 8px 15px; font-size: 0.85rem; color: #000; }
.btn-dark { background: #1a1a1a; color: var(--primary); border: 1px solid var(--border); }
.sync-panel { display: flex; gap: 10px; margin-bottom: 25px; flex-wrap: wrap; }
.sync-panel form { flex: 1; min-width: 200px; }
.sync-panel button { width: 100%; }
.card { background: var(--surface); padding: 20px; border-radius: 16px; border: 1px solid var(--border); margin-bottom: 20px; }
.card h2 { margin-top: 0; margin-bottom: 15px; font-size: 1.2rem; color: var(--primary); }
input[type="text"], input[type="number"], input[type="date"], select, textarea { width: 100%; padding: 12px 15px; border: 1px solid var(--border); border-radius: 10px; font-size: 0.95rem; outline: none; transition: border-color 0.2s; background: #1a1a1a; color: var(--text); }
input[type="text"]:focus, input[type="number"]:focus, input[type="date"]:focus, textarea:focus { border-color: var(--primary); background: #222; }
textarea { resize: vertical; min-height: 80px; font-family: inherit; }
label { display: block; margin-bottom: 5px; color: var(--text-muted); font-size: 0.85rem; font-weight: 600; }
.search-bar-admin { position: relative; margin-bottom: 20px; }
.search-bar-admin i { position: absolute; left: 15px; top: 50%; transform: translateY(-50%); color: var(--text-muted); }
.search-bar-admin input { padding-left: 40px; background: var(--surface); border: 1px solid var(--border); }
.category-block { border: 1px solid var(--border); margin-bottom: 15px; border-radius: 12px; overflow: hidden; background: var(--surface); }
.category-header { background: #1a1a1a; padding: 15px 20px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.2s; }
.category-header:hover { background: #222; }
.category-content { padding: 0; display: none; }
.category-content.active { display: block; }
.product-item { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 10px; }
.product-item:last-child { border-bottom: none; }
.product-info { display: flex; align-items: center; gap: 15px; min-width: 250px; flex: 1; }
.product-img { width: 50px; height: 50px; object-fit: cover; border-radius: 8px; border: 1px solid #333; background: #1a1a1a; }
.product-details { display: flex; flex-direction: column; }
.product-name { font-weight: 600; font-size: 0.95rem; color: var(--text); }
.product-desc { font-size: 0.85rem; color: var(--text-muted); margin-top: 2px; }
.product-meta { font-size: 0.8rem; color: var(--primary); margin-top: 4px; }
.product-actions { display: flex; gap: 5px; }
.add-product-wrapper { display: none; }
.add-product-wrapper.active { display: block; }
.toggle-add-product { width: 100%; text-align: center; background: #1a1a1a; padding: 15px; cursor: pointer; color: var(--primary); font-weight: 600; transition: background 0.2s; border-bottom: 1px solid var(--border); }
.toggle-add-product:hover { background: #222; }
.add-product-form { background: var(--surface); padding: 20px; display: flex; flex-direction: column; gap: 15px; }
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 150px; }
.file-input-wrapper { position: relative; width: 100%; }
input[type="file"] { width: 100%; padding: 10px; border: 1px dashed var(--border); border-radius: 10px; background: #1a1a1a; color: var(--text); font-size: 0.9rem; }
.orders-table { width: 100%; border-collapse: collapse; min-width: 800px; text-align: left; }
.orders-table th { padding: 12px; background: #1a1a1a; border-bottom: 2px solid var(--border); color: var(--primary); font-size: 0.85rem; text-transform: uppercase; }
.orders-table td { padding: 12px; border-bottom: 1px solid var(--border); vertical-align: middle; color: var(--text); }
.orders-table tr:hover { background: #1a1a1a; }
@media (max-width: 600px) {
.header-panel { flex-direction: column; align-items: stretch; text-align: center; }
.product-item { flex-direction: column; align-items: stretch; }
.product-info { width: 100%; }
.product-actions { align-self: flex-end; }
.form-row { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<div class="header-panel">
<h1><i class="fas fa-crown"></i> Админ-панель</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap; justify-content: center;">
<a href="/admin/reports" class="btn btn-success" style="color:#000;"><i class="fas fa-chart-line"></i> Отчеты</a>
<a href="/admin/salary" class="btn btn-warning" style="color:#000;"><i class="fas fa-money-bill-wave"></i> Зарплата</a>
<a href="/" class="btn btn-primary"><i class="fas fa-store"></i> В заведение</a>
</div>
</div>
<div class="card" style="padding: 0;">
<div class="category-header" onclick="toggleCategory('admin-settings')" style="border-radius: 16px; border-bottom: none;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-admin-settings" style="color: var(--text-muted);"></i>
<span style="font-weight: 800; font-size: 1.2rem; color: var(--text);"><i class="fas fa-cogs" style="color:var(--info);"></i> Настройки</span>
</div>
</div>
<div class="category-content" id="admin-settings" style="padding: 20px; border-top: 1px solid var(--border);">
<form method="POST" enctype="multipart/form-data" class="add-product-form" style="padding: 0;" onsubmit="showLoading(this)">
<input type="hidden" name="action" value="update_settings">
<div>
<label>Название заведения</label>
<input type="text" name="cafe_name" value="{{ settings.cafe_name }}" required>
</div>
<div style="margin-top: 10px;">
<label>Логотип заведения (оставьте пустым, чтобы не менять)</label>
<input type="file" name="logo" accept="image/png, image/jpeg, image/jpg, image/webp" style="width: 100%; padding: 10px; border: 1px dashed var(--border); border-radius: 10px; background: #1a1a1a; color: var(--text);">
</div>
<button type="submit" class="btn btn-success" style="color:#000; justify-content:center; margin-top:10px;"><i class="fas fa-save"></i> Сохранить настройки</button>
</form>
</div>
</div>
<div class="sync-panel">
<form method="POST" action="/force_upload" onsubmit="showLoading(this)">
<button type="submit" class="btn btn-success" style="color:#000;"><i class="fas fa-cloud-upload-alt"></i> Сохранить на сервер</button>
</form>
<form method="POST" action="/force_download" onsubmit="showLoading(this)">
<button type="submit" class="btn btn-primary" style="color:#000;"><i class="fas fa-cloud-download-alt"></i> Скачать с сервера</button>
</form>
</div>
<div class="card" style="padding: 0;">
<div class="category-header" onclick="toggleCategory('admin-employees')" style="border-radius: 16px; border-bottom: none;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-admin-employees" style="color: var(--text-muted);"></i>
<span style="font-weight: 800; font-size: 1.2rem; color: var(--text);"><i class="fas fa-users" style="color:var(--info);"></i> Сотрудники</span>
</div>
</div>
<div class="category-content" id="admin-employees" style="padding: 20px; border-top: 1px solid var(--border);">
<div style="background:var(--surface); border:1px solid var(--border); padding:15px; border-radius:10px; margin-bottom:20px;">
<h3 style="margin-top:0; color:var(--primary);">Добавить сотрудника</h3>
<form method="POST" style="display:flex; flex-wrap:wrap; gap:10px;">
<input type="hidden" name="action" value="add_employee">
<div style="flex:1; min-width:150px;"><label>Имя</label><input type="text" name="employee_name" required autocomplete="off"></div>
<div style="flex:1; min-width:100px;"><label>PIN (4 цифры)</label><input type="text" name="pin" pattern="\\d{4}" required autocomplete="off"></div>
<div style="flex:1; min-width:100px;"><label>Ставка за день</label><input type="number" name="daily_rate" value="230000" required></div>
<div style="flex:1; min-width:100px;"><label>Цель (n сумма)</label><input type="number" name="target_amount" value="1500000" required></div>
<div style="flex:1; min-width:100px;"><label>Бонус (%)</label><input type="number" name="bonus_percentage" value="10" step="0.1" required></div>
<button type="submit" class="btn btn-dark" style="width:100%; margin-top:10px;"><i class="fas fa-plus"></i> Добавить</button>
</form>
</div>
<h3 style="margin-top:0; color:var(--primary);">Список сотрудников</h3>
<div style="display:flex; flex-direction:column; gap:15px;">
{% for emp in employees %}
<div style="background:#1a1a1a; padding:15px; border-radius:8px; border:1px solid var(--border);">
<form method="POST" style="display:flex; flex-wrap:wrap; gap:10px; align-items:flex-end;">
<input type="hidden" name="action" value="edit_employee">
<input type="hidden" name="employee_id" value="{{ emp.id }}">
<div style="flex:1; min-width:150px;"><label>Имя</label><input type="text" name="name" value="{{ emp.name }}" required></div>
<div style="flex:1; min-width:100px;"><label>PIN</label><input type="text" name="pin" value="{{ emp.pin }}" pattern="\\d{4}" required></div>
<div style="flex:1; min-width:100px;"><label>Ставка</label><input type="number" name="daily_rate" value="{{ emp.daily_rate }}" required></div>
<div style="flex:1; min-width:100px;"><label>Цель (n)</label><input type="number" name="target_amount" value="{{ emp.target_amount }}" required></div>
<div style="flex:1; min-width:100px;"><label>Бонус %</label><input type="number" name="bonus_percentage" value="{{ emp.bonus_percentage }}" step="0.1" required></div>
<button type="submit" class="btn btn-primary" title="Сохранить"><i class="fas fa-save"></i></button>
</form>
<form method="POST" style="margin-top:10px; text-align:right;" onsubmit="return confirm('Удалить сотрудника?');">
<input type="hidden" name="action" value="delete_employee">
<input type="hidden" name="employee_id" value="{{ emp.id }}">
<button type="submit" class="btn btn-danger"><i class="fas fa-trash"></i> Удалить</button>
</form>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card" style="padding: 0;">
<div class="category-header" onclick="toggleCategory('orders-history')" style="border-radius: 16px; border-bottom: none;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-orders-history" style="color: var(--text-muted);"></i>
<span style="font-weight: 800; font-size: 1.2rem; color: var(--text);"><i class="fas fa-history" style="color:var(--info);"></i> История заказов</span>
</div>
</div>
<div class="category-content" id="orders-history" style="padding: 0 20px 20px 20px; border-top: 1px solid var(--border);">
<div style="overflow-x: auto; padding-top: 15px;">
<table class="orders-table">
<tr>
<th>ID / Дата</th>
<th>Детали заказа</th>
<th>Сумма</th>
<th>Оплата</th>
<th>Действия</th>
</tr>
{% for order in orders.values()|sort(attribute='created_at', reverse=True) %}
<tr>
<td>
<a href="/order/{{ order.id }}" target="_blank" style="color:var(--info); font-weight:bold; text-decoration:none;">{{ order.id }}</a><br>
<span style="font-size:0.8rem; color:var(--text-muted);">{{ order.created_at }}</span>
</td>
<td style="font-size:0.9rem;">
Столик: {{ order.table_number }}<br>
Сотрудник: {{ order.employee_name|default('Не указан') }}
</td>
<td style="font-weight:600;">{{ order.total_price }} {{ currency_code }}</td>
<td>
<span style="text-transform:uppercase; font-size:0.8rem;">
{% if order.payment_method == 'cash' %}Наличка
{% elif order.payment_method == 'card' %}Карточка
{% elif order.payment_method == 'click' %}Click
{% elif order.payment_method == 'payme' %}Payme
{% elif order.payment_method == 'paynet' %}Paynet
{% elif order.payment_method == 'qr' %}QR
{% else %}{{ order.payment_method }}{% endif %}
</span>
</td>
<td>
<a href="/order/{{ order.id }}" class="btn btn-primary" style="padding:6px 10px;" target="_blank"><i class="fas fa-eye"></i></a>
<form method="POST" style="display:inline-block; margin:0;" onsubmit="return confirm('Удалить из истории?');">
<input type="hidden" name="action" value="delete_order">
<input type="hidden" name="order_id" value="{{ order.id }}">
<button type="submit" class="btn btn-danger" style="padding:6px 10px;"><i class="fas fa-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
<div class="card">
<h2>Управление категориями</h2>
<form method="POST" style="display:flex; gap:10px; flex-wrap:wrap;">
<input type="hidden" name="action" value="add_category">
<input type="text" name="category_name" placeholder="Название новой категории" required autocomplete="off" style="flex:1; min-width:200px;">
<input type="text" name="category_icon" placeholder="Класс иконки (напр. fas fa-pizza-slice)" value="fas fa-utensils" required autocomplete="off" style="flex:1; min-width:200px;">
<button type="submit" class="btn btn-dark"><i class="fas fa-plus"></i> Добавить</button>
</form>
</div>
<div class="search-bar-admin">
<i class="fas fa-search"></i>
<input type="text" id="adminSearch" placeholder="Поиск по категориям и блюдам..." oninput="filterAdmin()">
</div>
{% for category in categories %}
<div class="category-block">
<div class="category-header" onclick="toggleCategory('cat-{{ loop.index }}')">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-down" id="icon-cat-{{ loop.index }}" style="color: var(--text-muted);"></i>
<span class="cat-title-text"><i class="{{ category.icon }}" style="color:var(--info); margin-right:5px;"></i> {{ category.name }}</span>
</div>
<form method="POST" style="margin:0;" onclick="event.stopPropagation();" onsubmit="return confirm('Удалить категорию и все ее блюда?');">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_name" value="{{ category.name }}">
<button type="submit" class="btn btn-danger"><i class="fas fa-trash-alt"></i></button>
</form>
</div>
<div class="category-content" id="cat-{{ loop.index }}">
<div style="padding: 15px; border-bottom: 1px solid var(--border);">
<form method="POST" style="display: flex; gap: 10px; flex-wrap: wrap;">
<input type="hidden" name="action" value="edit_category">
<input type="hidden" name="old_name" value="{{ category.name }}">
<input type="text" name="new_name" value="{{ category.name }}" required style="flex: 1; min-width: 150px;">
<input type="text" name="new_icon" value="{{ category.icon }}" required style="flex: 1; min-width: 150px;">
<button type="submit" class="btn btn-primary" style="white-space: nowrap;"><i class="fas fa-save"></i> Обновить</button>
</form>
</div>
<div class="toggle-add-product" onclick="toggleAddProduct('add-prod-{{ loop.index }}')">
<i class="fas fa-plus"></i> Добавить блюдо
</div>
<div class="add-product-wrapper" id="add-prod-{{ loop.index }}">
<form class="add-product-form" method="POST" enctype="multipart/form-data" onsubmit="showLoading(this)">
<input type="hidden" name="action" value="add_product">
<input type="hidden" name="category" value="{{ category.name }}">
<div class="form-row">
<input type="text" name="name" placeholder="Название блюда" required autocomplete="off" style="flex:2;">
<input type="number" name="price" placeholder="Цена" required step="0.01" style="flex:1;">
</div>
<textarea name="description" placeholder="Описание блюда (необязательно)"></textarea>
<div class="file-input-wrapper">
<input type="file" name="photos" accept="image/*" multiple max="10">
</div>
<button type="submit" class="btn btn-success" style="width: 100%; justify-content: center; color:#000;"><i class="fas fa-check"></i> Сохранить блюдо</button>
</form>
</div>
{% for product in products %}
{% if product.category == category.name %}
<div class="product-item">
<div class="product-info">
{% if product.photos and product.photos|length > 0 %}
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product.photos[0] }}" class="product-img">
{% else %}
<div class="product-img" style="display:flex;align-items:center;justify-content:center;color:#333;"><i class="fas fa-image"></i></div>
{% endif %}
<div class="product-details">
<span class="product-name">{{ product.name }}</span>
<span class="product-meta">{{ product.price }} {{ currency_code }}</span>
</div>
</div>
<div class="product-actions">
<button class="btn btn-warning" onclick="toggleEditProduct('edit-prod-{{ product.product_id }}')"><i class="fas fa-edit"></i></button>
<form method="POST" style="margin:0;" onsubmit="return confirm('Удалить блюдо?');">
<input type="hidden" name="action" value="delete_product">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<button type="submit" class="btn btn-danger"><i class="fas fa-times"></i></button>
</form>
</div>
<div class="add-product-wrapper" id="edit-prod-{{ product.product_id }}" style="width: 100%; margin-top: 15px; border-top: 1px dashed var(--border); padding-top: 15px;">
<form class="add-product-form" method="POST" enctype="multipart/form-data" onsubmit="showLoading(this)" style="padding: 0;">
<input type="hidden" name="action" value="edit_product">
<input type="hidden" name="product_id" value="{{ product.product_id }}">
<input type="hidden" name="category" value="{{ category.name }}">
<div class="form-row">
<input type="text" name="name" value="{{ product.name }}" required autocomplete="off" style="flex:2;">
<input type="number" name="price" value="{{ product.price }}" required step="0.01" style="flex:1;">
</div>
<textarea name="description">{{ product.description }}</textarea>
<div class="file-input-wrapper">
<input type="file" name="photos" accept="image/*" multiple max="10">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;"><i class="fas fa-save"></i> Сохранить изменения</button>
</form>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<script>
function showLoading(form) {
const btn = form.querySelector('button[type="submit"]');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Загрузка...';
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.7';
}
function toggleCategory(id) {
const content = document.getElementById(id);
const icon = document.getElementById('icon-' + id);
if(content.classList.contains('active')) {
content.classList.remove('active');
if(icon) { icon.classList.remove('fa-chevron-up'); icon.classList.add('fa-chevron-down'); }
} else {
content.classList.add('active');
if(icon) { icon.classList.remove('fa-chevron-down'); icon.classList.add('fa-chevron-up'); }
}
}
function toggleAddProduct(id) {
const form = document.getElementById(id);
form.classList.toggle('active');
}
function toggleEditProduct(id) {
const form = document.getElementById(id);
form.classList.toggle('active');
}
function filterAdmin() {
const query = document.getElementById('adminSearch').value.toLowerCase();
const categories = document.querySelectorAll('.category-block');
categories.forEach(cat => {
const catNameEl = cat.querySelector('.cat-title-text');
if(!catNameEl) return;
const catName = catNameEl.innerText.toLowerCase();
const products = cat.querySelectorAll('.product-item');
let catMatch = catName.includes(query);
let hasVisibleProduct = false;
products.forEach(prod => {
const prodName = prod.querySelector('.product-name').innerText.toLowerCase();
if (prodName.includes(query) || catMatch) {
prod.style.display = 'flex';
hasVisibleProduct = true;
} else {
prod.style.display = 'none';
}
});
if (catMatch || hasVisibleProduct) {
cat.style.display = 'block';
if (query && hasVisibleProduct) {
cat.querySelector('.category-content').classList.add('active');
const icon = cat.querySelector('.fas.fa-chevron-down, .fas.fa-chevron-up');
if(icon) icon.className = 'fas fa-chevron-up';
}
} else {
cat.style.display = 'none';
}
if (!query) {
cat.querySelector('.category-content').classList.remove('active');
const icon = cat.querySelector('.fas.fa-chevron-up, .fas.fa-chevron-down');
if(icon) icon.className = 'fas fa-chevron-down';
}
});
}
</script>
</body>
</html>
'''
@app.route('/logo.png')
def serve_logo():
return send_from_directory('.', 'logo.png')
@app.route('/')
def catalog():
data = load_data()
all_products = data.get('products', [])
categories = data.get('categories', [])
employees = data.get('employees', [])
settings = data.get('settings', {})
return render_template_string(
CATALOG_TEMPLATE,
products_json=json.dumps(all_products),
categories_json=json.dumps(categories),
employees=employees,
repo_id=REPO_ID,
currency_code=CURRENCY_CODE,
settings=settings
)
@app.route('/create_order', methods=['POST'])
def create_order():
order_data = request.get_json()
if not order_data or 'cart' not in order_data:
return jsonify({"error": "Bad request"}), 400
data = load_data()
cart_items = order_data['cart']
total_price = sum(float(item['price']) * int(item['quantity']) for item in cart_items)
order_type = order_data.get('order_type', 'dine_in')
table_number = order_data.get('table_number', 'Не указано')
payment_method = order_data.get('payment_method', 'cash')
employee_id = order_data.get('employee_id', '')
employee_name = order_data.get('employee_name', 'Не указан')
processed_cart = []
for item in cart_items:
cat_name = "Без категории"
for p in data.get('products', []):
if p.get('product_id') == item.get('product_id'):
cat_name = p.get('category', 'Без категории')
break
processed_cart.append({
"product_id": item.get('product_id'),
"name": item['name'],
"price": float(item['price']),
"quantity": int(item['quantity']),
"category": cat_name
})
order_id = f"HK-{datetime.now(tashkent_tz).strftime('%Y%m%d')}-{str(len(data.get('orders', {}))+1).zfill(3)}"
new_order = {
"id": order_id,
"created_at": get_current_time(),
"cart": processed_cart,
"discount": 0,
"total_price": total_price,
"order_type": order_type,
"table_number": table_number,
"payment_method": payment_method,
"employee_id": employee_id,
"employee_name": employee_name,
"status": "confirmed"
}
data['orders'][order_id] = new_order
save_data(data)
return jsonify({"order_id": order_id}), 201
@app.route('/order/<order_id>')
def view_order(order_id):
data = load_data()
order = data.get('orders', {}).get(order_id)
settings = data.get('settings', {})
if not order:
return "Order not found", 404
return render_template_string(
ORDER_TEMPLATE,
order=order,
currency_code=CURRENCY_CODE,
settings=settings
)
@app.route('/api/verify_pin', methods=['POST'])
def verify_pin():
req = request.get_json()
emp_id = req.get('employee_id')
pin = req.get('pin')
data = load_data()
for emp in data.get('employees', []):
if emp['id'] == emp_id:
if emp.get('pin', '0000') == pin:
today_str = get_current_date()
workdays = data.get('workdays', {})
if today_str not in workdays:
workdays[today_str] = []
if emp_id not in workdays[today_str]:
workdays[today_str].append(emp_id)
data['workdays'] = workdays
save_data(data)
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": "Неверный пин-код"})
return jsonify({"success": False, "error": "Сотрудник не найден"})
@app.route('/api/employee_report')
def employee_report():
emp_id = request.args.get('employee_id')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
data = load_data()
today_str = get_current_date()
if not start_date: start_date = today_str
if not end_date: end_date = today_str
total_sum = 0
order_count = 0
by_payment = {}
by_date = {}
by_product = {}
for o in data.get('orders', {}).values():
o_date = o.get('created_at', '')[:10]
if o.get('employee_id') == emp_id and start_date <= o_date <= end_date:
total_sum += o.get('total_price', 0)
order_count += 1
pm = o.get('payment_method', 'cash')
by_payment[pm] = by_payment.get(pm, 0) + o.get('total_price', 0)
by_date[o_date] = by_date.get(o_date, 0) + o.get('total_price', 0)
for item in o.get('cart', []):
p_name = item.get('name', 'Неизвестно')
p_qty = int(item.get('quantity', 0))
p_price = float(item.get('price', 0))
if p_name not in by_product:
by_product[p_name] = {'qty': 0, 'sum': 0}
by_product[p_name]['qty'] += p_qty
by_product[p_name]['sum'] += (p_qty * p_price)
return jsonify({
"total_sum": total_sum,
"order_count": order_count,
"by_payment": by_payment,
"by_date": by_date,
"by_product": by_product
})
@app.route('/admin/reports')
def admin_reports():
data = load_data()
orders = data.get('orders', {})
return render_template_string(
REPORTS_TEMPLATE,
orders_json=json.dumps(list(orders.values())),
currency_code=CURRENCY_CODE
)
@app.route('/admin/salary', methods=['GET', 'POST'])
def admin_salary():
data = load_data()
if request.method == 'POST':
action = request.form.get('action')
if action == 'add_fine':
emp_id = request.form.get('employee_id', '')
date_fine = request.form.get('date', '')
try:
amount = float(request.form.get('amount', 0))
except (ValueError, TypeError):
amount = 0.0
reason = request.form.get('reason', '')
if 'fines' not in data:
data['fines'] = []
data['fines'].append({
'id': uuid4().hex,
'employee_id': emp_id,
'date': date_fine,
'amount': amount,
'reason': reason
})
save_data(data)
return redirect(url_for('admin_salary'))
elif action == 'delete_fine':
fine_id = request.form.get('fine_id')
data['fines'] = [f for f in data.get('fines', []) if f.get('id') != fine_id]
save_data(data)
return redirect(url_for('admin_salary'))
orders = data.get('orders', {})
employees = data.get('employees', [])
workdays = data.get('workdays', {})
fines = data.get('fines', [])
fines = sorted(fines, key=lambda x: x.get('date', ''), reverse=True)
return render_template_string(
SALARY_TEMPLATE,
orders_json=json.dumps(list(orders.values())),
employees_json=json.dumps(employees),
workdays_json=json.dumps(workdays),
fines_json=json.dumps(fines),
fines=fines,
employees=employees,
currency_code=CURRENCY_CODE
)
@app.route('/admin', methods=['GET', 'POST'])
def admin():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
orders = data.get('orders', {})
employees = data.get('employees', [])
settings = data.get('settings', {})
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_settings':
settings['cafe_name'] = request.form.get('cafe_name', '').strip()
logo_file = request.files.get('logo')
if logo_file and logo_file.filename:
logo_file.save('logo.png')
settings['logo_version'] = str(uuid4().hex)[:8]
upload_db_to_hf(specific_file='logo.png')
data['settings'] = settings
save_data(data)
elif action == 'add_employee':
emp_name = request.form.get('employee_name', '').strip()
pin = request.form.get('pin', '0000').strip()
try: daily_rate = float(request.form.get('daily_rate', 0))
except: daily_rate = 230000
try: target_amount = float(request.form.get('target_amount', 0))
except: target_amount = 1500000
try: bonus_percentage = float(request.form.get('bonus_percentage', 0))
except: bonus_percentage = 10
if emp_name:
employees.append({
'id': uuid4().hex,
'name': emp_name,
'pin': pin,
'daily_rate': daily_rate,
'target_amount': target_amount,
'bonus_percentage': bonus_percentage
})
data['employees'] = employees
save_data(data)
elif action == 'edit_employee':
emp_id = request.form.get('employee_id')
for e in employees:
if e.get('id') == emp_id:
e['name'] = request.form.get('name', '').strip()
e['pin'] = request.form.get('pin', '0000').strip()
try: e['daily_rate'] = float(request.form.get('daily_rate', 0))
except: e['daily_rate'] = 230000
try: e['target_amount'] = float(request.form.get('target_amount', 0))
except: e['target_amount'] = 1500000
try: e['bonus_percentage'] = float(request.form.get('bonus_percentage', 0))
except: e['bonus_percentage'] = 10
break
data['employees'] = employees
save_data(data)
elif action == 'delete_employee':
emp_id = request.form.get('employee_id')
data['employees'] = [e for e in employees if e.get('id') != emp_id]
save_data(data)
elif action == 'delete_order':
order_id = request.form.get('order_id')
if order_id in orders:
del orders[order_id]
data['orders'] = orders
save_data(data)
elif action == 'add_category':
cat_name = request.form.get('category_name', '').strip()
cat_icon = request.form.get('category_icon', 'fas fa-utensils').strip()
if cat_name and not any(c.get('name') == cat_name for c in categories):
categories.append({'name': cat_name, 'icon': cat_icon})
data['categories'] = categories
save_data(data)
elif action == 'edit_category':
old_name = request.form.get('old_name')
new_name = request.form.get('new_name', '').strip()
new_icon = request.form.get('new_icon', 'fas fa-utensils').strip()
if new_name:
for c in categories:
if c.get('name') == old_name:
c['name'] = new_name
c['icon'] = new_icon
break
for p in products:
if p.get('category') == old_name:
p['category'] = new_name
data['categories'] = categories
data['products'] = products
save_data(data)
elif action == 'delete_category':
cat_name = request.form.get('category_name')
data['categories'] = [c for c in categories if c.get('name') != cat_name]
data['products'] = [p for p in products if p.get('category') != cat_name]
save_data(data)
elif action == 'add_product':
name = request.form.get('name', '').strip()
price = float(request.form.get('price', 0))
description = request.form.get('description', '').strip()
category = request.form.get('category')
uploaded_photos = request.files.getlist('photos')[:10]
photos_list = []
if uploaded_photos and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in uploaded_photos:
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
continue
photo_filename = f"{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
try:
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
photos_list.append(photo_filename)
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
new_product = {
'product_id': uuid4().hex,
'name': name,
'price': price,
'description': description,
'category': category,
'photos': photos_list
}
products.append(new_product)
data['products'] = products
save_data(data)
elif action == 'edit_product':
pid = request.form.get('product_id')
name = request.form.get('name', '').strip()
price = float(request.form.get('price', 0))
description = request.form.get('description', '').strip()
uploaded_photos = request.files.getlist('photos')[:10]
photos_list = []
if uploaded_photos and uploaded_photos[0].filename and HF_TOKEN_WRITE:
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in uploaded_photos:
if photo and photo.filename:
ext = os.path.splitext(photo.filename)[1].lower()
if ext not in ['.jpg', '.jpeg', '.png', '.webp', '.gif']:
continue
photo_filename = f"{uuid4().hex}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
try:
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=f"photos/{photo_filename}",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE
)
photos_list.append(photo_filename)
except Exception:
pass
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
for p in products:
if p.get('product_id') == pid:
p['name'] = name
p['price'] = price
p['description'] = description
if photos_list:
p['photos'] = photos_list
break
data['products'] = products
save_data(data)
elif action == 'delete_product':
pid = request.form.get('product_id')
data['products'] = [p for p in products if p.get('product_id') != pid]
save_data(data)
return redirect(url_for('admin'))
return render_template_string(
ADMIN_TEMPLATE,
products=products,
categories=categories,
orders=orders,
employees=employees,
repo_id=REPO_ID,
currency_code=CURRENCY_CODE,
settings=settings
)
@app.route('/force_upload', methods=['POST'])
def force_upload():
upload_db_to_hf()
return redirect(url_for('admin'))
@app.route('/force_download', methods=['POST'])
def force_download():
download_db_from_hf()
return redirect(url_for('admin'))
if __name__ == '__main__':
download_db_from_hf()
load_data()
if HF_TOKEN_WRITE:
threading.Thread(target=periodic_backup, daemon=True).start()
port = int(os.environ.get('PORT', 7860))
app.run(host='0.0.0.0', port=port)