work-license-server 1.0.15 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/admin.js CHANGED
@@ -89,10 +89,20 @@
89
89
  setTimeout(() => $('#admLoginEmail')?.focus(), 50);
90
90
  }
91
91
 
92
+ function moveIndicator() {
93
+ const active = $('#tabs .tab.active');
94
+ const ind = $('#tabInd');
95
+ if (!ind) return;
96
+ if (!active) { ind.style.width = '0px'; return; }
97
+ ind.style.left = active.offsetLeft + 'px';
98
+ ind.style.width = active.offsetWidth + 'px';
99
+ }
100
+
92
101
  function showAdminApp(login = 'BOSS') {
93
102
  $('#adminLoginScreen').style.display = 'none';
94
103
  $('#adminAppShell').style.display = '';
95
- if ($('#admUserLabel')) $('#admUserLabel').textContent = `Выйти (${login})`;
104
+ if ($('#admUserLabel')) $('#admUserLabel').textContent = login;
105
+ requestAnimationFrame(moveIndicator);
96
106
  }
97
107
 
98
108
  async function sendAdminOtpCode() {
@@ -201,7 +211,7 @@
201
211
  /* ================= Вкладки и форматирование ================= */
202
212
  function setTab(tab) {
203
213
  activeTab = tab || 'servers';
204
- $$('#admTopTabs [data-adm-tab]').forEach(b => b.classList.toggle('active', b.dataset.admTab === activeTab));
214
+ $$('[data-adm-tab]').forEach(b => b.classList.toggle('active', b.dataset.admTab === activeTab));
205
215
  $$('[data-adm-sec]').forEach(sec => {
206
216
  sec.style.display = sec.dataset.admSec === activeTab ? '' : 'none';
207
217
  });
@@ -265,34 +275,13 @@
265
275
  const users = data.users || [];
266
276
 
267
277
  const activeFranchises = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
268
- const totalSeats = licenses.reduce((s, l) => s + (l.totalSeats || 0), 0);
269
- const paidOrders = orders.filter(o => o.status === 'paid');
270
- const pendingOrders = orders.filter(o => o.status === 'pending' || o.status === 'awaiting_invoice');
271
- const paidRub = paidOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
272
- const pendingRub = pendingOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
273
278
  const openTickets = tickets.filter(t => t.status !== 'answered' && t.status !== 'closed');
274
279
 
275
- if ($('#tabCntServers')) $('#tabCntServers').textContent = String(licenses.length);
276
- if ($('#tabCntFranchises')) $('#tabCntFranchises').textContent = String(activeFranchises.length);
277
- if ($('#tabCntOrders')) $('#tabCntOrders').textContent = String(orders.length);
278
- if ($('#tabCntUsers')) $('#tabCntUsers').textContent = String(users.length);
279
- if ($('#tabCntTickets')) $('#tabCntTickets').textContent = String(openTickets.length);
280
-
281
- if ($('#admStatLic')) {
282
- $('#admStatLic').textContent = String(licenses.length);
283
- $('#admStatLicSub').textContent = `серверов · ${totalSeats} мест`;
284
- $('#admStatFranchises').textContent = String(activeFranchises.length);
285
- const overdueCnt = activeFranchises.filter(f => (f.overdueMonthsCount || 0) > 0).length;
286
- $('#admStatFranchisesSub').textContent = overdueCnt > 0 ? `просрочек: ${overdueCnt}` : 'активных франшиз';
287
- $('#admStatPaid').textContent = rub(paidRub);
288
- $('#admStatPaidSub').textContent = `оплачено заказов: ${paidOrders.length}`;
289
- $('#admStatPending').textContent = rub(pendingRub);
290
- $('#admStatPendingSub').textContent = `ожидают оплаты: ${pendingOrders.length}`;
291
- $('#admStatUsers').textContent = String(users.length);
292
- $('#admStatUsersSub').textContent = `админов BOSS: ${users.filter(u => u.isAdmin).length}`;
293
- $('#admStatTickets').textContent = String(openTickets.length);
294
- $('#admStatTicketsSub').textContent = `открытых из ${tickets.length}`;
295
- }
280
+ if ($('#navAdmServers')) $('#navAdmServers').textContent = `Серверы и лицензии (${licenses.length})`;
281
+ if ($('#navAdmFranchises')) $('#navAdmFranchises').textContent = `Франшизы (${activeFranchises.length})`;
282
+ if ($('#navAdmOrders')) $('#navAdmOrders').textContent = `Заказы и счета (${orders.length})`;
283
+ if ($('#navAdmUsers')) $('#navAdmUsers').textContent = `Клиенты и роли (${users.length})`;
284
+ if ($('#navAdmTickets')) $('#navAdmTickets').textContent = `Тикеты поддержки (${openTickets.length})`;
296
285
 
297
286
  renderServersTable(licenses);
298
287
  renderFranchisesTable(franchises);
@@ -364,10 +353,8 @@
364
353
  <button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-open-grant="${esc(lid)}" data-srv-name="${esc(srvName)}">+ Начислить доп. места</button>
365
354
  </div>
366
355
  </td>
367
- <td>
368
- <div style="display:flex;flex-direction:column;gap:6px;align-items:flex-start">
369
- <button class="btn btn-primary btn-sm" data-cert="${esc(lid)}">Файлы .lic.json (${(l.licenseFiles || []).length})</button>
370
- </div>
356
+ <td style="text-align:right">
357
+ <button class="btn btn-primary btn-sm" style="padding:6px 10px;font-size:12px" data-cert="${esc(lid)}">.lic.json (${(l.licenseFiles || []).length})</button>
371
358
  </td>
372
359
  </tr>`;
373
360
  }).join('') || '<tr><td colspan="6" class="muted">Серверов по заданному фильтру не найдено</td></tr>';
@@ -949,12 +936,14 @@
949
936
  $$('.modal.open').forEach(m => m.classList.remove('open'));
950
937
  }
951
938
  });
939
+ window.addEventListener('resize', moveIndicator);
952
940
  }
953
941
 
954
942
  function start() {
955
943
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
956
944
  bind();
957
945
  setTab('servers');
946
+ if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
958
947
  checkAdminSession();
959
948
  }
960
949
 
@@ -614,6 +614,156 @@ body[data-stage="admin"] .ai-fab { display: none; }
614
614
  .admin-left { position: static; }
615
615
  }
616
616
 
617
+ /* ---------- Двухколоночная навигация по разделам (эталон Скрина 1: Личный кабинет и Админ-панель) ---------- */
618
+ .sec-layout {
619
+ display: grid;
620
+ grid-template-columns: 230px minmax(0, 1fr);
621
+ gap: 28px;
622
+ align-items: start;
623
+ margin-top: 4px;
624
+ }
625
+ @media (max-width: 920px) {
626
+ .sec-layout { grid-template-columns: 1fr; gap: 16px; }
627
+ }
628
+ .sec-nav {
629
+ display: flex;
630
+ flex-direction: column;
631
+ gap: 4px;
632
+ position: sticky;
633
+ top: 84px;
634
+ }
635
+ .sec-nav-item {
636
+ display: flex;
637
+ align-items: center;
638
+ justify-content: space-between;
639
+ width: 100%;
640
+ padding: 9px 14px;
641
+ border-radius: 8px;
642
+ border: 1.5px solid transparent;
643
+ background: transparent;
644
+ color: var(--text-2);
645
+ font-size: 14px;
646
+ font-weight: 500;
647
+ text-align: left;
648
+ cursor: pointer;
649
+ transition: background .15s ease, border-color .15s ease, color .15s ease;
650
+ }
651
+ .sec-nav-item:hover {
652
+ background: color-mix(in srgb, var(--brand) 6%, transparent);
653
+ color: var(--text);
654
+ }
655
+ .sec-nav-item.active {
656
+ border-color: #3b82f6;
657
+ background: rgba(59, 130, 246, 0.08);
658
+ color: #1d4ed8;
659
+ font-weight: 600;
660
+ }
661
+ html[data-theme="dark"] .sec-nav-item.active {
662
+ border-color: #3b82f6;
663
+ background: rgba(59, 130, 246, 0.16);
664
+ color: #60a5fa;
665
+ }
666
+ .sec-nav-sep {
667
+ height: 1px;
668
+ background: var(--line);
669
+ margin: 6px 4px;
670
+ }
671
+ .sec-main {
672
+ min-width: 0;
673
+ display: flex;
674
+ flex-direction: column;
675
+ gap: 12px;
676
+ }
677
+ .sec-bar {
678
+ display: flex;
679
+ align-items: center;
680
+ justify-content: space-between;
681
+ gap: 12px;
682
+ flex-wrap: wrap;
683
+ min-height: 36px;
684
+ }
685
+ .sec-title {
686
+ margin: 0;
687
+ font-size: 16px;
688
+ font-weight: 700;
689
+ color: var(--text);
690
+ }
691
+ .sec-controls {
692
+ display: flex;
693
+ align-items: center;
694
+ gap: 8px;
695
+ flex-wrap: wrap;
696
+ }
697
+ .sec-controls input[type="text"],
698
+ .sec-controls input[type="search"],
699
+ .sec-controls select {
700
+ width: auto !important;
701
+ min-width: 0;
702
+ height: 34px;
703
+ padding: 6px 12px;
704
+ font-size: 13px;
705
+ border-radius: 8px;
706
+ border: 1px solid var(--line-strong);
707
+ background: var(--panel-solid);
708
+ color: var(--text);
709
+ }
710
+ .sec-controls input[type="search"],
711
+ .sec-controls input[type="text"] {
712
+ width: 230px !important;
713
+ }
714
+ .sec-controls select {
715
+ width: 185px !important;
716
+ }
717
+ .sec-controls .btn {
718
+ height: 34px;
719
+ padding: 0 13px;
720
+ font-size: 13px;
721
+ border-radius: 8px;
722
+ }
723
+ .sec-box {
724
+ background: var(--panel-solid);
725
+ border: 1px solid var(--line-strong);
726
+ border-radius: 12px;
727
+ padding: 16px 20px;
728
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
729
+ overflow: hidden;
730
+ }
731
+ .sec-table {
732
+ width: 100%;
733
+ border-collapse: collapse;
734
+ font-size: 13.5px;
735
+ }
736
+ .sec-table th,
737
+ .sec-table td {
738
+ padding: 10px 10px;
739
+ text-align: left;
740
+ border-bottom: 1px solid var(--line);
741
+ vertical-align: middle;
742
+ white-space: normal;
743
+ word-break: break-word;
744
+ }
745
+ .sec-table th {
746
+ color: var(--muted);
747
+ font-size: 11px;
748
+ font-weight: 700;
749
+ letter-spacing: .05em;
750
+ text-transform: uppercase;
751
+ padding-top: 4px;
752
+ padding-bottom: 8px;
753
+ }
754
+ .sec-table tbody tr:last-child td {
755
+ border-bottom: 0;
756
+ }
757
+ .sec-table tbody tr:hover {
758
+ background: color-mix(in srgb, var(--brand) 4%, transparent);
759
+ }
760
+ .sec-item {
761
+ padding: 14px 0;
762
+ border-bottom: 1px solid var(--line);
763
+ }
764
+ .sec-item:first-child { padding-top: 2px; }
765
+ .sec-item:last-child { border-bottom: 0; padding-bottom: 2px; }
766
+
617
767
  /* ---------- Оферта и вход администратора ---------- */
618
768
  .oferta-banner {
619
769
  padding: 16px 22px;
package/assets/landing.js CHANGED
@@ -278,6 +278,14 @@
278
278
  }
279
279
 
280
280
  let franchiseCountdownTimer = null;
281
+ let activeCabinetTab = 'servers';
282
+
283
+ function switchCabinetTab(tab = 'servers') {
284
+ activeCabinetTab = tab === 'franchises' ? 'franchises' : 'servers';
285
+ $$('[data-cab-tab]').forEach(b => b.classList.toggle('active', b.dataset.cabTab === activeCabinetTab));
286
+ if ($('#cabSecServers')) $('#cabSecServers').style.display = activeCabinetTab === 'servers' ? '' : 'none';
287
+ if ($('#cabSecFranchises')) $('#cabSecFranchises').style.display = activeCabinetTab === 'franchises' ? '' : 'none';
288
+ }
281
289
 
282
290
  function onAuthenticated() {
283
291
  if (!currentUser) return;
@@ -360,11 +368,11 @@
360
368
  const box = $('#cabinetServersRegistry');
361
369
  if (!box) return;
362
370
  const servers = getUserServersList();
363
- if ($('#badgeServersCount')) $('#badgeServersCount').textContent = String(servers.length);
371
+ if ($('#navCabServers')) $('#navCabServers').textContent = `Серверы и устройства (${servers.length})`;
364
372
 
365
373
  if (!servers.length) {
366
- box.innerHTML = `<div style="padding:22px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
367
- В вашем реестре пока нет добавленных серверов WORK. Введите название сервера выше и нажмите <b>«+ Добавить сервер (устройство)»</b>.
374
+ box.innerHTML = `<div style="padding:18px;color:var(--muted);text-align:center;">
375
+ В реестре пока нет серверов WORK. Введите название нового сервера справа вверху и нажмите <b>«+ Добавить сервер»</b>.
368
376
  </div>`;
369
377
  return;
370
378
  }
@@ -375,12 +383,12 @@
375
383
  const tok = s.hubToken || currentUser?.hubToken || '';
376
384
  const hwidBound = Boolean(s.hwidBound || s.hardware?.hwidBound || s.hardware?.nodeFingerprint);
377
385
  const hwidText = hwidBound
378
- ? `Железо привязано: <code>${esc(s.hardware?.hwid || '')}</code>${s.hardware?.nodeFingerprint ? ` · узел <code>${esc(s.hardware.nodeFingerprint.slice(0, 16))}…</code>` : ''}`
379
- : 'Ожидает привязки токена в локальном WORK (раздел WORK → Лицензия)';
386
+ ? `HWID: <code>${esc(s.hardware?.hwid || '')}</code>`
387
+ : 'ожидает привязки токена в WORK → Лицензия';
380
388
 
381
389
  const baseBadge = s.baseActivated
382
- ? `<span class="tag ok">✓ Базовая активна · ${s.baseSeats || 10} вечных мест + ИИ-агенты</span>`
383
- : `<span class="tag">Базовая не активирована (1 сессия BOSS · ИИ отключён)</span>`;
390
+ ? `<span class="tag ok">✓ Базовая: ${s.baseSeats || 10} вечн. мест + ИИ</span>`
391
+ : `<span class="tag">Базовая не активна (1 сессия BOSS)</span>`;
384
392
 
385
393
  const files = s.licenseFiles || [];
386
394
  const activeAddons = s.activeAddons || [];
@@ -390,118 +398,83 @@
390
398
  const canDelete = !s.baseActivated && !activeAddons.length && !paidOrders.length;
391
399
 
392
400
  const baseActionsHtml = !s.baseActivated
393
- ? `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
394
- <button class="btn btn-primary btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="license_only">Купить базовую лицензию (${rub(D.product?.baseLicensePriceRub || 4_000_000)})</button>
395
- <button class="btn btn-ghost btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="pak">Заказать ПАК с сервером (${rub(D.product?.basePriceRub || 5_000_000)})</button>
396
- </div>`
401
+ ? `<button class="btn btn-primary btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="license_only">Купить базовую (${rub(D.product?.baseLicensePriceRub || 4_000_000)})</button>
402
+ <button class="btn btn-ghost btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="pak">Купить ПАК с сервером (${rub(D.product?.basePriceRub || 5_000_000)})</button>`
397
403
  : '';
398
404
 
399
- const addonsListHtml = activeAddons.length
400
- ? `<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
401
- ${activeAddons.map(sub => {
402
- const exp = sub.expiresAt ? new Date(sub.expiresAt).toLocaleDateString('ru-RU') : '—';
403
- return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-radius:8px;background:rgba(255,255,255,.03);border:1px solid var(--border)">
404
- <span><b>+${sub.seats} конк. мест</b> · уровень <b>${esc(sub.tierName || sub.tier)}</b> (${sub.years || 1} г.)</span>
405
- <span class="small muted">действует до ${esc(exp)}</span>
406
- </div>`;
407
- }).join('')}
408
- </div>`
409
- : `<div class="small muted" style="margin-top:6px">Дополнительные временные места не подключены.</div>`;
405
+ const addonsSummaryHtml = activeAddons.length
406
+ ? activeAddons.map(sub => {
407
+ const exp = sub.expiresAt ? new Date(sub.expiresAt).toLocaleDateString('ru-RU') : '—';
408
+ return `<span class="tag ok" style="margin-right:6px">+${sub.seats} мест · ${esc(sub.tierName || sub.tier)} до ${esc(exp)}</span>`;
409
+ }).join('')
410
+ : `<span class="small muted">Доп. места не подключены</span>`;
410
411
 
411
412
  const filesHtml = files.length
412
- ? `<div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border)">
413
- <div class="small muted" style="margin-bottom:6px">Выпущенные подписанные файлы лицензий этого сервера (автоматически загружаются в <code>#system</code> по токену):</div>
414
- <div style="display:flex;flex-direction:column;gap:6px">
415
- ${files.map((f, fIdx) => {
416
- const lf = f.licenseFile || f;
417
- const isBase = lf.kind === 'base' || lf.perpetual;
418
- const label = isBase ? 'Базовая вечная лицензия (10 мест + ИИ)' : `Доп. лицензия (+${lf.seats} мест · ${String(lf.tier || '').toUpperCase()})`;
419
- return `<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;font-size:12.5px">
420
- <span><b>${esc(label)}</b> · <code>${esc(f.fileName || lf.fileId)}</code></span>
421
- <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-download-lic="${esc(lid)}" data-file-idx="${fIdx}">Скачать .lic.json</button>
422
- </div>`;
423
- }).join('')}
424
- </div>
413
+ ? `<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
414
+ <span class="small muted">Файлы лицензий (<code>#system</code>):</span>
415
+ ${files.map((f, fIdx) => `<button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-download-lic="${esc(lid)}" data-file-idx="${fIdx}">↓ ${esc(f.fileName || 'license.lic.json')}</button>`).join('')}
425
416
  </div>`
426
417
  : '';
427
418
 
428
419
  const pendingOrdersHtml = pendingOrders.length
429
- ? `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
430
- <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--warn);margin-bottom:8px">Ожидают оплаты по этому серверу (${pendingOrders.length})</div>
420
+ ? `<div style="margin-top:10px;padding:10px 12px;border-radius:8px;border:1px solid var(--line-strong);background:color-mix(in srgb, var(--warn) 6%, transparent)">
431
421
  ${pendingOrders.map(o => {
432
422
  const title = (o.items && o.items.length) ? o.items.map(it => esc(it.title)).join(' + ') : esc(o.tierName || o.orderId);
433
- return `<div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(245,158,11,.05);margin-bottom:8px">
434
- <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
435
- <div>
436
- <div class="small muted">Заказ <code>${esc(o.orderId)}</code></div>
437
- <div style="font-weight:700;font-size:14px;margin-top:2px">${title}</div>
438
- </div>
439
- <div style="font-size:17px;font-weight:800">${rub(o.amountRub)}</div>
440
- </div>
441
- <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center">
442
- <button class="btn btn-primary btn-sm" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить через ЮKassa (${rub(o.amountRub)})</button>
443
- <button class="btn btn-ghost btn-sm" data-invoice-toggle="${esc(o.orderId)}">Счёт-договор (для юр. лиц)</button>
444
- <button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}" style="color:var(--danger)">Отменить заказ</button>
423
+ return `<div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
424
+ <div class="small"><b>Ожидает оплаты:</b> ${title} · <b>${rub(o.amountRub)}</b></div>
425
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
426
+ <button class="btn btn-primary btn-sm" style="padding:5px 10px;font-size:12px" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить ЮKassa</button>
427
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-invoice-toggle="${esc(o.orderId)}">Счёт юрлицу</button>
428
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px;color:var(--danger)" data-cancel-order="${esc(o.orderId)}">Отменить</button>
445
429
  </div>
446
- <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:10px;padding:12px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
447
- <div class="small" style="margin-bottom:8px;font-weight:600">Реквизиты юр. лица для счёта-договора:</div>
448
- <div class="row2">
449
- <div class="field"><label>Наименование организации *</label><input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="ООО «Маслоторг»"></div>
450
- <div class="field"><label>ИНН *</label><input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="7701234567" inputmode="numeric"></div>
451
- </div>
452
- <button class="btn btn-primary btn-sm" style="margin-top:8px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Сформировать и открыть счёт-договор →</button>
430
+ </div>
431
+ <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)">
432
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
433
+ <input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="Организация (ООО «Маслоторг»)" style="width:240px;padding:6px 10px;font-size:13px">
434
+ <input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="ИНН" style="width:150px;padding:6px 10px;font-size:13px">
435
+ <button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12.5px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Открыть счёт-договор →</button>
453
436
  </div>
454
437
  </div>`;
455
438
  }).join('')}
456
439
  </div>`
457
440
  : '';
458
441
 
459
- return `<div style="padding:18px 20px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.015)">
460
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)">
461
- <div>
462
- <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
463
- <h4 class="h3" style="margin:0;font-size:18px">${esc(name)}</h4>
464
- <code style="font-size:12px;padding:2px 8px;border-radius:6px;background:rgba(255,255,255,.06)">${esc(lid)}</code>
465
- ${baseBadge}
466
- </div>
467
- <div class="small muted" style="margin-top:6px">${hwidText}</div>
442
+ return `<div class="sec-item">
443
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
444
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
445
+ <b style="font-size:15px">${esc(name)}</b>
446
+ <span class="small muted">· <code>${esc(lid)}</code> · ${hwidText}</span>
447
+ ${baseBadge}
468
448
  </div>
469
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
470
- <span class="pill" style="padding:4px 12px;font-size:12.5px">Сессий всего: <b>${s.totalSeats}</b> (${s.baseSeats || 0} вечн. + ${s.addonSeats || 0} доп.)</span>
471
- <button class="btn btn-ghost btn-sm" data-rename-server="${esc(lid)}" data-current-name="${esc(name)}">Переименовать</button>
472
- ${canDelete ? `<button class="btn btn-ghost btn-sm" data-delete-server="${esc(lid)}" style="color:var(--danger)">Удалить</button>` : ''}
449
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
450
+ <span class="small muted" style="margin-right:4px">Сессий: <b>${s.totalSeats}</b></span>
451
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-rename-server="${esc(lid)}" data-current-name="${esc(name)}">Переименовать</button>
452
+ ${canDelete ? `<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px;color:var(--danger)" data-delete-server="${esc(lid)}">Удалить</button>` : ''}
473
453
  </div>
474
454
  </div>
475
455
 
476
- <div class="split" style="margin-top:14px;gap:16px">
477
- <!-- Токен этого сервера -->
478
- <div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
479
- <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--brand-2);margin-bottom:4px">Токен привязки этого сервера (owk1...)</div>
480
- <p class="small muted" style="margin:0 0 8px">Вставьте этот токен в <b>WORK → Лицензия</b> на данном сервере. Токен привяжет устройство и автоматически скачает все его лицензии в <code>#system</code>.</p>
481
- <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
482
- <input type="text" id="srvTok-${esc(lid)}" readonly value="${esc(tok)}" style="flex:1;min-width:180px;font-family:monospace;font-size:12.5px;padding:7px 10px">
483
- <button class="btn btn-primary btn-sm" data-copy-server-token="${esc(lid)}">Скопировать токен</button>
484
- <button class="btn btn-ghost btn-sm" data-regen-server-token="${esc(lid)}">Перевыпустить</button>
485
- </div>
486
- <div class="small" id="srvTokStatus-${esc(lid)}" style="color:var(--ok);min-height:16px;margin-top:4px"></div>
487
- </div>
456
+ <!-- Строка токена привязки этого сервера -->
457
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px">
458
+ <span class="small muted" style="min-width:115px">Токен привязки:</span>
459
+ <input type="text" id="srvTok-${esc(lid)}" readonly value="${esc(tok)}" style="flex:1;min-width:200px;max-width:440px;padding:6px 10px;font-family:monospace;font-size:12px;border-radius:8px">
460
+ <button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12px" data-copy-server-token="${esc(lid)}">Скопировать токен</button>
461
+ <button class="btn btn-ghost btn-sm" style="padding:6px 10px;font-size:12px" data-regen-server-token="${esc(lid)}">Перевыпустить</button>
462
+ <span class="small" id="srvTokStatus-${esc(lid)}" style="color:var(--ok)"></span>
463
+ </div>
488
464
 
489
- <!-- Лицензии этого сервера -->
490
- <div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
491
- <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
492
- <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--brand-2)">Лицензии сервера (базовая и доп.)</div>
493
- <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-buy-for-server="${esc(lid)}" data-buy-variant="addons_only">+ Докупить рабочие места / SLA</button>
494
- </div>
495
- <div class="small" style="margin-top:6px">
496
- <b>Базовая лицензия:</b> ${s.baseActivated ? '<span style="color:var(--ok)">Активирована (10 вечных мест + ИИ-агенты)</span>' : '<span style="color:var(--muted)">Не активирована</span>'}
497
- </div>
465
+ <!-- Строка лицензий и докупки мест -->
466
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px">
467
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
468
+ <span class="small muted" style="min-width:115px">Доп. лицензии:</span>
469
+ ${addonsSummaryHtml}
470
+ </div>
471
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
498
472
  ${baseActionsHtml}
499
- <div class="small" style="margin-top:8px"><b>Дополнительные годовые лицензии:</b></div>
500
- ${addonsListHtml}
501
- ${filesHtml}
473
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-buy-for-server="${esc(lid)}" data-buy-variant="addons_only">+ Докупить рабочие места</button>
502
474
  </div>
503
475
  </div>
504
476
 
477
+ ${filesHtml}
505
478
  ${pendingOrdersHtml}
506
479
  </div>`;
507
480
  }).join('');
@@ -544,11 +517,11 @@
544
517
  if (!box) return;
545
518
  const franchises = currentUser?.franchises || currentUser?.reservations || [];
546
519
  const activeList = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
547
- if ($('#badgeFranchisesCount')) $('#badgeFranchisesCount').textContent = String(activeList.length);
520
+ if ($('#navCabFranchises')) $('#navCabFranchises').textContent = `Региональные франшизы (${activeList.length})`;
548
521
 
549
522
  if (!activeList.length) {
550
- box.innerHTML = `<div style="padding:22px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
551
- У вас пока нет зарезервированных или действующих региональных франшиз. Нажмите <b>«Зарезервировать франшизу на карте РФ»</b> выше, чтобы выбрать субъект РФ.
523
+ box.innerHTML = `<div style="padding:18px;color:var(--muted);text-align:center;">
524
+ У вас пока нет зарезервированных или действующих региональных франшиз. Нажмите <b>«+ Выбрать регион на карте РФ»</b> справа вверху.
552
525
  </div>`;
553
526
  startFranchiseCountdowns();
554
527
  return;
@@ -569,29 +542,28 @@
569
542
  const remSec = Math.max(0, Math.floor((deadline - now) / 1000));
570
543
 
571
544
  const statusBadge = !f.depositPaid
572
- ? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Ожидает оплаты залога · <b data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b></span>`
545
+ ? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Оплата залога: <b data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b></span>`
573
546
  : (remainingRub <= 0
574
- ? `<span class="tag ok">✓ Франшиза полностью оплачена (12/12)</span>`
575
- : `<span class="tag ok">✓ Залог оплачен · Погашение по графику (${paidMonths}/12 мес.)</span>`);
547
+ ? `<span class="tag ok">✓ Погашена полностью (12/12)</span>`
548
+ : `<span class="tag ok">✓ Залог оплачен · График ${paidMonths}/12 мес.</span>`);
576
549
 
