Kgshop commited on
Commit
f734017
·
verified ·
1 Parent(s): cb79533

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +903 -527
app.py CHANGED
@@ -8,165 +8,225 @@ import json
8
  from urllib.parse import unquote, parse_qs, quote
9
  import time
10
  from datetime import datetime
11
- from zoneinfo import ZoneInfo
12
  import logging
13
  import threading
14
  import random
15
  from huggingface_hub import HfApi, hf_hub_download
16
  from huggingface_hub.utils import RepositoryNotFoundError
17
- import uuid
18
 
19
  BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4")
20
  HOST = '0.0.0.0'
21
  PORT = 7860
22
  DATA_FILE = 'data.json'
 
23
 
24
  REPO_ID = "flpolprojects/examplebonus"
25
  HF_DATA_FILE_PATH = "data.json"
 
26
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
27
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
 
 
 
29
  app = Flask(__name__)
30
  logging.basicConfig(level=logging.INFO)
31
  app.secret_key = os.urandom(24)
32
 
33
  _data_lock = threading.Lock()
 
34
  visitor_data_cache = {}
35
- _organization_info_cache = {}
36
-
37
- BISHKEK_TZ = ZoneInfo("Asia/Bishkek")
38
 
39
- def get_now_bishkek():
40
  return datetime.now(BISHKEK_TZ)
41
 
42
  def generate_unique_id(all_data):
43
  while True:
44
  new_id = str(random.randint(10000, 99999))
45
- if new_id not in all_data and new_id != '__ORG_INFO__':
46
  return new_id
47
 
48
- def download_data_from_hf():
49
- global visitor_data_cache, _organization_info_cache
50
- if not HF_TOKEN_READ:
51
- return False
52
  try:
53
  hf_hub_download(
54
  repo_id=REPO_ID,
55
- filename=HF_DATA_FILE_PATH,
56
  repo_type="dataset",
57
- token=HF_TOKEN_READ,
58
  local_dir=".",
59
  local_dir_use_symlinks=False,
60
  force_download=True,
61
  etag_timeout=10
62
  )
63
- with _data_lock:
64
- try:
65
- with open(DATA_FILE, 'r', encoding='utf-8') as f:
66
- full_data = json.load(f)
67
- _organization_info_cache = full_data.pop('__ORG_INFO__', {})
68
- visitor_data_cache = full_data
69
- except (FileNotFoundError, json.JSONDecodeError):
70
- visitor_data_cache = {}
71
- _organization_info_cache = {}
72
  return True
73
  except RepositoryNotFoundError:
74
- pass
75
- except Exception:
76
- pass
77
  return False
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def load_visitor_data():
80
- global visitor_data_cache, _organization_info_cache
81
  with _data_lock:
82
- if not visitor_data_cache and not _organization_info_cache:
83
  try:
84
  with open(DATA_FILE, 'r', encoding='utf-8') as f:
85
- full_data = json.load(f)
86
- _organization_info_cache = full_data.pop('__ORG_INFO__', {})
87
- visitor_data_cache = full_data
88
  except FileNotFoundError:
 
89
  visitor_data_cache = {}
90
- _organization_info_cache = {}
91
  except json.JSONDecodeError:
 
92
  visitor_data_cache = {}
93
- _organization_info_cache = {}
94
- except Exception:
95
  visitor_data_cache = {}
96
- _organization_info_cache = {}
97
  return visitor_data_cache
98
 
99
- def get_organization_info():
100
- if not _organization_info_cache:
101
- return {
102
- "organization_name": "Название организации",
103
- "phone_numbers": [],
104
- "address": "Адрес организации",
105
- "whatsapp_link": "",
106
- "telegram_link": ""
107
- }
108
- return _organization_info_cache
109
-
110
- def save_visitor_data(data):
111
  with _data_lock:
 
 
 
 
 
112
  try:
113
- visitor_data_cache.update(data)
114
- data_to_save = visitor_data_cache.copy()
115
- if _organization_info_cache:
116
- data_to_save['__ORG_INFO__'] = _organization_info_cache
117
  with open(DATA_FILE, 'w', encoding='utf-8') as f:
118
- json.dump(data_to_save, f, ensure_ascii=False, indent=4)
119
- upload_data_to_hf_async()
120
- except Exception:
121
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- def save_organization_info(org_info):
124
- global _organization_info_cache
125
- with _data_lock:
 
126
  try:
127
- _organization_info_cache.update(org_info)
128
- data_to_save = visitor_data_cache.copy()
129
- data_to_save['__ORG_INFO__'] = _organization_info_cache
130
- with open(DATA_FILE, 'w', encoding='utf-8') as f:
131
- json.dump(data_to_save, f, ensure_ascii=False, indent=4)
132
- upload_data_to_hf_async()
133
- except Exception:
134
- pass
135
 
136
- def upload_data_to_hf():
137
  if not HF_TOKEN_WRITE:
 
138
  return
139
- if not os.path.exists(DATA_FILE):
140
- return
141
-
142
- try:
143
- api = HfApi()
144
- with _data_lock:
145
- file_content_exists = os.path.getsize(DATA_FILE) > 0
146
- if not file_content_exists:
147
- return
148
-
149
- api.upload_file(
150
- path_or_fileobj=DATA_FILE,
151
- path_in_repo=HF_DATA_FILE_PATH,
152
- repo_id=REPO_ID,
153
- repo_type="dataset",
154
- token=HF_TOKEN_WRITE,
155
- commit_message=f"Update bonus data {get_now_bishkek().strftime('%Y-%m-%d %H:%M:%S')}"
156
- )
157
- except Exception:
158
- pass
159
-
160
- def upload_data_to_hf_async():
161
- upload_thread = threading.Thread(target=upload_data_to_hf, daemon=True)
162
- upload_thread.start()
163
-
164
- def periodic_backup():
165
- if not HF_TOKEN_WRITE:
166
- return
167
- while True:
168
- time.sleep(3600)
169
- upload_data_to_hf()
170
 
171
  def verify_telegram_data(init_data_str):
172
  try:
@@ -188,11 +248,13 @@ def verify_telegram_data(init_data_str):
188
  auth_date = int(parsed_data.get('auth_date', [0])[0])
189
  current_time = int(time.time())
190
  if current_time - auth_date > 86400:
191
- pass
192
  return parsed_data, True
193
  else:
 
194
  return parsed_data, False
195
- except Exception:
 
196
  return None, False
197
 
198
  TEMPLATE = """
@@ -222,9 +284,6 @@ TEMPLATE = """
222
  --shadow-glow: 0 0 35px var(--shadow-color);
223
  --shadow-color-red: rgba(244, 67, 54, 0.15);
224
  --shadow-glow-red: 0 0 35px var(--shadow-color-red);
225
- --btn-bg: #333;
226
- --btn-text: #fff;
227
- --btn-hover: #555;
228
  }
229
  * { box-sizing: border-box; margin: 0; padding: 0; }
230
  html, body {
@@ -323,19 +382,45 @@ TEMPLATE = """
323
  padding: 4px 10px;
324
  border-radius: 8px;
325
  }
326
- .content-section {
327
- background-color: var(--card-bg);
328
- border-radius: var(--border-radius);
329
- padding: var(--padding-l);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  display: none;
331
  }
332
- .section-title {
 
 
 
333
  font-size: 1.4em;
334
  font-weight: 700;
335
  margin-bottom: var(--padding-m);
336
  padding-bottom: var(--padding-m);
337
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
338
  }
 
 
 
 
 
339
  .history-list, .invoice-list {
340
  list-style: none;
341
  padding: 0;
@@ -343,125 +428,137 @@ TEMPLATE = """
343
  max-height: 35vh;
344
  overflow-y: auto;
345
  }
346
- .history-item {
347
  display: flex;
348
  justify-content: space-between;
349
  align-items: center;
350
  padding: 14px 4px;
351
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
352
  }
353
- .history-item:last-child { border-bottom: none; }
354
- .history-details { display: flex; flex-direction: column; }
355
- .history-description { font-size: 1em; font-weight: 500; }
356
- .history-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; }
357
  .history-amount { font-size: 1.1em; font-weight: 700; }
