diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -1,49 +1,64 @@
#!/usr/bin/env python3
import os
-from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for
+from flask import Flask, request, Response, render_template_string, jsonify, redirect, url_for, session
import hmac
import hashlib
import json
from urllib.parse import unquote, parse_qs, quote
import time
-from datetime import datetime
+from datetime import datetime, timezone
import logging
import threading
import random
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError
-import pytz
+import pytz # For timezone handling
BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4")
HOST = '0.0.0.0'
PORT = 7860
DATA_FILE = 'data.json'
-COMPANY_DATA_FILE = 'company_data.json'
+ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") # For admin authentication
REPO_ID = "flpolprojects/examplebonus"
HF_DATA_FILE_PATH = "data.json"
-HF_COMPANY_DATA_FILE_PATH = "company_data.json"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
+APP_TIMEZONE = 'Asia/Bishkek' # Set the desired timezone
+
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
-app.secret_key = os.urandom(24)
+app.secret_key = os.urandom(24) # Required for session management
_data_lock = threading.Lock()
visitor_data_cache = {}
-company_data_cache = {}
-
-KYRGYZSTAN_TIMEZONE = 'Asia/Bishkek'
-local_tz = pytz.timezone(KYRGYZSTAN_TIMEZONE)
-def get_local_time():
- return datetime.now(local_tz)
+# --- Timezone Helper ---
+def get_current_time_in_app_timezone():
+ return datetime.now(pytz.timezone(APP_TIMEZONE))
-def format_local_time(dt):
- return dt.strftime('%Y-%m-%d %H:%M:%S')
+def format_time_in_app_timezone(dt_obj, fmt='%Y-%m-%d %H:%M:%S'):
+ if dt_obj:
+ try:
+ if isinstance(dt_obj, float): # Assuming timestamp
+ dt_obj = datetime.fromtimestamp(dt_obj, tz=pytz.timezone(APP_TIMEZONE))
+ elif isinstance(dt_obj, str): # Assuming ISO format
+ dt_obj = datetime.fromisoformat(dt_obj).astimezone(pytz.timezone(APP_TIMEZONE))
+ elif isinstance(dt_obj, datetime):
+ if dt_obj.tzinfo is None:
+ dt_obj = dt_obj.replace(tzinfo=pytz.timezone(APP_TIMEZONE))
+ else:
+ dt_obj = dt_obj.astimezone(pytz.timezone(APP_TIMEZONE))
+
+ return dt_obj.strftime(fmt)
+ except Exception as e:
+ logging.error(f"Error formatting time '{dt_obj}': {e}")
+ return str(dt_obj) # Fallback
+ return ""
+# --- Data Management ---
def generate_unique_id(all_data):
while True:
new_id = str(random.randint(10000, 99999))
@@ -51,7 +66,7 @@ def generate_unique_id(all_data):
return new_id
def download_data_from_hf():
- global visitor_data_cache, company_data_cache
+ global visitor_data_cache
if not HF_TOKEN_READ:
logging.warning("HF_TOKEN_READ not set. Skipping Hugging Face download.")
return False
@@ -72,32 +87,10 @@ def download_data_from_hf():
try:
with open(DATA_FILE, 'r', encoding='utf-8') as f:
visitor_data_cache = json.load(f)
- logging.info("Successfully loaded downloaded visitor data into cache.")
+ logging.info("Successfully loaded downloaded data into cache.")
except (FileNotFoundError, json.JSONDecodeError) as e:
logging.error(f"Error reading downloaded data file: {e}. Starting with empty cache.")
visitor_data_cache = {}
-
- logging.info(f"Attempting to download {HF_COMPANY_DATA_FILE_PATH} from {REPO_ID}...")
- hf_hub_download(
- repo_id=REPO_ID,
- filename=HF_COMPANY_DATA_FILE_PATH,
- repo_type="dataset",
- token=HF_TOKEN_READ,
- local_dir=".",
- local_dir_use_symlinks=False,
- force_download=True,
- etag_timeout=10
- )
- logging.info("Company data file successfully downloaded from Hugging Face.")
- with _data_lock:
- try:
- with open(COMPANY_DATA_FILE, 'r', encoding='utf-8') as f:
- company_data_cache = json.load(f)
- logging.info("Successfully loaded downloaded company data into cache.")
- except (FileNotFoundError, json.JSONDecodeError) as e:
- logging.error(f"Error reading downloaded company data file: {e}. Starting with empty cache.")
- company_data_cache = {}
-
return True
except RepositoryNotFoundError:
logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download data.")
@@ -113,6 +106,21 @@ def load_visitor_data():
with open(DATA_FILE, 'r', encoding='utf-8') as f:
visitor_data_cache = json.load(f)
logging.info("Visitor data loaded from local JSON.")
+ # Ensure all timestamps are timezone-aware (or converted to a consistent format)
+ for user_id, user_data in visitor_data_cache.items():
+ if 'visited_at' in user_data and isinstance(user_data['visited_at'], (int, float)):
+ user_data['visited_at'] = datetime.fromtimestamp(user_data['visited_at'], tz=pytz.timezone(APP_TIMEZONE))
+ if 'history' in user_data:
+ for item in user_data['history']:
+ if 'date' in item and isinstance(item['date'], str):
+ item['date'] = datetime.fromisoformat(item['date']).astimezone(pytz.timezone(APP_TIMEZONE))
+ item['date_str'] = format_time_in_app_timezone(item['date'])
+ if 'debt_history' in user_data:
+ for item in user_data['debt_history']:
+ if 'date' in item and isinstance(item['date'], str):
+ item['date'] = datetime.fromisoformat(item['date']).astimezone(pytz.timezone(APP_TIMEZONE))
+ item['date_str'] = format_time_in_app_timezone(item['date'])
+
except FileNotFoundError:
logging.warning(f"{DATA_FILE} not found locally. Starting with empty data.")
visitor_data_cache = {}
@@ -128,43 +136,29 @@ def save_visitor_data(data):
with _data_lock:
try:
visitor_data_cache.update(data)
+ # Before saving, ensure all datetime objects are converted to ISO strings
+ data_to_save = {}
+ for user_id, user_data in visitor_data_cache.items():
+ user_data_copy = user_data.copy()
+ if 'visited_at' in user_data_copy and isinstance(user_data_copy['visited_at'], datetime):
+ user_data_copy['visited_at'] = user_data_copy['visited_at'].isoformat()
+ if 'history' in user_data_copy:
+ for item in user_data_copy['history']:
+ if 'date' in item and isinstance(item['date'], datetime):
+ item['date'] = item['date'].isoformat()
+ if 'debt_history' in user_data_copy:
+ for item in user_data_copy['debt_history']:
+ if 'date' in item and isinstance(item['date'], datetime):
+ item['date'] = item['date'].isoformat()
+ data_to_save[user_id] = user_data_copy
+
with open(DATA_FILE, 'w', encoding='utf-8') as f:
- json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4)
+ json.dump(data_to_save, f, ensure_ascii=False, indent=4)
logging.info(f"Visitor data successfully saved to {DATA_FILE}.")
upload_data_to_hf_async()
except Exception as e:
logging.error(f"Error saving visitor data: {e}")
-def load_company_data():
- global company_data_cache
- with _data_lock:
- if not company_data_cache:
- try:
- with open(COMPANY_DATA_FILE, 'r', encoding='utf-8') as f:
- company_data_cache = json.load(f)
- logging.info("Company data loaded from local JSON.")
- except FileNotFoundError:
- logging.warning(f"{COMPANY_DATA_FILE} not found locally. Starting with empty data.")
- company_data_cache = {}
- except json.JSONDecodeError:
- logging.error(f"Error decoding {COMPANY_DATA_FILE}. Starting with empty data.")
- company_data_cache = {}
- except Exception as e:
- logging.error(f"Unexpected error loading company data: {e}")
- company_data_cache = {}
- return company_data_cache
-
-def save_company_data(data):
- with _data_lock:
- try:
- company_data_cache.update(data)
- with open(COMPANY_DATA_FILE, 'w', encoding='utf-8') as f:
- json.dump(company_data_cache, f, ensure_ascii=False, indent=4)
- logging.info(f"Company data successfully saved to {COMPANY_DATA_FILE}.")
- upload_company_data_to_hf_async()
- except Exception as e:
- logging.error(f"Error saving company data: {e}")
-
def upload_data_to_hf():
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.")
@@ -188,49 +182,16 @@ def upload_data_to_hf():
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
- commit_message=f"Update bonus data {get_local_time().strftime('%Y-%m-%d %H:%M:%S')}"
+ commit_message=f"Update bonus data {get_current_time_in_app_timezone().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}")
-def upload_company_data_to_hf():
- if not HF_TOKEN_WRITE:
- logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.")
- return
- if not os.path.exists(COMPANY_DATA_FILE):
- logging.warning(f"{COMPANY_DATA_FILE} does not exist. Skipping upload.")
- return
-
- try:
- api = HfApi()
- with _data_lock:
- file_content_exists = os.path.getsize(COMPANY_DATA_FILE) > 0
- if not file_content_exists:
- logging.warning(f"{COMPANY_DATA_FILE} is empty. Skipping upload.")
- return
-
- logging.info(f"Attempting to upload {COMPANY_DATA_FILE} to {REPO_ID}/{HF_COMPANY_DATA_FILE_PATH}...")
- api.upload_file(
- path_or_fileobj=COMPANY_DATA_FILE,
- path_in_repo=HF_COMPANY_DATA_FILE_PATH,
- repo_id=REPO_ID,
- repo_type="dataset",
- token=HF_TOKEN_WRITE,
- commit_message=f"Update company data {get_local_time().strftime('%Y-%m-%d %H:%M:%S')}"
- )
- logging.info("Company data successfully uploaded to Hugging Face.")
- except Exception as e:
- logging.error(f"Error uploading company data to Hugging Face: {e}")
-
def upload_data_to_hf_async():
upload_thread = threading.Thread(target=upload_data_to_hf, daemon=True)
upload_thread.start()
-def upload_company_data_to_hf_async():
- upload_thread = threading.Thread(target=upload_company_data_to_hf, daemon=True)
- upload_thread.start()
-
def periodic_backup():
if not HF_TOKEN_WRITE:
logging.info("Periodic backup disabled: HF_TOKEN_WRITE not set.")
@@ -239,7 +200,6 @@ def periodic_backup():
time.sleep(3600)
logging.info("Initiating periodic backup...")
upload_data_to_hf()
- upload_company_data_to_hf()
def verify_telegram_data(init_data_str):
try:
@@ -270,7 +230,9 @@ def verify_telegram_data(init_data_str):
logging.error(f"Error verifying Telegram data: {e}")
return None, False
-TEMPLATE = """
+# --- Templates ---
+
+USER_TEMPLATE = """
@@ -297,22 +259,35 @@ TEMPLATE = """
--shadow-glow: 0 0 35px var(--shadow-color);
--shadow-color-red: rgba(244, 67, 54, 0.15);
--shadow-glow-red: 0 0 35px var(--shadow-color-red);
+ --nav-button-color: var(--brand-black);
+ --nav-button-text-color: var(--text-color);
+ --nav-button-active-color: var(--brand-yellow);
+ --nav-button-active-text-color: var(--brand-black);
}
- * { box-sizing: border-box; margin: 0; padding: 0; }
- html, body {
+ body {
background-color: var(--brand-black);
font-family: var(--font-family);
color: var(--text-color);
- padding: var(--padding-m);
+ padding: 0; /* Remove default body padding */
overscroll-behavior-y: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
visibility: hidden;
min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ }
+ .main-content {
+ flex-grow: 1;
+ padding: var(--padding-m);
+ display: flex;
+ flex-direction: column;
+ gap: var(--padding-m);
}
.container {
max-width: 600px;
margin: 0 auto;
+ width: 100%;
display: flex;
flex-direction: column;
gap: var(--padding-m);
@@ -428,183 +403,373 @@ TEMPLATE = """
.history-amount { font-size: 1.1em; font-weight: 700; }
.history-amount.accrual { color: #4CAF50; }
.history-amount.deduction { color: #F44336; }
- .no-history {
- text-align: center;
- color: var(--text-secondary-color);
- padding: 2rem 0;
- }
- .action-buttons {
+
+ /* Navigation */
+ .nav-bar {
display: flex;
- gap: 12px;
- margin-top: 20px;
- justify-content: center;
- flex-wrap: wrap;
+ justify-content: space-around;
+ padding: 10px var(--padding-m);
+ background-color: var(--card-bg);
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ position: sticky;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ z-index: 10;
}
- .btn-action {
- background-color: var(--brand-yellow);
- color: var(--brand-black);
- padding: 12px 24px;
- border: none;
+ .nav-button {
+ background-color: var(--nav-button-color);
+ color: var(--nav-button-text-color);
+ padding: 12px 20px;
border-radius: 12px;
- font-size: 1.1em;
font-weight: 600;
- cursor: pointer;
- transition: background-color 0.2s ease, transform 0.2s ease;
text-decoration: none;
- display: inline-flex;
+ flex: 1;
+ text-align: center;
+ margin: 0 5px;
+ border: none;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
align-items: center;
justify-content: center;
+ gap: 8px;
+ }
+ .nav-button.active {
+ background-color: var(--nav-button-active-color);
+ color: var(--nav-button-active-text-color);
+ }
+ .nav-button i { font-size: 1.2em; }
+
+ /* Modal Styles for Invoices */
+ .modal {
+ display: none;
+ position: fixed;
+ z-index: 1050;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ background-color: rgba(0,0,0,0.6);
+ backdrop-filter: blur(5px);
+ font-family: var(--font-family);
+ }
+ .modal-dialog {
+ max-width: 600px;
+ margin: 20px auto;
+ background-color: var(--card-bg);
+ border-radius: var(--border-radius);
+ box-shadow: 0 8px 30px rgba(0,0,0,0.2);
+ overflow: hidden;
+ }
+ .modal-content {
+ padding: var(--padding-l);
+ }
+ .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid rgba(255,255,255,0.1);
+ padding-bottom: var(--padding-m);
+ margin-bottom: var(--padding-m);
+ }
+ .modal-title {
+ font-size: 1.5em;
+ font-weight: 700;
+ color: var(--brand-yellow);
+ }
+ .modal-close {
+ background: none;
+ border: none;
+ font-size: 2em;
+ color: var(--text-secondary-color);
+ cursor: pointer;
+ padding: 0 10px;
+ }
+ .modal-body {
+ margin-bottom: var(--padding-m);
+ }
+ .invoice-item {
+ display: grid;
+ grid-template-columns: 1fr 60px 100px 100px;
+ gap: 10px;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ }
+ .invoice-item:last-child { border-bottom: none; }
+ .item-name { font-size: 0.95em; }
+ .item-quantity, .item-price, .item-total { font-weight: 500; text-align: right; }
+ .invoice-summary {
+ text-align: right;
+ margin-top: var(--padding-m);
+ padding-top: var(--padding-m);
+ border-top: 1px solid rgba(255,255,255,0.1);
+ }
+ .invoice-total-label { color: var(--text-secondary-color); }
+ .invoice-total-amount {
+ font-size: 1.8em;
+ font-weight: 800;
+ color: var(--brand-yellow);
+ }
+ .invoice-list {
+ list-style: none;
+ padding: 0;
+ }
+ .invoice-item-summary {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ font-size: 0.9em;
}
- .btn-action:hover {
- background-color: #e0a800;
- transform: translateY(-2px);
+ .invoice-item-summary:last-child { border-bottom: none; }
+ .invoice-list-placeholder {
+ text-align: center;
+ color: var(--text-secondary-color);
+ padding: 2rem 0;
}
- .btn-action.secondary {
+ .visit-card-section {
background-color: var(--card-bg);
+ border-radius: var(--border-radius);
+ padding: var(--padding-l);
+ margin-top: var(--padding-m);
+ }
+ .visit-card-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);
+ }
+ .visit-card-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+ gap: 12px;
+ }
+ .visit-card-item .icon {
color: var(--brand-yellow);
- border: 1px solid var(--brand-yellow);
+ font-size: 1.3em;
+ min-width: 24px;
+ text-align: center;
+ }
+ .visit-card-item .label {
+ font-size: 1.1em;
+ font-weight: 500;
+ color: var(--text-secondary-color);
+ flex-shrink: 0;
}
- .btn-action.secondary:hover {
- background-color: #2a2a2c;
+ .visit-card-item .value {
+ font-size: 1.1em;
+ font-weight: 500;
+ word-break: break-all;
+ }
+ .visit-card-item .phone-link,
+ .visit-card-item .social-link {
+ text-decoration: none;
+ color: var(--text-color);
+ font-weight: 500;
+ font-size: 1.1em;
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
+ .visit-card-item .social-link { color: var(--brand-yellow); }
-
-
-
-
-
-
Ваши бонусы
-
{{ "%.2f"|format(user.bonuses|float) }}
-
-
-
Ваш долг
-
{{ "%.2f"|format(user.debts|float) }}
-
-
-
-
- Ваш ID клиента
- {{ user.id }}
-
-
-
-
-
- Визитная карточка компании
- {% if company_info %}
- Название: {{ company_info.name }}
- Адрес: {{ company_info.address }}
- {% if company_info.phones %}
- Телефоны:
- {% for phone in company_info.phones %}
-
- {{ phone.name if phone.name else phone.number }}
-
- {% if not loop.last %}, {% endif %}
- {% endfor %}
-
- {% endif %}
- {% if company_info.social_links %}
- Ссылки:
- {% for link in company_info.social_links %}
- {{ link.name }}
- {% if not loop.last %}, {% endif %}
- {% endfor %}
-
- {% endif %}
- {% else %}
- Информация о компании не найдена.
- {% endif %}
-
-
-
- Мои накладные
- {% if user.invoices and user.invoices|length > 0 %}
-
- {% for invoice in user.invoices %}
- -
-
-
Накладная #{{ invoice.id }} ({{ invoice.items|length }} {{ 'товар' if invoice.items|length == 1 else 'товара' }})
-
{{ invoice.date_str }}
+
+
+
+
+ {% if current_view == 'history' %}
+
+
+
Ваши бонусы
+
{{ "%.2f"|format(user.bonuses|float) }}
+
+
+
Ваш долг
+
{{ "%.2f"|format(user.debts|float) }}
+
+
+
+
+ Ваш ID клиента
+ {{ user.id }}
+
+
+
+ История операций
+ {% if user.combined_history %}
+
+ {% for item in user.combined_history %}
+ -
+
+ {{ item.description }}
+ {{ item.date_str }}
+
+ {% if item.transaction_type == 'bonus' %}
+
+ {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
+
+ {% elif item.transaction_type == 'debt' %}
+
+ {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
+
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ Операций пока не было.
+ {% endif %}
+
+ {% elif current_view == 'invoices' %}
+
+ Мои накладные
+ {% if user.invoices %}
+
+ {% for invoice in user.invoices|sort(attribute='date_created', reverse=True) %}
+ -
+ Накладная #{{ invoice.id }}
+ {{ invoice.date_str }}
+ {{ "%.2f"|format(invoice.total_amount|float) }}
+
+
+ {% endfor %}
+
+ {% else %}
+ У вас пока нет накладных.
+ {% endif %}
+
+ {% elif current_view == 'visit' %}
+
+ Визитка нашей компании
+ {% if company_data %}
+ {% if company_data.name %}
+
+ Компания:
+ {{ company_data.name }}
- {{ "%.2f"|format(invoice.total_amount) }}
-
- {% endfor %}
-
- {% else %}
- У вас пока нет накладных.
- {% endif %}
-
-
-
- История операций
- {% if user.combined_history %}
-
- {% for item in user.combined_history %}
- -
-
-
{{ item.description }}
-
{{ item.date_str }}
+ {% endif %}
+ {% if company_data.phones %}
+ {% for phone in company_data.phones %}
+
+ {% endfor %}
+ {% endif %}
+ {% if company_data.address %}
+
+ 📍
+ Адрес:
+ {{ company_data.address }}
- {% if item.transaction_type == 'bonus' %}
-
- {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
-
- {% elif item.transaction_type == 'debt' %}
-
- {{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
-
- {% elif item.transaction_type == 'invoice' %}
-
- -{{ "%.2f"|format(item.amount|float) }}
-
- {% endif %}
-
- {% endfor %}
-
- {% else %}
-
Операций пока не было.
+ {% endif %}
+ {% if company_data.social_links %}
+ {% for link in company_data.social_links %}
+
+ {% endfor %}
+ {% endif %}
+ {% else %}
+
Визитные данные еще не добавлены.
+ {% endif %}
+
{% endif %}
-
+
+
+
+
+
+
+
@@ -721,6 +933,8 @@ ADMIN_TEMPLATE = """
.btn-manage { display: block; width: 100%; padding: 10px; background-color: var(--admin-primary); color: #000; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background-color 0.2s; }
.btn-manage:hover { background-color: var(--admin-primary-dark); }
.no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
+
+ /* Modal Styles */
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(5px); }
.modal-content { background-color: var(--admin-bg); margin: 5% auto; padding: var(--padding); border: 1px solid var(--admin-border); width: 90%; max-width: 700px; 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; }
@@ -747,101 +961,150 @@ ADMIN_TEMPLATE = """
.history-item .amount.bonus-deduction { color: var(--admin-danger); font-weight: 600; }
.history-item .amount.debt-accrual { color: var(--admin-danger); font-weight: 600; }
.history-item .amount.debt-payment { color: var(--admin-success); font-weight: 600; }
- .history-item .amount.invoice-deduction { color: var(--admin-danger); font-weight: 600; }
.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-form { border: 1px solid var(--admin-border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; }
- .invoice-form h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.1em; }
- .invoice-item-row { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 1rem; align-items: flex-end; margin-bottom: 1rem; }
- .invoice-item-row input { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; }
- .invoice-item-row .btn-remove-item { background-color: var(--admin-danger); color: white; padding: 10px 15px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.9em; }
- .invoice-item-row .btn-remove-item:hover { background-color: #c82333; }
- .invoice-summary { margin-top: 1rem; border-top: 1px solid var(--admin-border); padding-top: 1rem; }
- .invoice-summary p { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
- .invoice-summary strong { font-weight: 600; }
- .btn-add-item { background-color: var(--admin-primary); color: #000; padding: 8px 15px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.9em; margin-top: 1rem; }
- .btn-add-item:hover { background-color: var(--admin-primary-dark); }
- .company-data-section { border: 1px solid var(--admin-border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; }
- .company-data-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.2em; }
- .company-data-section .form-group { margin-bottom: 1rem; }
- .company-data-section .form-group label { font-weight: 500; margin-bottom: 0.5rem; display: block; }
- .company-data-section .form-group input[type="text"],
- .company-data-section .form-group input[type="tel"],
- .company-data-section .form-group input[type="url"] { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; }
- .phone-row, .link-row { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; }
- .phone-row input, .link-row input { flex-grow: 1; }
- .btn-remove-phone, .btn-remove-link { background-color: var(--admin-danger); color: white; padding: 5px 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.8em; }
- .btn-remove-phone:hover, .btn-remove-link:hover { background-color: #c82333; }
- .btn-add-phone, .btn-add-link { background-color: var(--admin-primary); color: #000; padding: 5px 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.8em; margin-left: 5px; }
- .btn-add-phone:hover, .btn-add-link:hover { background-color: var(--admin-primary-dark); }
- .company-buttons { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
+
+ /* Invoice Creation Modal */
+ #createInvoiceModal .modal-content { max-width: 800px; }
+ .invoice-creation-form { display: flex; flex-direction: column; gap: 1rem; }
+ .invoice-item-input {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 10px;
+ background: var(--admin-card-bg);
+ border: 1px solid var(--admin-border);
+ border-radius: 8px;
+ }
+ .invoice-item-input input { width: calc(100% - 10px); }
+ .invoice-item-input .remove-item-btn {
+ background: var(--admin-danger);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-size: 0.9em;
+ }
+ .invoice-creation-controls { display: flex; gap: 1rem; margin-top: 1rem; }
+ .invoice-creation-controls .btn-add-item { flex-grow: 1; background-color: var(--admin-primary); color: #000; }
+ .invoice-creation-summary {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ background: #e9ecef;
+ border-radius: 8px;
+ text-align: right;
+ }
+ .invoice-creation-summary span { margin-left: 10px; }
+ .invoice-creation-summary .label { color: var(--admin-secondary); }
+ .invoice-creation-summary .total { font-size: 1.4em; font-weight: 700; color: var(--admin-primary-dark); }
+
+ /* Visit Card Admin */
+ .visit-card-admin-section { margin-top: 2rem; padding: var(--padding); background: var(--admin-card-bg); border-radius: var(--border-radius); box-shadow: 0 4px 15px var(--admin-shadow); border: 1px solid var(--admin-border); }
+ .visit-card-admin-section h2 { margin-top: 0; color: var(--admin-secondary); }
+ .visit-card-admin-form .form-group { margin-bottom: 1.5rem; }
+ .visit-card-admin-form .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
+ .visit-card-admin-form input[type="text"], .visit-card-admin-form input[type="tel"] { width: 100%; padding: 10px; border: 1px solid var(--admin-border); border-radius: 8px; }
+ .social-link-input-group { display: flex; gap: 10px; align-items: center; margin-top: 0.5rem; }
+ .social-link-input-group input[type="text"] { flex-grow: 1; }
+ .social-link-input-group button { padding: 8px 12px; font-size: 0.9em; }
+ .btn-remove-social { background-color: var(--admin-danger); color: white; }
+ .btn-add-social { background-color: var(--admin-success); color: white; }
+ .phone-input-group { display: flex; gap: 10px; align-items: center; margin-top: 0.5rem; }
+ .phone-input-group select { padding: 10px; border: 1px solid var(--admin-border); border-radius: 8px; }
+ .phone-input-group input[type="tel"] { flex-grow: 1; }
Панель администратора Bonus
-
-
-
{{ summary.total_users }}
-
Всего клиентов
-
-
-
{{ "%.2f"|format(summary.total_bonuses|float) }}
-
Всего бонусов
-
-
-
{{ "%.2f"|format(summary.total_debts|float) }}
-
Всего долгов
-
-
-
{{ summary.users_with_debt }}
-
Клиенты с долгом
+
+
+ {% if not admin_logged_in %}
+
+
+
+
+
+
+
+
+ {% endif %}
-
-
-
-
-
+
+
+
+
{{ summary.total_users }}
+
Всего клиентов
+
+
+
{{ "%.2f"|format(summary.total_bonuses|float) }}
+
Всего бонусов
+
+
+
{{ "%.2f"|format(summary.total_debts|float) }}
+
Всего долгов
+
+
+
{{ summary.users_with_debt }}
+
Клиенты с долгом
+
+
- {% if users %}
-
- {% for user in users|sort(attribute='visited_at', reverse=true) %}
-
-
-

-
-
{{ user.first_name or '' }} {{ user.last_name or '' }}
-
@{{ user.username if user.username else user.phone_number }} | ID: {{ user.id }}
+
+
+
+
+
+
+
+ {% if users %}
+
+ {% for user in users|sort(attribute='visited_at', reverse=True) %}
+
+
+

+
+
{{ user.first_name or '' }} {{ user.last_name or '' }}
+
@{{ user.username if user.username else user.phone_number }} | ID: {{ user.id }}
+
-
-
-
-
Бонусы
-
{{ "%.2f"|format(user.bonuses|float) }}
+
+
+
Бонусы
+
{{ "%.2f"|format(user.bonuses|float) }}
+
+
+
Долг
+
{{ "%.2f"|format(user.debts|float if user.debts else 0) }}
+
-
-
Долг
-
{{ "%.2f"|format(user.debts|float if user.debts else 0) }}
+
+
+ {% if user.telegram_id == None %}
+
+ {% endif %}
-
-
- {% if user.telegram_id == None %}
-
- {% endif %}
-
-
- {% endfor %}
-
- {% else %}
-
Пользователей пока нет.
- {% endif %}
+ {% endfor %}
+
+ {% else %}
+
Пользователей пока нет.
+ {% endif %}
+
+
×
@@ -893,24 +1156,6 @@ ADMIN_TEMPLATE = """
-
-
Общая история операций
@@ -922,6 +1167,7 @@ ADMIN_TEMPLATE = """
+
×
@@ -942,59 +1188,128 @@ ADMIN_TEMPLATE = """
-
-
-
-
×
-
-
-
-
-
-
-