577
550
  const depositAlertHtml = !f.depositPaid
578
- ? `<div style="margin-top:12px;padding:14px 16px;border-radius:12px;border:1px solid rgba(245,158,11,.45);background:rgba(245,158,11,.08);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
579
- <div>
580
- <div style="font-weight:700;color:var(--warn)">Требуется оплата начального взноса (${rub(depositRub)}) в течение 15 минут</div>
581
- <div class="small muted" style="margin-top:2px">Осталось времени: <b style="color:var(--text);font-family:monospace;font-size:14px" data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b>. Если оплата через ЮKassa не поступит вовремя, резерв региона автоматически отменится.</div>
551
+ ? `<div style="margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid rgba(245,158,11,.45);background:rgba(245,158,11,.08);display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
552
+ <div class="small">
553
+ <b>Оплатите начальный взнос (${rub(depositRub)}) через ЮKassa в течение <span style="font-family:monospace;font-size:13.5px" data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</span></b>, иначе резерв автоматически отменится.
582
554
  </div>
583
- <div style="display:flex;gap:8px;flex-wrap:wrap">
584
- <button class="btn btn-primary btn-sm" data-pay-order="${esc(f.orderId)}" data-pay-method="yookassa">Оплатить начальный взнос ${rub(depositRub)} (ЮKassa)</button>
585
- <button class="btn btn-ghost btn-sm" data-cancel-franchise="${esc(rid)}" style="color:var(--danger)">Отменить резерв</button>
555
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
556
+ <button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12px" data-pay-order="${esc(f.orderId)}" data-pay-method="yookassa">Оплатить ${rub(depositRub)} (ЮKassa)</button>
557
+ <button class="btn btn-ghost btn-sm" style="padding:6px 10px;font-size:12px;color:var(--danger)" data-cancel-franchise="${esc(rid)}">Отменить резерв</button>
586
558
  </div>