358
  .history-amount.accrual { color: #4CAF50; }
359
  .history-amount.deduction { color: #F44336; }
360
- .no-history {
 
361
  text-align: center;
362
  color: var(--text-secondary-color);
363
  padding: 2rem 0;
364
  }
365
- .nav-buttons {
366
- display: flex;
367
- gap: 10px;
368
- margin-bottom: var(--padding-m);
369
- }
370
- .nav-buttons .btn {
371
- flex: 1;
372
- padding: 12px 15px;
373
- background-color: var(--btn-bg);
374
- color: var(--btn-text);
375
- border: none;
376
- border-radius: 8px;
377
- font-size: 1em;
378
- font-weight: 600;
379
- cursor: pointer;
380
- transition: background-color 0.2s ease;
381
- }
382
- .nav-buttons .btn:hover {
383
- background-color: var(--btn-hover);
384
- }
385
- .nav-buttons .btn.active {
386
- background-color: var(--brand-yellow);
387
- color: var(--brand-black);
388
  }
389
- .biz-card-section .org-info-card {
390
  background-color: var(--card-bg);
 
 
391
  border-radius: var(--border-radius);
392
- padding: var(--padding-m);
 
 
 
393
  }
394
- .org-info-card p {
395
- margin-bottom: 10px;
396
- line-height: 1.4;
 
 
 
 
 
397
  }
398
- .org-info-card strong {
399
- color: var(--brand-yellow);
 
 
 
 
400
  }
401
- .org-info-card ul {
402
- list-style: none;
403
- padding-left: 20px;
404
- margin-bottom: 10px;
405
  }
406
- .org-info-card ul li {
 
 
 
407
  margin-bottom: 5px;
408
  }
409
- .org-info-card a {
410
- color: var(--brand-yellow);
 
 
411
  text-decoration: none;
 
412
  word-break: break-all;
413
  }
414
- .org-info-card a:hover {
415
- text-decoration: underline;
416
- }
417
- .social-link {
418
- display: inline-block;
419
- margin-top: 5px;
420
- padding: 8px 12px;
421
- border-radius: 8px;
422
- font-weight: 500;
423
  }
424
- .social-link.whatsapp {
425
- background-color: #25D366;
426
- color: white;
427
  }
428
- .social-link.telegram {
429
- background-color: #0088CC;
430
- color: white;
431
  }
432
- .invoice-item {
433
- display: flex;
434
- flex-direction: column;
435
- padding: 14px 4px;
436
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
437
- cursor: pointer;
 
438
  }
439
- .invoice-item:last-child { border-bottom: none; }
440
- .invoice-summary {
441
  display: flex;
442
  justify-content: space-between;
443
- font-weight: 600;
 
 
444
  }
445
- .invoice-date {
446
- font-size: 1em;
447
- color: var(--text-secondary-color);
448
  }
449
- .invoice-total {
450
- font-size: 1.1em;
451
- color: var(--brand-yellow);
452
  }
453
- .invoice-details {
454
- margin-top: 10px;
455
- padding-left: 10px;
456
- font-size: 0.9em;
457
  color: var(--text-secondary-color);
 
 
 
458
  }
459
- .invoice-details ul {
460
- list-style: disc;
461
- padding-left: 20px;
462
- }
463
- .invoice-details li {
464
- margin-bottom: 5px;
 
 
 
 
 
 
 
 
 
465
  }
466
  </style>
467
  </head>
@@ -488,14 +585,14 @@ TEMPLATE = """
488
  <p class="client-id-value">{{ user.id }}</p>
489
  </section>
490
 
491
- <div class="nav-buttons">
492
- <button id="historyBtn" onclick="navigate('history')" class="btn">История операций</button>
493
- <button id="invoicesBtn" onclick="navigate('invoices')" class="btn">Мои накладные</button>
494
- <button id="cardBtn" onclick="navigate('card')" class="btn">Визитка</button>
495
- </div>
496
 
497
- <section class="content-section" id="historySection">
498
- <h2 class="section-title">История операций</h2>
499
  {% if user.combined_history %}
500
  <ul class="history-list">
501
  {% for item in user.combined_history %}
@@ -517,54 +614,63 @@ TEMPLATE = """
517
  {% endfor %}
518
  </ul>
519
  {% else %}
520
- <p class="no-history">Операций пока не было.</p>
521
  {% endif %}
522
  </section>
523
-
524
- <section class="content-section" id="invoicesSection">
525
- <h2 class="section-title">Мои накладные</h2>
526
  {% if user.invoices %}
527
  <ul class="invoice-list">
528
- {% for invoice in user.invoices|sort(attribute='date', reverse=true) %}
529
- <li class="invoice-item" onclick="toggleInvoiceDetails('{{ invoice.id }}')">
530
- <div class="invoice-summary">
531
- <span class="invoice-date">{{ invoice.date_str }}</span>
532
- <span class="invoice-total">{{ "%.2f"|format(invoice.total_amount|float) }}</span>
533
- </div>
534
- <div class="invoice-details" id="invoiceDetails-{{ invoice.id }}" style="display: none;">
535
- <ul>
536
- {% for item in invoice.items %}
537
- <li>{{ item.name }} x {{ item.quantity }} @ {{ "%.2f"|format(item.price_per_unit|float) }} = {{ "%.2f"|format(item.quantity * item.price_per_unit|float) }}</li>
538
- {% endfor %}
539
- </ul>
540
  </div>
 
541
  </li>
542
  {% endfor %}
543
  </ul>
544
  {% else %}
545
- <p class="no-history">Накладных пока нет.</p>
546
  {% endif %}
547
  </section>
548
 
549
- <section class="content-section" id="cardSection">
550
- <h2 class="section-title">Визитка организации</h2>
551
- <div class="org-info-card">
552
- <p><strong>Организация:</strong> {{ org_info.organization_name }}</p>
553
- <p><strong>Адрес:</strong> {{ org_info.address }}</p>
554
- <p><strong>Телефоны:</strong></p>
555
- <ul>
556
- {% for phone in org_info.phone_numbers %}
557
- <li><a href="tel:{{ phone }}">{{ phone }}</a></li>
558
- {% endfor %}
559
- </ul>
560
- {% if org_info.whatsapp_link %}
561
- <p><a href="{{ org_info.whatsapp_link }}" target="_blank" class="social-link whatsapp">Написать в WhatsApp</a></p>
562
- {% endif %}
563
- {% if org_info.telegram_link %}
564
- <p><a href="{{ org_info.telegram_link }}" target="_blank" class="social-link telegram">Написать в Telegram</a></p>
565
- {% endif %}
 
 
566
  </div>
567
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  </div>
569
 
570
  <script>
@@ -579,47 +685,12 @@ TEMPLATE = """
579
  root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0');
580
  root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107');
581
  root.style.setProperty('--card-bg', themeParams.secondary_bg_color || (isDark ? '#1c1c1e' : '#f1f1f1'));
582
- root.style.setProperty('--btn-bg', themeParams.secondary_bg_color || (isDark ? '#333' : '#e0e0e0'));
583
- root.style.setProperty('--btn-text', themeParams.text_color || '#fff');
584
- root.style.setProperty('--btn-hover', isDark ? '#555' : '#c0c0c0');
585
  }
586
 
587
- function navigate(view) {
588
- const url = new URL(window.location.href);
589
- url.searchParams.set('view', view);
590
- window.location.href = url.toString();
591
- }
592
-
593
- function showSection(view) {
594
- document.querySelectorAll('.content-section').forEach(section => {
595
- section.style.display = 'none';
596
- });
597
- const targetSection = document.getElementById(view + 'Section');
598
- if (targetSection) {
599
- targetSection.style.display = 'block';
600
- } else {
601
- document.getElementById('historySection').style.display = 'block';
602
- }
603
-
604
- document.querySelectorAll('.nav-buttons .btn').forEach(btn => {
605
- btn.classList.remove('active');
606
- });
607
- document.getElementById(view + 'Btn').classList.add('active');
608
-
609
- document.body.style.visibility = 'visible';
610
- }
611
-
612
- function toggleInvoiceDetails(invoiceId) {
613
- const details = document.getElementById(`invoiceDetails-${invoiceId}`);
614
- if (details) {
615
- details.style.display = details.style.display === 'none' ? 'block' : 'none';
616
- }
617
- }
618
-
619
  function setupTelegram() {
620
  if (!tg || !tg.initData) {
 
621
  document.body.style.visibility = 'visible';
622
- showSection('history');
623
  return;
624
  }
625
 
@@ -633,7 +704,6 @@ TEMPLATE = """
633
 
634
  const urlParams = new URLSearchParams(window.location.search);
635
  const userIdForTest = urlParams.get('user_id_for_test');
636
- const currentView = urlParams.get('view') || 'history';
637
 
638
  if (!userIdForTest) {
639
  fetch('/verify', {
@@ -644,16 +714,18 @@ TEMPLATE = """
644
  .then(response => response.json())
645
  .then(data => {
646
  if (data.status === 'ok' && data.verified && data.user_id) {
647
- window.location.replace('/?user_id_for_test=' + data.user_id + '&view=' + currentView);
648
  } else {
649
- showSection(currentView);
 
650
  }
651
  })
652
  .catch(error => {
653
- showSection(currentView);
 
654
  });
655
  } else {
656
- showSection(currentView);
657
  }
658
 
659
  const user = tg.initDataUnsafe?.user;
@@ -673,10 +745,120 @@ TEMPLATE = """
673
  setTimeout(() => {
674
  if (document.body.style.visibility !== 'visible') {
675
  document.body.style.visibility = 'visible';
676
- showSection(new URLSearchParams(window.location.search).get('view') || 'history');
677
  }
678
  }, 3000);
679
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  </script>
681
  </body>
682
  </html>
@@ -770,23 +952,117 @@ ADMIN_TEMPLATE = """
770
  .btn-submit { background-color: var(--admin-success); color: white; }
771
  .status-message { text-align: center; font-weight: 500; flex-grow: 1; text-align: left; }
772
 
773
- .invoice-items-preview {
774
- border: 1px dashed var(--admin-border);
 
 
 
 
 
 
775
  border-radius: 8px;
776
- padding: 1rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  margin-top: 1rem;
 
 
 
 
778
  }
779
- .invoice-items-preview ul {
780
  list-style: none;
781
  padding: 0;
 
 
 
 
 
782
  }
783
- .invoice-items-preview li {
784
- padding: 5px 0;
785
- border-bottom: 1px dotted rgba(0,0,0,0.1);
 
 
 
786
  }
787
- .invoice-items-preview li:last-child {
788
  border-bottom: none;
789
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  </style>
791
  </head>
792
  <body>
@@ -814,7 +1090,7 @@ ADMIN_TEMPLATE = """
814
  <div class="controls-bar">
815
  <input type="text" id="searchInput" onkeyup="searchUsers()" placeholder="Поиск по имени, ID, username, номеру...">
816
  <button class="btn btn-primary" onclick="openAddClientModal()">Добавить клиента</button>
817
- <button class="btn btn-primary" onclick="openOrgSettingsModal()">Настройки организации</button>
818
  </div>
819
 
820
  {% if users %}
@@ -840,7 +1116,7 @@ ADMIN_TEMPLATE = """
840
  </div>
841
  <div class="user-actions">
842
  <button class="btn-manage" onclick='openTransactionModal({{ user|tojson }})'>Управление счетом</button>
843
- <button class="btn-manage" onclick='openInvoiceManagementModal({{ user|tojson }})'>Управление накладными</button>
844
  {% if user.telegram_id == None %}
845
  <button class="btn btn-delete" onclick='deleteClient("{{ user.id }}")'>Удалить клиента</button>
846
  {% endif %}
@@ -936,95 +1212,101 @@ ADMIN_TEMPLATE = """
936
  </div>
937
  </div>
938
 
939
- <div id="orgSettingsModal" class="modal">
940
  <div class="modal-content">
941
- <span class="modal-close" onclick="closeModal('orgSettingsModal')">×</span>
942
  <div class="modal-header">
943
- <h2>Настройки организации</h2>
 
944
  </div>
 
 
945
  <div class="form-section">
946
- <div class="form-group">
947
- <label for="orgName">Название организации</label>
948
- <input type="text" id="orgName" value="{{ org_info.organization_name }}">
 
 
 
 
 
 
 
 
 
 
 
 
949
  </div>
950
- <div class="form-group">
951
- <label for="orgAddress">Адрес</label>
952
- <textarea id="orgAddress">{{ org_info.address }}</textarea>
 
 
 
 
953
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
  <div class="form-group">
955
- <label for="orgPhones">Номера телефонов (через запятую)</label>
956
- <input type="text" id="orgPhones" value="{{ ','.join(org_info.phone_numbers) }}">
957
  </div>
958
  <div class="form-group">
959
- <label for="orgWhatsApp">Ссылка на WhatsApp (e.g., https://wa.me/79001234567)</label>
960
- <input type="text" id="orgWhatsApp" value="{{ org_info.whatsapp_link }}">
 
 
 
961
  </div>
962
  <div class="form-group">
963
- <label for="orgTelegram">Ссылка на Telegram (e.g., https://t.me/username)</label>
964
- <input type="text" id="orgTelegram" value="{{ org_info.telegram_link }}">
965
  </div>
966
- </div>
967
- <div class="modal-footer">
968
- <div id="orgSettingsStatus" class="status-message"></div>
969
- <button class="btn-submit" onclick="submitOrgSettings()">Сохранить настройки</button>
970
- </div>
971
- </div>
972
- </div>
973
-
974
- <div id="invoiceManagementModal" class="modal">
975
- <div class="modal-content">
976
- <span class="modal-close" onclick="closeModal('invoiceManagementModal')">×</span>
977
- <div class="modal-header">
978
- <h2 id="invoiceModalUserName"></h2>
979
- <div id="invoiceModalUserUsername" class="username"></div>
980
- </div>
981
- <input type="hidden" id="invoiceModalUserId">
982
-
983
- <div class="form-section">
984
- <h3>Создать новую накладную</h3>
985
- <div class="form-row">
986
  <div class="form-group">
987
- <label for="invoiceItemName">Название товара</label>
988
- <input type="text" id="invoiceItemName" placeholder="Название товара">
989
  </div>
990
  <div class="form-group">
991
- <label for="invoiceItemQuantity">Количество</label>
992
- <input type="number" id="invoiceItemQuantity" value="1" min="1">
993
  </div>
994
  <div class="form-group">
995
- <label for="invoiceItemPrice">Стоимость за единицу</label>
996
- <input type="number" id="invoiceItemPrice" placeholder="0.00" step="0.01">
997
  </div>
998
  </div>
999
- <button class="btn btn-primary" style="margin-top: 1rem;" onclick="addItemToNewInvoice()">Добавить товар</button>
1000
-
1001
- <div class="invoice-items-preview" style="margin-top: 1.5rem;">
1002
- <h4>Добавляемые товары:</h4>
1003
- <ul id="newInvoiceItemsList" style="list-style: none; padding: 0;">
1004
- </ul>
1005
- <p style="text-align: right; margin-top: 1rem;">Итого по новой накладной: <strong id="newInvoiceTotal">0.00</strong></p>
1006
  </div>
1007
  </div>
1008
-
1009
- <div class="history-container">
1010
- <h3>Существующие накладные клиента</h3>
1011
- <ul id="existingInvoicesList" class="history-list"></ul>
1012
- </div>
1013
-
1014
- <div class="modal-footer">
1015
- <div id="invoiceStatus" class="status-message"></div>
1016
- <button class="btn-submit" onclick="submitNewInvoice()">Сохранить накладную</button>
1017
- </div>
1018
- </div>
1019
  </div>
1020
 
1021
  <script>
1022
  const transactionModal = document.getElementById('transactionModal');
1023
  const addClientModal = document.getElementById('addClientModal');
1024
- const orgSettingsModal = document.getElementById('orgSettingsModal');
1025
- const invoiceManagementModal = document.getElementById('invoiceManagementModal');
1026
  let currentUserData = null;
1027
- let currentNewInvoiceItems = [];
1028
 
1029
  function searchUsers() {
1030
  const searchTerm = document.getElementById('searchInput').value.toLowerCase();
@@ -1039,6 +1321,17 @@ ADMIN_TEMPLATE = """
1039
  });
1040
  }
1041
 
 
 
 
 
 
 
 
 
 
 
 
1042
  function openTransactionModal(userData) {
1043
  currentUserData = userData;
1044
  document.getElementById('modalUserId').value = userData.id;
@@ -1066,7 +1359,7 @@ ADMIN_TEMPLATE = """
1066
  sign = item.type === 'accrual' ? '+' : '-';
1067
  amountClass = item.type === 'accrual' ? 'bonus-accrual' : 'bonus-deduction';
1068
  amountText = `${sign}${parseFloat(item.amount).toFixed(2)}`;
1069
- } else {
1070
  sign = item.type === 'accrual' ? '+' : '-';
1071
  amountClass = item.type === 'accrual' ? 'debt-accrual' : 'debt-payment';
1072
  amountText = `${sign}${parseFloat(item.amount).toFixed(2)}`;
@@ -1085,40 +1378,14 @@ ADMIN_TEMPLATE = """
1085
  }
1086
 
1087
  updateCalculations();
1088
- transactionModal.style.display = 'block';
1089
  }
1090
 
1091
  function openAddClientModal() {
1092
  document.getElementById('newClientFirstName').value = '';
1093
  document.getElementById('newClientPhone').value = '';
1094
  document.getElementById('addClientStatus').textContent = '';
1095
- addClientModal.style.display = 'block';
1096
- }
1097
-
1098
- function openOrgSettingsModal() {
1099
- orgSettingsModal.style.display = 'block';
1100
- document.getElementById('orgSettingsStatus').textContent = '';
1101
- }
1102
-
1103
- function openInvoiceManagementModal(userData) {
1104
- currentUserData = userData;
1105
- document.getElementById('invoiceModalUserId').value = userData.id;
1106
- document.getElementById('invoiceModalUserName').textContent = `${userData.first_name || ''} ${userData.last_name || ''}`;
1107
- document.getElementById('invoiceModalUserUsername').textContent = `@${userData.username || userData.phone_number} | ID: ${userData.id}`;
1108
- document.getElementById('invoiceStatus').textContent = '';
1109
-
1110
- currentNewInvoiceItems = [];
1111
- renderNewInvoiceItems();
1112
- renderExistingInvoices(userData.invoices || []);
1113
-
1114
- invoiceManagementModal.style.display = 'block';
1115
- }
1116
-
1117
- function closeModal(modalId) {
1118
- document.getElementById(modalId).style.display = 'none';
1119
- if (modalId === 'transactionModal' || modalId === 'invoiceManagementModal') {
1120
- currentUserData = null;
1121
- }
1122
  }
1123
 
1124
  function updateCalculations() {
@@ -1126,31 +1393,31 @@ ADMIN_TEMPLATE = """
1126
 
1127
  const currentBalance = parseFloat(currentUserData.bonuses) || 0;
1128
  const purchaseAmount = parseFloat(document.getElementById('purchaseAmount').value) || 0;
1129
- const deductAmount = parseFloat(document.getElementById('deductAmount').value) || 0;
1130
  const accrualAmount = purchaseAmount * 0.02;
1131
- let finalDeductAmount = deductAmount;
1132
  if (deductAmount > currentBalance) {
1133
- finalDeductAmount = currentBalance;
1134
- document.getElementById('deductAmount').value = finalDeductAmount > 0 ? finalDeductAmount.toFixed(2) : '';
1135
  }
1136
- const finalBalance = currentBalance + accrualAmount - finalDeductAmount;
1137
  document.getElementById('summaryCurrentBalance').textContent = currentBalance.toFixed(2);
1138
  document.getElementById('summaryAccrual').textContent = `+${accrualAmount.toFixed(2)}`;
1139
- document.getElementById('summaryDeduction').textContent = `-${finalDeductAmount.toFixed(2)}`;
1140
- document.getElementById('summaryFinalBalance').textContent = finalBalance.toFixed(2);
1141
 
1142
  const currentDebt = parseFloat(currentUserData.debts) || 0;
1143
  const addDebtAmount = parseFloat(document.getElementById('addDebtAmount').value) || 0;
1144
- const repayDebtAmount = parseFloat(document.getElementById('repayDebtAmount').value) || 0;
1145
- let finalRepayAmount = repayDebtAmount;
1146
  if (repayDebtAmount > currentDebt) {
1147
- finalRepayAmount = currentDebt;
1148
- document.getElementById('repayDebtAmount').value = finalRepayAmount > 0 ? finalRepayAmount.toFixed(2) : '';
1149
  }
1150
- const finalDebt = currentDebt + addDebtAmount - finalRepayAmount;
1151
  document.getElementById('summaryCurrentDebt').textContent = currentDebt.toFixed(2);
1152
  document.getElementById('summaryAddDebt').textContent = `+${addDebtAmount.toFixed(2)}`;
1153
- document.getElementById('summaryRepayDebt').textContent = `-${finalRepayAmount.toFixed(2)}`;
1154
  document.getElementById('summaryFinalDebt').textContent = finalDebt.toFixed(2);
1155
  }
1156
 
@@ -1228,91 +1495,116 @@ ADMIN_TEMPLATE = """
1228
  }
1229
  }
1230
 
1231
- async function submitOrgSettings() {
1232
- const statusEl = document.getElementById('orgSettingsStatus');
1233
- statusEl.style.color = 'var(--admin-secondary)';
1234
- statusEl.textContent = 'Сохранение...';
1235
-
1236
- const payload = {
1237
- organization_name: document.getElementById('orgName').value.trim(),
1238
- address: document.getElementById('orgAddress').value.trim(),
1239
- phone_numbers: document.getElementById('orgPhones').value.split(',').map(p => p.trim()).filter(p => p),
1240
- whatsapp_link: document.getElementById('orgWhatsApp').value.trim(),
1241
- telegram_link: document.getElementById('orgTelegram').value.trim(),
1242
- };
1243
-
1244
  try {
1245
- const response = await fetch('/admin/update_org_info', {
1246
  method: 'POST',
1247
  headers: { 'Content-Type': 'application/json' },
1248
- body: JSON.stringify(payload)
1249
  });
1250
  const result = await response.json();
1251
  if (response.ok) {
1252
- statusEl.style.color = 'var(--admin-success)';
1253
- statusEl.textContent = 'Настройки успешно сохранены!';
1254
- setTimeout(() => { location.reload(); }, 1500);
1255
  } else {
1256
- throw new Error(result.message || 'Произошла ошибка');
1257
  }
1258
  } catch (error) {
1259
- statusEl.style.color = 'var(--admin-danger)';
1260
- statusEl.textContent = `Ошибка: ${error.message}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1261
  }
 
1262
  }
1263
 
1264
- function addItemToNewInvoice() {
1265
  const name = document.getElementById('invoiceItemName').value.trim();
1266
  const quantity = parseInt(document.getElementById('invoiceItemQuantity').value);
1267
  const price = parseFloat(document.getElementById('invoiceItemPrice').value);
1268
 
1269
  if (!name || isNaN(quantity) || quantity <= 0 || isNaN(price) || price < 0) {
1270
- alert('Пожалуйста, введите корректные данные для товара (название, количество > 0, цена >= 0).');
1271
  return;
1272
  }
1273
 
1274
- currentNewInvoiceItems.push({ name, quantity, price_per_unit: price });
1275
- renderNewInvoiceItems();
 
 
 
 
1276
 
1277
  document.getElementById('invoiceItemName').value = '';
1278
  document.getElementById('invoiceItemQuantity').value = '1';
1279
  document.getElementById('invoiceItemPrice').value = '';
 
1280
  }
1281
 
1282
- function renderNewInvoiceItems() {
1283
- const listEl = document.getElementById('newInvoiceItemsList');
1284
- listEl.innerHTML = '';
1285
- let total = 0;
1286
- currentNewInvoiceItems.forEach((item, index) => {
1287
- const li = document.createElement('li');
1288
- const itemTotal = item.quantity * item.price_per_unit;
1289
- total += itemTotal;
1290
- li.innerHTML = `${item.name} x ${item.quantity} @ ${item.price_per_unit.toFixed(2)} = ${(itemTotal).toFixed(2)}
1291
- <button style="margin-left:10px; background: none; border: none; color: var(--admin-danger); cursor: pointer;" onclick="removeItemFromNewInvoice(${index})">x</button>`;
1292
- listEl.appendChild(li);
1293
- });
1294
- document.getElementById('newInvoiceTotal').textContent = total.toFixed(2);
1295
- }
1296
-
1297
- function removeItemFromNewInvoice(index) {
1298
- currentNewInvoiceItems.splice(index, 1);
1299
- renderNewInvoiceItems();
1300
  }
1301
 
1302
  async function submitNewInvoice() {
1303
- const statusEl = document.getElementById('invoiceStatus');
1304
  statusEl.style.color = 'var(--admin-secondary)';
1305
  statusEl.textContent = 'Сохранение накладной...';
1306
 
1307
- if (currentNewInvoiceItems.length === 0) {
1308
  statusEl.style.color = 'var(--admin-danger)';
1309
- statusEl.textContent = 'Добавьте хотя бы один товар в накладную.';
1310
  return;
1311
  }
1312
 
 
 
 
1313
  const payload = {
1314
- user_id: document.getElementById('invoiceModalUserId').value,
1315
- items: currentNewInvoiceItems
 
1316
  };
1317
 
1318
  try {
@@ -1327,7 +1619,7 @@ ADMIN_TEMPLATE = """
1327
  statusEl.textContent = 'Накладная успешно сохранена!';
1328
  setTimeout(() => { location.reload(); }, 1500);
1329
  } else {
1330
- throw new Error(result.message || 'Произошла ошибка');
1331
  }
1332
  } catch (error) {
1333
  statusEl.style.color = 'var(--admin-danger)';
@@ -1335,48 +1627,105 @@ ADMIN_TEMPLATE = """
1335
  }
1336
  }
1337
 
1338
- function renderExistingInvoices(invoices) {
1339
- const listEl = document.getElementById('existingInvoicesList');
1340
- listEl.innerHTML = '';
1341
- if (invoices.length === 0) {
1342
- listEl.innerHTML = '<li style="text-align:center; padding: 1rem; color: var(--admin-secondary);">Нет накладных</li>';
1343
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1344
  }
1345
- invoices.sort((a, b) => new Date(b.date) - new Date(a.date));
1346
- invoices.forEach(invoice => {
1347
- const li = document.createElement('li');
1348
- li.className = 'history-item';
1349
- li.innerHTML = `
1350
- <div>
1351
- <div class="desc">Накладная от ${invoice.date_str}</div>
1352
- <div class="date">${invoice.items.length} товар(ов)</div>
1353
- </div>
1354
- <div class="amount bonus-accrual">${parseFloat(invoice.total_amount).toFixed(2)}</div>
1355
- `;
1356
- li.onclick = () => { alert('Детали накладной:\n' + invoice.items.map(item => `${item.name} x ${item.quantity} @ ${item.price_per_unit.toFixed(2)}`).join('\n') + `\nИтого: ${parseFloat(invoice.total_amount).toFixed(2)}`); };
1357
- listEl.appendChild(li);
1358
- });
1359
  }
1360
 
1361
-
1362
- async function deleteClient(userId) {
1363
- if (!confirm(`Вы уверены, что хотите удалить клиента с ID ${userId}? Это действие необратимо.`)) {
1364
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1365
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1366
  try {
1367
- const response = await fetch('/admin/delete_client', {
1368
  method: 'POST',
1369
  headers: { 'Content-Type': 'application/json' },
1370
- body: JSON.stringify({ user_id: userId })
1371
  });
1372
  const result = await response.json();
1373
  if (response.ok) {
1374
- location.reload();
 
 
1375
  } else {
1376
- throw new Error(result.message || 'Не удалось удалить клиента.');
1377
  }
1378
  } catch (error) {
1379
- alert(`Ошибка: ${error.message}`);
 
1380
  }
1381
  }
1382
 
@@ -1387,11 +1736,11 @@ ADMIN_TEMPLATE = """
1387
  if (event.target == addClientModal) {
1388
  closeModal('addClientModal');
1389
  }
1390
- if (event.target == orgSettingsModal) {
1391
- closeModal('orgSettingsModal');
1392
  }
1393
- if (event.target == invoiceManagementModal) {
1394
- closeModal('invoiceManagementModal');
1395
  }
1396
  }
1397
  </script>
@@ -1417,14 +1766,16 @@ def index():
1417
  debt_history = user_data.get('debt_history', [])
1418
  for item in debt_history:
1419
  item['transaction_type'] = 'debt'
1420
-
 
 
1421
  combined_history = sorted(
1422
  bonus_history + debt_history,
1423
- key=lambda x: datetime.fromisoformat(x['date']),
1424
  reverse=True
1425
  )
1426
  user_data['combined_history'] = combined_history
1427
- user_data['invoices'] = user_data.get('invoices', [])
1428
  else:
1429
  user_data = {
1430
  "id": "N/A",
@@ -1436,8 +1787,7 @@ def index():
1436
  "invoices": []
1437
  }
1438
 
1439
- org_info = get_organization_info()
1440
- return render_template_string(TEMPLATE, user=user_data, org_info=org_info)
1441
 
1442
  @app.route('/verify', methods=['POST'])
1443
  def verify_data():
@@ -1454,13 +1804,14 @@ def verify_data():
1454
  try:
1455
  user_json_str = unquote(user_data_parsed['user'][0])
1456
  user_info_dict = json.loads(user_json_str)
1457
- except Exception:
 
1458
  user_info_dict = {}
1459
 
1460
  if is_valid:
1461
  tg_user_id = user_info_dict.get('id')
1462
  if tg_user_id:
1463
- now = get_now_bishkek()
1464
  all_data = load_visitor_data()
1465
 
1466
  existing_user_key = None
@@ -1469,6 +1820,9 @@ def verify_data():
1469
  existing_user_key = key
1470
  break
1471
 
 
 
 
1472
  if existing_user_key:
1473
  user_entry = all_data[existing_user_key]
1474
  user_entry.update({
@@ -1480,6 +1834,7 @@ def verify_data():
1480
  'visited_at': now.timestamp(),
1481
  'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S')
1482
  })
 
1483
  user_id_to_save = existing_user_key
1484
  else:
1485
  new_user_id = generate_unique_id(all_data)
@@ -1501,19 +1856,27 @@ def verify_data():
1501
  'debt_history': [],
1502
  'invoices': []
1503
  }
 
1504
  user_id_to_save = new_user_id
1505
 
1506
- save_visitor_data({user_id_to_save: user_entry})
1507
 
1508
  return jsonify({"status": "ok", "verified": True, "user_id": user_id_to_save})
1509
  else:
1510
  return jsonify({"status": "error", "verified": True, "message": "User ID not found in parsed data"}), 400
1511
  else:
 
1512
  return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
1513
 
1514
- except Exception:
 
1515
  return jsonify({"status": "error", "message": "Internal server error"}), 500
1516
 
 
 
 
 
 
1517
  @app.route('/admin')
1518
  def admin_panel():
1519
  current_data = load_visitor_data()
@@ -1534,8 +1897,7 @@ def admin_panel():
1534
  "users_with_debt": users_with_debt
1535
  }
1536
 
1537
- org_info = get_organization_info()
1538
- return render_template_string(ADMIN_TEMPLATE, users=users_list, summary=summary_stats, org_info=org_info)
1539
 
1540
  @app.route('/admin/add_client', methods=['POST'])
1541
  def add_client():
@@ -1553,7 +1915,7 @@ def add_client():
1553
  if user.get('phone_number') == phone_number:
1554
  return jsonify({"status": "error", "message": "Клиент с таким номером телефона уже существует."}), 409
1555
 
1556
- now = get_now_bishkek()
1557
  new_id = generate_unique_id(all_data)
1558
 
1559
  new_client = {
@@ -1580,9 +1942,9 @@ def add_client():
1580
  return jsonify({"status": "ok", "message": "Client added successfully"}), 201
1581
 
1582
  except Exception as e:
 
1583
  return jsonify({"status": "error", "message": str(e)}), 500
1584
 
1585
-
1586
  @app.route('/admin/add_transaction', methods=['POST'])
1587
  def add_transaction():
1588
  try:
@@ -1603,7 +1965,7 @@ def add_transaction():
1603
  return jsonify({"status": "error", "message": "User not found"}), 404
1604
 
1605
  user = all_data[user_id_str]
1606
- now = get_now_bishkek()
1607
  now_iso = now.isoformat()
1608
  now_str = now.strftime('%Y-%m-%d %H:%M:%S')
1609
 
@@ -1656,29 +2018,7 @@ def add_transaction():
1656
  }), 200
1657
 
1658
  except Exception as e:
1659
- return jsonify({"status": "error", "message": str(e)}), 500
1660
-
1661
- @app.route('/admin/update_org_info', methods=['POST'])
1662
- def update_org_info():
1663
- try:
1664
- data = request.get_json()
1665
- org_name = data.get('organization_name')
1666
- address = data.get('address')
1667
- phone_numbers = data.get('phone_numbers', [])
1668
- whatsapp_link = data.get('whatsapp_link')
1669
- telegram_link = data.get('telegram_link')
1670
-
1671
- updated_org_info = {
1672
- "organization_name": org_name,
1673
- "address": address,
1674
- "phone_numbers": phone_numbers,
1675
- "whatsapp_link": whatsapp_link,
1676
- "telegram_link": telegram_link
1677
- }
1678
-
1679
- save_organization_info(updated_org_info)
1680
- return jsonify({"status": "ok", "message": "Organization settings updated successfully"}), 200
1681
- except Exception as e:
1682
  return jsonify({"status": "error", "message": str(e)}), 500
1683
 
1684
  @app.route('/admin/add_invoice', methods=['POST'])
@@ -1686,12 +2026,11 @@ def add_invoice():
1686
  try:
1687
  data = request.get_json()
1688
  user_id = data.get('user_id')
1689
- items = data.get('items', [])
 
1690
 
1691
- if not user_id:
1692
- return jsonify({"status": "error", "message": "User ID is required"}), 400
1693
- if not items:
1694
- return jsonify({"status": "error", "message": "Invoice must contain items"}), 400
1695
 
1696
  user_id_str = str(user_id)
1697
  all_data = load_visitor_data()
@@ -1700,28 +2039,28 @@ def add_invoice():
1700
  return jsonify({"status": "error", "message": "User not found"}), 404
1701
 
1702
  user = all_data[user_id_str]
1703
- now = get_now_bishkek()
1704
  now_iso = now.isoformat()
1705
  now_str = now.strftime('%Y-%m-%d %H:%M:%S')
1706
 
1707
- total_amount = sum(item['quantity'] * item['price_per_unit'] for item in items)
 
1708
 
1709
  new_invoice = {
1710
- "id": str(uuid.uuid4()),
1711
  "date": now_iso,
1712
  "date_str": now_str,
1713
  "items": items,
1714
- "total_amount": total_amount
1715
  }
1716
-
1717
- if 'invoices' not in user or not isinstance(user['invoices'], list):
1718
- user['invoices'] = []
1719
  user['invoices'].append(new_invoice)
1720
 
1721
  save_visitor_data({user_id_str: user})
1722
- return jsonify({"status": "ok", "message": "Invoice added successfully", "invoice_id": new_invoice['id']}), 200
 
1723
 
1724
  except Exception as e:
 
1725
  return jsonify({"status": "error", "message": str(e)}), 500
1726
 
1727
  @app.route('/admin/delete_client', methods=['POST'])
@@ -1747,26 +2086,63 @@ def delete_client():
1747
  del visitor_data_cache[user_id_str]
1748
 
1749
  try:
1750
- data_to_save = visitor_data_cache.copy()
1751
- if _organization_info_cache:
1752
- data_to_save['__ORG_INFO__'] = _organization_info_cache
1753
  with open(DATA_FILE, 'w', encoding='utf-8') as f:
1754
- json.dump(data_to_save, f, ensure_ascii=False, indent=4)
1755
- upload_data_to_hf_async()
1756
- except Exception:
 
 
1757
  return jsonify({"status": "error", "message": "Failed to save data after deletion"}), 500
1758
 
1759
  return jsonify({"status": "ok", "message": "Client deleted successfully"}), 200
1760
 
1761
  except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1762
  return jsonify({"status": "error", "message": str(e)}), 500
1763
 
 
1764
  if __name__ == '__main__':
1765
- download_data_from_hf()
 
 
 
 
 
 
 
1766
  load_visitor_data()
 
1767
 
 
 
1768
  if HF_TOKEN_WRITE:
1769
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1770
  backup_thread.start()
 
1771
 
 
1772
  app.run(host=HOST, port=PORT, debug=False)
 
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
14
  from huggingface_hub import HfApi, hf_hub_download
15
  from huggingface_hub.utils import RepositoryNotFoundError
16
+ import pytz
17
 
18
  BOT_TOKEN = os.getenv("BOT_TOKEN", "7835463659:AAGNePbelZIAOeaglyQi1qulOqnjs4BGQn4")
19
  HOST = '0.0.0.0'
20
  PORT = 7860
21
  DATA_FILE = 'data.json'
22
+ ORG_INFO_FILE = 'organization_info.json'
23
 
24
  REPO_ID = "flpolprojects/examplebonus"
25
  HF_DATA_FILE_PATH = "data.json"
26
+ HF_ORG_INFO_FILE_PATH = "organization_info.json"
27
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
28
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
29
 
30
+ BISHKEK_TZ = pytz.timezone('Asia/Bishkek')
31
+
32
  app = Flask(__name__)
33
  logging.basicConfig(level=logging.INFO)
34
  app.secret_key = os.urandom(24)
35
 
36
  _data_lock = threading.Lock()
37
+ _org_info_lock = threading.Lock()
38
  visitor_data_cache = {}
39
+ organization_info_cache = {}
 
 
40
 
41
+ def get_current_bishkek_time():
42
  return datetime.now(BISHKEK_TZ)
43
 
44
  def generate_unique_id(all_data):
45
  while True:
46
  new_id = str(random.randint(10000, 99999))
47
+ if new_id not in all_data:
48
  return new_id
49
 
50
+ def download_file_from_hf(filename, hf_path, token):
 
 
 
51
  try:
52
  hf_hub_download(
53
  repo_id=REPO_ID,
54
+ filename=hf_path,
55
  repo_type="dataset",
56
+ token=token,
57
  local_dir=".",
58
  local_dir_use_symlinks=False,
59
  force_download=True,
60
  etag_timeout=10
61
  )
62
+ logging.info(f"File {filename} successfully downloaded from Hugging Face.")
 
 
 
 
 
 
 
 
63
  return True
64
  except RepositoryNotFoundError:
65
+ logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download {filename}.")
66
+ except Exception as e:
67
+ logging.error(f"Error downloading {filename} from Hugging Face: {e}")
68
  return False
69
 
70
+ def upload_file_to_hf(file_path, hf_path, commit_message):
71
+ if not HF_TOKEN_WRITE:
72
+ logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.")
73
+ return
74
+ if not os.path.exists(file_path):
75
+ logging.warning(f"{file_path} does not exist. Skipping upload.")
76
+ return
77
+
78
+ try:
79
+ api = HfApi()
80
+ file_content_exists = os.path.getsize(file_path) > 0
81
+ if not file_content_exists:
82
+ logging.warning(f"{file_path} is empty. Skipping upload.")
83
+ return
84
+
85
+ logging.info(f"Attempting to upload {file_path} to {REPO_ID}/{hf_path}...")
86
+ api.upload_file(
87
+ path_or_fileobj=file_path,
88
+ path_in_repo=hf_path,
89
+ repo_id=REPO_ID,
90
+ repo_type="dataset",
91
+ token=HF_TOKEN_WRITE,
92
+ commit_message=commit_message
93
+ )
94
+ logging.info(f"{file_path} successfully uploaded to Hugging Face.")
95
+ except Exception as e:
96
+ logging.error(f"Error uploading {file_path} to Hugging Face: {e}")
97
+
98
+ def upload_file_to_hf_async(file_path, hf_path, commit_message):
99
+ upload_thread = threading.Thread(target=upload_file_to_hf, args=(file_path, hf_path, commit_message), daemon=True)
100
+ upload_thread.start()
101
+
102
+ def download_all_data_from_hf():
103
+ global visitor_data_cache, organization_info_cache
104
+ if not HF_TOKEN_READ:
105
+ logging.warning("HF_TOKEN_READ not set. Skipping Hugging Face download.")
106
+ return False
107
+
108
+ success_data = download_file_from_hf(DATA_FILE, HF_DATA_FILE_PATH, HF_TOKEN_READ)
109
+ success_org_info = download_file_from_hf(ORG_INFO_FILE, HF_ORG_INFO_FILE_PATH, HF_TOKEN_READ)
110
+
111
+ with _data_lock:
112
+ try:
113
+ if success_data and os.path.exists(DATA_FILE) and os.path.getsize(DATA_FILE) > 0:
114
+ with open(DATA_FILE, 'r', encoding='utf-8') as f:
115
+ visitor_data_cache = json.load(f)
116
+ logging.info(f"Successfully loaded downloaded {DATA_FILE} into cache.")
117
+ else:
118
+ logging.warning(f"Downloaded {DATA_FILE} is empty or not found after download. Starting with empty visitor cache.")
119
+ visitor_data_cache = {}
120
+ except json.JSONDecodeError as e:
121
+ logging.error(f"Error decoding downloaded {DATA_FILE}: {e}. Starting with empty visitor cache.")
122
+ visitor_data_cache = {}
123
+
124
+ with _org_info_lock:
125
+ try:
126
+ if success_org_info and os.path.exists(ORG_INFO_FILE) and os.path.getsize(ORG_INFO_FILE) > 0:
127
+ with open(ORG_INFO_FILE, 'r', encoding='utf-8') as f:
128
+ organization_info_cache = json.load(f)
129
+ logging.info(f"Successfully loaded downloaded {ORG_INFO_FILE} into cache.")
130
+ else:
131
+ logging.warning(f"Downloaded {ORG_INFO_FILE} is empty or not found after download. Loading default organization info.")
132
+ organization_info_cache = {}
133
+ load_organization_data()
134
+ except json.JSONDecodeError as e:
135
+ logging.error(f"Error decoding downloaded {ORG_INFO_FILE}: {e}. Loading default organization info.")
136
+ organization_info_cache = {}
137
+ load_organization_data()
138
+
139
+ return success_data and success_org_info
140
+
141
  def load_visitor_data():
142
+ global visitor_data_cache
143
  with _data_lock:
144
+ if not visitor_data_cache:
145
  try:
146
  with open(DATA_FILE, 'r', encoding='utf-8') as f:
147
+ visitor_data_cache = json.load(f)
148
+ logging.info("Visitor data loaded from local JSON.")
 
149
  except FileNotFoundError:
150
+ logging.warning(f"{DATA_FILE} not found locally. Starting with empty data.")
151
  visitor_data_cache = {}
 
152
  except json.JSONDecodeError:
153
+ logging.error(f"Error decoding {DATA_FILE}. Starting with empty data.")
154
  visitor_data_cache = {}
155
+ except Exception as e:
156
+ logging.error(f"Unexpected error loading visitor data: {e}")
157
  visitor_data_cache = {}
 
158
  return visitor_data_cache
159
 
160
+ def save_visitor_data(data_to_save):
 
 
 
 
 
 
 
 
 
 
 
161
  with _data_lock:
162
+ for user_id, user_entry in data_to_save.items():
163
+ if user_id in visitor_data_cache:
164
+ visitor_data_cache[user_id].update(user_entry)
165
+ else:
166
+ visitor_data_cache[user_id] = user_entry
167
  try:
 
 
 
 
168
  with open(DATA_FILE, 'w', encoding='utf-8') as f:
169
+ json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4)
170
+ logging.info(f"Visitor data successfully saved to {DATA_FILE}.")
171
+ upload_file_to_hf_async(DATA_FILE, HF_DATA_FILE_PATH, f"Update user data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}")
172
+ except Exception as e:
173
+ logging.error(f"Error saving visitor data: {e}")
174
+
175
+ def load_organization_data():
176
+ global organization_info_cache
177
+ with _org_info_lock:
178
+ if not organization_info_cache:
179
+ try:
180
+ with open(ORG_INFO_FILE, 'r', encoding='utf-8') as f:
181
+ organization_info_cache = json.load(f)
182
+ logging.info("Organization data loaded from local JSON.")
183
+ except (FileNotFoundError, json.JSONDecodeError):
184
+ logging.warning(f"{ORG_INFO_FILE} not found or invalid. Initializing with default data.")
185
+ organization_info_cache = {
186
+ "org_name": "Бонус Система",
187
+ "phone_numbers": ["+996555123456", "+996777654321"],
188
+ "address": "г. Бишкек, ул. Примерная 123",
189
+ "links": {
190
+ "website": "https://example.com",
191
+ "telegram": "https://t.me/your_telegram_channel",
192
+ "whatsapp": "https://wa.me/996555123456"
193
+ }
194
+ }
195
+ except Exception as e:
196
+ logging.error(f"Unexpected error loading organization data: {e}")
197
+ organization_info_cache = {
198
+ "org_name": "Бонус Система",
199
+ "phone_numbers": ["+996555123456", "+996777654321"],
200
+ "address": "г. Бишкек, ул. Примерная 123",
201
+ "links": {
202
+ "website": "https://example.com",
203
+ "telegram": "https://t.me/your_telegram_channel",
204
+ "whatsapp": "https://wa.me/996555123456"
205
+ }
206
+ }
207
+ return organization_info_cache
208
 
