diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -7,13 +7,12 @@ import hashlib import json from urllib.parse import unquote, parse_qs, quote import time -from datetime import datetime +from datetime import datetime, timezone, timedelta import logging import threading import random from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError -import pytz # Added for timezone support BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4") HOST = '0.0.0.0' @@ -30,26 +29,23 @@ logging.basicConfig(level=logging.INFO) app.secret_key = os.urandom(24) _data_lock = threading.Lock() -visitor_data_cache = {} # Will hold {"users": {}, "organization_info": {}} +visitor_data_cache = {} -BISHKEK_TZ = pytz.timezone('Asia/Bishkek') +BISHKEK_TZ = timezone(timedelta(hours=6)) -DEFAULT_ORGANIZATION_INFO = { +DEFAULT_ORGANIZATION_DETAILS = { "name": "Название вашей организации", - "phone_numbers": ["+996 (XXX) XX-XX-XX"], - "address": "г. Бишкек, ул. Примерная, д. 1", - "links": [{"label": "Веб-сайт", "url": "https://example.com"}] + "phones": [], + "address": "Ваш адрес", + "links": [] } -def generate_unique_id(users_data): +def generate_unique_id(existing_ids_dict): while True: new_id = str(random.randint(10000, 99999)) - if new_id not in users_data: + if new_id not in existing_ids_dict: return new_id -def generate_invoice_id(): - return f"INV-{int(time.time() * 1000)}-{random.randint(100,999)}" - def download_data_from_hf(): global visitor_data_cache if not HF_TOKEN_READ: @@ -72,18 +68,13 @@ def download_data_from_hf(): try: with open(DATA_FILE, 'r', encoding='utf-8') as f: loaded_data = json.load(f) - if not isinstance(loaded_data, dict): # Basic check for structure - raise json.JSONDecodeError("Root is not a dictionary", "", 0) visitor_data_cache = loaded_data + if '_organization_details' not in visitor_data_cache: + visitor_data_cache['_organization_details'] = DEFAULT_ORGANIZATION_DETAILS.copy() logging.info("Successfully loaded downloaded data into cache.") except (FileNotFoundError, json.JSONDecodeError) as e: - logging.error(f"Error reading downloaded data file: {e}. Initializing with default structure.") - visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} - # Ensure essential keys exist - if 'users' not in visitor_data_cache: - visitor_data_cache['users'] = {} - if 'organization_info' not in visitor_data_cache: - visitor_data_cache['organization_info'] = DEFAULT_ORGANIZATION_INFO.copy() + logging.error(f"Error reading downloaded data file: {e}. Initializing cache.") + visitor_data_cache = {'_organization_details': DEFAULT_ORGANIZATION_DETAILS.copy()} return True except RepositoryNotFoundError: logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download data.") @@ -94,41 +85,36 @@ def download_data_from_hf(): def load_visitor_data(): global visitor_data_cache with _data_lock: - if not visitor_data_cache or 'users' not in visitor_data_cache: # Ensure it's loaded and has basic structure + if not visitor_data_cache: try: with open(DATA_FILE, 'r', encoding='utf-8') as f: - loaded_data = json.load(f) - if not isinstance(loaded_data, dict): - raise json.JSONDecodeError("Root is not a dictionary", "", 0) - visitor_data_cache = loaded_data + visitor_data_cache = json.load(f) + if '_organization_details' not in visitor_data_cache: + visitor_data_cache['_organization_details'] = DEFAULT_ORGANIZATION_DETAILS.copy() logging.info("Visitor data loaded from local JSON.") except FileNotFoundError: - logging.warning(f"{DATA_FILE} not found locally. Initializing with default structure.") - visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} + logging.warning(f"{DATA_FILE} not found locally. Starting with empty data.") + visitor_data_cache = {'_organization_details': DEFAULT_ORGANIZATION_DETAILS.copy()} except json.JSONDecodeError: - logging.error(f"Error decoding {DATA_FILE}. Initializing with default structure.") - visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} + logging.error(f"Error decoding {DATA_FILE}. Starting with empty data.") + visitor_data_cache = {'_organization_details': DEFAULT_ORGANIZATION_DETAILS.copy()} except Exception as e: logging.error(f"Unexpected error loading visitor data: {e}") - visitor_data_cache = {"users": {}, "organization_info": DEFAULT_ORGANIZATION_INFO.copy()} - - # Ensure essential keys exist after loading or initializing - if 'users' not in visitor_data_cache: - visitor_data_cache['users'] = {} - if 'organization_info' not in visitor_data_cache: - visitor_data_cache['organization_info'] = DEFAULT_ORGANIZATION_INFO.copy() - + visitor_data_cache = {'_organization_details': DEFAULT_ORGANIZATION_DETAILS.copy()} return visitor_data_cache -def save_current_data(): +def save_data_locally_and_upload_async(data_to_update=None): with _data_lock: + if data_to_update: + for key, value in data_to_update.items(): + visitor_data_cache[key] = value try: with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4) logging.info(f"Data successfully saved to {DATA_FILE}.") upload_data_to_hf_async() except Exception as e: - logging.error(f"Error saving current data: {e}") + logging.error(f"Error saving data: {e}") def upload_data_to_hf(): if not HF_TOKEN_WRITE: @@ -140,26 +126,21 @@ def upload_data_to_hf(): try: api = HfApi() - # No need for separate lock here if called from save_current_data which holds the lock, - # but if called directly (e.g. periodic backup), it's fine. - # However, to be safe and consistent, let's assume it should always grab the lock if it might write. - # For an async upload, it's better if it reads the file path rather than shared memory. - # The current design copies the file, so it's okay. - - file_content_exists = os.path.getsize(DATA_FILE) > 0 - if not file_content_exists: - logging.warning(f"{DATA_FILE} is empty. Skipping upload.") - return - - logging.info(f"Attempting to upload {DATA_FILE} to {REPO_ID}/{HF_DATA_FILE_PATH}...") - api.upload_file( - path_or_fileobj=DATA_FILE, - path_in_repo=HF_DATA_FILE_PATH, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Update bonus data {datetime.now(BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S %Z')}" - ) + with _data_lock: + file_content_exists = os.path.getsize(DATA_FILE) > 0 + if not file_content_exists: + logging.warning(f"{DATA_FILE} is empty. Skipping upload.") + return + + logging.info(f"Attempting to upload {DATA_FILE} to {REPO_ID}/{HF_DATA_FILE_PATH}...") + api.upload_file( + path_or_fileobj=DATA_FILE, + path_in_repo=HF_DATA_FILE_PATH, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Update bonus data {datetime.now(BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S')}" + ) logging.info("Bonus data successfully uploaded to Hugging Face.") except Exception as e: logging.error(f"Error uploading data to Hugging Face: {e}") @@ -175,9 +156,7 @@ def periodic_backup(): while True: time.sleep(3600) logging.info("Initiating periodic backup...") - # This will save the current state from memory to file, then upload - save_current_data() - + upload_data_to_hf() def verify_telegram_data(init_data_str): try: @@ -196,12 +175,10 @@ def verify_telegram_data(init_data_str): calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() if calculated_hash == received_hash: - auth_date_ts = int(parsed_data.get('auth_date', [0])[0]) - current_time_ts = int(time.time()) - if current_time_ts - auth_date_ts > 86400: # 24 hours - auth_date_str = datetime.fromtimestamp(auth_date_ts, BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S %Z') - current_time_str = datetime.fromtimestamp(current_time_ts, BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S %Z') - logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date_str}, Current: {current_time_str}).") + auth_date = int(parsed_data.get('auth_date', [0])[0]) + current_time = int(time.time()) + if current_time - auth_date > 86400: # 24 hours + logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}).") return parsed_data, True else: logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}") @@ -250,25 +227,27 @@ TEMPLATE = """ visibility: hidden; min-height: 100vh; } - .container { - max-width: 600px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--padding-m); - } + .container { max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--padding-m); } .header { text-align: left; padding: var(--padding-m) 0; } .logo { font-size: 2.5em; font-weight: 800; color: var(--text-color); letter-spacing: -1px; } .logo span { color: var(--brand-yellow); } .welcome-text { font-size: 1em; color: var(--text-secondary-color); margin-top: 4px; } + + .tabs { display: flex; gap: 8px; margin-bottom: var(--padding-m); border-bottom: 1px solid var(--card-bg); } + .tab-button { + padding: 10px 15px; background-color: transparent; color: var(--text-secondary-color); + border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 1em; font-weight: 600; + transition: color 0.3s, border-color 0.3s; + } + .tab-button.active { color: var(--brand-yellow); border-bottom-color: var(--brand-yellow); } + .tab-content { display: none; flex-direction: column; gap: var(--padding-m); } + .tab-content.active { display: flex; } + .card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--padding-m); } .bonus-card, .debt-card { background: linear-gradient(145deg, #2a2a2a, #1c1c1c); - border-radius: calc(var(--border-radius) + 8px); - padding: var(--padding-l); - text-align: center; - position: relative; - overflow: hidden; + border-radius: calc(var(--border-radius) + 8px); padding: var(--padding-l); text-align: center; + position: relative; overflow: hidden; } .bonus-card { box-shadow: var(--shadow-glow); border: 1px solid rgba(255, 193, 7, 0.2); } .debt-card { box-shadow: var(--shadow-glow-red); border: 1px solid rgba(244, 67, 54, 0.2); } @@ -276,62 +255,35 @@ TEMPLATE = """ .bonus-amount, .debt-amount { font-size: 3em; font-weight: 800; letter-spacing: -2px; line-height: 1; } .bonus-amount { color: var(--brand-yellow); } .debt-amount { color: var(--brand-red); } - .client-id-card { - background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-m); - display: flex; justify-content: space-between; align-items: center; - } + + .client-id-card { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-m); display: flex; justify-content: space-between; align-items: center; } .client-id-label { font-weight: 500; color: var(--text-secondary-color); } - .client-id-value { - font-size: 1.3em; font-weight: 700; color: var(--brand-yellow); - letter-spacing: 2px; background-color: rgba(255,193,7,0.1); padding: 4px 10px; border-radius: 8px; - } - .history-section { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-l); } - .history-title { font-size: 1.4em; font-weight: 700; margin-bottom: var(--padding-m); padding-bottom: var(--padding-m); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } - .history-list { list-style: none; padding: 0; margin: 0; max-height: 35vh; overflow-y: auto; } - .history-item { display: flex; justify-content: space-between; align-items: center; padding: 14px 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } - .history-item:last-child { border-bottom: none; } - .history-details { display: flex; flex-direction: column; } - .history-description { font-size: 1em; font-weight: 500; } - .history-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; } - .history-amount { font-size: 1.1em; font-weight: 700; } - .history-amount.accrual { color: #4CAF50; } - .history-amount.deduction { color: #F44336; } - .history-amount.invoice { color: var(--text-secondary-color); } - .view-invoice-btn { - background: rgba(255,255,255,0.1); color: var(--text-color); border: none; padding: 6px 10px; - border-radius: 6px; font-size: 0.8em; cursor: pointer; margin-left: 10px; - } - .no-history { text-align: center; color: var(--text-secondary-color); padding: 2rem 0; } - - .user-modal { - display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; - overflow: auto; background-color: rgba(0,0,0,0.7); backdrop-filter: blur(3px); - align-items: center; justify-content: center; padding: 15px; - } - .user-modal-content { - background-color: var(--card-bg); color: var(--text-color); margin: auto; padding: 20px; - border: 1px solid rgba(255,255,255,0.2); border-radius: var(--border-radius); - width: auto; max-width: 90%; box-shadow: 0 5px 25px rgba(0,0,0,0.3); - position: relative; - } - .user-modal-close { - color: var(--text-secondary-color); position: absolute; top: 10px; right: 15px; - font-size: 24px; font-weight: bold; cursor: pointer; - } - .user-modal-close:hover { color: var(--text-color); } - .user-modal-content h4 { margin-top: 0; margin-bottom: 15px; font-size: 1.2em; } - .user-modal-content p { margin-bottom: 8px; font-size: 0.9em; } - .user-modal-content ul { list-style: none; padding-left: 0; margin-bottom: 15px; } - .user-modal-content li { - padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 0.9em; - display: flex; justify-content: space-between; + .client-id-value { font-size: 1.3em; font-weight: 700; color: var(--brand-yellow); letter-spacing: 2px; background-color: rgba(255,193,7,0.1); padding: 4px 10px; border-radius: 8px; } + + .section-card { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-l); } + .section-title { font-size: 1.4em; font-weight: 700; margin-bottom: var(--padding-m); padding-bottom: var(--padding-m); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } + + .list { list-style: none; padding: 0; margin: 0; max-height: 40vh; overflow-y: auto; } + .list-item { display: flex; justify-content: space-between; align-items: center; padding: 14px 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } + .list-item:last-child { border-bottom: none; } + .item-details { display: flex; flex-direction: column; } + .item-description { font-size: 1em; font-weight: 500; } + .item-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; } + .item-amount { font-size: 1.1em; font-weight: 700; } + .item-amount.accrual { color: #4CAF50; } + .item-amount.deduction { color: #F44336; } + .no-items { text-align: center; color: var(--text-secondary-color); padding: 2rem 0; } + + .visiting-card-item { margin-bottom: 12px; } + .visiting-card-item strong { color: var(--brand-yellow); display: block; margin-bottom: 4px; font-size: 0.9em; } + .visiting-card-item span, .visiting-card-item a { color: var(--text-color); text-decoration: none; font-size: 1.1em; } + .visiting-card-item a:hover { text-decoration: underline; } + .phone-actions button { + background-color: rgba(255, 193, 7, 0.2); color: var(--brand-yellow); + border: none; padding: 8px 12px; margin-right: 8px; margin-top: 4px; border-radius: 8px; cursor: pointer; } - .user-modal-content li:last-child { border-bottom: none; } - .user-modal-content strong { font-weight: 600; } - .item-name { flex-grow: 1; } - .item-qty-price { color: var(--text-secondary-color); margin-left:10px; white-space:nowrap; } - .item-total { font-weight: 500; margin-left:10px; white-space:nowrap; } - + .invoice-item-details { background-color: rgba(255,255,255,0.05); padding: 10px; margin-top: 10px; border-radius: 8px;} + .invoice-item-product { display: flex; justify-content: space-between; font-size: 0.9em; margin-bottom: 5px; } @@ -341,75 +293,127 @@ TEMPLATE = """