587
559
  </div>`
588
560
  : '';
589
561
 
590
562
  const nextInst = f.nextInstallment || schedule.find(x => x.status !== 'paid');
591
563
  const scheduleControlsHtml = (f.depositPaid && remainingRub > 0)
592
- ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
593
- ${nextInst ? `<button class="btn btn-primary btn-sm" data-pay-installment="${esc(rid)}" data-month="${nextInst.monthNumber}">Оплатить платёж №${nextInst.monthNumber} (${rub(nextInst.amountRub)} · ЮKassa)</button>` : ''}
594
- <button class="btn btn-ghost btn-sm" data-pay-installment="${esc(rid)}" data-early="1">Досрочно погасить весь остаток (${rub(remainingRub)})</button>
564
+ ? `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
565
+ ${nextInst ? `<button class="btn btn-primary btn-sm" style="padding:5px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${nextInst.monthNumber}">Оплатить месяц №${nextInst.monthNumber} (${rub(nextInst.amountRub)})</button>` : ''}
566
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-early="1">Погасить досрочно (${rub(remainingRub)})</button>
595
567
  </div>`
596
568
  : '';
597
569
 
@@ -607,62 +579,48 @@
607
579
  stHtml = '<span class="tag bad">Просрочен</span>';
608
580
  }
609
581
  else if (inst.displayStatus === 'next') {
610
- stHtml = '<span class="tag" style="color:var(--brand-2)">Ближайший к оплате</span>';
582
+ stHtml = '<span class="tag" style="color:var(--brand-2)">К оплате</span>';
611
583
  }
612
584
 
613
585
  const actHtml = (!isPaid && f.depositPaid)
614
586
  ? `<div style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap">