209
+ def save_organization_data(data):
210
+ global organization_info_cache
211
+ with _org_info_lock:
212
+ organization_info_cache.update(data)
213
  try:
214
+ with open(ORG_INFO_FILE, 'w', encoding='utf-8') as f:
215
+ json.dump(organization_info_cache, f, ensure_ascii=False, indent=4)
216
+ logging.info(f"Organization data successfully saved to {ORG_INFO_FILE}.")
217
+ upload_file_to_hf_async(ORG_INFO_FILE, HF_ORG_INFO_FILE_PATH, f"Update org data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}")
218
+ except Exception as e:
219
+ logging.error(f"Error saving organization data: {e}")
 
 
220
 
221
+ def periodic_backup():
222
  if not HF_TOKEN_WRITE:
223
+ logging.info("Periodic backup disabled: HF_TOKEN_WRITE not set.")
224
  return
225
+ while True:
226
+ time.sleep(3600)
227
+ logging.info("Initiating periodic backup...")
228
+ upload_file_to_hf(DATA_FILE, HF_DATA_FILE_PATH, f"Periodic backup of user data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}")
229
+ upload_file_to_hf(ORG_INFO_FILE, HF_ORG_INFO_FILE_PATH, f"Periodic backup of org data {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  def verify_telegram_data(init_data_str):
232
  try:
 
248
  auth_date = int(parsed_data.get('auth_date', [0])[0])
249
  current_time = int(time.time())
250
  if current_time - auth_date > 86400:
251
+ logging.warning(f"Telegram InitData is older than 24 hours (Auth Date: {auth_date}, Current: {current_time}).")
252
  return parsed_data, True
253
  else:
254
+ logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}")
255
  return parsed_data, False
