Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -7,7 +7,7 @@ import hashlib
|
|
| 7 |
import json
|
| 8 |
from urllib.parse import unquote, parse_qs, quote
|
| 9 |
import time
|
| 10 |
-
from datetime import datetime
|
| 11 |
import logging
|
| 12 |
import threading
|
| 13 |
import random
|
|
@@ -31,12 +31,17 @@ app.secret_key = os.urandom(24)
|
|
| 31 |
_data_lock = threading.Lock()
|
| 32 |
visitor_data_cache = {}
|
| 33 |
|
|
|
|
|
|
|
| 34 |
def generate_unique_id(all_data):
|
| 35 |
while True:
|
| 36 |
new_id = str(random.randint(10000, 99999))
|
| 37 |
if new_id not in all_data:
|
| 38 |
return new_id
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
def download_data_from_hf():
|
| 41 |
global visitor_data_cache
|
| 42 |
if not HF_TOKEN_READ:
|
|
@@ -123,7 +128,7 @@ def upload_data_to_hf():
|
|
| 123 |
repo_id=REPO_ID,
|
| 124 |
repo_type="dataset",
|
| 125 |
token=HF_TOKEN_WRITE,
|
| 126 |
-
commit_message=f"Update bonus data {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 127 |
)
|
| 128 |
logging.info("Bonus data successfully uploaded to Hugging Face.")
|
| 129 |
except Exception as e:
|
|
@@ -198,6 +203,7 @@ TEMPLATE = """
|
|
| 198 |
--shadow-glow: 0 0 35px var(--shadow-color);
|
| 199 |
--shadow-color-red: rgba(244, 67, 54, 0.15);
|
| 200 |
--shadow-glow-red: 0 0 35px var(--shadow-color-red);
|
|
|
|
| 201 |
}
|
| 202 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 203 |
html, body {
|
|
@@ -218,27 +224,11 @@ TEMPLATE = """
|
|
| 218 |
flex-direction: column;
|
| 219 |
gap: var(--padding-m);
|
| 220 |
}
|
| 221 |
-
.header {
|
| 222 |
-
|
| 223 |
-
padding: var(--padding-m) 0;
|
| 224 |
-
}
|
| 225 |
-
.logo {
|
| 226 |
-
font-size: 2.5em;
|
| 227 |
-
font-weight: 800;
|
| 228 |
-
color: var(--text-color);
|
| 229 |
-
letter-spacing: -1px;
|
| 230 |
-
}
|
| 231 |
.logo span { color: var(--brand-yellow); }
|
| 232 |
-
.welcome-text {
|
| 233 |
-
|
| 234 |
-
color: var(--text-secondary-color);
|
| 235 |
-
margin-top: 4px;
|
| 236 |
-
}
|
| 237 |
-
.card-grid {
|
| 238 |
-
display: grid;
|
| 239 |
-
grid-template-columns: 1fr 1fr;
|
| 240 |
-
gap: var(--padding-m);
|
| 241 |
-
}
|
| 242 |
.bonus-card, .debt-card {
|
| 243 |
background: linear-gradient(145deg, #2a2a2a, #1c1c1c);
|
| 244 |
border-radius: calc(var(--border-radius) + 8px);
|
|
@@ -247,93 +237,51 @@ TEMPLATE = """
|
|
| 247 |
position: relative;
|
| 248 |
overflow: hidden;
|
| 249 |
}
|
| 250 |
-
.bonus-card {
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
}
|
| 254 |
-
.
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
}
|
| 258 |
-
.
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
}
|
| 264 |
-
.bonus-amount {
|
| 265 |
-
font-size: 3em;
|
| 266 |
-
font-weight: 800;
|
| 267 |
-
color: var(--brand-yellow);
|
| 268 |
-
letter-spacing: -2px;
|
| 269 |
-
line-height: 1;
|
| 270 |
-
}
|
| 271 |
-
.debt-amount {
|
| 272 |
-
font-size: 3em;
|
| 273 |
-
font-weight: 800;
|
| 274 |
-
color: var(--brand-red);
|
| 275 |
-
letter-spacing: -2px;
|
| 276 |
-
line-height: 1;
|
| 277 |
-
}
|
| 278 |
-
.client-id-card {
|
| 279 |
-
background-color: var(--card-bg);
|
| 280 |
-
border-radius: var(--border-radius);
|
| 281 |
-
padding: var(--padding-m);
|
| 282 |
-
display: flex;
|
| 283 |
-
justify-content: space-between;
|
| 284 |
-
align-items: center;
|
| 285 |
-
}
|
| 286 |
-
.client-id-label {
|
| 287 |
-
font-weight: 500;
|
| 288 |
-
color: var(--text-secondary-color);
|
| 289 |
-
}
|
| 290 |
-
.client-id-value {
|
| 291 |
-
font-size: 1.3em;
|
| 292 |
-
font-weight: 700;
|
| 293 |
-
color: var(--brand-yellow);
|
| 294 |
-
letter-spacing: 2px;
|
| 295 |
-
background-color: rgba(255,193,7,0.1);
|
| 296 |
-
padding: 4px 10px;
|
| 297 |
-
border-radius: 8px;
|
| 298 |
-
}
|
| 299 |
-
.history-section {
|
| 300 |
-
background-color: var(--card-bg);
|
| 301 |
-
border-radius: var(--border-radius);
|
| 302 |
-
padding: var(--padding-l);
|
| 303 |
-
}
|
| 304 |
-
.history-title {
|
| 305 |
-
font-size: 1.4em;
|
| 306 |
-
font-weight: 700;
|
| 307 |
-
margin-bottom: var(--padding-m);
|
| 308 |
-
padding-bottom: var(--padding-m);
|
| 309 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 310 |
-
}
|
| 311 |
-
.history-list {
|
| 312 |
-
list-style: none;
|
| 313 |
-
padding: 0;
|
| 314 |
-
margin: 0;
|
| 315 |
-
max-height: 35vh;
|
| 316 |
-
overflow-y: auto;
|
| 317 |
-
}
|
| 318 |
-
.history-item {
|
| 319 |
-
display: flex;
|
| 320 |
-
justify-content: space-between;
|
| 321 |
-
align-items: center;
|
| 322 |
-
padding: 14px 4px;
|
| 323 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 324 |
-
}
|
| 325 |
.history-item:last-child { border-bottom: none; }
|
| 326 |
.history-details { display: flex; flex-direction: column; }
|
| 327 |
.history-description { font-size: 1em; font-weight: 500; }
|
|
|
|
| 328 |
.history-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; }
|
| 329 |
.history-amount { font-size: 1.1em; font-weight: 700; }
|
| 330 |
.history-amount.accrual { color: #4CAF50; }
|
| 331 |
.history-amount.deduction { color: #F44336; }
|
| 332 |
-
.
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
</style>
|
| 338 |
</head>
|
| 339 |
<body>
|
|
@@ -367,6 +315,9 @@ TEMPLATE = """
|
|
| 367 |
<li class="history-item">
|
| 368 |
<div class="history-details">
|
| 369 |
<span class="history-description">{{ item.description }}</span>
|
|
|
|
|
|
|
|
|
|
| 370 |
<span class="history-date">{{ item.date_str }}</span>
|
| 371 |
</div>
|
| 372 |
{% if item.transaction_type == 'bonus' %}
|
|
@@ -377,6 +328,10 @@ TEMPLATE = """
|
|
| 377 |
<span class="history-amount {{ 'deduction' if item.type == 'accrual' else 'accrual' }}">
|
| 378 |
{{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
|
| 379 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
{% endif %}
|
| 381 |
</li>
|
| 382 |
{% endfor %}
|
|
@@ -387,6 +342,26 @@ TEMPLATE = """
|
|
| 387 |
</section>
|
| 388 |
</div>
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
<script>
|
| 391 |
const tg = window.Telegram.WebApp;
|
| 392 |
|
|
@@ -398,6 +373,7 @@ TEMPLATE = """
|
|
| 398 |
root.style.setProperty('--text-color', themeParams.text_color || '#ffffff');
|
| 399 |
root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0');
|
| 400 |
root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107');
|
|
|
|
| 401 |
root.style.setProperty('--card-bg', themeParams.secondary_bg_color || (isDark ? '#1c1c1e' : '#f1f1f1'));
|
| 402 |
}
|
| 403 |
|
|
@@ -462,6 +438,36 @@ TEMPLATE = """
|
|
| 462 |
}
|
| 463 |
}, 3000);
|
| 464 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
</script>
|
| 466 |
</body>
|
| 467 |
</html>
|
|
@@ -479,19 +485,10 @@ ADMIN_TEMPLATE = """
|
|
| 479 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 480 |
<style>
|
| 481 |
:root {
|
| 482 |
-
--admin-bg: #f8f9fa;
|
| 483 |
-
--admin-
|
| 484 |
-
--admin-
|
| 485 |
-
--admin-
|
| 486 |
-
--admin-shadow: rgba(0, 0, 0, 0.05);
|
| 487 |
-
--admin-primary: #FFC107;
|
| 488 |
-
--admin-primary-dark: #e0a800;
|
| 489 |
-
--admin-secondary: #6c757d;
|
| 490 |
-
--admin-success: #198754;
|
| 491 |
-
--admin-danger: #dc3545;
|
| 492 |
-
--border-radius: 12px;
|
| 493 |
-
--padding: 1.5rem;
|
| 494 |
-
--font-family: 'Inter', sans-serif;
|
| 495 |
}
|
| 496 |
body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; }
|
| 497 |
.container { max-width: 1200px; margin: 0 auto; }
|
|
@@ -509,6 +506,8 @@ ADMIN_TEMPLATE = """
|
|
| 509 |
.btn-primary:hover { background-color: var(--admin-primary-dark); }
|
| 510 |
.btn-delete { background-color: var(--admin-danger); color: white; }
|
| 511 |
.btn-delete:hover { background-color: #c82333; }
|
|
|
|
|
|
|
| 512 |
.user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--padding); margin-top: var(--padding); }
|
| 513 |
.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; }
|
| 514 |
.user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); }
|
|
@@ -520,12 +519,14 @@ ADMIN_TEMPLATE = """
|
|
| 520 |
.user-balances .label { font-size: 0.9em; color: var(--admin-secondary); }
|
| 521 |
.user-balances .amount.bonus { font-size: 1.8em; font-weight: 700; color: var(--admin-primary-dark); }
|
| 522 |
.user-balances .amount.debt { font-size: 1.8em; font-weight: 700; color: var(--admin-danger); }
|
| 523 |
-
.user-actions { margin-top: auto; display:
|
|
|
|
| 524 |
.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; }
|
| 525 |
.btn-manage:hover { background-color: var(--admin-primary-dark); }
|
| 526 |
.no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
|
| 527 |
.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); }
|
| 528 |
-
.modal-content { background-color: var(--admin-bg); margin: 5% auto; padding: var(--padding); border: 1px solid var(--admin-border); width: 90%; max-width:
|
|
|
|
| 529 |
.modal-close { color: #aaa; position: absolute; top: 15px; right: 25px; font-size: 28px; font-weight: bold; cursor: pointer; }
|
| 530 |
.modal-header { padding-bottom: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--admin-border); }
|
| 531 |
.modal-header h2 { margin: 0; font-size: 1.5rem; }
|
|
@@ -533,93 +534,80 @@ ADMIN_TEMPLATE = """
|
|
| 533 |
.form-section { border: 1px solid var(--admin-border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 534 |
.form-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.1em; }
|
| 535 |
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: flex-end; }
|
|
|
|
|
|
|
| 536 |
.form-group { display: flex; flex-direction: column; }
|
| 537 |
.form-group label { margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9em; }
|
| 538 |
-
.form-group input { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; }
|
| 539 |
-
.
|
|
|
|
|
|
|
| 540 |
.summary-item { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.95em; }
|
| 541 |
.summary-item strong { font-weight: 600; }
|
| 542 |
-
.history-container { margin-top: 1.5rem; }
|
| 543 |
-
.history-container h3 { font-size: 1.2rem; margin-bottom: 1rem; }
|
| 544 |
-
.history-list { list-style: none; padding: 0; max-height: 200px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 8px; }
|
| 545 |
-
.history-item { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--admin-border); }
|
| 546 |
-
.history-item:last-child { border-bottom: none; }
|
| 547 |
-
.history-item .desc { font-size: 0.9em; }
|
| 548 |
-
.
|
| 549 |
-
.
|
| 550 |
-
.
|
| 551 |
-
.history-item .
|
| 552 |
-
.history-item .amount.
|
|
|
|
|
|
|
|
|
|
| 553 |
.modal-footer { margin-top: 1.5rem; display: flex; justify-content: flex-end; align-items: center; gap: 1rem;}
|
| 554 |
.modal-footer button { padding: 12px 25px; font-size: 1.1em; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
| 555 |
.btn-submit { background-color: var(--admin-success); color: white; }
|
| 556 |
-
.status-message {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
</style>
|
| 558 |
</head>
|
| 559 |
<body>
|
| 560 |
<div class="container">
|
| 561 |
<h1>Панель администратора Bonus</h1>
|
| 562 |
<div class="summary-bar">
|
| 563 |
-
<div class="summary-card">
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
</div>
|
| 567 |
-
<div class="summary-card">
|
| 568 |
-
<div class="value bonus">{{ "%.2f"|format(summary.total_bonuses|float) }}</div>
|
| 569 |
-
<div class="label">Всего бонусов</div>
|
| 570 |
-
</div>
|
| 571 |
-
<div class="summary-card">
|
| 572 |
-
<div class="value debt">{{ "%.2f"|format(summary.total_debts|float) }}</div>
|
| 573 |
-
<div class="label">Всего долгов</div>
|
| 574 |
-
</div>
|
| 575 |
-
<div class="summary-card">
|
| 576 |
-
<div class="value debt">{{ summary.users_with_debt }}</div>
|
| 577 |
-
<div class="label">Клиенты с долгом</div>
|
| 578 |
-
</div>
|
| 579 |
</div>
|
| 580 |
-
|
| 581 |
<div class="controls-bar">
|
| 582 |
<input type="text" id="searchInput" onkeyup="searchUsers()" placeholder="Поиск по имени, ID, username, номеру...">
|
| 583 |
<button class="btn btn-primary" onclick="openAddClientModal()">Добавить клиента</button>
|
| 584 |
</div>
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
<div class="user-
|
| 591 |
-
<
|
| 592 |
-
<div class="user
|
| 593 |
-
<div class="name">{{ user.first_name or '' }} {{ user.last_name or '' }}</div>
|
| 594 |
-
<div class="username">@{{ user.username if user.username else user.phone_number }} | ID: {{ user.id }}</div>
|
| 595 |
-
</div>
|
| 596 |
-
</div>
|
| 597 |
-
<div class="user-balances">
|
| 598 |
-
<div>
|
| 599 |
-
<div class="label">Бонусы</div>
|
| 600 |
-
<div class="amount bonus">{{ "%.2f"|format(user.bonuses|float) }}</div>
|
| 601 |
-
</div>
|
| 602 |
-
<div>
|
| 603 |
-
<div class="label">Долг</div>
|
| 604 |
-
<div class="amount debt">{{ "%.2f"|format(user.debts|float if user.debts else 0) }}</div>
|
| 605 |
-
</div>
|
| 606 |
-
</div>
|
| 607 |
-
<div class="user-actions">
|
| 608 |
-
<button class="btn-manage" onclick='openTransactionModal({{ user|tojson }})'>Управление счетом</button>
|
| 609 |
-
{% if user.telegram_id == None %}
|
| 610 |
-
<button class="btn btn-delete" onclick='deleteClient("{{ user.id }}")'>Удалить клиента</button>
|
| 611 |
-
{% endif %}
|
| 612 |
</div>
|
| 613 |
</div>
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
</div>
|
| 620 |
|
| 621 |
<div id="transactionModal" class="modal">
|
| 622 |
-
<div class="modal-content">
|
| 623 |
<span class="modal-close" onclick="closeModal('transactionModal')">×</span>
|
| 624 |
<div class="modal-header">
|
| 625 |
<h2 id="modalUserName"></h2>
|
|
@@ -630,20 +618,13 @@ ADMIN_TEMPLATE = """
|
|
| 630 |
<div class="form-section">
|
| 631 |
<h3>Бонусы</h3>
|
| 632 |
<div class="form-row">
|
| 633 |
-
<div class="form-group">
|
| 634 |
-
|
| 635 |
-
<input type="number" id="purchaseAmount" placeholder="1500" oninput="updateCalculations()">
|
| 636 |
-
</div>
|
| 637 |
-
<div class="form-group">
|
| 638 |
-
<label for="deductAmount">Списать бонусов</label>
|
| 639 |
-
<input type="number" id="deductAmount" placeholder="100" oninput="updateCalculations()">
|
| 640 |
-
</div>
|
| 641 |
</div>
|
| 642 |
<div class="calculation-summary">
|
| 643 |
<div class="summary-item"><span>Текущий баланс:</span> <strong id="summaryCurrentBalance">0.00</strong></div>
|
| 644 |
<div class="summary-item"><span>Будет начислено (2%):</span> <strong id="summaryAccrual">+0.00</strong></div>
|
| 645 |
-
<div class="summary-item"><span>Будет списано:</span> <strong id="summaryDeduction">-0.00</strong></div>
|
| 646 |
-
<hr>
|
| 647 |
<div class="summary-item"><strong>Итоговый баланс бонусов:</strong> <strong id="summaryFinalBalance">0.00</strong></div>
|
| 648 |
</div>
|
| 649 |
</div>
|
|
@@ -651,71 +632,104 @@ ADMIN_TEMPLATE = """
|
|
| 651 |
<div class="form-section">
|
| 652 |
<h3>Долги</h3>
|
| 653 |
<div class="form-row">
|
| 654 |
-
<div class="form-group">
|
| 655 |
-
|
| 656 |
-
<input type="number" id="addDebtAmount" placeholder="500" oninput="updateCalculations()">
|
| 657 |
-
</div>
|
| 658 |
-
<div class="form-group">
|
| 659 |
-
<label for="repayDebtAmount">Погасить долг</label>
|
| 660 |
-
<input type="number" id="repayDebtAmount" placeholder="200" oninput="updateCalculations()">
|
| 661 |
-
</div>
|
| 662 |
</div>
|
| 663 |
<div class="calculation-summary">
|
| 664 |
<div class="summary-item"><span>Текущий долг:</span> <strong id="summaryCurrentDebt">0.00</strong></div>
|
| 665 |
<div class="summary-item"><span>Будет добавлено:</span> <strong id="summaryAddDebt">+0.00</strong></div>
|
| 666 |
-
<div class="summary-item"><span>Будет погашено:</span> <strong id="summaryRepayDebt">-0.00</strong></div>
|
| 667 |
-
<hr>
|
| 668 |
<div class="summary-item"><strong>Итоговый долг:</strong> <strong id="summaryFinalDebt">0.00</strong></div>
|
| 669 |
</div>
|
| 670 |
</div>
|
| 671 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 672 |
<div class="history-container">
|
| 673 |
<h3>Общая история операций</h3>
|
| 674 |
<ul id="modalHistoryList" class="history-list"></ul>
|
| 675 |
</div>
|
| 676 |
-
<div class="modal-footer">
|
| 677 |
-
<div id="modalStatus" class="status-message"></div>
|
| 678 |
-
<button class="btn-submit" onclick="submitTransaction()">Провести операцию</button>
|
| 679 |
-
</div>
|
| 680 |
</div>
|
| 681 |
</div>
|
| 682 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
<div id="addClientModal" class="modal">
|
| 684 |
<div class="modal-content">
|
| 685 |
<span class="modal-close" onclick="closeModal('addClientModal')">×</span>
|
| 686 |
-
<div class="modal-header">
|
| 687 |
-
|
| 688 |
-
</div>
|
| 689 |
-
<div class="
|
| 690 |
-
<label for="newClientFirstName">Имя</label>
|
| 691 |
-
<input type="text" id="newClientFirstName" placeholder="Иван">
|
| 692 |
-
</div>
|
| 693 |
-
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 694 |
-
<label for="newClientPhone">Номер телефона (уникальный)</label>
|
| 695 |
-
<input type="tel" id="newClientPhone" placeholder="+79001234567">
|
| 696 |
-
</div>
|
| 697 |
-
<div class="modal-footer">
|
| 698 |
-
<div id="addClientStatus" class="status-message"></div>
|
| 699 |
-
<button class="btn-submit" onclick="submitNewClient()">Сохранить клиента</button>
|
| 700 |
-
</div>
|
| 701 |
</div>
|
| 702 |
</div>
|
| 703 |
|
| 704 |
<script>
|
| 705 |
const transactionModal = document.getElementById('transactionModal');
|
| 706 |
const addClientModal = document.getElementById('addClientModal');
|
|
|
|
|
|
|
|
|
|
| 707 |
let currentUserData = null;
|
|
|
|
| 708 |
|
| 709 |
function searchUsers() {
|
| 710 |
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
| 711 |
const userCards = document.querySelectorAll('.user-card');
|
| 712 |
userCards.forEach(card => {
|
| 713 |
const cardSearchTerm = card.getAttribute('data-search-term');
|
| 714 |
-
if (cardSearchTerm.includes(searchTerm))
|
| 715 |
-
|
| 716 |
-
} else {
|
| 717 |
-
card.style.display = 'none';
|
| 718 |
-
}
|
| 719 |
});
|
| 720 |
}
|
| 721 |
|
|
@@ -724,48 +738,182 @@ ADMIN_TEMPLATE = """
|
|
| 724 |
document.getElementById('modalUserId').value = userData.id;
|
| 725 |
document.getElementById('modalUserName').textContent = `${userData.first_name || ''} ${userData.last_name || ''}`;
|
| 726 |
document.getElementById('modalUserUsername').textContent = `@${userData.username || userData.phone_number} | ID: ${userData.id}`;
|
| 727 |
-
document.getElementById(
|
| 728 |
-
document.getElementById('deductAmount').value = '';
|
| 729 |
-
document.getElementById('addDebtAmount').value = '';
|
| 730 |
-
document.getElementById('repayDebtAmount').value = '';
|
| 731 |
document.getElementById('modalStatus').textContent = '';
|
| 732 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
const historyList = document.getElementById('modalHistoryList');
|
| 734 |
historyList.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
|
| 736 |
-
const
|
| 737 |
-
const debtHistory = (userData.debt_history || []).map(h => ({...h, transaction_type: 'debt'}));
|
| 738 |
-
const combinedHistory = [...bonusHistory, ...debtHistory].sort((a, b) => new Date(b.date) - new Date(a.date));
|
| 739 |
|
| 740 |
if (combinedHistory.length > 0) {
|
| 741 |
combinedHistory.forEach(item => {
|
| 742 |
const li = document.createElement('li');
|
| 743 |
li.className = 'history-item';
|
| 744 |
-
let
|
| 745 |
if (item.transaction_type === 'bonus') {
|
| 746 |
-
sign = item.type === 'accrual' ? '+' : '-';
|
| 747 |
amountClass = item.type === 'accrual' ? 'bonus-accrual' : 'bonus-deduction';
|
| 748 |
-
amountText = `${
|
| 749 |
-
} else
|
| 750 |
-
sign = item.type === 'accrual' ? '+' : '-';
|
| 751 |
amountClass = item.type === 'accrual' ? 'debt-accrual' : 'debt-payment';
|
| 752 |
-
amountText = `${
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
}
|
| 754 |
-
li.innerHTML =
|
| 755 |
-
<div>
|
| 756 |
-
<div class="desc">${item.description}</div>
|
| 757 |
-
<div class="date">${item.date_str}</div>
|
| 758 |
-
</div>
|
| 759 |
-
<div class="amount ${amountClass}">${amountText}</div>
|
| 760 |
-
`;
|
| 761 |
historyList.appendChild(li);
|
| 762 |
});
|
| 763 |
} else {
|
| 764 |
historyList.innerHTML = '<li style="text-align:center; padding: 1rem; color: var(--admin-secondary);">Нет истории</li>';
|
| 765 |
}
|
| 766 |
|
| 767 |
-
|
| 768 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
}
|
| 770 |
|
| 771 |
function openAddClientModal() {
|
|
@@ -777,23 +925,25 @@ ADMIN_TEMPLATE = """
|
|
| 777 |
|
| 778 |
function closeModal(modalId) {
|
| 779 |
document.getElementById(modalId).style.display = 'none';
|
| 780 |
-
if (modalId === 'transactionModal')
|
| 781 |
-
|
| 782 |
-
}
|
| 783 |
}
|
| 784 |
|
| 785 |
function updateCalculations() {
|
| 786 |
if (!currentUserData) return;
|
| 787 |
-
|
| 788 |
const currentBalance = parseFloat(currentUserData.bonuses) || 0;
|
| 789 |
const purchaseAmount = parseFloat(document.getElementById('purchaseAmount').value) || 0;
|
| 790 |
const deductAmount = parseFloat(document.getElementById('deductAmount').value) || 0;
|
| 791 |
const accrualAmount = purchaseAmount * 0.02;
|
| 792 |
-
let finalDeductAmount = deductAmount;
|
| 793 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 794 |
finalDeductAmount = currentBalance;
|
| 795 |
document.getElementById('deductAmount').value = finalDeductAmount > 0 ? finalDeductAmount.toFixed(2) : '';
|
| 796 |
}
|
|
|
|
| 797 |
const finalBalance = currentBalance + accrualAmount - finalDeductAmount;
|
| 798 |
document.getElementById('summaryCurrentBalance').textContent = currentBalance.toFixed(2);
|
| 799 |
document.getElementById('summaryAccrual').textContent = `+${accrualAmount.toFixed(2)}`;
|
|
@@ -803,11 +953,17 @@ ADMIN_TEMPLATE = """
|
|
| 803 |
const currentDebt = parseFloat(currentUserData.debts) || 0;
|
| 804 |
const addDebtAmount = parseFloat(document.getElementById('addDebtAmount').value) || 0;
|
| 805 |
const repayDebtAmount = parseFloat(document.getElementById('repayDebtAmount').value) || 0;
|
| 806 |
-
let finalRepayAmount = repayDebtAmount;
|
| 807 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
finalRepayAmount = currentDebt;
|
| 809 |
document.getElementById('repayDebtAmount').value = finalRepayAmount > 0 ? finalRepayAmount.toFixed(2) : '';
|
| 810 |
}
|
|
|
|
|
|
|
| 811 |
const finalDebt = currentDebt + addDebtAmount - finalRepayAmount;
|
| 812 |
document.getElementById('summaryCurrentDebt').textContent = currentDebt.toFixed(2);
|
| 813 |
document.getElementById('summaryAddDebt').textContent = `+${addDebtAmount.toFixed(2)}`;
|
|
@@ -830,7 +986,7 @@ ADMIN_TEMPLATE = """
|
|
| 830 |
|
| 831 |
if (payload.purchase_amount <= 0 && payload.deduct_amount <= 0 && payload.add_debt_amount <= 0 && payload.repay_debt_amount <= 0) {
|
| 832 |
statusEl.style.color = 'var(--admin-danger)';
|
| 833 |
-
statusEl.textContent = 'Введите сумму для
|
| 834 |
return;
|
| 835 |
}
|
| 836 |
try {
|
|
@@ -843,10 +999,8 @@ ADMIN_TEMPLATE = """
|
|
| 843 |
if (response.ok) {
|
| 844 |
statusEl.style.color = 'var(--admin-success)';
|
| 845 |
statusEl.textContent = 'Операция успешно проведена!';
|
| 846 |
-
setTimeout(() => {
|
| 847 |
-
} else {
|
| 848 |
-
throw new Error(result.message || 'Произошла ошибка');
|
| 849 |
-
}
|
| 850 |
} catch (error) {
|
| 851 |
statusEl.style.color = 'var(--admin-danger)';
|
| 852 |
statusEl.textContent = `Ошибка: ${error.message}`;
|
|
@@ -857,18 +1011,15 @@ ADMIN_TEMPLATE = """
|
|
| 857 |
const statusEl = document.getElementById('addClientStatus');
|
| 858 |
statusEl.style.color = 'var(--admin-secondary)';
|
| 859 |
statusEl.textContent = 'Сохранение...';
|
| 860 |
-
|
| 861 |
const payload = {
|
| 862 |
first_name: document.getElementById('newClientFirstName').value.trim(),
|
| 863 |
phone_number: document.getElementById('newClientPhone').value.trim(),
|
| 864 |
};
|
| 865 |
-
|
| 866 |
if (!payload.first_name || !payload.phone_number) {
|
| 867 |
statusEl.style.color = 'var(--admin-danger)';
|
| 868 |
statusEl.textContent = 'Имя и номер телефона обязательны.';
|
| 869 |
return;
|
| 870 |
}
|
| 871 |
-
|
| 872 |
try {
|
| 873 |
const response = await fetch('/admin/add_client', {
|
| 874 |
method: 'POST',
|
|
@@ -880,9 +1031,7 @@ ADMIN_TEMPLATE = """
|
|
| 880 |
statusEl.style.color = 'var(--admin-success)';
|
| 881 |
statusEl.textContent = 'Клиент успешно добавлен!';
|
| 882 |
setTimeout(() => { location.reload(); }, 1500);
|
| 883 |
-
} else {
|
| 884 |
-
throw new Error(result.message || 'Произошла ошибка');
|
| 885 |
-
}
|
| 886 |
} catch (error) {
|
| 887 |
statusEl.style.color = 'var(--admin-danger)';
|
| 888 |
statusEl.textContent = `Ошибка: ${error.message}`;
|
|
@@ -890,9 +1039,7 @@ ADMIN_TEMPLATE = """
|
|
| 890 |
}
|
| 891 |
|
| 892 |
async function deleteClient(userId) {
|
| 893 |
-
if (!confirm(`Вы уверены, что хотите удалить клиента с ID ${userId}? Это действие необратимо.`))
|
| 894 |
-
return;
|
| 895 |
-
}
|
| 896 |
try {
|
| 897 |
const response = await fetch('/admin/delete_client', {
|
| 898 |
method: 'POST',
|
|
@@ -900,23 +1047,41 @@ ADMIN_TEMPLATE = """
|
|
| 900 |
body: JSON.stringify({ user_id: userId })
|
| 901 |
});
|
| 902 |
const result = await response.json();
|
| 903 |
-
if (response.ok)
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
} catch (error) {
|
| 909 |
-
|
|
|
|
| 910 |
}
|
| 911 |
}
|
| 912 |
|
|
|
|
| 913 |
window.onclick = function(event) {
|
| 914 |
-
if (event.target == transactionModal)
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
if (event.target ==
|
| 918 |
-
closeModal('addClientModal');
|
| 919 |
-
}
|
| 920 |
}
|
| 921 |
</script>
|
| 922 |
</body>
|
|
@@ -926,38 +1091,41 @@ ADMIN_TEMPLATE = """
|
|
| 926 |
@app.route('/')
|
| 927 |
def index():
|
| 928 |
user_id_str = request.args.get('user_id_for_test')
|
| 929 |
-
|
| 930 |
current_data = load_visitor_data()
|
| 931 |
user_data = {}
|
| 932 |
|
| 933 |
if user_id_str and user_id_str in current_data:
|
| 934 |
-
user_data = current_data[user_id_str]
|
| 935 |
user_data['id'] = user_id_str
|
| 936 |
|
|
|
|
| 937 |
bonus_history = user_data.get('history', [])
|
| 938 |
for item in bonus_history:
|
| 939 |
-
|
|
|
|
|
|
|
| 940 |
|
| 941 |
debt_history = user_data.get('debt_history', [])
|
| 942 |
for item in debt_history:
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
user_data['combined_history'] = combined_history
|
| 951 |
else:
|
| 952 |
user_data = {
|
| 953 |
-
"id": "N/A",
|
| 954 |
-
"
|
| 955 |
-
"debts": 0,
|
| 956 |
-
"history": [],
|
| 957 |
-
"debt_history": [],
|
| 958 |
"combined_history": []
|
| 959 |
}
|
| 960 |
-
|
| 961 |
return render_template_string(TEMPLATE, user=user_data)
|
| 962 |
|
| 963 |
@app.route('/verify', methods=['POST'])
|
|
@@ -969,7 +1137,6 @@ def verify_data():
|
|
| 969 |
return jsonify({"status": "error", "message": "Missing initData"}), 400
|
| 970 |
|
| 971 |
user_data_parsed, is_valid = verify_telegram_data(init_data_str)
|
| 972 |
-
|
| 973 |
user_info_dict = {}
|
| 974 |
if user_data_parsed and 'user' in user_data_parsed:
|
| 975 |
try:
|
|
@@ -982,7 +1149,7 @@ def verify_data():
|
|
| 982 |
if is_valid:
|
| 983 |
tg_user_id = user_info_dict.get('id')
|
| 984 |
if tg_user_id:
|
| 985 |
-
|
| 986 |
all_data = load_visitor_data()
|
| 987 |
|
| 988 |
existing_user_key = None
|
|
@@ -991,6 +1158,7 @@ def verify_data():
|
|
| 991 |
existing_user_key = key
|
| 992 |
break
|
| 993 |
|
|
|
|
| 994 |
if existing_user_key:
|
| 995 |
user_entry = all_data[existing_user_key]
|
| 996 |
user_entry.update({
|
|
@@ -999,40 +1167,27 @@ def verify_data():
|
|
| 999 |
'username': user_info_dict.get('username'),
|
| 1000 |
'photo_url': user_info_dict.get('photo_url'),
|
| 1001 |
'language_code': user_info_dict.get('language_code'),
|
| 1002 |
-
'visited_at':
|
| 1003 |
-
'visited_at_str':
|
| 1004 |
})
|
| 1005 |
-
user_id_to_save = existing_user_key
|
| 1006 |
else:
|
| 1007 |
new_user_id = generate_unique_id(all_data)
|
|
|
|
| 1008 |
user_entry = {
|
| 1009 |
-
'id': new_user_id,
|
| 1010 |
-
'
|
| 1011 |
-
'
|
| 1012 |
-
'
|
| 1013 |
-
'
|
| 1014 |
-
'
|
| 1015 |
-
'language_code': user_info_dict.get('language_code'),
|
| 1016 |
-
'is_premium': user_info_dict.get('is_premium', False),
|
| 1017 |
-
'phone_number': None,
|
| 1018 |
-
'visited_at': now.timestamp(),
|
| 1019 |
-
'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S'),
|
| 1020 |
-
'bonuses': 0,
|
| 1021 |
-
'history': [],
|
| 1022 |
-
'debts': 0,
|
| 1023 |
-
'debt_history': []
|
| 1024 |
}
|
| 1025 |
-
user_id_to_save = new_user_id
|
| 1026 |
-
|
| 1027 |
save_visitor_data({user_id_to_save: user_entry})
|
| 1028 |
-
|
| 1029 |
return jsonify({"status": "ok", "verified": True, "user_id": user_id_to_save})
|
| 1030 |
else:
|
| 1031 |
return jsonify({"status": "error", "verified": True, "message": "User ID not found in parsed data"}), 400
|
| 1032 |
else:
|
| 1033 |
logging.warning(f"Verification failed for user: {user_info_dict.get('id')}")
|
| 1034 |
return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
|
| 1035 |
-
|
| 1036 |
except Exception as e:
|
| 1037 |
logging.exception("Error in /verify endpoint")
|
| 1038 |
return jsonify({"status": "error", "message": "Internal server error"}), 500
|
|
@@ -1042,8 +1197,9 @@ def admin_panel():
|
|
| 1042 |
current_data = load_visitor_data()
|
| 1043 |
users_list = []
|
| 1044 |
for user_id, user_data in current_data.items():
|
| 1045 |
-
|
| 1046 |
-
|
|
|
|
| 1047 |
|
| 1048 |
total_users = len(users_list)
|
| 1049 |
total_bonuses = sum(u.get('bonuses', 0) for u in users_list)
|
|
@@ -1051,14 +1207,21 @@ def admin_panel():
|
|
| 1051 |
users_with_debt = sum(1 for u in users_list if u.get('debts', 0) > 0)
|
| 1052 |
|
| 1053 |
summary_stats = {
|
| 1054 |
-
"total_users": total_users,
|
| 1055 |
-
"
|
| 1056 |
-
"total_debts": total_debts,
|
| 1057 |
-
"users_with_debt": users_with_debt
|
| 1058 |
}
|
| 1059 |
-
|
| 1060 |
return render_template_string(ADMIN_TEMPLATE, users=users_list, summary=summary_stats)
|
| 1061 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
@app.route('/admin/add_client', methods=['POST'])
|
| 1063 |
def add_client():
|
| 1064 |
try:
|
|
@@ -1070,41 +1233,25 @@ def add_client():
|
|
| 1070 |
return jsonify({"status": "error", "message": "Имя и номер телефона обязательны."}), 400
|
| 1071 |
|
| 1072 |
all_data = load_visitor_data()
|
| 1073 |
-
|
| 1074 |
for user in all_data.values():
|
| 1075 |
if user.get('phone_number') == phone_number:
|
| 1076 |
return jsonify({"status": "error", "message": "Клиент с таким номером телефона уже существует."}), 409
|
| 1077 |
|
| 1078 |
-
|
| 1079 |
new_id = generate_unique_id(all_data)
|
| 1080 |
|
| 1081 |
new_client = {
|
| 1082 |
-
'id': new_id,
|
| 1083 |
-
'
|
| 1084 |
-
'
|
| 1085 |
-
'
|
| 1086 |
-
'username': None,
|
| 1087 |
-
'photo_url': None,
|
| 1088 |
-
'language_code': 'ru',
|
| 1089 |
-
'is_premium': False,
|
| 1090 |
-
'phone_number': phone_number,
|
| 1091 |
-
'visited_at': now.timestamp(),
|
| 1092 |
-
'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S'),
|
| 1093 |
-
'bonuses': 0,
|
| 1094 |
-
'history': [],
|
| 1095 |
-
'debts': 0,
|
| 1096 |
-
'debt_history': []
|
| 1097 |
}
|
| 1098 |
-
|
| 1099 |
save_visitor_data({new_id: new_client})
|
| 1100 |
-
|
| 1101 |
return jsonify({"status": "ok", "message": "Client added successfully"}), 201
|
| 1102 |
-
|
| 1103 |
except Exception as e:
|
| 1104 |
logging.exception("Error in /admin/add_client endpoint")
|
| 1105 |
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1106 |
|
| 1107 |
-
|
| 1108 |
@app.route('/admin/add_transaction', methods=['POST'])
|
| 1109 |
def add_transaction():
|
| 1110 |
try:
|
|
@@ -1125,62 +1272,76 @@ def add_transaction():
|
|
| 1125 |
return jsonify({"status": "error", "message": "User not found"}), 404
|
| 1126 |
|
| 1127 |
user = all_data[user_id_str]
|
| 1128 |
-
|
| 1129 |
-
now_iso =
|
| 1130 |
-
now_str =
|
| 1131 |
-
|
| 1132 |
-
if deduct_amount > user.get('bonuses', 0):
|
| 1133 |
-
return jsonify({"status": "error", "message": "Недостаточно бонусов для списания"}), 400
|
| 1134 |
|
| 1135 |
-
|
| 1136 |
-
return jsonify({"status": "error", "message": "Сумма погашения превышает текущий долг"}), 400
|
| 1137 |
-
|
| 1138 |
-
# Bonus operations
|
| 1139 |
accrual_amount = purchase_amount * 0.02
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1143 |
|
|
|
|
|
|
|
| 1144 |
if accrual_amount > 0:
|
| 1145 |
-
user['history'].append({
|
| 1146 |
-
"type": "accrual", "amount": accrual_amount,
|
| 1147 |
-
"description": f"Начисление с покупки {purchase_amount}",
|
| 1148 |
-
"date": now_iso, "date_str": now_str
|
| 1149 |
-
})
|
| 1150 |
if deduct_amount > 0:
|
| 1151 |
-
user['history'].append({
|
| 1152 |
-
"type": "deduction", "amount": deduct_amount,
|
| 1153 |
-
"description": "Списание бонусов",
|
| 1154 |
-
"date": now_iso, "date_str": now_str
|
| 1155 |
-
})
|
| 1156 |
|
| 1157 |
-
# Debt operations
|
| 1158 |
user['debts'] = user.get('debts', 0) + add_debt_amount - repay_debt_amount
|
| 1159 |
-
if 'debt_history' not in user or not isinstance(user['debt_history'], list):
|
| 1160 |
-
user['debt_history'] = []
|
| 1161 |
-
|
| 1162 |
if add_debt_amount > 0:
|
| 1163 |
-
user['debt_history'].append({
|
| 1164 |
-
"type": "accrual", "amount": add_debt_amount,
|
| 1165 |
-
"description": "Добавление долга",
|
| 1166 |
-
"date": now_iso, "date_str": now_str
|
| 1167 |
-
})
|
| 1168 |
if repay_debt_amount > 0:
|
| 1169 |
-
user['debt_history'].append({
|
| 1170 |
-
"type": "payment", "amount": repay_debt_amount,
|
| 1171 |
-
"description": "Погашение долга",
|
| 1172 |
-
"date": now_iso, "date_str": now_str
|
| 1173 |
-
})
|
| 1174 |
|
| 1175 |
save_visitor_data({user_id_str: user})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
except Exception as e:
|
| 1183 |
-
logging.exception("Error in /admin/
|
| 1184 |
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1185 |
|
| 1186 |
@app.route('/admin/delete_client', methods=['POST'])
|
|
@@ -1188,23 +1349,16 @@ def delete_client():
|
|
| 1188 |
try:
|
| 1189 |
data = request.get_json()
|
| 1190 |
user_id = data.get('user_id')
|
| 1191 |
-
|
| 1192 |
-
if not user_id:
|
| 1193 |
-
return jsonify({"status": "error", "message": "User ID is required"}), 400
|
| 1194 |
-
|
| 1195 |
user_id_str = str(user_id)
|
| 1196 |
load_visitor_data()
|
| 1197 |
-
|
| 1198 |
with _data_lock:
|
| 1199 |
if user_id_str not in visitor_data_cache:
|
| 1200 |
return jsonify({"status": "error", "message": "User not found"}), 404
|
| 1201 |
-
|
| 1202 |
user_to_delete = visitor_data_cache[user_id_str]
|
| 1203 |
if user_to_delete.get('telegram_id') is not None:
|
| 1204 |
return jsonify({"status": "error", "message": "Cannot delete a Telegram-linked user"}), 403
|
| 1205 |
-
|
| 1206 |
del visitor_data_cache[user_id_str]
|
| 1207 |
-
|
| 1208 |
try:
|
| 1209 |
with open(DATA_FILE, 'w', encoding='utf-8') as f:
|
| 1210 |
json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4)
|
|
@@ -1213,9 +1367,7 @@ def delete_client():
|
|
| 1213 |
except Exception as e:
|
| 1214 |
logging.error(f"Error saving data after deletion: {e}")
|
| 1215 |
return jsonify({"status": "error", "message": "Failed to save data after deletion"}), 500
|
| 1216 |
-
|
| 1217 |
return jsonify({"status": "ok", "message": "Client deleted successfully"}), 200
|
| 1218 |
-
|
| 1219 |
except Exception as e:
|
| 1220 |
logging.exception("Error in /admin/delete_client endpoint")
|
| 1221 |
return jsonify({"status": "error", "message": str(e)}), 500
|
|
@@ -1228,15 +1380,11 @@ if __name__ == '__main__':
|
|
| 1228 |
else:
|
| 1229 |
print("Attempting initial data download from Hugging Face...")
|
| 1230 |
download_data_from_hf()
|
| 1231 |
-
|
| 1232 |
load_visitor_data()
|
| 1233 |
-
|
| 1234 |
print("WARNING: The /admin route is NOT protected. Implement proper authentication for production.")
|
| 1235 |
-
|
| 1236 |
if HF_TOKEN_WRITE:
|
| 1237 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1238 |
backup_thread.start()
|
| 1239 |
print("Periodic backup thread started (every hour).")
|
| 1240 |
-
|
| 1241 |
print("--- Server Ready ---")
|
| 1242 |
app.run(host=HOST, port=PORT, debug=False)
|
|
|
|
| 7 |
import json
|
| 8 |
from urllib.parse import unquote, parse_qs, quote
|
| 9 |
import time
|
| 10 |
+
from datetime import datetime, timezone, timedelta
|
| 11 |
import logging
|
| 12 |
import threading
|
| 13 |
import random
|
|
|
|
| 31 |
_data_lock = threading.Lock()
|
| 32 |
visitor_data_cache = {}
|
| 33 |
|
| 34 |
+
BISHKEK_TZ = timezone(timedelta(hours=6))
|
| 35 |
+
|
| 36 |
def generate_unique_id(all_data):
|
| 37 |
while True:
|
| 38 |
new_id = str(random.randint(10000, 99999))
|
| 39 |
if new_id not in all_data:
|
| 40 |
return new_id
|
| 41 |
|
| 42 |
+
def generate_invoice_id():
|
| 43 |
+
return f"inv_{int(time.time() * 1000)}_{random.randint(100,999)}"
|
| 44 |
+
|
| 45 |
def download_data_from_hf():
|
| 46 |
global visitor_data_cache
|
| 47 |
if not HF_TOKEN_READ:
|
|
|
|
| 128 |
repo_id=REPO_ID,
|
| 129 |
repo_type="dataset",
|
| 130 |
token=HF_TOKEN_WRITE,
|
| 131 |
+
commit_message=f"Update bonus data {datetime.now(BISHKEK_TZ).strftime('%Y-%m-%d %H:%M:%S')}"
|
| 132 |
)
|
| 133 |
logging.info("Bonus data successfully uploaded to Hugging Face.")
|
| 134 |
except Exception as e:
|
|
|
|
| 203 |
--shadow-glow: 0 0 35px var(--shadow-color);
|
| 204 |
--shadow-color-red: rgba(244, 67, 54, 0.15);
|
| 205 |
--shadow-glow-red: 0 0 35px var(--shadow-color-red);
|
| 206 |
+
--link-color: var(--brand-yellow);
|
| 207 |
}
|
| 208 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 209 |
html, body {
|
|
|
|
| 224 |
flex-direction: column;
|
| 225 |
gap: var(--padding-m);
|
| 226 |
}
|
| 227 |
+
.header { text-align: left; padding: var(--padding-m) 0; }
|
| 228 |
+
.logo { font-size: 2.5em; font-weight: 800; color: var(--text-color); letter-spacing: -1px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
.logo span { color: var(--brand-yellow); }
|
| 230 |
+
.welcome-text { font-size: 1em; color: var(--text-secondary-color); margin-top: 4px; }
|
| 231 |
+
.card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--padding-m); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
.bonus-card, .debt-card {
|
| 233 |
background: linear-gradient(145deg, #2a2a2a, #1c1c1c);
|
| 234 |
border-radius: calc(var(--border-radius) + 8px);
|
|
|
|
| 237 |
position: relative;
|
| 238 |
overflow: hidden;
|
| 239 |
}
|
| 240 |
+
.bonus-card { box-shadow: var(--shadow-glow); border: 1px solid rgba(255, 193, 7, 0.2); }
|
| 241 |
+
.debt-card { box-shadow: var(--shadow-glow-red); border: 1px solid rgba(244, 67, 54, 0.2); }
|
| 242 |
+
.card-label { font-size: 1.1em; font-weight: 500; color: var(--text-secondary-color); margin-bottom: 12px; }
|
| 243 |
+
.bonus-amount, .debt-amount { font-size: 3em; font-weight: 800; letter-spacing: -2px; line-height: 1; }
|
| 244 |
+
.bonus-amount { color: var(--brand-yellow); }
|
| 245 |
+
.debt-amount { color: var(--brand-red); }
|
| 246 |
+
.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; }
|
| 247 |
+
.client-id-label { font-weight: 500; color: var(--text-secondary-color); }
|
| 248 |
+
.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; }
|
| 249 |
+
.history-section { background-color: var(--card-bg); border-radius: var(--border-radius); padding: var(--padding-l); }
|
| 250 |
+
.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); }
|
| 251 |
+
.history-list { list-style: none; padding: 0; margin: 0; max-height: 35vh; overflow-y: auto; }
|
| 252 |
+
.history-item { display: flex; justify-content: space-between; align-items: center; padding: 14px 4px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
.history-item:last-child { border-bottom: none; }
|
| 254 |
.history-details { display: flex; flex-direction: column; }
|
| 255 |
.history-description { font-size: 1em; font-weight: 500; }
|
| 256 |
+
.history-invoice-details-link { font-size: 0.8em; color: var(--link-color); cursor: pointer; text-decoration: underline; margin-top: 4px; }
|
| 257 |
.history-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; }
|
| 258 |
.history-amount { font-size: 1.1em; font-weight: 700; }
|
| 259 |
.history-amount.accrual { color: #4CAF50; }
|
| 260 |
.history-amount.deduction { color: #F44336; }
|
| 261 |
+
.history-amount.invoice { color: var(--brand-yellow); }
|
| 262 |
+
.no-history { text-align: center; color: var(--text-secondary-color); padding: 2rem 0; }
|
| 263 |
+
|
| 264 |
+
.modal {
|
| 265 |
+
display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%;
|
| 266 |
+
overflow: auto; background-color: rgba(0,0,0,0.7); backdrop-filter: blur(3px);
|
| 267 |
+
align-items: center; justify-content: center; padding: var(--padding-m);
|
| 268 |
}
|
| 269 |
+
.modal-content {
|
| 270 |
+
background-color: var(--card-bg); color: var(--text-color);
|
| 271 |
+
padding: var(--padding-l); border-radius: var(--border-radius);
|
| 272 |
+
width: 100%; max-width: 500px;
|
| 273 |
+
box-shadow: 0 5px 25px rgba(0,0,0,0.3);
|
| 274 |
+
position: relative;
|
| 275 |
+
}
|
| 276 |
+
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--padding-m); padding-bottom: var(--padding-m); border-bottom: 1px solid rgba(255,255,255,0.1); }
|
| 277 |
+
.modal-title { font-size: 1.3em; font-weight: 700; }
|
| 278 |
+
.modal-close { font-size: 1.8em; font-weight: bold; color: var(--text-secondary-color); cursor: pointer; line-height: 1; }
|
| 279 |
+
.modal-body table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
| 280 |
+
.modal-body th, .modal-body td { text-align: left; padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); font-size: 0.9em; }
|
| 281 |
+
.modal-body th { font-weight: 600; }
|
| 282 |
+
.modal-body td.item-total, .modal-body td.grand-total { text-align: right; }
|
| 283 |
+
.modal-body .grand-total-row td { font-weight: bold; font-size: 1em; padding-top: 10px; border-top: 2px solid rgba(255,255,255,0.2); }
|
| 284 |
+
|
| 285 |
</style>
|
| 286 |
</head>
|
| 287 |
<body>
|
|
|
|
| 315 |
<li class="history-item">
|
| 316 |
<div class="history-details">
|
| 317 |
<span class="history-description">{{ item.description }}</span>
|
| 318 |
+
{% if item.transaction_type == 'invoice' %}
|
| 319 |
+
<span class="history-invoice-details-link" onclick="showClientInvoiceDetails('{{ item.items | tojson | e }}', '{{ item.id }}')">Посмотреть детали</span>
|
| 320 |
+
{% endif %}
|
| 321 |
<span class="history-date">{{ item.date_str }}</span>
|
| 322 |
</div>
|
| 323 |
{% if item.transaction_type == 'bonus' %}
|
|
|
|
| 328 |
<span class="history-amount {{ 'deduction' if item.type == 'accrual' else 'accrual' }}">
|
| 329 |
{{ '+' if item.type == 'accrual' else '-' }}{{ "%.2f"|format(item.amount|float) }}
|
| 330 |
</span>
|
| 331 |
+
{% elif item.transaction_type == 'invoice' %}
|
| 332 |
+
<span class="history-amount invoice">
|
| 333 |
+
{{ "%.2f"|format(item.total_amount|float) }}
|
| 334 |
+
</span>
|
| 335 |
{% endif %}
|
| 336 |
</li>
|
| 337 |
{% endfor %}
|
|
|
|
| 342 |
</section>
|
| 343 |
</div>
|
| 344 |
|
| 345 |
+
<div id="clientInvoiceDetailModal" class="modal">
|
| 346 |
+
<div class="modal-content">
|
| 347 |
+
<div class="modal-header">
|
| 348 |
+
<h3 id="clientInvoiceModalTitle" class="modal-title">Детали накладной</h3>
|
| 349 |
+
<span class="modal-close" onclick="closeClientInvoiceModal()">×</span>
|
| 350 |
+
</div>
|
| 351 |
+
<div class="modal-body">
|
| 352 |
+
<table id="clientInvoiceItemsTable">
|
| 353 |
+
<thead>
|
| 354 |
+
<tr><th>Товар</th><th>Кол-во</th><th>Цена</th><th>Сумма</th></tr>
|
| 355 |
+
</thead>
|
| 356 |
+
<tbody></tbody>
|
| 357 |
+
<tfoot>
|
| 358 |
+
<tr class="grand-total-row"><td colspan="3">Итого:</td><td id="clientInvoiceGrandTotal" class="grand-total"></td></tr>
|
| 359 |
+
</tfoot>
|
| 360 |
+
</table>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
<script>
|
| 366 |
const tg = window.Telegram.WebApp;
|
| 367 |
|
|
|
|
| 373 |
root.style.setProperty('--text-color', themeParams.text_color || '#ffffff');
|
| 374 |
root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0');
|
| 375 |
root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107');
|
| 376 |
+
root.style.setProperty('--link-color', themeParams.link_color || themeParams.button_color || '#FFC107');
|
| 377 |
root.style.setProperty('--card-bg', themeParams.secondary_bg_color || (isDark ? '#1c1c1e' : '#f1f1f1'));
|
| 378 |
}
|
| 379 |
|
|
|
|
| 438 |
}
|
| 439 |
}, 3000);
|
| 440 |
}
|
| 441 |
+
|
| 442 |
+
function showClientInvoiceDetails(invoiceItemsJson, invoiceId) {
|
| 443 |
+
const items = JSON.parse(invoiceItemsJson);
|
| 444 |
+
const modal = document.getElementById('clientInvoiceDetailModal');
|
| 445 |
+
document.getElementById('clientInvoiceModalTitle').textContent = `Детали накладной #${invoiceId.substring(0,8)}...`;
|
| 446 |
+
const tableBody = modal.querySelector('#clientInvoiceItemsTable tbody');
|
| 447 |
+
tableBody.innerHTML = '';
|
| 448 |
+
let grandTotal = 0;
|
| 449 |
+
items.forEach(item => {
|
| 450 |
+
const row = tableBody.insertRow();
|
| 451 |
+
row.insertCell().textContent = item.name;
|
| 452 |
+
row.insertCell().textContent = item.quantity;
|
| 453 |
+
row.insertCell().textContent = parseFloat(item.price_per_unit).toFixed(2);
|
| 454 |
+
row.insertCell().textContent = parseFloat(item.item_total).toFixed(2);
|
| 455 |
+
row.cells[3].classList.add('item-total');
|
| 456 |
+
grandTotal += parseFloat(item.item_total);
|
| 457 |
+
});
|
| 458 |
+
document.getElementById('clientInvoiceGrandTotal').textContent = grandTotal.toFixed(2);
|
| 459 |
+
modal.style.display = 'flex';
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
function closeClientInvoiceModal() {
|
| 463 |
+
document.getElementById('clientInvoiceDetailModal').style.display = 'none';
|
| 464 |
+
}
|
| 465 |
+
window.onclick = function(event) {
|
| 466 |
+
if (event.target == document.getElementById('clientInvoiceDetailModal')) {
|
| 467 |
+
closeClientInvoiceModal();
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
</script>
|
| 472 |
</body>
|
| 473 |
</html>
|
|
|
|
| 485 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 486 |
<style>
|
| 487 |
:root {
|
| 488 |
+
--admin-bg: #f8f9fa; --admin-text: #212529; --admin-card-bg: #ffffff; --admin-border: #dee2e6;
|
| 489 |
+
--admin-shadow: rgba(0, 0, 0, 0.05); --admin-primary: #FFC107; --admin-primary-dark: #e0a800;
|
| 490 |
+
--admin-secondary: #6c757d; --admin-success: #198754; --admin-danger: #dc3545;
|
| 491 |
+
--admin-info: #0dcaf0; --border-radius: 12px; --padding: 1.5rem; --font-family: 'Inter', sans-serif;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
}
|
| 493 |
body { font-family: var(--font-family); background-color: var(--admin-bg); color: var(--admin-text); margin: 0; padding: var(--padding); line-height: 1.6; }
|
| 494 |
.container { max-width: 1200px; margin: 0 auto; }
|
|
|
|
| 506 |
.btn-primary:hover { background-color: var(--admin-primary-dark); }
|
| 507 |
.btn-delete { background-color: var(--admin-danger); color: white; }
|
| 508 |
.btn-delete:hover { background-color: #c82333; }
|
| 509 |
+
.btn-info { background-color: var(--admin-info); color: white; }
|
| 510 |
+
.btn-info:hover { background-color: #0baccc; }
|
| 511 |
.user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--padding); margin-top: var(--padding); }
|
| 512 |
.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; }
|
| 513 |
.user-card:hover { transform: translateY(-5px); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); }
|
|
|
|
| 519 |
.user-balances .label { font-size: 0.9em; color: var(--admin-secondary); }
|
| 520 |
.user-balances .amount.bonus { font-size: 1.8em; font-weight: 700; color: var(--admin-primary-dark); }
|
| 521 |
.user-balances .amount.debt { font-size: 1.8em; font-weight: 700; color: var(--admin-danger); }
|
| 522 |
+
.user-actions { margin-top: auto; display: grid; grid-template-columns: 1fr; gap: 0.5rem; }
|
| 523 |
+
.user-actions.two-buttons { grid-template-columns: 1fr 1fr; }
|
| 524 |
.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; }
|
| 525 |
.btn-manage:hover { background-color: var(--admin-primary-dark); }
|
| 526 |
.no-users { text-align: center; color: var(--admin-secondary); margin-top: 2rem; font-size: 1.1em; }
|
| 527 |
.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); }
|
| 528 |
+
.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); }
|
| 529 |
+
.modal-content.large { max-width: 950px; }
|
| 530 |
.modal-close { color: #aaa; position: absolute; top: 15px; right: 25px; font-size: 28px; font-weight: bold; cursor: pointer; }
|
| 531 |
.modal-header { padding-bottom: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--admin-border); }
|
| 532 |
.modal-header h2 { margin: 0; font-size: 1.5rem; }
|
|
|
|
| 534 |
.form-section { border: 1px solid var(--admin-border); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; }
|
| 535 |
.form-section h3 { margin-top: 0; margin-bottom: 1rem; font-size: 1.1em; }
|
| 536 |
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: flex-end; }
|
| 537 |
+
.form-row.three-col { grid-template-columns: 2fr 1fr 1fr; }
|
| 538 |
+
.form-row.four-col { grid-template-columns: 2fr 1fr 1fr auto; }
|
| 539 |
.form-group { display: flex; flex-direction: column; }
|
| 540 |
.form-group label { margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9em; }
|
| 541 |
+
.form-group input, .form-group button { padding: 10px; font-size: 1rem; border: 1px solid var(--admin-border); border-radius: 8px; width: 100%; box-sizing: border-box; }
|
| 542 |
+
.form-group button { background-color: var(--admin-info); color: white; cursor: pointer; }
|
| 543 |
+
.form-group button:hover { background-color: #0baccc; }
|
| 544 |
+
.calculation-summary, .invoice-summary { background: #f0f0f0; padding: 1rem; border-radius: 8px; margin-top: 1rem; }
|
| 545 |
.summary-item { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.95em; }
|
| 546 |
.summary-item strong { font-weight: 600; }
|
| 547 |
+
.history-container, .invoice-items-container, .existing-invoices-container { margin-top: 1.5rem; }
|
| 548 |
+
.history-container h3, .invoice-items-container h3, .existing-invoices-container h3 { font-size: 1.2rem; margin-bottom: 1rem; }
|
| 549 |
+
.history-list, .invoice-items-list, .existing-invoices-list { list-style: none; padding: 0; max-height: 200px; overflow-y: auto; border: 1px solid var(--admin-border); border-radius: 8px; }
|
| 550 |
+
.history-item, .invoice-item-row, .existing-invoice-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--admin-border); }
|
| 551 |
+
.history-item:last-child, .invoice-item-row:last-child, .existing-invoice-item:last-child { border-bottom: none; }
|
| 552 |
+
.history-item .desc, .invoice-item-row .name, .existing-invoice-item .desc { font-size: 0.9em; flex-grow: 1; }
|
| 553 |
+
.invoice-item-row .qty, .invoice-item-row .price, .invoice-item-row .total { font-size: 0.9em; width: 60px; text-align: right;}
|
| 554 |
+
.invoice-item-row .actions { width: 40px; text-align: right; }
|
| 555 |
+
.existing-invoice-item .actions button { font-size: 0.8em; padding: 4px 8px; }
|
| 556 |
+
.history-item .date, .existing-invoice-item .date { font-size: 0.8em; color: var(--admin-secondary); }
|
| 557 |
+
.history-item .amount, .existing-invoice-item .amount { font-weight: 600; }
|
| 558 |
+
.history-item .amount.bonus-accrual, .history-item .amount.debt-payment { color: var(--admin-success); }
|
| 559 |
+
.history-item .amount.bonus-deduction, .history-item .amount.debt-accrual { color: var(--admin-danger); }
|
| 560 |
+
.history-item .amount.invoice { color: var(--admin-info); }
|
| 561 |
.modal-footer { margin-top: 1.5rem; display: flex; justify-content: flex-end; align-items: center; gap: 1rem;}
|
| 562 |
.modal-footer button { padding: 12px 25px; font-size: 1.1em; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; }
|
| 563 |
.btn-submit { background-color: var(--admin-success); color: white; }
|
| 564 |
+
.status-message { font-weight: 500; flex-grow: 1; text-align: left; }
|
| 565 |
+
table.invoice-items-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
| 566 |
+
table.invoice-items-table th, table.invoice-items-table td { text-align: left; padding: 8px; border-bottom: 1px solid var(--admin-border); font-size: 0.9em; }
|
| 567 |
+
table.invoice-items-table th { font-weight: 600; background-color: #f0f0f0; }
|
| 568 |
+
table.invoice-items-table td.number { text-align: right; }
|
| 569 |
</style>
|
| 570 |
</head>
|
| 571 |
<body>
|
| 572 |
<div class="container">
|
| 573 |
<h1>Панель администратора Bonus</h1>
|
| 574 |
<div class="summary-bar">
|
| 575 |
+
<div class="summary-card"><div class="value">{{ summary.total_users }}</div><div class="label">Всего клиентов</div></div>
|
| 576 |
+
<div class="summary-card"><div class="value bonus">{{ "%.2f"|format(summary.total_bonuses|float) }}</div><div class="label">Всего бонусов</div></div>
|
| 577 |
+
<div class="summary-card"><div class="value debt">{{ "%.2f"|format(summary.total_debts|float) }}</div><div class="label">Всего долгов</div></div>
|
| 578 |
+
<div class="summary-card"><div class="value debt">{{ summary.users_with_debt }}</div><div class="label">Клиенты с долгом</div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
</div>
|
|
|
|
| 580 |
<div class="controls-bar">
|
| 581 |
<input type="text" id="searchInput" onkeyup="searchUsers()" placeholder="Поиск по имени, ID, username, номеру...">
|
| 582 |
<button class="btn btn-primary" onclick="openAddClientModal()">Добавить клиента</button>
|
| 583 |
</div>
|
| 584 |
+
<div class="user-grid" id="userGrid">
|
| 585 |
+
{% for user in users|sort(attribute='visited_at', reverse=true) %}
|
| 586 |
+
<div class="user-card" data-user-id="{{ user.id }}" data-search-term="{{ user.first_name|lower }} {{ user.last_name|lower if user.last_name }} {{ user.username|lower if user.username }} {{ user.id }} {{ user.phone_number|lower if user.phone_number }}">
|
| 587 |
+
<div class="user-info">
|
| 588 |
+
<img src="{{ user.photo_url if user.photo_url else 'data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3e%3crect width=%27100%27 height=%27100%27 fill=%27%23e9ecef%27/%3e%3ctext x=%2750%25%27 y=%2755%25%27 dominant-baseline=%27middle%27 text-anchor=%27middle%27 font-size=%2745%27 font-family=%27sans-serif%27 fill=%27%23adb5bd%27%3e?%3c/text%3e%3c/svg%3e' }}" alt="User Avatar">
|
| 589 |
+
<div class="user-details">
|
| 590 |
+
<div class="name">{{ user.first_name or '' }} {{ user.last_name or '' }}</div>
|
| 591 |
+
<div class="username">@{{ user.username if user.username else user.phone_number }} | ID: {{ user.id }}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
</div>
|
| 593 |
</div>
|
| 594 |
+
<div class="user-balances">
|
| 595 |
+
<div><div class="label">Бонусы</div><div class="amount bonus">{{ "%.2f"|format(user.bonuses|float) }}</div></div>
|
| 596 |
+
<div><div class="label">Долг</div><div class="amount debt">{{ "%.2f"|format(user.debts|float if user.debts else 0) }}</div></div>
|
| 597 |
+
</div>
|
| 598 |
+
<div class="user-actions {% if user.telegram_id == None %}two-buttons{% endif %}">
|
| 599 |
+
<button class="btn-manage" onclick='openTransactionModal({{ user|tojson }})'>Управление счетом</button>
|
| 600 |
+
{% if user.telegram_id == None %}<button class="btn btn-delete" onclick='deleteClient("{{ user.id }}")'>Удалить</button>{% endif %}
|
| 601 |
+
</div>
|
| 602 |
+
</div>
|
| 603 |
+
{% else %}
|
| 604 |
+
<p class="no-users">Пользователей пока нет.</p>
|
| 605 |
+
{% endfor %}
|
| 606 |
+
</div>
|
| 607 |
</div>
|
| 608 |
|
| 609 |
<div id="transactionModal" class="modal">
|
| 610 |
+
<div class="modal-content large">
|
| 611 |
<span class="modal-close" onclick="closeModal('transactionModal')">×</span>
|
| 612 |
<div class="modal-header">
|
| 613 |
<h2 id="modalUserName"></h2>
|
|
|
|
| 618 |
<div class="form-section">
|
| 619 |
<h3>Бонусы</h3>
|
| 620 |
<div class="form-row">
|
| 621 |
+
<div class="form-group"><label for="purchaseAmount">Сумма покупки (для начисления)</label><input type="number" id="purchaseAmount" placeholder="1500" oninput="updateCalculations()"></div>
|
| 622 |
+
<div class="form-group"><label for="deductAmount">Списать бонусов</label><input type="number" id="deductAmount" placeholder="100" oninput="updateCalculations()"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
</div>
|
| 624 |
<div class="calculation-summary">
|
| 625 |
<div class="summary-item"><span>Текущий баланс:</span> <strong id="summaryCurrentBalance">0.00</strong></div>
|
| 626 |
<div class="summary-item"><span>Будет начислено (2%):</span> <strong id="summaryAccrual">+0.00</strong></div>
|
| 627 |
+
<div class="summary-item"><span>Будет списано:</span> <strong id="summaryDeduction">-0.00</strong></div><hr>
|
|
|
|
| 628 |
<div class="summary-item"><strong>Итоговый баланс бонусов:</strong> <strong id="summaryFinalBalance">0.00</strong></div>
|
| 629 |
</div>
|
| 630 |
</div>
|
|
|
|
| 632 |
<div class="form-section">
|
| 633 |
<h3>Долги</h3>
|
| 634 |
<div class="form-row">
|
| 635 |
+
<div class="form-group"><label for="addDebtAmount">Добавить долг</label><input type="number" id="addDebtAmount" placeholder="500" oninput="updateCalculations()"></div>
|
| 636 |
+
<div class="form-group"><label for="repayDebtAmount">Погасить долг</label><input type="number" id="repayDebtAmount" placeholder="200" oninput="updateCalculations()"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
</div>
|
| 638 |
<div class="calculation-summary">
|
| 639 |
<div class="summary-item"><span>Текущий долг:</span> <strong id="summaryCurrentDebt">0.00</strong></div>
|
| 640 |
<div class="summary-item"><span>Будет добавлено:</span> <strong id="summaryAddDebt">+0.00</strong></div>
|
| 641 |
+
<div class="summary-item"><span>Будет погашено:</span> <strong id="summaryRepayDebt">-0.00</strong></div><hr>
|
|
|
|
| 642 |
<div class="summary-item"><strong>Итоговый долг:</strong> <strong id="summaryFinalDebt">0.00</strong></div>
|
| 643 |
</div>
|
| 644 |
</div>
|
| 645 |
+
<div class="modal-footer">
|
| 646 |
+
<div id="modalStatus" class="status-message"></div>
|
| 647 |
+
<button class="btn-submit" onclick="submitTransaction()">Провести операции с бонусами/долгами</button>
|
| 648 |
+
</div>
|
| 649 |
+
<hr style="margin: 2rem 0;">
|
| 650 |
+
<div class="form-section">
|
| 651 |
+
<h3>Накладные клиента</h3>
|
| 652 |
+
<div style="margin-bottom: 1rem;"><button class="btn btn-info" onclick="openNewInvoiceModal()">Создать новую накладную</button></div>
|
| 653 |
+
<div class="existing-invoices-container">
|
| 654 |
+
<h4>Существующие накладные (<span id="existingInvoicesCount">0</span>)</h4>
|
| 655 |
+
<ul id="existingInvoicesList" class="existing-invoices-list"></ul>
|
| 656 |
+
</div>
|
| 657 |
+
</div>
|
| 658 |
<div class="history-container">
|
| 659 |
<h3>Общая история операций</h3>
|
| 660 |
<ul id="modalHistoryList" class="history-list"></ul>
|
| 661 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
</div>
|
| 663 |
</div>
|
| 664 |
|
| 665 |
+
<div id="newInvoiceModal" class="modal">
|
| 666 |
+
<div class="modal-content large">
|
| 667 |
+
<span class="modal-close" onclick="closeModal('newInvoiceModal')">×</span>
|
| 668 |
+
<div class="modal-header"><h2>Новая накладная для <span id="newInvoiceModalUserName"></span></h2></div>
|
| 669 |
+
<div class="form-section">
|
| 670 |
+
<h3>Добавить товары</h3>
|
| 671 |
+
<div class="form-row four-col">
|
| 672 |
+
<div class="form-group"><label for="invoiceItemName">Название товара</label><input type="text" id="invoiceItemName"></div>
|
| 673 |
+
<div class="form-group"><label for="invoiceItemQty">Кол-во</label><input type="number" id="invoiceItemQty" value="1" min="1"></div>
|
| 674 |
+
<div class="form-group"><label for="invoiceItemPrice">Цена за ед.</label><input type="number" id="invoiceItemPrice" min="0" step="0.01"></div>
|
| 675 |
+
<div class="form-group" style="justify-content: flex-end;"><button onclick="addItemToInvoiceTable()">Добавить</button></div>
|
| 676 |
+
</div>
|
| 677 |
+
</div>
|
| 678 |
+
<div class="invoice-items-container">
|
| 679 |
+
<h3>Товары в накладной</h3>
|
| 680 |
+
<table id="currentInvoiceItemsTable" class="invoice-items-table">
|
| 681 |
+
<thead><tr><th>Название</th><th class="number">Кол-во</th><th class="number">Цена</th><th class="number">Сумма</th><th></th></tr></thead>
|
| 682 |
+
<tbody></tbody>
|
| 683 |
+
</table>
|
| 684 |
+
<div class="invoice-summary" style="text-align: right; margin-top:1rem;"><strong>Итого по накладной: <span id="currentInvoiceTotalAmount">0.00</span></strong></div>
|
| 685 |
+
</div>
|
| 686 |
+
<div class="modal-footer">
|
| 687 |
+
<div id="newInvoiceStatus" class="status-message"></div>
|
| 688 |
+
<button class="btn-submit" onclick="submitNewInvoice()">Сохранить накладную</button>
|
| 689 |
+
</div>
|
| 690 |
+
</div>
|
| 691 |
+
</div>
|
| 692 |
+
|
| 693 |
+
<div id="viewAdminInvoiceDetailModal" class="modal">
|
| 694 |
+
<div class="modal-content">
|
| 695 |
+
<span class="modal-close" onclick="closeModal('viewAdminInvoiceDetailModal')">×</span>
|
| 696 |
+
<div class="modal-header"><h2 id="viewAdminInvoiceModalTitle">Детали накладной</h2></div>
|
| 697 |
+
<div class="modal-body">
|
| 698 |
+
<table id="viewAdminInvoiceItemsTable" class="invoice-items-table">
|
| 699 |
+
<thead><tr><th>Товар</th><th class="number">Кол-во</th><th class="number">Цена</th><th class="number">Сумма</th></tr></thead>
|
| 700 |
+
<tbody></tbody>
|
| 701 |
+
<tfoot><tr class="grand-total-row"><td colspan="3"><strong>Итого:</strong></td><td id="viewAdminInvoiceGrandTotal" class="number"><strong></strong></td></tr></tfoot>
|
| 702 |
+
</table>
|
| 703 |
+
</div>
|
| 704 |
+
</div>
|
| 705 |
+
</div>
|
| 706 |
+
|
| 707 |
<div id="addClientModal" class="modal">
|
| 708 |
<div class="modal-content">
|
| 709 |
<span class="modal-close" onclick="closeModal('addClientModal')">×</span>
|
| 710 |
+
<div class="modal-header"><h2>Добавить нового клиента</h2></div>
|
| 711 |
+
<div class="form-group" style="margin-bottom: 1rem;"><label for="newClientFirstName">Имя</label><input type="text" id="newClientFirstName" placeholder="Иван"></div>
|
| 712 |
+
<div class="form-group" style="margin-bottom: 1.5rem;"><label for="newClientPhone">Номер телефона (уникальный)</label><input type="tel" id="newClientPhone" placeholder="+79001234567"></div>
|
| 713 |
+
<div class="modal-footer"><div id="addClientStatus" class="status-message"></div><button class="btn-submit" onclick="submitNewClient()">Сохранить клиента</button></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
</div>
|
| 715 |
</div>
|
| 716 |
|
| 717 |
<script>
|
| 718 |
const transactionModal = document.getElementById('transactionModal');
|
| 719 |
const addClientModal = document.getElementById('addClientModal');
|
| 720 |
+
const newInvoiceModal = document.getElementById('newInvoiceModal');
|
| 721 |
+
const viewAdminInvoiceDetailModal = document.getElementById('viewAdminInvoiceDetailModal');
|
| 722 |
+
|
| 723 |
let currentUserData = null;
|
| 724 |
+
let currentInvoiceItems = [];
|
| 725 |
|
| 726 |
function searchUsers() {
|
| 727 |
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
| 728 |
const userCards = document.querySelectorAll('.user-card');
|
| 729 |
userCards.forEach(card => {
|
| 730 |
const cardSearchTerm = card.getAttribute('data-search-term');
|
| 731 |
+
if (cardSearchTerm.includes(searchTerm)) card.style.display = 'flex';
|
| 732 |
+
else card.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
| 733 |
});
|
| 734 |
}
|
| 735 |
|
|
|
|
| 738 |
document.getElementById('modalUserId').value = userData.id;
|
| 739 |
document.getElementById('modalUserName').textContent = `${userData.first_name || ''} ${userData.last_name || ''}`;
|
| 740 |
document.getElementById('modalUserUsername').textContent = `@${userData.username || userData.phone_number} | ID: ${userData.id}`;
|
| 741 |
+
['purchaseAmount', 'deductAmount', 'addDebtAmount', 'repayDebtAmount'].forEach(id => document.getElementById(id).value = '');
|
|
|
|
|
|
|
|
|
|
| 742 |
document.getElementById('modalStatus').textContent = '';
|
| 743 |
+
|
| 744 |
+
populateModalHistoryAndInvoices(userData);
|
| 745 |
+
updateCalculations();
|
| 746 |
+
transactionModal.style.display = 'block';
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
function populateModalHistoryAndInvoices(userData) {
|
| 750 |
const historyList = document.getElementById('modalHistoryList');
|
| 751 |
historyList.innerHTML = '';
|
| 752 |
+
const existingInvoicesList = document.getElementById('existingInvoicesList');
|
| 753 |
+
existingInvoicesList.innerHTML = '';
|
| 754 |
+
|
| 755 |
+
const bonusHistory = (userData.history || []).map(h => ({...h, transaction_type: 'bonus', date_obj: new Date(h.date)}));
|
| 756 |
+
const debtHistory = (userData.debt_history || []).map(h => ({...h, transaction_type: 'debt', date_obj: new Date(h.date)}));
|
| 757 |
+
const invoices = (userData.invoices || []).map(inv => ({...inv, transaction_type: 'invoice', date_obj: new Date(inv.date)}));
|
| 758 |
|
| 759 |
+
const combinedHistory = [...bonusHistory, ...debtHistory, ...invoices].sort((a, b) => b.date_obj - a.date_obj);
|
|
|
|
|
|
|
| 760 |
|
| 761 |
if (combinedHistory.length > 0) {
|
| 762 |
combinedHistory.forEach(item => {
|
| 763 |
const li = document.createElement('li');
|
| 764 |
li.className = 'history-item';
|
| 765 |
+
let amountClass, amountText, descText = item.description;
|
| 766 |
if (item.transaction_type === 'bonus') {
|
|
|
|
| 767 |
amountClass = item.type === 'accrual' ? 'bonus-accrual' : 'bonus-deduction';
|
| 768 |
+
amountText = `${item.type === 'accrual' ? '+' : '-'}${parseFloat(item.amount).toFixed(2)}`;
|
| 769 |
+
} else if (item.transaction_type === 'debt') {
|
|
|
|
| 770 |
amountClass = item.type === 'accrual' ? 'debt-accrual' : 'debt-payment';
|
| 771 |
+
amountText = `${item.type === 'accrual' ? '+' : '-'}${parseFloat(item.amount).toFixed(2)}`;
|
| 772 |
+
} else if (item.transaction_type === 'invoice') {
|
| 773 |
+
amountClass = 'invoice';
|
| 774 |
+
descText = `Накладная #${item.id.substring(0,8)}...`;
|
| 775 |
+
amountText = `${parseFloat(item.total_amount).toFixed(2)}`;
|
| 776 |
+
li.style.cursor = "pointer";
|
| 777 |
+
li.onclick = () => showAdminInvoiceDetails(item);
|
| 778 |
}
|
| 779 |
+
li.innerHTML = `<div><div class="desc">${descText}</div><div class="date">${item.date_str}</div></div><div class="amount ${amountClass}">${amountText}</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
historyList.appendChild(li);
|
| 781 |
});
|
| 782 |
} else {
|
| 783 |
historyList.innerHTML = '<li style="text-align:center; padding: 1rem; color: var(--admin-secondary);">Нет истории</li>';
|
| 784 |
}
|
| 785 |
|
| 786 |
+
document.getElementById('existingInvoicesCount').textContent = invoices.length;
|
| 787 |
+
if (invoices.length > 0) {
|
| 788 |
+
invoices.sort((a,b) => b.date_obj - a.date_obj).forEach(inv => {
|
| 789 |
+
const invLi = document.createElement('li');
|
| 790 |
+
invLi.className = 'existing-invoice-item';
|
| 791 |
+
invLi.innerHTML = `
|
| 792 |
+
<div class="desc">Накладная #${inv.id.substring(0,8)}... (${inv.items.length} поз.)</div>
|
| 793 |
+
<div class="date">${inv.date_str}</div>
|
| 794 |
+
<div class="amount">${parseFloat(inv.total_amount).toFixed(2)}</div>
|
| 795 |
+
<div class="actions"><button class="btn btn-info btn-sm" onclick='showAdminInvoiceDetails(${JSON.stringify(inv)})'>Смотреть</button></div>
|
| 796 |
+
`;
|
| 797 |
+
existingInvoicesList.appendChild(invLi);
|
| 798 |
+
});
|
| 799 |
+
} else {
|
| 800 |
+
existingInvoicesList.innerHTML = '<li style="text-align:center; padding: 1rem; color: var(--admin-secondary);">Нет накладных</li>';
|
| 801 |
+
}
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
function openNewInvoiceModal() {
|
| 805 |
+
if (!currentUserData) return;
|
| 806 |
+
document.getElementById('newInvoiceModalUserName').textContent = `${currentUserData.first_name || ''} ${currentUserData.last_name || ''}`;
|
| 807 |
+
currentInvoiceItems = [];
|
| 808 |
+
renderCurrentInvoiceItems();
|
| 809 |
+
updateCurrentInvoiceTotal();
|
| 810 |
+
['invoiceItemName', 'invoiceItemQty', 'invoiceItemPrice'].forEach(id => document.getElementById(id).value = (id === 'invoiceItemQty' ? '1' : ''));
|
| 811 |
+
document.getElementById('newInvoiceStatus').textContent = '';
|
| 812 |
+
newInvoiceModal.style.display = 'block';
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
function addItemToInvoiceTable() {
|
| 816 |
+
const name = document.getElementById('invoiceItemName').value.trim();
|
| 817 |
+
const quantity = parseInt(document.getElementById('invoiceItemQty').value);
|
| 818 |
+
const price_per_unit = parseFloat(document.getElementById('invoiceItemPrice').value);
|
| 819 |
+
|
| 820 |
+
if (!name || isNaN(quantity) || quantity <= 0 || isNaN(price_per_unit) || price_per_unit < 0) {
|
| 821 |
+
alert('Пожалуйста, введите корректные данные для товара.');
|
| 822 |
+
return;
|
| 823 |
+
}
|
| 824 |
+
currentInvoiceItems.push({ name, quantity, price_per_unit, item_total: quantity * price_per_unit });
|
| 825 |
+
renderCurrentInvoiceItems();
|
| 826 |
+
updateCurrentInvoiceTotal();
|
| 827 |
+
document.getElementById('invoiceItemName').value = '';
|
| 828 |
+
document.getElementById('invoiceItemQty').value = '1';
|
| 829 |
+
document.getElementById('invoiceItemPrice').value = '';
|
| 830 |
+
document.getElementById('invoiceItemName').focus();
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
function renderCurrentInvoiceItems() {
|
| 834 |
+
const tableBody = document.getElementById('currentInvoiceItemsTable').querySelector('tbody');
|
| 835 |
+
tableBody.innerHTML = '';
|
| 836 |
+
currentInvoiceItems.forEach((item, index) => {
|
| 837 |
+
const row = tableBody.insertRow();
|
| 838 |
+
row.insertCell().textContent = item.name;
|
| 839 |
+
row.insertCell().textContent = item.quantity;
|
| 840 |
+
row.insertCell().textContent = item.price_per_unit.toFixed(2);
|
| 841 |
+
row.insertCell().textContent = item.item_total.toFixed(2);
|
| 842 |
+
const actionCell = row.insertCell();
|
| 843 |
+
const removeBtn = document.createElement('button');
|
| 844 |
+
removeBtn.textContent = '×';
|
| 845 |
+
removeBtn.className = 'btn-delete btn-sm';
|
| 846 |
+
removeBtn.style.padding = '2px 6px';
|
| 847 |
+
removeBtn.onclick = () => removeInvoiceItem(index);
|
| 848 |
+
actionCell.appendChild(removeBtn);
|
| 849 |
+
['.number', '.number', '.number'].forEach((sel, i) => row.cells[i+1].classList.add(sel.substring(1)));
|
| 850 |
+
});
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
function removeInvoiceItem(index) {
|
| 854 |
+
currentInvoiceItems.splice(index, 1);
|
| 855 |
+
renderCurrentInvoiceItems();
|
| 856 |
+
updateCurrentInvoiceTotal();
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
function updateCurrentInvoiceTotal() {
|
| 860 |
+
const total = currentInvoiceItems.reduce((sum, item) => sum + item.item_total, 0);
|
| 861 |
+
document.getElementById('currentInvoiceTotalAmount').textContent = total.toFixed(2);
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
async function submitNewInvoice() {
|
| 865 |
+
const statusEl = document.getElementById('newInvoiceStatus');
|
| 866 |
+
if (currentInvoiceItems.length === 0) {
|
| 867 |
+
statusEl.style.color = 'var(--admin-danger)';
|
| 868 |
+
statusEl.textContent = 'Добавьте хотя бы один товар в накладную.';
|
| 869 |
+
return;
|
| 870 |
+
}
|
| 871 |
+
statusEl.style.color = 'var(--admin-secondary)';
|
| 872 |
+
statusEl.textContent = 'Сохранение накладной...';
|
| 873 |
+
|
| 874 |
+
const payload = {
|
| 875 |
+
user_id: currentUserData.id,
|
| 876 |
+
items: currentInvoiceItems,
|
| 877 |
+
total_amount: currentInvoiceItems.reduce((sum, item) => sum + item.item_total, 0)
|
| 878 |
+
};
|
| 879 |
+
|
| 880 |
+
try {
|
| 881 |
+
const response = await fetch('/admin/add_invoice', {
|
| 882 |
+
method: 'POST',
|
| 883 |
+
headers: { 'Content-Type': 'application/json' },
|
| 884 |
+
body: JSON.stringify(payload)
|
| 885 |
+
});
|
| 886 |
+
const result = await response.json();
|
| 887 |
+
if (response.ok) {
|
| 888 |
+
statusEl.style.color = 'var(--admin-success)';
|
| 889 |
+
statusEl.textContent = 'Накладная успешно сохранена!';
|
| 890 |
+
setTimeout(() => {
|
| 891 |
+
closeModal('newInvoiceModal');
|
| 892 |
+
refreshUserDataAndModal();
|
| 893 |
+
}, 1500);
|
| 894 |
+
} else { throw new Error(result.message || 'Ошибка сохранения'); }
|
| 895 |
+
} catch (error) {
|
| 896 |
+
statusEl.style.color = 'var(--admin-danger)';
|
| 897 |
+
statusEl.textContent = `Ошибка: ${error.message}`;
|
| 898 |
+
}
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
function showAdminInvoiceDetails(invoiceData) {
|
| 902 |
+
document.getElementById('viewAdminInvoiceModalTitle').textContent = `Детали накладной #${invoiceData.id.substring(0,8)}...`;
|
| 903 |
+
const tableBody = viewAdminInvoiceDetailModal.querySelector('#viewAdminInvoiceItemsTable tbody');
|
| 904 |
+
tableBody.innerHTML = '';
|
| 905 |
+
let grandTotal = 0;
|
| 906 |
+
invoiceData.items.forEach(item => {
|
| 907 |
+
const row = tableBody.insertRow();
|
| 908 |
+
row.insertCell().textContent = item.name;
|
| 909 |
+
row.insertCell().textContent = item.quantity;
|
| 910 |
+
row.insertCell().textContent = parseFloat(item.price_per_unit).toFixed(2);
|
| 911 |
+
row.insertCell().textContent = parseFloat(item.item_total).toFixed(2);
|
| 912 |
+
grandTotal += parseFloat(item.item_total);
|
| 913 |
+
['.number', '.number', '.number'].forEach((sel, i) => row.cells[i+1].classList.add(sel.substring(1)));
|
| 914 |
+
});
|
| 915 |
+
document.getElementById('viewAdminInvoiceGrandTotal').textContent = grandTotal.toFixed(2);
|
| 916 |
+
viewAdminInvoiceDetailModal.style.display = 'block';
|
| 917 |
}
|
| 918 |
|
| 919 |
function openAddClientModal() {
|
|
|
|
| 925 |
|
| 926 |
function closeModal(modalId) {
|
| 927 |
document.getElementById(modalId).style.display = 'none';
|
| 928 |
+
if (modalId === 'transactionModal') currentUserData = null;
|
| 929 |
+
if (modalId === 'newInvoiceModal') currentInvoiceItems = [];
|
|
|
|
| 930 |
}
|
| 931 |
|
| 932 |
function updateCalculations() {
|
| 933 |
if (!currentUserData) return;
|
|
|
|
| 934 |
const currentBalance = parseFloat(currentUserData.bonuses) || 0;
|
| 935 |
const purchaseAmount = parseFloat(document.getElementById('purchaseAmount').value) || 0;
|
| 936 |
const deductAmount = parseFloat(document.getElementById('deductAmount').value) || 0;
|
| 937 |
const accrualAmount = purchaseAmount * 0.02;
|
| 938 |
+
let finalDeductAmount = Math.max(0, deductAmount);
|
| 939 |
+
if (finalDeductAmount > currentBalance + accrualAmount) { // Allow deduction from newly accrued as well
|
| 940 |
+
finalDeductAmount = currentBalance + accrualAmount; // Cap at total available after accrual
|
| 941 |
+
document.getElementById('deductAmount').value = finalDeductAmount > 0 ? finalDeductAmount.toFixed(2) : '';
|
| 942 |
+
} else if (finalDeductAmount > currentBalance && purchaseAmount === 0){ // Cap at current if no accrual
|
| 943 |
finalDeductAmount = currentBalance;
|
| 944 |
document.getElementById('deductAmount').value = finalDeductAmount > 0 ? finalDeductAmount.toFixed(2) : '';
|
| 945 |
}
|
| 946 |
+
|
| 947 |
const finalBalance = currentBalance + accrualAmount - finalDeductAmount;
|
| 948 |
document.getElementById('summaryCurrentBalance').textContent = currentBalance.toFixed(2);
|
| 949 |
document.getElementById('summaryAccrual').textContent = `+${accrualAmount.toFixed(2)}`;
|
|
|
|
| 953 |
const currentDebt = parseFloat(currentUserData.debts) || 0;
|
| 954 |
const addDebtAmount = parseFloat(document.getElementById('addDebtAmount').value) || 0;
|
| 955 |
const repayDebtAmount = parseFloat(document.getElementById('repayDebtAmount').value) || 0;
|
| 956 |
+
let finalRepayAmount = Math.max(0, repayDebtAmount);
|
| 957 |
+
|
| 958 |
+
if (finalRepayAmount > currentDebt + addDebtAmount) { // If repaying more than current debt + new debt (e.g. error or covering future)
|
| 959 |
+
finalRepayAmount = currentDebt + addDebtAmount; // Cap at total debt after adding new
|
| 960 |
+
document.getElementById('repayDebtAmount').value = finalRepayAmount > 0 ? finalRepayAmount.toFixed(2) : '';
|
| 961 |
+
} else if (finalRepayAmount > currentDebt && addDebtAmount === 0) { // If only repaying and more than current debt
|
| 962 |
finalRepayAmount = currentDebt;
|
| 963 |
document.getElementById('repayDebtAmount').value = finalRepayAmount > 0 ? finalRepayAmount.toFixed(2) : '';
|
| 964 |
}
|
| 965 |
+
|
| 966 |
+
|
| 967 |
const finalDebt = currentDebt + addDebtAmount - finalRepayAmount;
|
| 968 |
document.getElementById('summaryCurrentDebt').textContent = currentDebt.toFixed(2);
|
| 969 |
document.getElementById('summaryAddDebt').textContent = `+${addDebtAmount.toFixed(2)}`;
|
|
|
|
| 986 |
|
| 987 |
if (payload.purchase_amount <= 0 && payload.deduct_amount <= 0 && payload.add_debt_amount <= 0 && payload.repay_debt_amount <= 0) {
|
| 988 |
statusEl.style.color = 'var(--admin-danger)';
|
| 989 |
+
statusEl.textContent = 'Введите сумму для одной из операций с бонусами/долгами.';
|
| 990 |
return;
|
| 991 |
}
|
| 992 |
try {
|
|
|
|
| 999 |
if (response.ok) {
|
| 1000 |
statusEl.style.color = 'var(--admin-success)';
|
| 1001 |
statusEl.textContent = 'Операция успешно проведена!';
|
| 1002 |
+
setTimeout(() => { refreshUserDataAndModal(); statusEl.textContent = '';}, 1500);
|
| 1003 |
+
} else { throw new Error(result.message || 'Произошла ошибка'); }
|
|
|
|
|
|
|
| 1004 |
} catch (error) {
|
| 1005 |
statusEl.style.color = 'var(--admin-danger)';
|
| 1006 |
statusEl.textContent = `Ошибка: ${error.message}`;
|
|
|
|
| 1011 |
const statusEl = document.getElementById('addClientStatus');
|
| 1012 |
statusEl.style.color = 'var(--admin-secondary)';
|
| 1013 |
statusEl.textContent = 'Сохранение...';
|
|
|
|
| 1014 |
const payload = {
|
| 1015 |
first_name: document.getElementById('newClientFirstName').value.trim(),
|
| 1016 |
phone_number: document.getElementById('newClientPhone').value.trim(),
|
| 1017 |
};
|
|
|
|
| 1018 |
if (!payload.first_name || !payload.phone_number) {
|
| 1019 |
statusEl.style.color = 'var(--admin-danger)';
|
| 1020 |
statusEl.textContent = 'Имя и номер телефона обязательны.';
|
| 1021 |
return;
|
| 1022 |
}
|
|
|
|
| 1023 |
try {
|
| 1024 |
const response = await fetch('/admin/add_client', {
|
| 1025 |
method: 'POST',
|
|
|
|
| 1031 |
statusEl.style.color = 'var(--admin-success)';
|
| 1032 |
statusEl.textContent = 'Клиент успешно добавлен!';
|
| 1033 |
setTimeout(() => { location.reload(); }, 1500);
|
| 1034 |
+
} else { throw new Error(result.message || 'Произошла ошибка'); }
|
|
|
|
|
|
|
| 1035 |
} catch (error) {
|
| 1036 |
statusEl.style.color = 'var(--admin-danger)';
|
| 1037 |
statusEl.textContent = `Ошибка: ${error.message}`;
|
|
|
|
| 1039 |
}
|
| 1040 |
|
| 1041 |
async function deleteClient(userId) {
|
| 1042 |
+
if (!confirm(`Вы уверены, что хотите удалить клиента с ID ${userId}? Это действие необратимо.`)) return;
|
|
|
|
|
|
|
| 1043 |
try {
|
| 1044 |
const response = await fetch('/admin/delete_client', {
|
| 1045 |
method: 'POST',
|
|
|
|
| 1047 |
body: JSON.stringify({ user_id: userId })
|
| 1048 |
});
|
| 1049 |
const result = await response.json();
|
| 1050 |
+
if (response.ok) location.reload();
|
| 1051 |
+
else throw new Error(result.message || 'Не удалось удалить клиента.');
|
| 1052 |
+
} catch (error) { alert(`Ошибка: ${error.message}`); }
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
async function refreshUserDataAndModal() {
|
| 1056 |
+
if (!currentUserData || !currentUserData.id) return;
|
| 1057 |
+
try {
|
| 1058 |
+
const response = await fetch(`/admin/get_user_data/${currentUserData.id}`);
|
| 1059 |
+
if (!response.ok) throw new Error('Failed to fetch updated user data');
|
| 1060 |
+
const updatedUserData = await response.json();
|
| 1061 |
+
|
| 1062 |
+
const userCard = document.querySelector(`.user-card[data-user-id="${currentUserData.id}"]`);
|
| 1063 |
+
if(userCard) {
|
| 1064 |
+
userCard.querySelector('.user-balances .amount.bonus').textContent = parseFloat(updatedUserData.bonuses).toFixed(2);
|
| 1065 |
+
userCard.querySelector('.user-balances .amount.debt').textContent = parseFloat(updatedUserData.debts).toFixed(2);
|
| 1066 |
+
userCard.setAttribute('data-search-term', `${updatedUserData.first_name||''} ${updatedUserData.last_name||''} ${updatedUserData.username||''} ${updatedUserData.id} ${updatedUserData.phone_number||''}`.toLowerCase());
|
| 1067 |
}
|
| 1068 |
+
|
| 1069 |
+
currentUserData = updatedUserData;
|
| 1070 |
+
populateModalHistoryAndInvoices(updatedUserData);
|
| 1071 |
+
updateCalculations();
|
| 1072 |
+
|
| 1073 |
} catch (error) {
|
| 1074 |
+
console.error("Error refreshing user data:", error);
|
| 1075 |
+
document.getElementById('modalStatus').textContent = 'Ошибка обновления данных. Перезагрузите.';
|
| 1076 |
}
|
| 1077 |
}
|
| 1078 |
|
| 1079 |
+
|
| 1080 |
window.onclick = function(event) {
|
| 1081 |
+
if (event.target == transactionModal) closeModal('transactionModal');
|
| 1082 |
+
if (event.target == addClientModal) closeModal('addClientModal');
|
| 1083 |
+
if (event.target == newInvoiceModal) closeModal('newInvoiceModal');
|
| 1084 |
+
if (event.target == viewAdminInvoiceDetailModal) closeModal('viewAdminInvoiceDetailModal');
|
|
|
|
|
|
|
| 1085 |
}
|
| 1086 |
</script>
|
| 1087 |
</body>
|
|
|
|
| 1091 |
@app.route('/')
|
| 1092 |
def index():
|
| 1093 |
user_id_str = request.args.get('user_id_for_test')
|
|
|
|
| 1094 |
current_data = load_visitor_data()
|
| 1095 |
user_data = {}
|
| 1096 |
|
| 1097 |
if user_id_str and user_id_str in current_data:
|
| 1098 |
+
user_data = current_data[user_id_str].copy()
|
| 1099 |
user_data['id'] = user_id_str
|
| 1100 |
|
| 1101 |
+
combined_history = []
|
| 1102 |
bonus_history = user_data.get('history', [])
|
| 1103 |
for item in bonus_history:
|
| 1104 |
+
item_copy = item.copy()
|
| 1105 |
+
item_copy['transaction_type'] = 'bonus'
|
| 1106 |
+
combined_history.append(item_copy)
|
| 1107 |
|
| 1108 |
debt_history = user_data.get('debt_history', [])
|
| 1109 |
for item in debt_history:
|
| 1110 |
+
item_copy = item.copy()
|
| 1111 |
+
item_copy['transaction_type'] = 'debt'
|
| 1112 |
+
combined_history.append(item_copy)
|
| 1113 |
+
|
| 1114 |
+
invoices = user_data.get('invoices', [])
|
| 1115 |
+
for inv in invoices:
|
| 1116 |
+
inv_copy = inv.copy()
|
| 1117 |
+
inv_copy['transaction_type'] = 'invoice'
|
| 1118 |
+
inv_copy['description'] = f"Покупка по накладной #{inv_copy['id'][:8]}..."
|
| 1119 |
+
combined_history.append(inv_copy)
|
| 1120 |
+
|
| 1121 |
+
combined_history.sort(key=lambda x: x['date'], reverse=True)
|
| 1122 |
user_data['combined_history'] = combined_history
|
| 1123 |
else:
|
| 1124 |
user_data = {
|
| 1125 |
+
"id": "N/A", "bonuses": 0, "debts": 0,
|
| 1126 |
+
"history": [], "debt_history": [], "invoices": [],
|
|
|
|
|
|
|
|
|
|
| 1127 |
"combined_history": []
|
| 1128 |
}
|
|
|
|
| 1129 |
return render_template_string(TEMPLATE, user=user_data)
|
| 1130 |
|
| 1131 |
@app.route('/verify', methods=['POST'])
|
|
|
|
| 1137 |
return jsonify({"status": "error", "message": "Missing initData"}), 400
|
| 1138 |
|
| 1139 |
user_data_parsed, is_valid = verify_telegram_data(init_data_str)
|
|
|
|
| 1140 |
user_info_dict = {}
|
| 1141 |
if user_data_parsed and 'user' in user_data_parsed:
|
| 1142 |
try:
|
|
|
|
| 1149 |
if is_valid:
|
| 1150 |
tg_user_id = user_info_dict.get('id')
|
| 1151 |
if tg_user_id:
|
| 1152 |
+
now_dt = datetime.now(BISHKEK_TZ)
|
| 1153 |
all_data = load_visitor_data()
|
| 1154 |
|
| 1155 |
existing_user_key = None
|
|
|
|
| 1158 |
existing_user_key = key
|
| 1159 |
break
|
| 1160 |
|
| 1161 |
+
user_id_to_save = existing_user_key
|
| 1162 |
if existing_user_key:
|
| 1163 |
user_entry = all_data[existing_user_key]
|
| 1164 |
user_entry.update({
|
|
|
|
| 1167 |
'username': user_info_dict.get('username'),
|
| 1168 |
'photo_url': user_info_dict.get('photo_url'),
|
| 1169 |
'language_code': user_info_dict.get('language_code'),
|
| 1170 |
+
'visited_at': now_dt.timestamp(),
|
| 1171 |
+
'visited_at_str': now_dt.strftime('%Y-%m-%d %H:%M:%S')
|
| 1172 |
})
|
|
|
|
| 1173 |
else:
|
| 1174 |
new_user_id = generate_unique_id(all_data)
|
| 1175 |
+
user_id_to_save = new_user_id
|
| 1176 |
user_entry = {
|
| 1177 |
+
'id': new_user_id, 'telegram_id': tg_user_id,
|
| 1178 |
+
'first_name': user_info_dict.get('first_name'), 'last_name': user_info_dict.get('last_name'),
|
| 1179 |
+
'username': user_info_dict.get('username'), 'photo_url': user_info_dict.get('photo_url'),
|
| 1180 |
+
'language_code': user_info_dict.get('language_code'), 'is_premium': user_info_dict.get('is_premium', False),
|
| 1181 |
+
'phone_number': None, 'visited_at': now_dt.timestamp(), 'visited_at_str': now_dt.strftime('%Y-%m-%d %H:%M:%S'),
|
| 1182 |
+
'bonuses': 0, 'history': [], 'debts': 0, 'debt_history': [], 'invoices': []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
}
|
|
|
|
|
|
|
| 1184 |
save_visitor_data({user_id_to_save: user_entry})
|
|
|
|
| 1185 |
return jsonify({"status": "ok", "verified": True, "user_id": user_id_to_save})
|
| 1186 |
else:
|
| 1187 |
return jsonify({"status": "error", "verified": True, "message": "User ID not found in parsed data"}), 400
|
| 1188 |
else:
|
| 1189 |
logging.warning(f"Verification failed for user: {user_info_dict.get('id')}")
|
| 1190 |
return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
|
|
|
|
| 1191 |
except Exception as e:
|
| 1192 |
logging.exception("Error in /verify endpoint")
|
| 1193 |
return jsonify({"status": "error", "message": "Internal server error"}), 500
|
|
|
|
| 1197 |
current_data = load_visitor_data()
|
| 1198 |
users_list = []
|
| 1199 |
for user_id, user_data in current_data.items():
|
| 1200 |
+
user_data_copy = user_data.copy()
|
| 1201 |
+
user_data_copy['id'] = user_id
|
| 1202 |
+
users_list.append(user_data_copy)
|
| 1203 |
|
| 1204 |
total_users = len(users_list)
|
| 1205 |
total_bonuses = sum(u.get('bonuses', 0) for u in users_list)
|
|
|
|
| 1207 |
users_with_debt = sum(1 for u in users_list if u.get('debts', 0) > 0)
|
| 1208 |
|
| 1209 |
summary_stats = {
|
| 1210 |
+
"total_users": total_users, "total_bonuses": total_bonuses,
|
| 1211 |
+
"total_debts": total_debts, "users_with_debt": users_with_debt
|
|
|
|
|
|
|
| 1212 |
}
|
|
|
|
| 1213 |
return render_template_string(ADMIN_TEMPLATE, users=users_list, summary=summary_stats)
|
| 1214 |
|
| 1215 |
+
@app.route('/admin/get_user_data/<user_id>', methods=['GET'])
|
| 1216 |
+
def get_user_data(user_id):
|
| 1217 |
+
all_data = load_visitor_data()
|
| 1218 |
+
if user_id in all_data:
|
| 1219 |
+
user_data = all_data[user_id].copy()
|
| 1220 |
+
user_data['id'] = user_id # Ensure id is part of the returned data
|
| 1221 |
+
return jsonify(user_data), 200
|
| 1222 |
+
return jsonify({"status": "error", "message": "User not found"}), 404
|
| 1223 |
+
|
| 1224 |
+
|
| 1225 |
@app.route('/admin/add_client', methods=['POST'])
|
| 1226 |
def add_client():
|
| 1227 |
try:
|
|
|
|
| 1233 |
return jsonify({"status": "error", "message": "Имя и номер телефона обязательны."}), 400
|
| 1234 |
|
| 1235 |
all_data = load_visitor_data()
|
|
|
|
| 1236 |
for user in all_data.values():
|
| 1237 |
if user.get('phone_number') == phone_number:
|
| 1238 |
return jsonify({"status": "error", "message": "Клиент с таким номером телефона уже существует."}), 409
|
| 1239 |
|
| 1240 |
+
now_dt = datetime.now(BISHKEK_TZ)
|
| 1241 |
new_id = generate_unique_id(all_data)
|
| 1242 |
|
| 1243 |
new_client = {
|
| 1244 |
+
'id': new_id, 'telegram_id': None, 'first_name': first_name, 'last_name': None, 'username': None,
|
| 1245 |
+
'photo_url': None, 'language_code': 'ru', 'is_premium': False, 'phone_number': phone_number,
|
| 1246 |
+
'visited_at': now_dt.timestamp(), 'visited_at_str': now_dt.strftime('%Y-%m-%d %H:%M:%S'),
|
| 1247 |
+
'bonuses': 0, 'history': [], 'debts': 0, 'debt_history': [], 'invoices': []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1248 |
}
|
|
|
|
| 1249 |
save_visitor_data({new_id: new_client})
|
|
|
|
| 1250 |
return jsonify({"status": "ok", "message": "Client added successfully"}), 201
|
|
|
|
| 1251 |
except Exception as e:
|
| 1252 |
logging.exception("Error in /admin/add_client endpoint")
|
| 1253 |
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1254 |
|
|
|
|
| 1255 |
@app.route('/admin/add_transaction', methods=['POST'])
|
| 1256 |
def add_transaction():
|
| 1257 |
try:
|
|
|
|
| 1272 |
return jsonify({"status": "error", "message": "User not found"}), 404
|
| 1273 |
|
| 1274 |
user = all_data[user_id_str]
|
| 1275 |
+
now_dt = datetime.now(BISHKEK_TZ)
|
| 1276 |
+
now_iso = now_dt.isoformat()
|
| 1277 |
+
now_str = now_dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
|
|
|
|
| 1278 |
|
| 1279 |
+
current_bonuses = user.get('bonuses', 0)
|
|
|
|
|
|
|
|
|
|
| 1280 |
accrual_amount = purchase_amount * 0.02
|
| 1281 |
+
|
| 1282 |
+
# Ensure deduct_amount is not more than available after potential accrual
|
| 1283 |
+
if deduct_amount > current_bonuses + accrual_amount:
|
| 1284 |
+
return jsonify({"status": "error", "message": f"Недостаточно бонусов для списания. Доступно: {(current_bonuses + accrual_amount):.2f}"}), 400
|
| 1285 |
+
|
| 1286 |
+
if repay_debt_amount > user.get('debts', 0) + add_debt_amount:
|
| 1287 |
+
return jsonify({"status": "error", "message": f"Сумма погашения превышает текущий долг. Доступно к погашению: {(user.get('debts', 0) + add_debt_amount):.2f}"}), 400
|
| 1288 |
|
| 1289 |
+
user['bonuses'] = current_bonuses + accrual_amount - deduct_amount
|
| 1290 |
+
if 'history' not in user or not isinstance(user['history'], list): user['history'] = []
|
| 1291 |
if accrual_amount > 0:
|
| 1292 |
+
user['history'].append({"type": "accrual", "amount": accrual_amount, "description": f"Начисление с покупки {purchase_amount}", "date": now_iso, "date_str": now_str})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1293 |
if deduct_amount > 0:
|
| 1294 |
+
user['history'].append({"type": "deduction", "amount": deduct_amount, "description": "Списание бонусов", "date": now_iso, "date_str": now_str})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1295 |
|
|
|
|
| 1296 |
user['debts'] = user.get('debts', 0) + add_debt_amount - repay_debt_amount
|
| 1297 |
+
if 'debt_history' not in user or not isinstance(user['debt_history'], list): user['debt_history'] = []
|
|
|
|
|
|
|
| 1298 |
if add_debt_amount > 0:
|
| 1299 |
+
user['debt_history'].append({"type": "accrual", "amount": add_debt_amount, "description": "Добавление долга", "date": now_iso, "date_str": now_str})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
if repay_debt_amount > 0:
|
| 1301 |
+
user['debt_history'].append({"type": "payment", "amount": repay_debt_amount, "description": "Погашение долга", "date": now_iso, "date_str": now_str})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1302 |
|
| 1303 |
save_visitor_data({user_id_str: user})
|
| 1304 |
+
return jsonify({"status": "ok", "message": "Transaction successful", "new_balance": user['bonuses'], "new_debt": user['debts']}), 200
|
| 1305 |
+
except Exception as e:
|
| 1306 |
+
logging.exception("Error in /admin/add_transaction endpoint")
|
| 1307 |
+
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1308 |
|
| 1309 |
+
@app.route('/admin/add_invoice', methods=['POST'])
|
| 1310 |
+
def add_invoice():
|
| 1311 |
+
try:
|
| 1312 |
+
data = request.get_json()
|
| 1313 |
+
user_id = data.get('user_id')
|
| 1314 |
+
items = data.get('items')
|
| 1315 |
+
total_amount = float(data.get('total_amount', 0))
|
| 1316 |
+
|
| 1317 |
+
if not user_id or not items or not isinstance(items, list) or len(items) == 0:
|
| 1318 |
+
return jsonify({"status": "error", "message": "Некорректные данные для накладной."}), 400
|
| 1319 |
|
| 1320 |
+
user_id_str = str(user_id)
|
| 1321 |
+
all_data = load_visitor_data()
|
| 1322 |
+
if user_id_str not in all_data:
|
| 1323 |
+
return jsonify({"status": "error", "message": "Клиент не найден."}), 404
|
| 1324 |
+
|
| 1325 |
+
user = all_data[user_id_str]
|
| 1326 |
+
now_dt = datetime.now(BISHKEK_TZ)
|
| 1327 |
+
|
| 1328 |
+
invoice_id = generate_invoice_id()
|
| 1329 |
+
new_invoice = {
|
| 1330 |
+
"id": invoice_id,
|
| 1331 |
+
"date": now_dt.isoformat(),
|
| 1332 |
+
"date_str": now_dt.strftime('%Y-%m-%d %H:%M:%S'),
|
| 1333 |
+
"items": items,
|
| 1334 |
+
"total_amount": total_amount
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
if 'invoices' not in user or not isinstance(user['invoices'], list):
|
| 1338 |
+
user['invoices'] = []
|
| 1339 |
+
user['invoices'].append(new_invoice)
|
| 1340 |
+
|
| 1341 |
+
save_visitor_data({user_id_str: user})
|
| 1342 |
+
return jsonify({"status": "ok", "message": "Накладная успешно добавлена.", "invoice_id": invoice_id}), 201
|
| 1343 |
except Exception as e:
|
| 1344 |
+
logging.exception("Error in /admin/add_invoice endpoint")
|
| 1345 |
return jsonify({"status": "error", "message": str(e)}), 500
|
| 1346 |
|
| 1347 |
@app.route('/admin/delete_client', methods=['POST'])
|
|
|
|
| 1349 |
try:
|
| 1350 |
data = request.get_json()
|
| 1351 |
user_id = data.get('user_id')
|
| 1352 |
+
if not user_id: return jsonify({"status": "error", "message": "User ID is required"}), 400
|
|
|
|
|
|
|
|
|
|
| 1353 |
user_id_str = str(user_id)
|
| 1354 |
load_visitor_data()
|
|
|
|
| 1355 |
with _data_lock:
|
| 1356 |
if user_id_str not in visitor_data_cache:
|
| 1357 |
return jsonify({"status": "error", "message": "User not found"}), 404
|
|
|
|
| 1358 |
user_to_delete = visitor_data_cache[user_id_str]
|
| 1359 |
if user_to_delete.get('telegram_id') is not None:
|
| 1360 |
return jsonify({"status": "error", "message": "Cannot delete a Telegram-linked user"}), 403
|
|
|
|
| 1361 |
del visitor_data_cache[user_id_str]
|
|
|
|
| 1362 |
try:
|
| 1363 |
with open(DATA_FILE, 'w', encoding='utf-8') as f:
|
| 1364 |
json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4)
|
|
|
|
| 1367 |
except Exception as e:
|
| 1368 |
logging.error(f"Error saving data after deletion: {e}")
|
| 1369 |
return jsonify({"status": "error", "message": "Failed to save data after deletion"}), 500
|
|
|
|
| 1370 |
return jsonify({"status": "ok", "message": "Client deleted successfully"}), 200
|
|
|
|
| 1371 |
except Exception as e:
|
| 1372 |
logging.exception("Error in /admin/delete_client endpoint")
|
| 1373 |
return jsonify({"status": "error", "message": str(e)}), 500
|
|
|
|
| 1380 |
else:
|
| 1381 |
print("Attempting initial data download from Hugging Face...")
|
| 1382 |
download_data_from_hf()
|
|
|
|
| 1383 |
load_visitor_data()
|
|
|
|
| 1384 |
print("WARNING: The /admin route is NOT protected. Implement proper authentication for production.")
|
|
|
|
| 1385 |
if HF_TOKEN_WRITE:
|
| 1386 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1387 |
backup_thread.start()
|
| 1388 |
print("Periodic backup thread started (every hour).")
|
|
|
|
| 1389 |
print("--- Server Ready ---")
|
| 1390 |
app.run(host=HOST, port=PORT, debug=False)
|