Добро пожаловать!

-
-
-

Ваши бонусы

-

{{ "%.2f"|format(user.bonuses|float) }}

-
-
-

Ваш долг

-

{{ "%.2f"|format(user.debts|float) }}

-
-
- -
-

Ваш ID клиента

-

{{ user.id }}

-
- -
-

История операций

- {% if user.combined_history %} - - {% else %} -

Операций пока не было.

- {% endif %} -
- - - - -
-
- × -

Детали накладной

-

Дата:

- -

Итого:

+
+ {% endif %} + {% if organization.links %} +
+ Ссылки: + {% for link in organization.links %} +

{{ link.text }}

+ {% endfor %} +
+ {% endif %} +
@@ -419,7 +423,6 @@ TEMPLATE = """ function applyTheme(themeParams) { const root = document.documentElement; const isDark = themeParams.bg_color ? (parseInt(themeParams.bg_color.substring(1, 3), 16) < 128) : true; - root.style.setProperty('--brand-black', themeParams.bg_color || '#101010'); root.style.setProperty('--text-color', themeParams.text_color || '#ffffff'); root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0'); @@ -433,7 +436,6 @@ TEMPLATE = """ document.body.style.visibility = 'visible'; return; } - tg.ready(); tg.expand(); @@ -483,32 +485,36 @@ TEMPLATE = """ } else { window.addEventListener('load', setupTelegram, {once: true}); setTimeout(() => { - if (document.body.style.visibility !== 'visible') { - document.body.style.visibility = 'visible'; - } + if (document.body.style.visibility !== 'visible') { document.body.style.visibility = 'visible'; } }, 3000); } - function openUserInvoiceModal(invoiceData) { - document.getElementById('userInvoiceModalId').textContent = invoiceData.id; - document.getElementById('userInvoiceModalDate').textContent = invoiceData.date_str; - const itemsList = document.getElementById('userInvoiceModalItems'); - itemsList.innerHTML = ''; - invoiceData.items.forEach(item => { - const li = document.createElement('li'); - li.innerHTML = `${item.name} ${item.quantity} x ${parseFloat(item.price_per_unit).toFixed(2)} ${parseFloat(item.total).toFixed(2)}`; - itemsList.appendChild(li); - }); - document.getElementById('userInvoiceModalTotal').textContent = parseFloat(invoiceData.grand_total).toFixed(2); - document.getElementById('userInvoiceModal').style.display = 'flex'; + function showTab(tabId, Tgelement) { + document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active')); + document.querySelectorAll('.tab-button').forEach(tb => tb.classList.remove('active')); + document.getElementById(tabId + '_content').classList.add('active'); + Tgelement.classList.add('active'); } - - function closeUserInvoiceModal() { - document.getElementById('userInvoiceModal').style.display = 'none'; + + function toggleInvoiceDetails(invoiceId) { + const detailsElement = document.getElementById(invoiceId); + if (detailsElement) { + detailsElement.style.display = detailsElement.style.display === 'none' ? 'flex' : 'none'; + } } - window.onclick = function(event) { - if (event.target == document.getElementById('userInvoiceModal')) { - closeUserInvoiceModal(); + + function handlePhoneAction(action, number) { + const cleanNumber = number.replace(/\\D/g, ''); + let url = ''; + if (action === 'call') { + url = `tel:${number}`; + } else if (action === 'whatsapp') { + url = `https://wa.me/${cleanNumber}`; + } else if (action === 'telegram_number') { + url = `https://t.me/${cleanNumber.startsWith('+') ? cleanNumber.substring(1) : cleanNumber}`; + } + if (url) { + tg.openLink(url); } } @@ -535,29 +541,24 @@ ADMIN_TEMPLATE = """ } body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; } .container { max-width: 1200px; margin: 0 auto; } - h1, h2, h3 { color: var(--admin-secondary); font-weight: 600; } - h1 { text-align: center; margin-bottom: var(--padding); } - .tabs { display: flex; margin-bottom: var(--padding); border-bottom: 1px solid var(--admin-border); } - .tab-button { padding: 10px 20px; cursor: pointer; background: none; border: none; font-size: 1.1em; font-weight: 500; color: var(--admin-secondary); border-bottom: 3px solid transparent; } - .tab-button.active { color: var(--admin-primary-dark); border-bottom-color: var(--admin-primary-dark); } - .tab-content { display: none; } - .tab-content.active { display: block; } - .summary-bar { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--padding); margin-bottom: var(--padding); } - .summary-card, .org-info-card { background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); } + h1, h2 { text-align: center; color: var(--admin-secondary); margin-bottom: var(--padding); font-weight: 600; } + .section-box { background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); margin-bottom: var(--padding); } + .summary-bar { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: var(--padding); } + .summary-card { background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); text-align: center; } .summary-card .value { font-size: 2em; font-weight: 700; } .summary-card .label { font-size: 0.9em; color: var(--admin-secondary); margin-top: 0.5rem; } .summary-card .value.bonus { color: var(--admin-primary-dark); } .summary-card .value.debt { color: var(--admin-danger); } - .controls-bar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; background: var(--admin-card-bg); padding: var(--padding); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); margin-bottom: var(--padding); } + .controls-bar { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; } .controls-bar input[type="text"] { flex-grow: 1; padding: 12px 15px; font-size: 1.1em; border-radius: 8px; border: 1px solid var(--admin-border); box-sizing: border-box; min-width: 250px; } .btn { padding: 12px 20px; font-size: 1em; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background-color 0.2s ease; } - .btn-sm { padding: 8px 12px; font-size: 0.9em; } .btn-primary { background-color: var(--admin-primary); color: #000; } .btn-primary:hover { background-color: var(--admin-primary-dark); } - .btn-secondary { background-color: var(--admin-secondary); color: white; } - .btn-secondary:hover { background-color: #5a6268; } .btn-delete { background-color: var(--admin-danger); color: white; } .btn-delete:hover { background-color: #c82333; } + .btn-success { background-color: var(--admin-success); color: white; } + .btn-success:hover { background-color: #157347; } + .btn-small { padding: 6px 10px; font-size: 0.9em; } .user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--padding); margin-top: var(--padding); } .user-card { background-color: var(--admin-card-bg); border-radius: var(--border-radius); padding: var(--padding); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); display: flex; flex-direction: column; transition: transform 0.2s ease, box-shadow 0.2s ease; } .user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); } @@ -577,313 +578,340 @@ ADMIN_TEMPLATE = """ .modal-content { background-color: var(--admin-bg); margin: 5% auto; padding: var(--padding); border: 1px solid var(--admin-border); width: 90%; max-width: 800px; border-radius: var(--border-radius); position: relative; box-shadow: 0 8px 30px rgba(0,0,0,0.15); } .modal-close { color: #aaa; position: absolute; top: 15px; right: 25px; font-size: 28px; font-weight: bold; cursor: pointer; } .modal-header { padding-bottom: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--admin-border); } - .modal-header h2, .modal-header h3 { margin: 0; font-size: 1.5rem; } + .modal-header h2 { margin: 0; font-size: 1.5rem; text-align:left; } .modal-header .username { font-size: 1rem; color: var(--admin-secondary); } .form-section { border: 1px solid var(--admin-border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } - .form-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.2em; } - .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: flex-end; margin-bottom: 1rem;} + .form-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.1em; text-align:left; } + .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: flex-end; } .form-group { display: flex; flex-direction: column; margin-bottom: 1rem; } .form-group label { margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9em; } - .form-group input, .form-group textarea { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; } - .dynamic-field-group { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; } - .dynamic-field-group input { flex-grow: 1; } + .form-group input, .form-group select, .form-group textarea { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; } .calculation-summary { background: #f0f0f0; padding: 1rem; border-radius: 8px; margin-top: 1rem; } .summary-item { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.95em; } .summary-item strong { font-weight: 600; } - .history-container, .invoice-list-container { margin-top: 1.5rem; } - .history-container h3, .invoice-list-container h3 { font-size: 1.2rem; margin-bottom: 1rem; } + .history-container, .invoice-container { margin-top: 1.5rem; } + .history-container h3, .invoice-container h3 { font-size: 1.2rem; margin-bottom: 1rem; text-align:left;} .history-list, .invoice-list { list-style: none; padding: 0; max-height: 200px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 8px; } - .history-item, .invoice-item { display: flex; justify-content: space-between; align-items:center; padding: 8px 12px; border-bottom: 1px solid var(--admin-border); } - .history-item:last-child, .invoice-item:last-child { border-bottom: none; } - .history-item .desc, .invoice-item .desc { font-size: 0.9em; } - .history-item .date, .invoice-item .date { font-size: 0.8em; color: var(--admin-secondary); } - .history-item .amount.bonus-accrual, .history-item .amount.debt-payment { color: var(--admin-success); font-weight: 600; } + .history-item, .invoice-list-item { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--admin-border); } + .history-item:last-child, .invoice-list-item:last-child { border-bottom: none; } + .history-item .desc, .invoice-list-item .desc { font-size: 0.9em; } + .history-item .date, .invoice-list-item .date { font-size: 0.8em; color: var(--admin-secondary); } + .history-item .amount.bonus-accrual, .invoice-list-item .amount.invoice-total { color: var(--admin-success); font-weight: 600; } .history-item .amount.bonus-deduction, .history-item .amount.debt-accrual { color: var(--admin-danger); font-weight: 600; } - .history-item .amount.invoice-amount { color: var(--admin-secondary); font-weight: 600; } + .history-item .amount.debt-payment { color: var(--admin-success); font-weight: 600; } + .invoice-details { display: none; padding: 10px; background-color: #f9f9f9; border-top: 1px solid var(--admin-border); } + .invoice-details p { margin: 5px 0; } .modal-footer { margin-top: 1.5rem; display: flex; justify-content: flex-end; align-items: center; gap: 1rem;} .modal-footer button { padding: 12px 25px; font-size: 1.1em; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; } .btn-submit { background-color: var(--admin-success); color: white; } .status-message { text-align: center; font-weight: 500; flex-grow: 1; text-align: left; } - .invoice-item-row { display: grid; grid-template-columns: 3fr 1fr 1fr 1fr auto; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; } - .invoice-item-row input { padding: 8px; font-size: 0.9rem; } + .dynamic-list-item { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; } + .dynamic-list-item input, .dynamic-list-item select { flex-grow: 1; } + .modal-tabs { display: flex; margin-bottom: 1rem; border-bottom: 1px solid var(--admin-border); } + .modal-tab-button { padding: 10px 15px; cursor: pointer; border: none; background: transparent; font-size: 1em; } + .modal-tab-button.active { border-bottom: 2px solid var(--admin-primary); font-weight: 600; } + .modal-tab-content { display: none; } + .modal-tab-content.active { display: block; }

Панель администратора Bonus

-
- - -
- -
+
+

Сводка по клиентам

{{ summary.total_users }}
Всего клиентов
{{ "%.2f"|format(summary.total_bonuses|float) }}
Всего бонусов
{{ "%.2f"|format(summary.total_debts|float) }}
Всего долгов
{{ summary.users_with_debt }}
Клиенты с долгом
+
+ +
+

Данные организации (Визитка)

+
+
+ + +
+
+ + +
+ +

Телефоны

+
+ {% for phone in organization_details.phones %} +
+ + + + +
+ {% endfor %} +
+ + +

Ссылки

+ + + +
+ +
+
+
+
+ + +
+

Управление клиентами

+ {% if users %}
{% for user in users|sort(attribute='visited_at', reverse=true) %} -
- - {% else %}

Пользователей пока нет.

{% endif %} -
- -
-
-

Данные организации

-
-
- - -
-
- -
- {% for phone in organization_info.phone_numbers %} -
- - +
+
Бонусы
{{ "%.2f"|format(user.bonuses|float) }}
+
Долг
{{ "%.2f"|format(user.debts|float if user.debts else 0) }}
- {% endfor %} -
- -
-
- - -
-
- - - - -
+ {% endfor %} +
+ {% else %} +

Пользователей пока нет.

+ {% endif %}
-