256
+ except Exception as e:
257
+ logging.error(f"Error verifying Telegram data: {e}")
258
  return None, False
259
 
260
  TEMPLATE = """
 
284
  --shadow-glow: 0 0 35px var(--shadow-color);
285
  --shadow-color-red: rgba(244, 67, 54, 0.15);
286
  --shadow-glow-red: 0 0 35px var(--shadow-color-red);
 
 
 
287
  }
288
  * { box-sizing: border-box; margin: 0; padding: 0; }
289
  html, body {
 
382
  padding: 4px 10px;
383
  border-radius: 8px;
384
  }
385
+ .navigation-buttons {
386
+ display: flex;
387
+ gap: 10px;
388
+ margin-bottom: var(--padding-m);
389
+ flex-wrap: wrap;
390
+ }
391
+ .navigation-buttons button {
392
+ flex-grow: 1;
393
+ background-color: var(--brand-yellow);
394
+ color: var(--brand-black);
395
+ border: none;
396
+ border-radius: 12px;
397
+ padding: 12px 15px;
398
+ font-size: 1em;
399
+ font-weight: 600;
400
+ cursor: pointer;
401
+ transition: background-color 0.2s ease;
402
+ }
403
+ .navigation-buttons button:hover {
404
+ background-color: #e0a800;
405
+ }
406
+ .section-container {
407
  display: none;
408
  }
409
+ .section-container.active {
410
+ display: block;
411
+ }
412
+ .section-header {
413
  font-size: 1.4em;
414
  font-weight: 700;
415
  margin-bottom: var(--padding-m);
416
  padding-bottom: var(--padding-m);
417
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
418
  }
419
+ .section-card {
420
+ background-color: var(--card-bg);
421
+ border-radius: var(--border-radius);
422
+ padding: var(--padding-l);
423
+ }
424
  .history-list, .invoice-list {
425
  list-style: none;
426
  padding: 0;
 
428
  max-height: 35vh;
429
  overflow-y: auto;
430
  }
431
+ .history-item, .invoice-item {
432
  display: flex;
433
  justify-content: space-between;
434
  align-items: center;
435
  padding: 14px 4px;
436
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
437
  }
438
+ .history-item:last-child, .invoice-item:last-child { border-bottom: none; }
439
+ .history-details, .invoice-details { display: flex; flex-direction: column; }
440
+ .history-description, .invoice-description { font-size: 1em; font-weight: 500; }
441
+ .history-date, .invoice-date { font-size: 0.8em; color: var(--text-secondary-color); margin-top: 4px; }
442
  .history-amount { font-size: 1.1em; font-weight: 700; }
443
  .history-amount.accrual { color: #4CAF50; }
444
  .history-amount.deduction { color: #F44336; }
445
+ .invoice-total { font-size: 1.1em; font-weight: 700; color: var(--brand-yellow); }
446
+ .no-records {
447
  text-align: center;
448
  color: var(--text-secondary-color);
449
  padding: 2rem 0;
450
  }
451
+ .invoice-item { cursor: pointer; }
452
+
453
+ .modal {
454
+ display: none;
455
+ position: fixed;
456
+ z-index: 1000;
457
+ left: 0;
458
+ top: 0;
459
+ width: 100%;
460
+ height: 100%;
461
+ overflow: auto;
462
+ background-color: rgba(0,0,0,0.7);
463
+ backdrop-filter: blur(5px);
464
+ padding: var(--padding-m);
 
 
 
 
 
 
 
 
 
465
  }
466
+ .modal-content {
467
  background-color: var(--card-bg);
468
+ margin: auto;
469
+ padding: var(--padding-l);
470
  border-radius: var(--border-radius);
471
+ max-width: 500px;
472
+ width: 100%;
473
+ box-shadow: 0 5px 15px rgba(0,0,0,0.3);
474
+ position: relative;
475
  }
476
+ .modal-close {
477
+ color: var(--text-secondary-color);
478
+ position: absolute;
479
+ top: 15px;
480
+ right: 25px;
481
+ font-size: 28px;
482
+ font-weight: bold;
483
+ cursor: pointer;
484
  }
485
+ .modal-header {
486
+ font-size: 1.5em;
487
+ font-weight: 700;
488
+ margin-bottom: var(--padding-m);
489
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
490
+ padding-bottom: 10px;
491
  }
492
+ .org-info-item {
493
+ margin-bottom: 15px;
 
 
494
  }
495
+ .org-info-item label {
496
+ font-size: 0.9em;
497
+ color: var(--text-secondary-color);
498
+ display: block;
499
  margin-bottom: 5px;
500
  }
501
+ .org-info-item p, .org-info-item a {
502
+ font-size: 1.1em;
503
+ font-weight: 500;
504
+ color: var(--text-color);
505
  text-decoration: none;
506
+ display: block;
507
  word-break: break-all;
508
  }
509
+ .org-info-item a:hover {
510
+ color: var(--brand-yellow);
 
 
 
 
 
 
 
511
  }
512
+ .org-info-item a.phone-link {
513
+ color: var(--brand-yellow);
514
+ text-decoration: underline;
515
  }
516
+ .org-info-item a.phone-link:hover {
517
+ color: #e0a800;
 
518
  }
519
+
520
+ .invoice-detail-list {
521
+ list-style: none;
522
+ padding: 0;
523
+ margin: 0;
524
+ border-top: 1px solid rgba(255,255,255,0.1);
525
+ padding-top: 10px;
526
  }
527
+ .invoice-detail-item {
 
528
  display: flex;
529
  justify-content: space-between;
530
+ padding: 8px 0;
531
+ border-bottom: 1px solid rgba(255,255,255,0.05);
532
+ font-size: 0.95em;
533
  }
534
+ .invoice-detail-item:last-child {
535
+ border-bottom: none;
 
536
  }
537
+ .invoice-detail-item .item-name {
538
+ font-weight: 500;
539
+ flex-grow: 1;
540
  }
541
+ .invoice-detail-item .item-qty-price {
 
 
 
542
  color: var(--text-secondary-color);
543
+ margin-left: 10px;
544
+ min-width: 80px;
545
+ text-align: right;
546
  }
547
+ .invoice-detail-item .item-total {
548
+ font-weight: 600;
549
+ margin-left: 15px;
550
+ min-width: 60px;
551
+ text-align: right;
552
+ }
553
+ .invoice-detail-total {
554
+ padding-top: 15px;
555
+ margin-top: 15px;
556
+ border-top: 1px dashed rgba(255,255,255,0.2);
557
+ font-size: 1.2em;
558
+ font-weight: 700;
559
+ display: flex;
560
+ justify-content: space-between;
561
+ color: var(--brand-yellow);
562
  }
563
  </style>
564
  </head>
 
585
  <p class="client-id-value">{{ user.id }}</p>
586
  </section>
587
 
588
+ <section class="navigation-buttons">
589
+ <button id="showBusinessCardBtn">Визитка</button>
590
+ <button id="showInvoicesBtn">Мои накладные</button>
591
+ <button id="showHistoryBtn">История операций</button>
592
+ </section>
593
 
594
+ <section id="historySection" class="section-container active section-card">
595
+ <h2 class="section-header">История операций</h2>
596
  {% if user.combined_history %}
597
  <ul class="history-list">
598
  {% for item in user.combined_history %}
 
614
  {% endfor %}
615
  </ul>
616
  {% else %}
617
+ <p class="no-records">Операций пока не было.</p>
618
  {% endif %}
619
  </section>
620
+
621
+ <section id="invoicesSection" class="section-container section-card">
622
+ <h2 class="section-header">Мои накладные</h2>
623
  {% if user.invoices %}
624
  <ul class="invoice-list">
625
+ {% for invoice in user.invoices %}
626
+ <li class="invoice-item" onclick="openInvoiceDetailModal({{ invoice|tojson }})">
627
+ <div class="invoice-details">
628
+ <span class="invoice-description">Накладная от {{ invoice.date_str }}</span>
629
+ <span class="invoice-date">Товаров: {{ invoice.items|length }}</span>
 
 
 
 
 
 
 
630
  </div>
631
+ <span class="invoice-total">{{ "%.2f"|format(invoice.grand_total|float) }}</span>
632
  </li>
633
  {% endfor %}
634
  </ul>
635
  {% else %}
636
+ <p class="no-records" вас пока нет накладных.</p>
637
  {% endif %}
638
  </section>
639
 
640
+ </div>
641
+
642
+ <div id="businessCardModal" class="modal">
643
+ <div class="modal-content">
644
+ <span class="modal-close" onclick="closeModal('businessCardModal')">×</span>
645
+ <h2 class="modal-header" id="orgName"></h2>
646
+ <div id="orgInfoContent">
647
+ <div class="org-info-item">
648
+ <label>Телефон:</label>
649
+ <div id="orgPhoneNumbers"></div>
650
+ </div>
651
+ <div class="org-info-item">
652
+ <label>Адрес:</label>
653
+ <p id="orgAddress"></p>
654
+ </div>
655
+ <div class="org-info-item">
656
+ <label>Ссылки:</label>
657
+ <div id="orgLinks"></div>
658
+ </div>
659
  </div>
660
+ </div>
661
+ </div>
662
+
663
+ <div id="invoiceDetailModal" class="modal">
664
+ <div class="modal-content">
665
+ <span class="modal-close" onclick="closeModal('invoiceDetailModal')">×</span>
666
+ <h2 class="modal-header">Детали накладной <span id="invoiceDetailId"></span></h2>
667
+ <p class="invoice-date" style="margin-bottom: 15px;">Дата: <span id="invoiceDetailDate"></span></p>
668
+ <ul id="invoiceDetailItems" class="invoice-detail-list"></ul>
669
+ <div class="invoice-detail-total">
670
+ <span>Итого:</span>
671
+ <span id="invoiceDetailGrandTotal"></span>
672
+ </div>
673
+ </div>
674
  </div>
675
 
676
  <script>
 
685
  root.style.setProperty('--text-secondary-color', themeParams.hint_color || '#a0a0a0');
686
  root.style.setProperty('--brand-yellow', themeParams.button_color || '#FFC107');
687
  root.style.setProperty('--card-bg', themeParams.secondary_bg_color || (isDark ? '#1c1c1e' : '#f1f1f1'));
 
 
 
688
  }
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  function setupTelegram() {
691
  if (!tg || !tg.initData) {
692
+ console.error("Telegram WebApp script not loaded or initData is missing.");
693
  document.body.style.visibility = 'visible';
 
694
  return;
695
  }
696
 
 
704
 
705
  const urlParams = new URLSearchParams(window.location.search);
706
  const userIdForTest = urlParams.get('user_id_for_test');
 
707
 
708
  if (!userIdForTest) {
709
  fetch('/verify', {
 
714
  .then(response => response.json())
715
  .then(data => {
716
  if (data.status === 'ok' && data.verified && data.user_id) {
717
+ window.location.replace('/?user_id_for_test=' + data.user_id);
718
  } else {
719
+ console.warn('Backend verification failed:', data.message);
720
+ document.body.style.visibility = 'visible';
721
  }
722
  })
723
  .catch(error => {
724
+ console.error('Error sending initData for verification:', error);
725
+ document.body.style.visibility = 'visible';
726
  });
727
  } else {
728
+ document.body.style.visibility = 'visible';
729
  }
730
 
731
  const user = tg.initDataUnsafe?.user;
 
745
  setTimeout(() => {
746
  if (document.body.style.visibility !== 'visible') {
747
  document.body.style.visibility = 'visible';
 
748
  }
749
  }, 3000);
750
  }
751
+
752
+ // --- Navigation Logic ---
753
+ const sections = {
754
+ 'historySection': document.getElementById('historySection'),
755
+ 'invoicesSection': document.getElementById('invoicesSection'),
756
+ };
757
+
758
+ function showSection(sectionId) {
759
+ for (const id in sections) {
760
+ if (sections[id]) {
761
+ sections[id].classList.remove('active');
762
+ }
763
+ }
764
+ if (sections[sectionId]) {
765
+ sections[sectionId].classList.add('active');
766
+ }
767
+ }
768
+
769
+ document.getElementById('showHistoryBtn').addEventListener('click', () => showSection('historySection'));
770
+ document.getElementById('showInvoicesBtn').addEventListener('click', () => showSection('invoicesSection'));
771
+
772
+ // --- Modals Logic ---
773
+ function openModal(modalId) {
774
+ document.getElementById(modalId).style.display = 'block';
775
+ }
776
+
777
+ function closeModal(modalId) {
778
+ document.getElementById(modalId).style.display = 'none';
779
+ }
780
+
781
+ window.onclick = function(event) {
782
+ if (event.target.classList.contains('modal')) {
783
+ event.target.style.display = 'none';
784
+ }
785
+ }
786
+
787
+ // --- Business Card Modal Logic ---
788
+ document.getElementById('showBusinessCardBtn').addEventListener('click', fetchAndShowBusinessCard);
789
+
790
+ async function fetchAndShowBusinessCard() {
791
+ try {
792
+ const response = await fetch('/organization_info');
793
+ const orgInfo = await response.json();
794
+
795
+ document.getElementById('orgName').textContent = orgInfo.org_name || 'Информация об организации';
796
+
797
+ const phoneNumbersDiv = document.getElementById('orgPhoneNumbers');
798
+ phoneNumbersDiv.innerHTML = '';
799
+ if (orgInfo.phone_numbers && orgInfo.phone_numbers.length > 0) {
800
+ orgInfo.phone_numbers.forEach(phone => {
801
+ const p = document.createElement('p');
802
+ p.innerHTML = `<a href="tel:${phone}" class="phone-link">${phone} (позвонить)</a><br>` +
803
+ `<a href="https://wa.me/${phone.replace(/[^0-9]/g, '')}" target="_blank" class="phone-link">Написать в WhatsApp</a><br>` +
804
+ `<a href="https://t.me/share/url?url=&text=${encodeURIComponent(phone)}" target="_blank" class="phone-link">Написать в Telegram</a>`;
805
+ phoneNumbersDiv.appendChild(p);
806
+ });
807
+ } else {
808
+ phoneNumbersDiv.innerHTML = '<p>Не указано</p>';
809
+ }
810
+
811
+ document.getElementById('orgAddress').textContent = orgInfo.address || 'Не указан';
812
+
813
+ const linksDiv = document.getElementById('orgLinks');
814
+ linksDiv.innerHTML = '';
815
+ if (orgInfo.links) {
816
+ for (const key in orgInfo.links) {
817
+ if (orgInfo.links[key]) {
818
+ const p = document.createElement('p');
819
+ p.innerHTML = `<a href="${orgInfo.links[key]}" target="_blank">${key.charAt(0).toUpperCase() + key.slice(1)}</a>`;
820
+ linksDiv.appendChild(p);
821
+ }
822
+ }
823
+ }
824
+ if (linksDiv.innerHTML === '') {
825
+ linksDiv.innerHTML = '<p>Не указаны</p>';
826
+ }
827
+
828
+ openModal('businessCardModal');
829
+ } catch (error) {
830
+ console.error('Error fetching organization info:', error);
831
+ alert('Не удалось загрузить информацию об организации.');
832
+ }
833
+ }
834
+
835
+ // --- Invoice Detail Modal Logic ---
836
+ function openInvoiceDetailModal(invoiceData) {
837
+ document.getElementById('invoiceDetailId').textContent = invoiceData.invoice_id;
838
+ document.getElementById('invoiceDetailDate').textContent = invoiceData.date_str;
839
+ document.getElementById('invoiceDetailGrandTotal').textContent = parseFloat(invoiceData.grand_total).toFixed(2);
840
+
841
+ const itemsList = document.getElementById('invoiceDetailItems');
842
+ itemsList.innerHTML = '';
843
+
844
+ if (invoiceData.items && invoiceData.items.length > 0) {
845
+ invoiceData.items.forEach(item => {
846
+ const li = document.createElement('li');
847
+ li.className = 'invoice-detail-item';
848
+ li.innerHTML = `
849
+ <span class="item-name">${item.name}</span>
850
+ <span class="item-qty-price">${item.quantity} x ${parseFloat(item.price_per_unit).toFixed(2)}</span>
851
+ <span class="item-total">${parseFloat(item.total).toFixed(2)}</span>
852
+ `;
853
+ itemsList.appendChild(li);
854
+ });
855
+ } else {
856
+ itemsList.innerHTML = '<li class="no-records" style="text-align:left;">Нет товаров в этой накладной.</li>';
857
+ }
858
+
859
+ openModal('invoiceDetailModal');
860
+ }
861
+
862
  </script>
863
  </body>
864
  </html>
 
952
  .btn-submit { background-color: var(--admin-success); color: white; }
953
  .status-message { text-align: center; font-weight: 500; flex-grow: 1; text-align: left; }
954
 
955
+ /* Invoice Admin Modal Specific Styles */
956
+ #invoiceItemList {
957
+ list-style: none;
958
+ padding: 0;
959
+ margin-top: 1rem;
960
+ max-height: 150px;
961
+ overflow-y: auto;
962
+ border: 1px solid var(--admin-border);
963
  border-radius: 8px;
964
+ }
965
+ .invoice-item-entry {
966
+ display: flex;
967
+ justify-content: space-between;
968
+ align-items: center;
969
+ padding: 8px 12px;
970
+ border-bottom: 1px solid var(--admin-border);
971
+ }
972
+ .invoice-item-entry:last-child {
973
+ border-bottom: none;
974
+ }
975
+ .invoice-item-entry .item-info {
976
+ flex-grow: 1;
977
+ font-size: 0.9em;
978
+ }
979
+ .invoice-item-entry .item-actions button {
980
+ background: none;
981
+ border: none;
982
+ color: var(--admin-danger);
983
+ cursor: pointer;
984
+ font-size: 1em;
985
+ padding: 5px;
986
+ }
987
+ .invoice-summary-total {
988
+ font-size: 1.2em;
989
+ font-weight: 700;
990
+ margin-top: 1rem;
991
+ text-align: right;
992
+ color: var(--admin-primary-dark);
993
+ }
994
+ .invoice-add-form {
995
+ display: grid;
996
+ grid-template-columns: repeat(3, 1fr) auto;
997
+ gap: 10px;
998
  margin-top: 1rem;
999
+ align-items: flex-end;
1000
+ }
1001
+ .invoice-add-form button {
1002
+ height: fit-content;
1003
  }
1004
+ .invoice-history-list {
1005
  list-style: none;
1006
  padding: 0;
1007
+ max-height: 200px;
1008
+ overflow-y: auto;
1009
+ border: 1px solid var(--admin-border);
1010
+ border-radius: 8px;
1011
+ margin-top: 1rem;
1012
  }
1013
+ .invoice-history-item {
1014
+ padding: 8px 12px;
1015
+ border-bottom: 1px solid var(--admin-border);
1016
+ display: flex;
1017
+ justify-content: space-between;
1018
+ align-items: center;
1019
  }
1020
+ .invoice-history-item:last-child {
1021
  border-bottom: none;
1022
  }
1023
+ .invoice-history-item .details {
1024
+ font-size: 0.9em;
1025
+ }
1026
+ .invoice-history-item .total {
1027
+ font-weight: 600;
1028
+ color: var(--admin-primary-dark);
1029
+ }
1030
+
1031
+ /* Org Info Modal Specific Styles */
1032
+ .org-info-form label {
1033
+ font-weight: 500;
1034
+ margin-bottom: 5px;
1035
+ display: block;
1036
+ }
1037
+ .org-info-form input, .org-info-form textarea {
1038
+ margin-bottom: 15px;
1039
+ }
1040
+ .org-info-form .add-phone-btn {
1041
+ background-color: var(--admin-secondary);
1042
+ color: white;
1043
+ padding: 8px 15px;
1044
+ border-radius: 8px;
1045
+ cursor: pointer;
1046
+ margin-left: 10px;
1047
+ }
1048
+ .phone-input-group {
1049
+ display: flex;
1050
+ align-items: center;
1051
+ margin-bottom: 10px;
1052
+ }
1053
+ .phone-input-group input {
1054
+ flex-grow: 1;
1055
+ margin-bottom: 0;
1056
+ }
1057
+ .phone-input-group .remove-phone-btn {
1058
+ background: none;
1059
+ border: none;
1060
+ color: var(--admin-danger);
1061
+ cursor: pointer;
1062
+ font-size: 1.2em;
1063
+ margin-left: 10px;
1064
+ }
1065
+
1066
  </style>
1067
  </head>
1068
  <body>
 
1090
  <div class="controls-bar">
1091
  <input type="text" id="searchInput" onkeyup="searchUsers()" placeholder="Поиск по имени, ID, username, номеру...">
1092
  <button class="btn btn-primary" onclick="openAddClientModal()">Добавить клиента</button>
1093
+ <button class="btn btn-primary" onclick="openOrgInfoModal()">Настройки организации</button>
1094
  </div>
1095
 
1096
  {% if users %}
 
1116
  </div>
1117
  <div class="user-actions">
1118
  <button class="btn-manage" onclick='openTransactionModal({{ user|tojson }})'>Управление счетом</button>
1119
+ <button class="btn-manage" onclick='openInvoiceAdminModal({{ user|tojson }})'>Управление накладными</button>
1120
  {% if user.telegram_id == None %}
1121
  <button class="btn btn-delete" onclick='deleteClient("{{ user.id }}")'>Удалить клиента</button>
1122
  {% endif %}
 
1212
  </div>
1213
  </div>
1214
 
1215
+ <div id="invoiceAdminModal" class="modal">
1216
  <div class="modal-content">
1217
+ <span class="modal-close" onclick="closeModal('invoiceAdminModal')">×</span>
1218
  <div class="modal-header">
1219
+ <h2>Управление накладными для <span id="invoiceModalUserName"></span></h2>
1220
+ <div id="invoiceModalUserUsername" class="username"></div>
1221
  </div>
1222
+ <input type="hidden" id="invoiceModalUserId">
1223
+
1224
  <div class="form-section">
1225
+ <h3>Добавить новую накладную</h3>
1226
+ <div class="invoice-add-form">
1227
+ <div class="form-group">
1228
+ <label for="invoiceItemName">Название товара</label>
1229
+ <input type="text" id="invoiceItemName" placeholder="Название">
1230
+ </div>
1231
+ <div class="form-group">
1232
+ <label for="invoiceItemQuantity">Кол-во</label>
1233
+ <input type="number" id="invoiceItemQuantity" value="1" min="1">
1234
+ </div>
1235
+ <div class="form-group">
1236
+ <label for="invoiceItemPrice">Цена за ед.</label>
1237
+ <input type="number" id="invoiceItemPrice" placeholder="0.00" min="0" step="0.01">
1238
+ </div>
1239
+ <button class="btn btn-primary" onclick="addInvoiceItem()">Добавить товар</button>
1240
  </div>
1241
+ <ul id="invoiceItemList">
1242
+ <!-- Items will be added here -->
1243
+ </ul>
1244
+ <div class="invoice-summary-total">Итоговая сумма: <span id="currentInvoiceGrandTotal">0.00</span></div>
1245
+ <div class="modal-footer" style="justify-content: flex-start; margin-top: 1rem; border-top: 1px solid var(--admin-border); padding-top: 1rem;">
1246
+ <div id="invoiceAddStatus" class="status-message"></div>
1247
+ <button class="btn-submit" onclick="submitNewInvoice()">Сохранить накладную</button>
1248
  </div>
1249
+ </div>
1250
+
1251
+ <div class="history-container">
1252
+ <h3>История накладных</h3>
1253
+ <ul id="invoiceHistoryList" class="invoice-history-list">
1254
+ <!-- Existing invoices will be listed here -->
1255
+ </ul>
1256
+ <p id="noInvoiceHistory" class="no-users" style="display: none;">Накладных пока нет.</p>
1257
+ </div>
1258
+ </div>
1259
+ </div>
1260
+
1261
+ <div id="orgInfoModal" class="modal">
1262
+ <div class="modal-content">
1263
+ <span class="modal-close" onclick="closeModal('orgInfoModal')">×</span>
1264
+ <div class="modal-header">
1265
+ <h2>Настройки организации</h2>
1266
+ </div>
1267
+ <div class="org-info-form">
1268
  <div class="form-group">
1269
+ <label for="orgNameInput">Название организации</label>
1270
+ <input type="text" id="orgNameInput">
1271
  </div>
1272
  <div class="form-group">
1273
+ <label>Номера телефонов</label>
1274
+ <div id="phoneNumbersContainer">
1275
+ <!-- Phone inputs will be added here -->
1276
+ </div>
1277
+ <button type="button" class="btn add-phone-btn" onclick="addPhoneInputField()">Добавить телефон</button>
1278
  </div>
1279
  <div class="form-group">
1280
+ <label for="orgAddressInput">Адрес</label>
1281
+ <textarea id="orgAddressInput" rows="3"></textarea>
1282
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1283
  <div class="form-group">
1284
+ <label for="orgWebsiteInput">Ссылка на сайт</label>
1285
+ <input type="url" id="orgWebsiteInput">
1286
  </div>
1287
  <div class="form-group">
1288
+ <label for="orgTelegramInput">Ссылка на Telegram</label>
1289
+ <input type="url" id="orgTelegramInput">
1290
  </div>
1291
  <div class="form-group">
1292
+ <label for="orgWhatsappInput">Ссылка на WhatsApp (с кодом страны, без '+')</label>
1293
+ <input type="url" id="orgWhatsappInput">
1294
  </div>
1295
  </div>
1296
+ <div class="modal-footer">
1297
+ <div id="orgInfoStatus" class="status-message"></div>
1298
+ <button class="btn-submit" onclick="submitOrgInfo()">Сохранить</button>
 
 
 
 
1299
  </div>
1300
  </div>
 
 
 
 
 
 
 
 
 
 
 
1301
  </div>
1302
 
1303
  <script>
1304
  const transactionModal = document.getElementById('transactionModal');
1305
  const addClientModal = document.getElementById('addClientModal');
1306
+ const invoiceAdminModal = document.getElementById('invoiceAdminModal');
1307
+ const orgInfoModal = document.getElementById('orgInfoModal');
1308
  let currentUserData = null;
1309
+ let currentInvoiceItems = [];
1310
 
1311
  function searchUsers() {
1312
  const searchTerm = document.getElementById('searchInput').value.toLowerCase();
 
1321
  });
1322
  }
1323
 
1324
+ function openModal(modalId) {
1325
+ document.getElementById(modalId).style.display = 'block';
1326
+ }
1327
+
1328
+ function closeModal(modalId) {
1329
+ document.getElementById(modalId).style.display = 'none';
1330
+ if (modalId === 'transactionModal' || modalId === 'invoiceAdminModal') {
1331
+ currentUserData = null;
1332
+ }
1333
+ }
1334
+
1335
  function openTransactionModal(userData) {
1336
  currentUserData = userData;
1337
  document.getElementById('modalUserId').value = userData.id;
 
1359
  sign = item.type === 'accrual' ? '+' : '-';
1360
  amountClass = item.type === 'accrual' ? 'bonus-accrual' : 'bonus-deduction';
1361
  amountText = `${sign}${parseFloat(item.amount).toFixed(2)}`;
1362
+ } else { // debt
1363
  sign = item.type === 'accrual' ? '+' : '-';
1364
  amountClass = item.type === 'accrual' ? 'debt-accrual' : 'debt-payment';
1365
  amountText = `${sign}${parseFloat(item.amount).toFixed(2)}`;
 
1378
  }
1379
 
1380
  updateCalculations();
1381
+ openModal('transactionModal');
1382
  }
1383
 
1384
  function openAddClientModal() {
1385
  document.getElementById('newClientFirstName').value = '';
1386
  document.getElementById('newClientPhone').value = '';
1387
  document.getElementById('addClientStatus').textContent = '';
1388
+ openModal('addClientModal');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1389
  }
1390
 
1391
  function updateCalculations() {
 
1393
 
1394
  const currentBalance = parseFloat(currentUserData.bonuses) || 0;
1395
  const purchaseAmount = parseFloat(document.getElementById('purchaseAmount').value) || 0;
1396
+ let deductAmount = parseFloat(document.getElementById('deductAmount').value) || 0;
1397
  const accrualAmount = purchaseAmount * 0.02;
1398
+
1399
  if (deductAmount > currentBalance) {
1400
+ deductAmount = currentBalance;
1401
+ document.getElementById('deductAmount').value = deductAmount > 0 ? deductAmount.toFixed(2) : '';
1402
  }
1403
+ const finalBonusBalance = currentBalance + accrualAmount - deductAmount;
1404
  document.getElementById('summaryCurrentBalance').textContent = currentBalance.toFixed(2);
1405
  document.getElementById('summaryAccrual').textContent = `+${accrualAmount.toFixed(2)}`;
1406
+ document.getElementById('summaryDeduction').textContent = `-${deductAmount.toFixed(2)}`;
1407
+ document.getElementById('summaryFinalBalance').textContent = finalBonusBalance.toFixed(2);
1408
 
1409
  const currentDebt = parseFloat(currentUserData.debts) || 0;
1410
  const addDebtAmount = parseFloat(document.getElementById('addDebtAmount').value) || 0;
1411
+ let repayDebtAmount = parseFloat(document.getElementById('repayDebtAmount').value) || 0;
1412
+
1413
  if (repayDebtAmount > currentDebt) {
1414
+ repayDebtAmount = currentDebt;
1415
+ document.getElementById('repayDebtAmount').value = repayDebtAmount > 0 ? repayDebtAmount.toFixed(2) : '';
1416
  }
1417
+ const finalDebt = currentDebt + addDebtAmount - repayDebtAmount;
1418
  document.getElementById('summaryCurrentDebt').textContent = currentDebt.toFixed(2);
1419
  document.getElementById('summaryAddDebt').textContent = `+${addDebtAmount.toFixed(2)}`;
1420
+ document.getElementById('summaryRepayDebt').textContent = `-${repayDebtAmount.toFixed(2)}`;
1421
  document.getElementById('summaryFinalDebt').textContent = finalDebt.toFixed(2);
1422
  }
1423
 
 
1495
  }
1496
  }
1497
 
1498
+ async function deleteClient(userId) {
1499
+ if (!confirm(`Вы уверены, что хотите удалить клиента с ID ${userId}? Это действие необратимо.`)) {
1500
+ return;
1501
+ }
 
 
 
 
 
 
 
 
 
1502
  try {
1503
+ const response = await fetch('/admin/delete_client', {
1504
  method: 'POST',
1505
  headers: { 'Content-Type': 'application/json' },
1506
+ body: JSON.stringify({ user_id: userId })
1507
  });
1508
  const result = await response.json();
1509
  if (response.ok) {
1510
+ location.reload();
 
 
1511
  } else {
1512
+ throw new Error(result.message || 'Не удалось удалить клиента.');
1513
  }
1514
  } catch (error) {
1515
+ alert(`Ошибка: ${error.message}`);
1516
+ }
1517
+ }
1518
+
1519
+ // --- Invoice Admin Modal Logic ---
1520
+ function openInvoiceAdminModal(userData) {
1521
+ currentUserData = userData;
1522
+ document.getElementById('invoiceModalUserId').value = userData.id;
1523
+ document.getElementById('invoiceModalUserName').textContent = `${userData.first_name || ''} ${userData.last_name || ''}`;
1524
+ document.getElementById('invoiceModalUserUsername').textContent = `@${userData.username || userData.phone_number} | ID: ${userData.id}`;
1525
+ document.getElementById('invoiceItemName').value = '';
1526
+ document.getElementById('invoiceItemQuantity').value = '1';
1527
+ document.getElementById('invoiceItemPrice').value = '';
1528
+ document.getElementById('invoiceAddStatus').textContent = '';
1529
+ currentInvoiceItems = [];
1530
+ updateInvoiceItemList();
1531
+ loadInvoiceHistory(userData.invoices || []);
1532
+ openModal('invoiceAdminModal');
1533
+ }
1534
+
1535
+ function updateInvoiceItemList() {
1536
+ const list = document.getElementById('invoiceItemList');
1537
+ list.innerHTML = '';
1538
+ let grandTotal = 0;
1539
+ if (currentInvoiceItems.length === 0) {
1540
+ list.innerHTML = '<li style="padding: 10px; text-align: center; color: var(--admin-secondary);">Нет добавленных товаров</li>';
1541
+ } else {
1542
+ currentInvoiceItems.forEach((item, index) => {
1543
+ const li = document.createElement('li');
1544
+ li.className = 'invoice-item-entry';
1545
+ const itemTotal = item.quantity * item.price_per_unit;
1546
+ grandTotal += itemTotal;
1547
+ li.innerHTML = `
1548
+ <div class="item-info">
1549
+ ${item.name} (${item.quantity} x ${item.price_per_unit.toFixed(2)})
1550
+ </div>
1551
+ <div class="item-total">${itemTotal.toFixed(2)}</div>
1552
+ <div class="item-actions">
1553
+ <button onclick="removeInvoiceItem(${index})">×</button>
1554
+ </div>
1555
+ `;
1556
+ list.appendChild(li);
1557
+ });
1558
  }
1559
+ document.getElementById('currentInvoiceGrandTotal').textContent = grandTotal.toFixed(2);
1560
  }
1561
 
1562
+ function addInvoiceItem() {
1563
  const name = document.getElementById('invoiceItemName').value.trim();
1564
  const quantity = parseInt(document.getElementById('invoiceItemQuantity').value);
1565
  const price = parseFloat(document.getElementById('invoiceItemPrice').value);
1566
 
1567
  if (!name || isNaN(quantity) || quantity <= 0 || isNaN(price) || price < 0) {
1568
+ alert('Пожалуйста, введите корректные данные для товара (название, кол-во > 0, цена >= 0).');
1569
  return;
1570
  }
1571
 
1572
+ currentInvoiceItems.push({
1573
+ name: name,
1574
+ quantity: quantity,
1575
+ price_per_unit: price,
1576
+ total: quantity * price
1577
+ });
1578
 
1579
  document.getElementById('invoiceItemName').value = '';
1580
  document.getElementById('invoiceItemQuantity').value = '1';
1581
  document.getElementById('invoiceItemPrice').value = '';
1582
+ updateInvoiceItemList();
1583
  }
1584
 
1585
+ function removeInvoiceItem(index) {
1586
+ currentInvoiceItems.splice(index, 1);
1587
+ updateInvoiceItemList();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1588
  }
1589
 
1590
  async function submitNewInvoice() {
1591
+ const statusEl = document.getElementById('invoiceAddStatus');
1592
  statusEl.style.color = 'var(--admin-secondary)';
1593
  statusEl.textContent = 'Сохранение накладной...';
1594
 
1595
+ if (currentInvoiceItems.length === 0) {
1596
  statusEl.style.color = 'var(--admin-danger)';
1597
+ statusEl.textContent = 'Накладная не может быть пустой.';
1598
  return;
1599
  }
1600
 
1601
+ const userId = document.getElementById('invoiceModalUserId').value;
1602
+ const grandTotal = parseFloat(document.getElementById('currentInvoiceGrandTotal').textContent);
1603
+
1604
  const payload = {
1605
+ user_id: userId,
1606
+ items: currentInvoiceItems,
1607
+ grand_total: grandTotal
1608
  };
1609
 
1610
  try {
 
1619
  statusEl.textContent = 'Накладная успешно сохранена!';
1620
  setTimeout(() => { location.reload(); }, 1500);
1621
  } else {
1622
+ throw new Error(result.message || 'Произошла ошибка при сохранении накладной');
1623
  }
1624
  } catch (error) {
1625
  statusEl.style.color = 'var(--admin-danger)';
 
1627
  }
1628
  }
1629
 
1630
+ function loadInvoiceHistory(invoices) {
1631
+ const list = document.getElementById('invoiceHistoryList');
1632
+ const noHistoryText = document.getElementById('noInvoiceHistory');
1633
+ list.innerHTML = '';
1634
+ if (invoices && invoices.length > 0) {
1635
+ const sortedInvoices = [...invoices].sort((a, b) => new Date(b.date) - new Date(a.date));
1636
+ sortedInvoices.forEach(invoice => {
1637
+ const li = document.createElement('li');
1638
+ li.className = 'invoice-history-item';
1639
+ li.innerHTML = `
1640
+ <div class="details">
1641
+ Накладная от ${invoice.date_str} <br>
1642
+ ${invoice.items.length} товаров
1643
+ </div>
1644
+ <div class="total">${parseFloat(invoice.grand_total).toFixed(2)}</div>
1645
+ `;
1646
+ list.appendChild(li);
1647
+ });
1648
+ noHistoryText.style.display = 'none';
1649
+ } else {
1650
+ noHistoryText.style.display = 'block';
1651
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1652
  }
1653
 
1654
+ // --- Organization Info Modal Logic ---
1655
+ async function openOrgInfoModal() {
1656
+ document.getElementById('orgInfoStatus').textContent = '';
1657
+ try {
1658
+ const response = await fetch('/admin/organization_info');
1659
+ const orgInfo = await response.json();
1660
+
1661
+ document.getElementById('orgNameInput').value = orgInfo.org_name || '';
1662
+ document.getElementById('orgAddressInput').value = orgInfo.address || '';
1663
+ document.getElementById('orgWebsiteInput').value = orgInfo.links?.website || '';
1664
+ document.getElementById('orgTelegramInput').value = orgInfo.links?.telegram || '';
1665
+ document.getElementById('orgWhatsappInput').value = orgInfo.links?.whatsapp || '';
1666
+
1667
+ const phoneNumbersContainer = document.getElementById('phoneNumbersContainer');
1668
+ phoneNumbersContainer.innerHTML = '';
1669
+ if (orgInfo.phone_numbers && orgInfo.phone_numbers.length > 0) {
1670
+ orgInfo.phone_numbers.forEach(phone => addPhoneInputField(phone));
1671
+ } else {
1672
+ addPhoneInputField(); // Add one empty field if none exist
1673
+ }
1674
+
1675
+ openModal('orgInfoModal');
1676
+ } catch (error) {
1677
+ console.error('Error fetching organization info for admin:', error);
1678
+ alert('Не удалось загрузить информацию об организации.');
1679
  }
1680
+ }
1681
+
1682
+ function addPhoneInputField(phoneNumber = '') {
1683
+ const container = document.getElementById('phoneNumbersContainer');
1684
+ const div = document.createElement('div');
1685
+ div.className = 'phone-input-group';
1686
+ div.innerHTML = `
1687
+ <input type="tel" class="phone-input" value="${phoneNumber}" placeholder="+996XXXXXXXXX">
1688
+ <button type="button" class="remove-phone-btn" onclick="this.parentNode.remove()">×</button>
1689
+ `;
1690
+ container.appendChild(div);
1691
+ }
1692
+
1693
+ async function submitOrgInfo() {
1694
+ const statusEl = document.getElementById('orgInfoStatus');
1695
+ statusEl.style.color = 'var(--admin-secondary)';
1696
+ statusEl.textContent = 'Сохранение...';
1697
+
1698
+ const phoneInputs = document.querySelectorAll('.phone-input');
1699
+ const phoneNumbers = Array.from(phoneInputs).map(input => input.value.trim()).filter(phone => phone !== '');
1700
+
1701
+ const payload = {
1702
+ org_name: document.getElementById('orgNameInput').value.trim(),
1703
+ phone_numbers: phoneNumbers,
1704
+ address: document.getElementById('orgAddressInput').value.trim(),
1705
+ links: {
1706
+ website: document.getElementById('orgWebsiteInput').value.trim(),
1707
+ telegram: document.getElementById('orgTelegramInput').value.trim(),
1708
+ whatsapp: document.getElementById('orgWhatsappInput').value.trim()
1709
+ }
1710
+ };
1711
+
1712
  try {
1713
+ const response = await fetch('/admin/update_organization_info', {
1714
  method: 'POST',
1715
  headers: { 'Content-Type': 'application/json' },
1716
+ body: JSON.stringify(payload)
1717
  });
1718
  const result = await response.json();
1719
  if (response.ok) {
1720
+ statusEl.style.color = 'var(--admin-success)';
1721
+ statusEl.textContent = 'Информация сохранена!';
1722
+ // No reload needed, but status message helps
1723
  } else {
1724
+ throw new Error(result.message || 'Произошла ошибка при сохранении.');
1725
  }
1726
  } catch (error) {
1727
+ statusEl.style.color = 'var(--admin-danger)';
1728
+ statusEl.textContent = `Ошибка: ${error.message}`;
1729
  }
1730
  }
1731
 
 
1736
  if (event.target == addClientModal) {
1737
  closeModal('addClientModal');
1738
  }
1739
+ if (event.target == invoiceAdminModal) {
1740
+ closeModal('invoiceAdminModal');
1741
  }
1742
+ if (event.target == orgInfoModal) {
1743
+ closeModal('orgInfoModal');
1744
  }
1745
  }
1746
  </script>
 
1766
  debt_history = user_data.get('debt_history', [])
1767
  for item in debt_history:
1768
  item['transaction_type'] = 'debt'
1769
+
1770
+ invoices = user_data.get('invoices', [])
1771
+
1772
  combined_history = sorted(
1773
  bonus_history + debt_history,
1774
+ key=lambda x: x['date'],
1775
  reverse=True
1776
  )
1777
  user_data['combined_history'] = combined_history
1778
+ user_data['invoices'] = invoices
1779
  else:
1780
  user_data = {
1781
  "id": "N/A",
 
1787
  "invoices": []
1788
  }
1789
 
1790
+ return render_template_string(TEMPLATE, user=user_data)
 
1791
 
1792
  @app.route('/verify', methods=['POST'])
1793
  def verify_data():
 
1804
  try:
1805
  user_json_str = unquote(user_data_parsed['user'][0])
1806
  user_info_dict = json.loads(user_json_str)
1807
+ except Exception as e:
1808
+ logging.error(f"Could not parse user JSON: {e}")
1809
  user_info_dict = {}
1810
 
1811
  if is_valid:
1812
  tg_user_id = user_info_dict.get('id')
1813
  if tg_user_id:
1814
+ now = get_current_bishkek_time()
1815
  all_data = load_visitor_data()
1816
 
1817
  existing_user_key = None
 
1820
  existing_user_key = key
1821
  break
1822
 
1823
+ user_entry_to_save = {}
1824
+ user_id_to_save = ""
1825
+
1826
  if existing_user_key:
1827
  user_entry = all_data[existing_user_key]
1828
  user_entry.update({
 
1834
  'visited_at': now.timestamp(),
1835
  'visited_at_str': now.strftime('%Y-%m-%d %H:%M:%S')
1836
  })
1837
+ user_entry_to_save = {existing_user_key: user_entry}
1838
  user_id_to_save = existing_user_key
1839
  else:
1840
  new_user_id = generate_unique_id(all_data)
 
1856
  'debt_history': [],
1857
  'invoices': []
1858
  }
1859
+ user_entry_to_save = {new_user_id: user_entry}
1860
  user_id_to_save = new_user_id
1861
 
1862
+ save_visitor_data(user_entry_to_save)
1863
 
1864
  return jsonify({"status": "ok", "verified": True, "user_id": user_id_to_save})
1865
  else:
1866
  return jsonify({"status": "error", "verified": True, "message": "User ID not found in parsed data"}), 400
1867
  else:
1868
+ logging.warning(f"Verification failed for user: {user_info_dict.get('id')}")
1869
  return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
1870
 
1871
+ except Exception as e:
1872
+ logging.exception("Error in /verify endpoint")
1873
  return jsonify({"status": "error", "message": "Internal server error"}), 500
1874
 
1875
+ @app.route('/organization_info')
1876
+ def get_organization_info_for_client():
1877
+ org_info = load_organization_data()
1878
+ return jsonify(org_info)
1879
+
1880
  @app.route('/admin')
1881
  def admin_panel():
1882
  current_data = load_visitor_data()
 
1897
  "users_with_debt": users_with_debt
1898
  }
1899
 
1900
+ return render_template_string(ADMIN_TEMPLATE, users=users_list, summary=summary_stats)
 
1901
 
1902
  @app.route('/admin/add_client', methods=['POST'])
1903
  def add_client():
 
1915
  if user.get('phone_number') == phone_number:
1916
  return jsonify({"status": "error", "message": "Клиент с таким номером телефона уже существует."}), 409
1917
 
1918
+ now = get_current_bishkek_time()
1919
  new_id = generate_unique_id(all_data)
1920
 
1921
  new_client = {
 
1942
  return jsonify({"status": "ok", "message": "Client added successfully"}), 201
1943
 
1944
  except Exception as e:
1945
+ logging.exception("Error in /admin/add_client endpoint")
1946
  return jsonify({"status": "error", "message": str(e)}), 500
1947
 
 
1948
  @app.route('/admin/add_transaction', methods=['POST'])
1949
  def add_transaction():
1950
  try:
 
1965
  return jsonify({"status": "error", "message": "User not found"}), 404
1966
 
1967
  user = all_data[user_id_str]
1968
+ now = get_current_bishkek_time()
1969
  now_iso = now.isoformat()
1970
  now_str = now.strftime('%Y-%m-%d %H:%M:%S')
1971
 
 
2018
  }), 200
2019
 
2020
  except Exception as e:
2021
+ logging.exception("Error in /admin/add_transaction endpoint")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2022
  return jsonify({"status": "error", "message": str(e)}), 500
2023
 
2024
  @app.route('/admin/add_invoice', methods=['POST'])
 
2026
  try:
2027
  data = request.get_json()
2028
  user_id = data.get('user_id')
2029
+ items = data.get('items')
2030
+ grand_total = float(data.get('grand_total', 0))
2031
 
2032
+ if not user_id or not items:
2033
+ return jsonify({"status": "error", "message": "User ID and invoice items are required"}), 400
 
 
2034
 
2035
  user_id_str = str(user_id)
2036
  all_data = load_visitor_data()
 
2039
  return jsonify({"status": "error", "message": "User not found"}), 404
2040
 
2041
  user = all_data[user_id_str]
2042
+ now = get_current_bishkek_time()
2043
  now_iso = now.isoformat()
2044
  now_str = now.strftime('%Y-%m-%d %H:%M:%S')
2045
 
2046
+ if 'invoices' not in user or not isinstance(user['invoices'], list):
2047
+ user['invoices'] = []
2048
 
2049
  new_invoice = {
2050
+ "invoice_id": f"INV-{generate_unique_id(user.get('invoices', []))}",
2051
  "date": now_iso,
2052
  "date_str": now_str,
2053
  "items": items,
2054
+ "grand_total": grand_total
2055
  }
 
 
 
2056
  user['invoices'].append(new_invoice)
2057
 
2058
  save_visitor_data({user_id_str: user})
2059
+
2060
+ return jsonify({"status": "ok", "message": "Invoice added successfully", "invoice": new_invoice}), 200
2061
 
2062
  except Exception as e:
2063
+ logging.exception("Error in /admin/add_invoice endpoint")
2064
  return jsonify({"status": "error", "message": str(e)}), 500
2065
 
2066
  @app.route('/admin/delete_client', methods=['POST'])
 
2086
  del visitor_data_cache[user_id_str]
2087
 
2088
  try:
 
 
 
2089
  with open(DATA_FILE, 'w', encoding='utf-8') as f:
2090
+ json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4)
2091
+ logging.info(f"User {user_id_str} deleted. Data saved to {DATA_FILE}.")
2092
+ upload_file_to_hf_async(DATA_FILE, HF_DATA_FILE_PATH, f"Delete user {user_id_str} {get_current_bishkek_time().strftime('%Y-%m-%d %H:%M:%S')}")
2093
+ except Exception as e:
2094
+ logging.error(f"Error saving data after deletion: {e}")
2095
  return jsonify({"status": "error", "message": "Failed to save data after deletion"}), 500
2096
 
2097
  return jsonify({"status": "ok", "message": "Client deleted successfully"}), 200
2098
 
2099
  except Exception as e:
2100
+ logging.exception("Error in /admin/delete_client endpoint")
2101
+ return jsonify({"status": "error", "message": str(e)}), 500
2102
+
2103
+ @app.route('/admin/organization_info')
2104
+ def admin_get_organization_info():
2105
+ org_info = load_organization_data()
2106
+ return jsonify(org_info)
2107
+
2108
+ @app.route('/admin/update_organization_info', methods=['POST'])
2109
+ def admin_update_organization_info():
2110
+ try:
2111
+ data = request.get_json()
2112
+
2113
+ # Validate data types
2114
+ if not isinstance(data.get('org_name'), str): return jsonify({"status": "error", "message": "Invalid org_name"}), 400
2115
+ if not isinstance(data.get('phone_numbers'), list): return jsonify({"status": "error", "message": "Invalid phone_numbers"}), 400
2116
+ if not all(isinstance(p, str) for p in data['phone_numbers']): return jsonify({"status": "error", "message": "Invalid phone_numbers format"}), 400
2117
+ if not isinstance(data.get('address'), str): return jsonify({"status": "error", "message": "Invalid address"}), 400
2118
+ if not isinstance(data.get('links'), dict): return jsonify({"status": "error", "message": "Invalid links"}), 400
2119
+ if not all(isinstance(v, str) for v in data['links'].values()): return jsonify({"status": "error", "message": "Invalid links format"}), 400
2120
+
2121
+ save_organization_data(data)
2122
+ return jsonify({"status": "ok", "message": "Organization info updated successfully"}), 200
2123
+ except Exception as e:
2124
+ logging.exception("Error in /admin/update_organization_info endpoint")
2125
  return jsonify({"status": "error", "message": str(e)}), 500
2126
 
2127
+
2128
  if __name__ == '__main__':
2129
+ print("--- BONUS SYSTEM SERVER ---")
2130
+ print(f"Server starting on http://{HOST}:{PORT}")
2131
+ if not HF_TOKEN_READ or not HF_TOKEN_WRITE:
2132
+ print("WARNING: Hugging Face token(s) not set. Backup/restore functionality will be limited.")
2133
+ else:
2134
+ print("Attempting initial data download from Hugging Face...")
2135
+ download_all_data_from_hf()
2136
+
2137
  load_visitor_data()
2138
+ load_organization_data()
2139
 
2140
+ print("WARNING: The /admin route is NOT protected. Implement proper authentication for production.")
2141
+
2142
  if HF_TOKEN_WRITE:
2143
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
2144
  backup_thread.start()
2145
+ print("Periodic backup thread started (every hour).")
2146
 
2147
+ print("--- Server Ready ---")
2148
  app.run(host=HOST, port=PORT, debug=False)