615
- <button class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="yookassa">Оплатить ЮKassa</button>
587
+ <button class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="yookassa">ЮKassa</button>
616
588
  <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="invoice">Счёт</button>
617
589
  </div>`
618
- : (isPaid ? `<span class="small muted">Закрыт</span>` : `<span class="small muted">После оплаты залога</span>`);
590
+ : (isPaid ? `<span class="small muted">Закрыт</span>` : `<span class="small muted">После залога</span>`);
619
591
 
620
592
  return `<tr>
621
593
  <td><b>Месяц ${inst.monthNumber} / 12</b></td>
622
594
  <td>${esc(dueStr)}</td>
623
- <td class="num"><b>${rub(inst.amountRub)}</b></td>
595
+ <td><b>${rub(inst.amountRub)}</b></td>
624
596
  <td>${stHtml}</td>
625
597
  <td style="text-align:right">${actHtml}</td>
626
598
  </tr>`;
627
599
  }).join('');
628
600
 
629
- return `<div style="padding:18px 20px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.015)">
630
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)">
631
- <div>
632
- <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
633
- <h4 class="h3" style="margin:0;font-size:18px">${esc(f.regionName)}</h4>
634
- <span class="tag">${esc(f.district || f.regionCode)}</span>
635
- <code style="font-size:12px;color:var(--muted)">${esc(rid)}</code>
636
- ${statusBadge}
637
- </div>
638
- <div class="small muted" style="margin-top:6px">
639
- Паушальный взнос региона: <b>${rub(totalFeeRub)}</b> · Начальный взнос (залог): <b>${rub(depositRub)}</b> (${f.depositPaid ? 'оплачен' : 'ожидает оплаты'}) · Погашено всего: <b>${rub(totalPaidRub)}</b> · Остаток: <b>${rub(remainingRub)}</b>
640
- </div>
601
+ return `<div class="sec-item">
602
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
603
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
604
+ <b style="font-size:15px">${esc(f.regionName)}</b>
605
+ <span class="small muted">· ${esc(f.district || f.regionCode)} · <code>${esc(rid)}</code></span>
606
+ ${statusBadge}
641
607
  </div>
642
608
  ${scheduleControlsHtml}
643
609
  </div>
644
-
645
- ${depositAlertHtml}
646
-
647
- <div style="margin-top:14px">
648
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap">
649
- <span class="small" style="font-weight:700">Годовой график погашения платежей (12 месяцев, платёж раз в месяц)</span>
650
- <span class="small muted">Погашено ${pct}% (${rub(totalPaidRub)} из ${rub(totalFeeRub)})</span>
651
- </div>
652
- <div class="bar" style="width:100%;height:8px;margin-bottom:12px"><i style="--p:${pct}%"></i></div>
653
- <div class="table-wrap" style="max-height:340px">
654
- <table class="t">
655
- <thead><tr>
656
- <th>Период</th>
657
- <th>Дата платежа</th>
658
- <th class="num">Сумма взноса</th>
659
- <th>Статус</th>
660
- <th style="text-align:right">Действие</th>
661
- </tr></thead>
662
- <tbody>${scheduleRowsHtml}</tbody>
663
- </table>
664
- </div>
610
+ <div class="small muted" style="margin-top:4px">
611
+ Паушальный взнос: <b>${rub(totalFeeRub)}</b> · Залог: <b>${rub(depositRub)}</b> · Оплачено: <b>${rub(totalPaidRub)}</b> (${pct}%) · Остаток: <b>${rub(remainingRub)}</b>
665
612
  </div>
613
+ ${depositAlertHtml}
614
+ <table class="sec-table" style="margin-top:10px">
615
+ <thead><tr>
616
+ <th>Период</th>
617
+ <th>Дата платежа</th>
618
+ <th>Сумма</th>
619
+ <th>Статус</th>
620
+ <th style="text-align:right">Оплата</th>
621
+ </tr></thead>
622
+ <tbody>${scheduleRowsHtml}</tbody>
623
+ </table>
666
624
  </div>`;
667
625
  }).join('');
668
626
 
@@ -958,6 +916,7 @@
958
916
  onAuthenticated();
959
917
  }
960
918
  if (st) st.textContent = '';
919
+ switchCabinetTab('servers');
961
920
  switchStage('addons');
962
921
  }
963
922
  catch (err) {
@@ -1428,6 +1387,7 @@
1428
1387
  }
1429
1388
  if (msg) msg.textContent = '';
1430
1389
  refreshCatalogRegions();
1390
+ switchCabinetTab('franchises');
1431
1391
  switchStage('addons');
1432
1392
  }
1433
1393
  catch (err) {
@@ -1688,8 +1648,9 @@
1688
1648
  /* ================= Привязка событий ================= */
1689
1649
  function bind() {
1690
1650
  document.addEventListener('click', (e) => {
1691
- const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-copy-server-token],[data-regen-server-token],[data-rename-server],[data-delete-server],[data-download-lic],[data-buy-for-server],[data-pay-installment],[data-cancel-franchise],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1651
+ const el = e.target.closest('[data-stage],[data-cab-tab],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-copy-server-token],[data-regen-server-token],[data-rename-server],[data-delete-server],[data-download-lic],[data-buy-for-server],[data-pay-installment],[data-cancel-franchise],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1692
1652
  if (!el) return;
1653
+ if (el.matches('[data-cab-tab]')) { e.preventDefault(); switchCabinetTab(el.dataset.cabTab); return; }
1693
1654
  if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
1694
1655
  if (el.matches('[data-pay-order]')) { e.stopPropagation(); payCabinetOrder(el.dataset.payOrder, el.dataset.payMethod || 'yookassa'); return; }
1695
1656
  if (el.matches('[data-invoice-toggle]')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/admin.js CHANGED
@@ -1,15 +1,8 @@
1
1
  /**
2
- * Рабочая консоль администратора центра лицензирования и биллинга ODANT-WORK (/admin).
2
+ * Админ-панель центра лицензирования и франшиз ODANT-WORK (/admin).
3
3
  *
4
- * Доступна пользователям с ролью BOSS (первый зарегистрированный по Email пользователь
5
- * или назначенный администратор) по их штатной сессии без отдельных паролей.
6
- *
7
- * Разделы:
8
- * 1. Реестр серверов (устройств) и лицензий клиентов
9
- * 2. Реестр региональных франшиз и 12-месячных графиков погашения
10
- * 3. Заказы ЮKassa и безналичные счета-договоры
11
- * 4. Зарегистрированные клиенты, оферты (ПЭП) и роли BOSS
12
- * 5. Тикеты техподдержки SLA (MAX · Telegram · VK)
4
+ * Единая шапка с основным сайтом (О платформе · Клиентам · Франшиза · Личный кабинет · Админ-панель)
5
+ * и двухколоночная навигация со счётчиками в скобках (по эталону Скрина 1).
13
6
  */
14
7
  import {
15
8
  PAK_PRODUCT,
@@ -44,7 +37,7 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
44
37
  <head>
45
38
  <meta charset="utf-8">
46
39
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
47
- <title>ODANT-WORK · Админ-панель центра лицензирования и франшиз</title>
40
+ <title>ODANT-WORK · Админ-панель</title>
48
41
  <meta name="robots" content="noindex, nofollow">
49
42
  <meta name="theme-color" content="#04060b">
50
43
  <link rel="icon" type="image/png" href="/favicon.ico">
@@ -97,210 +90,196 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
97
90
  <div style="margin-top:8px;text-align:center">
98
91
  <a href="/" class="small muted">← Вернуться на главную страницу work.odant.org</a>
99
92
  </div>
100
- <div class="small muted" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">
101
- Ключ ЦС Ed25519: <code>${esc(publicKey.slice(0, 26))}…</code>
102
- </div>
103
93
  </div>
104
94
  </div>
105
95
 
106
- <!-- Основная операционная консоль администратора -->
96
+ <!-- Основная оболочка с единой шапкой сайта и двухколоночным меню разделов -->
107
97
  <div class="shell" id="adminAppShell" style="display:none">
108
98
  <header class="topbar">
109
- <a href="/" class="brand" style="text-decoration:none">
99
+ <a href="/" class="brand" style="text-decoration:none" aria-label="ODANT-WORK — на главную">
110
100
  <img src="/assets/odant-logo.png" alt="ODANT">
111
101
  <span>
112
- <span class="brand-name">ODANT<b>·</b>WORK <span class="tag ok" style="margin-left:6px;vertical-align:middle">BOSS · АДМИН</span></span><br>
113
- <span class="brand-sub">реестр серверов · реестр франшиз · платежи и счета</span>
102
+ <span class="brand-name">ODANT<b>·</b>WORK</span><br>
103
+ <span class="brand-sub">учёт · документооборот · процессы · приватный ИИ</span>
114
104
  </span>
115
105
  </a>
116
106
 
117
- <nav class="tabs" id="admTopTabs" aria-label="Реестры администратора">
118
- <button class="tab active" data-adm-tab="servers">Серверы и лицензии <span class="tag" id="tabCntServers" style="margin-left:4px">0</span></button>
119
- <button class="tab" data-adm-tab="franchises">Франшизы и графики <span class="tag" id="tabCntFranchises" style="margin-left:4px">0</span></button>
120
- <button class="tab" data-adm-tab="orders">Заказы и счета <span class="tag" id="tabCntOrders" style="margin-left:4px">0</span></button>
121
- <button class="tab" data-adm-tab="users">Клиенты и роли BOSS <span class="tag" id="tabCntUsers" style="margin-left:4px">0</span></button>
122
- <button class="tab" data-adm-tab="tickets">Тикеты SLA <span class="tag" id="tabCntTickets" style="margin-left:4px">0</span></button>
107
+ <nav class="tabs" id="tabs" aria-label="Разделы">
108
+ <span class="tab-ind" id="tabInd"></span>
109
+ <a class="tab" href="/#product" style="text-decoration:none">О платформе</a>
110
+ <a class="tab" href="/#clients" style="text-decoration:none">Клиентам</a>
111
+ <a class="tab" href="/#franchise" style="text-decoration:none">Франшиза</a>
112
+ <a class="tab" href="/#addons" style="text-decoration:none">Личный кабинет</a>
113
+ <a class="tab active" href="/admin" id="tileAdmin" style="text-decoration:none">Админ-панель</a>
123
114
  </nav>
124
115
 
125
116
  <div class="top-actions">
126
- <a href="/#addons" class="btn btn-ghost btn-sm" style="text-decoration:none">← Личный кабинет</a>
127
117
  <button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
128
- <button class="btn btn-ghost btn-sm" data-action="registry">${icon('server')}<span>Обновить</span></button>
129
- <button class="btn btn-ghost btn-sm" data-action="admin-logout" title="Завершить сессию">${icon('lock')}<span id="admUserLabel">Выйти</span></button>
118
+ <button class="btn btn-primary btn-sm" data-action="admin-logout" title="Нажмите, чтобы выйти из сессии">${icon('user')}<span id="admUserLabel">BOSS</span></button>
130
119
  </div>
131
120
  </header>
132
121
 
133
- <section class="stage active" style="padding-top:20px">
134
- <!-- Сводные показатели -->
135
- <div class="stats" style="grid-template-columns:repeat(6,1fr);margin-bottom:20px">
136
- <div class="stat"><b id="admStatLic">0</b><span id="admStatLicSub">серверов · 0 мест</span></div>
137
- <div class="stat"><b id="admStatFranchises">0</b><span id="admStatFranchisesSub">активных франшиз</span></div>
138
- <div class="stat"><b id="admStatPaid">0 ₽</b><span id="admStatPaidSub">оплачено заказов: 0</span></div>
139
- <div class="stat"><b id="admStatPending">0 ₽</b><span id="admStatPendingSub">ожидают оплаты: 0</span></div>
140
- <div class="stat"><b id="admStatUsers">0</b><span id="admStatUsersSub">клиентов в базе</span></div>
141
- <div class="stat"><b id="admStatTickets">0</b><span id="admStatTicketsSub">открытых тикетов SLA</span></div>
142
- </div>
122
+ <section class="stage active" style="padding-top:24px">
123
+ <div class="sec-layout">
124
+ <!-- Левое вертикальное меню разделов со счётчиками в скобках (как на Скрине 1) -->
125
+ <aside class="sec-nav" aria-label="Разделы администрирования">
126
+ <button type="button" class="sec-nav-item active" data-adm-tab="servers" id="navAdmServers">Серверы и лицензии (0)</button>
127
+ <button type="button" class="sec-nav-item" data-adm-tab="franchises" id="navAdmFranchises">Франшизы (0)</button>
128
+ <div class="sec-nav-sep"></div>
129
+ <button type="button" class="sec-nav-item" data-adm-tab="orders" id="navAdmOrders">Заказы и счета (0)</button>
130
+ <button type="button" class="sec-nav-item" data-adm-tab="users" id="navAdmUsers">Клиенты и роли (0)</button>
131
+ <button type="button" class="sec-nav-item" data-adm-tab="tickets" id="navAdmTickets">Тикеты поддержки (0)</button>
132
+ </aside>
143
133
 
144
- <!-- 1. РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) И ЛИЦЕНЗИЙ -->
145
- <div class="card adm-section" data-adm-sec="servers">
146
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
147
- <div>
148
- <span class="kicker">Реестр 1 · Устройства и лицензии</span>
149
- <h3 class="h3" style="margin:2px 0 0">Серверы (устройства) WORK и лицензии клиентов</h3>
134
+ <!-- Правая область выбранного раздела -->
135
+ <div class="sec-main">
136
+ <!-- 1. СЕРВЕРЫ И ЛИЦЕНЗИИ -->
137
+ <div data-adm-sec="servers">
138
+ <div class="sec-bar" style="margin-bottom:12px">
139
+ <h2 class="sec-title">Серверы (устройства) WORK и лицензии клиентов</h2>
140
+ <div class="sec-controls">
141
+ <input type="search" id="inpFilterServers" placeholder="Поиск по клиенту, Email, HWID…">
142
+ <select id="selFilterServers">
143
+ <option value="all">Все серверы</option>
144
+ <option value="base_active">Базовая активна</option>
145
+ <option value="base_inactive">Без базовой лицензии</option>
146
+ <option value="hwid_bound">HWID привязан</option>
147
+ </select>
148
+ <button type="button" class="btn btn-primary btn-sm" data-action="open-create-server-modal">+ Добавить сервер</button>
149
+ </div>
150
+ </div>
151
+ <div class="sec-box">
152
+ <table class="sec-table">
153
+ <thead><tr>
154
+ <th>Сервер / ID</th>
155
+ <th>Владелец / Организация</th>
156
+ <th>Железо (HWID)</th>
157
+ <th>Базовая лицензия</th>
158
+ <th>Доп. места и SLA</th>
159
+ <th style="text-align:right">Файлы</th>
160
+ </tr></thead>
161
+ <tbody id="admLicensesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
162
+ </table>
163
+ </div>
150
164
  </div>
151
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
152
- <input type="search" id="inpFilterServers" placeholder="Поиск по клиенту, Email, ИНН, серверу, HWID…" style="width:280px;padding:8px 12px;font-size:13px">
153
- <select id="selFilterServers" style="padding:8px 12px;font-size:13px">
154
- <option value="all">Все серверы</option>
155
- <option value="base_active">Базовая активирована</option>
156
- <option value="base_inactive">Без базовой лицензии</option>
157
- <option value="hwid_bound">Железо (HWID) привязано</option>
158
- </select>
159
- <button class="btn btn-primary btn-sm" data-action="open-create-server-modal">${icon('plus')} Добавить сервер / лицензию</button>
160
- </div>
161
- </div>
162
- <div class="table-wrap">
163
- <table class="t">
164
- <thead><tr>
165
- <th>Сервер / ID</th>
166
- <th>Владелец / Организация</th>
167
- <th>Привязка железа (HWID)</th>
168
- <th>Базовая лицензия (10 вечн.)</th>
169
- <th>Доп. места и SLA</th>
170
- <th>Управление</th>
171
- </tr></thead>
172
- <tbody id="admLicensesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
173
- </table>
174
- </div>
175
- </div>
176
165
 
177
- <!-- 2. РЕЕСТР ФРАНШИЗ И ГРАФИКОВ ПОГАШЕНИЯ (12 МЕС.) -->
178
- <div class="card adm-section" data-adm-sec="franchises" style="display:none">
179
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
180
- <div>
181
- <span class="kicker">Реестр 2 · Региональная партнёрская сеть</span>
182
- <h3 class="h3" style="margin:2px 0 0">Региональные франшизы, резервы (15 мин) и годовые графики погашения</h3>
183
- </div>
184
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
185
- <input type="search" id="inpFilterFranchises" placeholder="Поиск по региону, компании, ИНН, Email…" style="width:280px;padding:8px 12px;font-size:13px">
186
- <select id="selFilterFranchises" style="padding:8px 12px;font-size:13px">
187
- <option value="all">Все активные франшизы и резервы</option>
188
- <option value="pending_deposit">Ожидают залога (таймер 15 мин)</option>
189
- <option value="deposit_paid">Погашение по графику (12 мес.)</option>
190
- <option value="overdue">С просрочкой ежемесячного платежа</option>
191
- <option value="paid_in_full">Полностью погашены</option>
192
- <option value="closed">Отменённые / Истёкшие</option>
193
- </select>
166
+ <!-- 2. ФРАНШИЗЫ И ГРАФИКИ ПОГАШЕНИЯ (12 МЕС.) -->
167
+ <div data-adm-sec="franchises" style="display:none">
168
+ <div class="sec-bar" style="margin-bottom:12px">
169
+ <h2 class="sec-title">Региональные франшизы и графики погашения (12 мес.)</h2>
170
+ <div class="sec-controls">
171
+ <input type="search" id="inpFilterFranchises" placeholder="Поиск по региону, компании, ИНН…">
172
+ <select id="selFilterFranchises">
173
+ <option value="all">Все активные</option>
174
+ <option value="pending_deposit">Ожидают залога (15 мин)</option>
175
+ <option value="deposit_paid">Погашение по графику</option>
176
+ <option value="overdue">С просрочкой</option>
177
+ <option value="paid_in_full">Полностью погашены</option>
178
+ <option value="closed">Отменённые / Истёкшие</option>
179
+ </select>
180
+ </div>
181
+ </div>
182
+ <div class="sec-box">
183
+ <table class="sec-table">
184
+ <thead><tr>
185
+ <th>Субъект РФ / ID</th>
186
+ <th>Франчайзи</th>
187
+ <th>Залог (100 тыс. ₽)</th>
188
+ <th>График (12 мес.)</th>
189
+ <th>Оплачено / Остаток</th>
190
+ <th style="text-align:right">Управление</th>
191
+ </tr></thead>
192
+ <tbody id="admFranchisesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
193
+ </table>
194
+ </div>
194
195
  </div>
195
- </div>
196
- <div class="table-wrap">
197
- <table class="t">
198
- <thead><tr>
199
- <th>Субъект РФ / ID</th>
200
- <th>Франчайзи / Контакты</th>
201
- <th>Начальный взнос (залог 100 тыс. ₽)</th>
202
- <th>График погашения (12 мес.)</th>
203
- <th>Финансовая сводка</th>
204
- <th>Управление</th>
205
- </tr></thead>
206
- <tbody id="admFranchisesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
207
- </table>
208
- </div>
209
- </div>
210
196
 
211
- <!-- 3. ЗАКАЗЫ ЮKASSA И БЕЗНАЛИЧНЫЕ СЧЕТА-ДОГОВОРЫ -->
212
- <div class="card adm-section" data-adm-sec="orders" style="display:none">
213
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
214
- <div>
215
- <span class="kicker">Реестр 3 · Биллинг и расчёты</span>
216
- <h3 class="h3" style="margin:2px 0 0">Заказы ЮKassa, счета-договоры юрлиц и взносы по франшизам</h3>
197
+ <!-- 3. ЗАКАЗЫ И СЧЕТА -->
198
+ <div data-adm-sec="orders" style="display:none">
199
+ <div class="sec-bar" style="margin-bottom:12px">
200
+ <h2 class="sec-title">Заказы ЮKassa и безналичные счета-договоры</h2>
201
+ <div class="sec-controls">
202
+ <input type="search" id="inpFilterOrders" placeholder="Поиск по заказу, счёту, ИНН…">
203
+ <select id="selFilterOrders">
204
+ <option value="all">Все заказы</option>
205
+ <option value="pending">Ожидают оплаты</option>
206
+ <option value="paid">Оплачены</option>
207
+ <option value="canceled">Отменены</option>
208
+ </select>
209
+ </div>
210
+ </div>
211
+ <div class="sec-box">
212
+ <table class="sec-table">
213
+ <thead><tr>
214
+ <th>Заказ / Счёт</th>
215
+ <th>Назначение</th>
216
+ <th>Плательщик</th>
217
+ <th>Сумма</th>
218
+ <th>Статус</th>
219
+ <th style="text-align:right">Действия</th>
220
+ </tr></thead>
221
+ <tbody id="admOrdersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
222
+ </table>
223
+ </div>
217
224
  </div>
218
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
219
- <input type="search" id="inpFilterOrders" placeholder="Поиск по номеру заказа, счёту, плательщику, ИНН…" style="width:280px;padding:8px 12px;font-size:13px">
220
- <select id="selFilterOrders" style="padding:8px 12px;font-size:13px">
221
- <option value="all">Все заказы</option>
222
- <option value="pending">Ожидают оплаты</option>
223
- <option value="paid">Оплачены</option>
224
- <option value="canceled">Отменены</option>
225
- </select>
226
- </div>
227
- </div>
228
- <div class="table-wrap">
229
- <table class="t">
230
- <thead><tr>
231
- <th>Заказ / Счёт / Дата</th>
232
- <th>Назначение и объект</th>
233
- <th>Плательщик / ИНН</th>
234
- <th class="num">Сумма</th>
235
- <th>Статус</th>
236
- <th>Действия</th>
237
- </tr></thead>
238
- <tbody id="admOrdersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
239
- </table>
240
- </div>
241
- </div>
242
225
 
243
- <!-- 4. КЛИЕНТЫ, ОФЕРТЫ ПЭП И РОЛИ BOSS -->
244
- <div class="card adm-section" data-adm-sec="users" style="display:none">
245
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
246
- <div>
247
- <span class="kicker">Реестр 4 · Аккаунты владельцев и права доступа</span>
248
- <h3 class="h3" style="margin:2px 0 0">Зарегистрированные клиенты, подписанные оферты (ПЭП) и администраторы (BOSS)</h3>
249
- </div>
250
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
251
- <input type="search" id="inpFilterUsers" placeholder="Поиск по Email, организации, ИНН…" style="width:280px;padding:8px 12px;font-size:13px">
226
+ <!-- 4. КЛИЕНТЫ И РОЛИ BOSS -->
227
+ <div data-adm-sec="users" style="display:none">
228
+ <div class="sec-bar" style="margin-bottom:12px">
229
+ <h2 class="sec-title">Зарегистрированные клиенты и права администраторов (BOSS)</h2>
230
+ <div class="sec-controls">
231
+ <input type="search" id="inpFilterUsers" placeholder="Поиск по Email, организации, ИНН…">
232
+ </div>
233
+ </div>
234
+ <div class="sec-box">
235
+ <table class="sec-table">
236
+ <thead><tr>
237
+ <th>Email / ID</th>
238
+ <th>Роль</th>
239
+ <th>Организация / ИНН</th>
240
+ <th>Реестры клиента</th>
241
+ <th>Оферта (ПЭП)</th>
242
+ <th style="text-align:right">Вход</th>
243
+ </tr></thead>
244
+ <tbody id="admUsersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
245
+ </table>
246
+ </div>
252
247
  </div>
253
- </div>
254
- <div class="table-wrap">
255
- <table class="t">
256
- <thead><tr>
257
- <th>Email / ID</th>
258
- <th>Роль на сервере</th>
259
- <th>Организация / ИНН</th>
260
- <th>Серверов / Франшиз</th>
261
- <th>Договор-оферта (ПЭП)</th>
262
- <th>Регистрация / Вход</th>
263
- </tr></thead>
264
- <tbody id="admUsersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
265
- </table>
266
- </div>
267
- </div>
268
248
 
269
- <!-- 5. ТИКЕТЫ SLA -->
270
- <div class="card adm-section" data-adm-sec="tickets" style="display:none">
271
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
272
- <div>
273
- <span class="kicker">Реестр 5 · Служба поддержки</span>
274
- <h3 class="h3" style="margin:2px 0 0">Обращения техподдержки SLA (MAX · Telegram · VK)</h3>
249
+ <!-- 5. ТИКЕТЫ ПОДДЕРЖКИ SLA -->
250
+ <div data-adm-sec="tickets" style="display:none">
251
+ <div class="sec-bar" style="margin-bottom:12px">
252
+ <h2 class="sec-title">Тикеты техподдержки SLA (MAX · Telegram · VK)</h2>
253
+ <div class="sec-controls">
254
+ <select id="selFilterTickets">
255
+ <option value="all">Все обращения</option>
256
+ <option value="open">Открытые</option>
257
+ <option value="answered">Отвеченные</option>
258
+ <option value="closed">Закрытые</option>
259
+ </select>
260
+ </div>
261
+ </div>
262
+ <div class="sec-box">
263
+ <table class="sec-table">
264
+ <thead><tr>
265
+ <th>Тикет / Канал</th>
266
+ <th>Клиент / Сервер</th>
267
+ <th>SLA</th>
268
+ <th>Переписка</th>
269
+ <th style="text-align:right">Ответ инженера</th>
270
+ </tr></thead>
271
+ <tbody id="admTicketsBody"><tr><td colspan="5" class="muted">Загрузка…</td></tr></tbody>
272
+ </table>
273
+ </div>
275
274
  </div>
276
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
277
- <select id="selFilterTickets" style="padding:8px 12px;font-size:13px">
278
- <option value="all">Все обращения</option>
279
- <option value="open">Открытые (ожидают ответа)</option>
280
- <option value="answered">Отвеченные</option>
281
- <option value="closed">Закрытые</option>
282
- </select>
283
- </div>
284
- </div>
285
- <div class="table-wrap">
286
- <table class="t">
287
- <thead><tr>
288
- <th>Тикет / Канал</th>
289
- <th>Клиент / Сервер</th>
290
- <th>Тариф SLA</th>
291
- <th>Переписка</th>
292
- <th>Ответ инженера</th>
293
- </tr></thead>
294
- <tbody id="admTicketsBody"><tr><td colspan="5" class="muted">Загрузка…</td></tr></tbody>
295
- </table>
296
275
  </div>
297
276
  </div>
298
277
  </section>
299
278
 
300
279
  <footer>
301
280
  <div class="footer-bottom">
302
- <span>© ODANT · Консоль администратора центра лицензирования ${esc(P.domain)}</span>
303
- <span>Ключ центра сертификации Ed25519: <code>${esc(publicKey)}</code></span>
281
+ <span>© ODANT · ${esc(P.domain)}</span>
282
+ <span>Ключ центра сертификации Ed25519: <code>${esc(publicKey.slice(0, 28))}…</code></span>
304
283
  </div>
305
284
  </footer>
306
285
  </div>
@@ -386,14 +365,14 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
386
365
  <button class="icon-btn" data-action="close-sched-modal" aria-label="Закрыть">${icon('close')}</button>
387
366
  </div>
388
367
  <div id="admSchedSummary" style="margin-bottom:12px"></div>
389
- <div class="table-wrap" style="max-height:420px">
390
- <table class="t">
368
+ <div style="max-height:420px;overflow-y:auto">
369
+ <table class="sec-table">
391
370
  <thead><tr>
392
371
  <th>Месяц</th>
393
372
  <th>Дата платежа</th>
394
- <th class="num">Сумма</th>
373
+ <th>Сумма</th>
395
374
  <th>Статус</th>
396
- <th style="text-align:right">Действие администратора</th>
375
+ <th style="text-align:right">Действие</th>
397
376
  </tr></thead>
398
377
  <tbody id="admSchedBody"></tbody>
399
378
  </table>
package/src/landing.js CHANGED
@@ -337,50 +337,38 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
337
337
  <div class="tiers">${renderTiers()}</div>
338
338
  </section>
339
339
 
340
- <!-- ============ ЛИЧНЫЙ КАБИНЕТ: 2 ОСНОВНЫХ РЕЕСТРА (СЕРВЕРЫ И ФРАНШИЗЫ) ============ -->
340
+ <!-- ============ ЛИЧНЫЙ КАБИНЕТ: ЛЕВОЕ МЕНЮ РАЗДЕЛОВ + ЧИСТЫЕ РЕЕСТРЫ ============ -->
341
341
  <section class="stage" id="stage-addons">
342
- <div class="sec-head">
343
- <div>
344
- <span class="kicker">Личный кабинет владельца</span>
345
- <h2 class="h2">Реестр серверов (устройств) и реестр франшиз</h2>
346
- </div>
347
- <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px">
348
- <p id="userBadgeBox" style="margin:0"></p>
349
- <div style="display:flex;gap:8px;flex-wrap:wrap">
350
- <button class="btn btn-ghost btn-sm" data-action="refresh-cabinet">Обновить данные</button>
351
- </div>
352
- </div>
353
- </div>
342
+ <div class="sec-layout">
343
+ <aside class="sec-nav" aria-label="Разделы Личного кабинета">
344
+ <button type="button" class="sec-nav-item active" data-cab-tab="servers" id="navCabServers">Серверы и устройства (0)</button>
345
+ <button type="button" class="sec-nav-item" data-cab-tab="franchises" id="navCabFranchises">Региональные франшизы (0)</button>
346
+ </aside>
354
347
 
355
- <!-- 1.1 РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) WORK -->
356
- <div class="card" style="margin-bottom:24px">
357
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)">
358
- <div>
359
- <span class="kicker">1. Реестр серверов (устройств) WORK</span>
360
- <h3 class="h3" style="margin:4px 0 4px">Ваши лицензируемые серверы и устройства <span class="tag" id="badgeServersCount" style="margin-left:6px">0</span></h3>
361
- <p class="small muted" style="margin:0">Вы можете добавить любое количество серверов WORK. Для каждого сервера выпускается свой собственный токен привязки (<code>owk1...</code>) и свой набор лицензий (базовая вечная на 10 мест + дополнительные годовые пакеты).</p>
362
- </div>
363
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
364
- <input type="text" id="inpNewServerName" placeholder="Название сервера (напр., Центральный офис)…" style="width:280px;padding:8px 12px;font-size:13.5px">
365
- <button class="btn btn-primary btn-sm" data-action="create-server">${icon('plus')} Добавить сервер (устройство)</button>
348
+ <div class="sec-main">
349
+ <!-- 1.1 РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) WORK -->
350
+ <div id="cabSecServers">
351
+ <div class="sec-bar" style="margin-bottom:12px">
352
+ <h2 class="sec-title">Серверы и устройства WORK</h2>
353
+ <div class="sec-controls">
354
+ <input type="text" id="inpNewServerName" placeholder="Название нового сервера…">
355
+ <button type="button" class="btn btn-primary btn-sm" data-action="create-server">+ Добавить сервер</button>
356
+ </div>
357
+ </div>
358
+ <div class="sec-box" id="cabinetServersRegistry"></div>
366
359
  </div>
367
- </div>
368
- <div id="cabinetServersRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
369
- </div>
370
360
 
371
- <!-- 1.2 РЕЕСТР ФРАНШИЗ -->
372
- <div class="card">
373
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)">
374
- <div>
375
- <span class="kicker">2. Реестр региональных франшиз</span>
376
- <h3 class="h3" style="margin:4px 0 4px">Зарезервированные и действующие франшизы <span class="tag" id="badgeFranchisesCount" style="margin-left:6px">0</span></h3>
377
- <p class="small muted" style="margin:0">Можно резервировать франшизы в нескольких разных субъектах РФ (не более 1 франшизы в одном регионе). После резервирования необходимо оплатить начальный взнос <b>${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</b> через ЮKassa в течение <b>15 минут</b>, иначе резерв автоматически отменяется. Остаток паушального взноса погашается по графику равными платежами раз в месяц в течение года.</p>
378
- </div>
379
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
380
- <button class="btn btn-primary btn-sm" data-stage="franchise">${icon('map')} Зарезервировать франшизу на карте РФ</button>
361
+ <!-- 1.2 РЕЕСТР РЕГИОНАЛЬНЫХ ФРАНШИЗ -->
362
+ <div id="cabSecFranchises" style="display:none">
363
+ <div class="sec-bar" style="margin-bottom:12px">
364
+ <h2 class="sec-title">Региональные франшизы</h2>
365
+ <div class="sec-controls">
366
+ <button type="button" class="btn btn-primary btn-sm" data-stage="franchise">+ Выбрать регион на карте РФ</button>
367
+ </div>
368
+ </div>
369
+ <div class="sec-box" id="cabinetFranchisesRegistry"></div>
381
370
  </div>
382
371
  </div>
383
- <div id="cabinetFranchisesRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
384
372
  </div>
385
373
  </section>
386
374