work-license-server 1.0.21 → 1.0.23

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
@@ -13,8 +13,10 @@
13
13
  let lastAdminData = null;
14
14
  let currentCertLic = null;
15
15
  let currentScheduleResId = '';
16
- let activeTab = 'servers';
16
+ let activeTab = 'clients';
17
17
  let admCountdownTimer = null;
18
+ const expandedClients = new Set();
19
+ const expandedFranchisers = new Set();
18
20
 
19
21
  function readCookieSession() {
20
22
  try {
@@ -239,7 +241,7 @@
239
241
 
240
242
  /* ================= Вкладки и форматирование ================= */
241
243
  function setTab(tab) {
242
- activeTab = tab || 'servers';
244
+ activeTab = tab || 'clients';
243
245
  $$('[data-adm-tab]').forEach(b => b.classList.toggle('active', b.dataset.admTab === activeTab));
244
246
  $$('[data-adm-sec]').forEach(sec => {
245
247
  sec.style.display = sec.dataset.admSec === activeTab ? '' : 'none';
@@ -296,6 +298,147 @@
296
298
  }
297
299
  }
298
300
 
301
+ function cleanOrgName(raw = '') {
302
+ const s = String(raw || '').trim();
303
+ if (!s || s.includes('@') || s === 'Организация ПАК WORK' || s === 'Клиент WORK' || s === 'Партнёр ODANT-WORK')
304
+ return '';
305
+ return s;
306
+ }
307
+
308
+ function buildClientGroups(users = [], licenses = []) {
309
+ const map = new Map();
310
+
311
+ for (const u of users) {
312
+ if (!u) continue;
313
+ const email = String(u.email || '').trim().toLowerCase();
314
+ const key = email || String(u.userId || '').toLowerCase();
315
+ if (!key) continue;
316
+ map.set(key, {
317
+ key,
318
+ email: u.email || key,
319
+ userId: u.userId || '',
320
+ isAdmin: Boolean(u.isAdmin),
321
+ name: (u.name && !String(u.name).includes('@')) ? String(u.name).trim() : '',
322
+ position: String(u.position || u.signerPosition || '').trim(),
323
+ companyName: cleanOrgName(u.companyName),
324
+ inn: String(u.inn || '').trim(),
325
+ kpp: String(u.kpp || '').trim(),
326
+ phone: String(u.phone || '').trim(),
327
+ ofertaAccepted: Boolean(u.ofertaAccepted),
328
+ ofertaSignatureId: String(u.ofertaSignatureId || '').trim(),
329
+ createdAt: Number(u.createdAt || 0),
330
+ lastLoginAt: Number(u.lastLoginAt || u.createdAt || 0),
331
+ servers: [],
332
+ });
333
+ }
334
+
335
+ const groupsList = () => Array.from(map.values());
336
+
337
+ for (const lic of licenses) {
338
+ if (!lic) continue;
339
+ const licEmail = String(lic.ownerEmail || lic.customer?.email || '').trim().toLowerCase();
340
+ let grp = licEmail ? map.get(licEmail) : null;
341
+ if (!grp && lic.ownerAccountId) {
342
+ grp = groupsList().find(g => g.userId && g.userId === lic.ownerAccountId) || null;
343
+ }
344
+ if (!grp) {
345
+ const key = licEmail || String(lic.licenseId || '').toLowerCase();
346
+ grp = {
347
+ key,
348
+ email: licEmail || lic.licenseId,
349
+ userId: lic.ownerAccountId || '',
350
+ isAdmin: false,
351
+ name: '',
352
+ position: '',
353
+ companyName: cleanOrgName(lic.customer?.name),
354
+ inn: String(lic.customer?.inn || '').trim(),
355
+ kpp: '',
356
+ phone: String(lic.customer?.phone || '').trim(),
357
+ ofertaAccepted: false,
358
+ ofertaSignatureId: '',
359
+ createdAt: Number(lic.issuedAt || 0),
360
+ lastLoginAt: Number(lic.updatedAt || lic.issuedAt || 0),
361
+ servers: [],
362
+ };
363
+ map.set(key, grp);
364
+ }
365
+ else {
366
+ if (!grp.companyName && cleanOrgName(lic.customer?.name))
367
+ grp.companyName = cleanOrgName(lic.customer?.name);
368
+ if (!grp.inn && lic.customer?.inn)
369
+ grp.inn = String(lic.customer.inn).trim();
370
+ if (!grp.phone && lic.customer?.phone)
371
+ grp.phone = String(lic.customer.phone).trim();
372
+ }
373
+ if (!grp.servers.some(s => s.licenseId === lic.licenseId)) {
374
+ grp.servers.push(lic);
375
+ }
376
+ }
377
+
378
+ const result = groupsList();
379
+ for (const g of result) {
380
+ g.servers.sort((a, b) => Number(a.issuedAt || 0) - Number(b.issuedAt || 0));
381
+ }
382
+ result.sort((a, b) => {
383
+ if (a.isAdmin !== b.isAdmin) return a.isAdmin ? -1 : 1;
384
+ return (b.lastLoginAt || b.createdAt || 0) - (a.lastLoginAt || a.createdAt || 0);
385
+ });
386
+ return result;
387
+ }
388
+
389
+ function buildFranchiserGroups(franchises = [], users = []) {
390
+ const userByEmail = new Map();
391
+ const userById = new Map();
392
+ for (const u of users) {
393
+ if (!u) continue;
394
+ if (u.email) userByEmail.set(String(u.email).trim().toLowerCase(), u);
395
+ if (u.userId) userById.set(String(u.userId), u);
396
+ }
397
+
398
+ const map = new Map();
399
+ for (const f of franchises) {
400
+ if (!f) continue;
401
+ const u = (f.email && userByEmail.get(String(f.email).trim().toLowerCase()))
402
+ || (f.ownerAccountId && userById.get(String(f.ownerAccountId)))
403
+ || null;
404
+ const email = String(f.email || u?.email || '').trim().toLowerCase();
405
+ const key = email || String(f.ownerAccountId || f.inn || f.reservationId || '').toLowerCase();
406
+ if (!key) continue;
407
+
408
+ let grp = map.get(key);
409
+ if (!grp) {
410
+ grp = {
411
+ key,
412
+ email: f.email || u?.email || key,
413
+ userId: f.ownerAccountId || u?.userId || '',
414
+ name: (u?.name && !String(u.name).includes('@')) ? String(u.name).trim() : '',
415
+ companyName: cleanOrgName(f.companyName || u?.companyName),
416
+ inn: String(f.inn || u?.inn || '').trim(),
417
+ phone: String(f.phone || u?.phone || '').trim(),
418
+ items: [],
419
+ };
420
+ map.set(key, grp);
421
+ }
422
+ else {
423
+ if (!grp.companyName && cleanOrgName(f.companyName)) grp.companyName = cleanOrgName(f.companyName);
424
+ if (!grp.inn && f.inn) grp.inn = String(f.inn).trim();
425
+ if (!grp.phone && f.phone) grp.phone = String(f.phone).trim();
426
+ }
427
+ grp.items.push(f);
428
+ }
429
+
430
+ const result = Array.from(map.values());
431
+ for (const g of result) {
432
+ g.items.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
433
+ }
434
+ result.sort((a, b) => {
435
+ const aMax = Math.max(0, ...a.items.map(x => Number(x.createdAt || 0)));
436
+ const bMax = Math.max(0, ...b.items.map(x => Number(x.createdAt || 0)));
437
+ return bMax - aMax;
438
+ });
439
+ return result;
440
+ }
441
+
299
442
  function renderAdminPanel(data = {}) {
300
443
  const licenses = data.licenses || [];
301
444
  const franchises = data.franchises || data.reservations || [];
@@ -303,101 +446,232 @@
303
446
  const tickets = data.tickets || [];
304
447
  const users = data.users || [];
305
448
 
449
+ const clientGroups = buildClientGroups(users, licenses);
306
450
  const activeFranchises = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
451
+ const activeFranchiserGroups = buildFranchiserGroups(activeFranchises, users);
307
452
  const openTickets = tickets.filter(t => t.status !== 'answered' && t.status !== 'closed');
308
453
 
309
- if ($('#navAdmServers')) $('#navAdmServers').textContent = `Серверы и лицензии (${licenses.length})`;
310
- if ($('#navAdmFranchises')) $('#navAdmFranchises').textContent = `Франшизы (${activeFranchises.length})`;
454
+ if ($('#navAdmClients')) $('#navAdmClients').textContent = `Клиенты (${clientGroups.length})`;
455
+ if ($('#navAdmFranchisers')) $('#navAdmFranchisers').textContent = `Франчайзеры (${activeFranchiserGroups.length})`;
311
456
  if ($('#navAdmOrders')) $('#navAdmOrders').textContent = `Заказы и счета (${orders.length})`;
312
- if ($('#navAdmUsers')) $('#navAdmUsers').textContent = `Клиенты и администраторы (${users.length})`;
313
457
  if ($('#navAdmTickets')) $('#navAdmTickets').textContent = `Тикеты поддержки (${openTickets.length})`;
458
+ if ($('#admDataDirLabel') && data.dataDir) {
459
+ $('#admDataDirLabel').textContent = `Папка БД: ${data.dataDir}`;
460
+ }
314
461
 
315
- renderServersTable(licenses);
316
- renderFranchisesTable(franchises);
462
+ renderClientsPanels(clientGroups);
463
+ renderFranchisersPanels(franchises, users);
317
464
  renderOrdersTable(orders);
318
- renderUsersTable(users);
319
465
  renderTicketsTable(tickets);
466
+ renderPortalSettings(data.settings || {});
320
467
  startAdminCountdowns();
321
468
  }
322
469
 
323
- /* 1. Таблица серверов и лицензий */
324
- function renderServersTable(licenses = []) {
325
- const tbody = $('#admLicensesBody');
326
- if (!tbody) return;
327
- const q = ($('#inpFilterServers')?.value || '').trim().toLowerCase();
328
- const mode = $('#selFilterServers')?.value || 'all';
329
-
330
- const filtered = licenses.filter(l => {
331
- const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
332
- if (mode === 'base_active' && !l.baseActivated) return false;
333
- if (mode === 'base_inactive' && l.baseActivated) return false;
334
- if (mode === 'hwid_bound' && !hwidBound) return false;
470
+ function renderPortalSettings(settings = {}) {
471
+ const smtp = settings.smtp || {};
472
+ const yk = settings.yookassa || {};
473
+ const bots = settings.bots || {};
474
+ const legal = settings.legal || {};
475
+
476
+ if ($('#navBadgeSmtp')) {
477
+ $('#navBadgeSmtp').textContent = smtp.connected ? 'активна' : 'не задана';
478
+ $('#navBadgeSmtp').className = smtp.connected ? 'tag ok' : 'tag';
479
+ }
480
+ if ($('#admSmtpStatusTag')) {
481
+ $('#admSmtpStatusTag').textContent = smtp.connected
482
+ ? `✓ Подключена (${smtp.mailFile || 'mail.json'})`
483
+ : 'Не настроена';
484
+ $('#admSmtpStatusTag').className = smtp.connected ? 'tag ok' : 'tag';
485
+ }
486
+ if ($('#admSmtpHost')) $('#admSmtpHost').value = smtp.host || '';
487
+ if ($('#admSmtpPort')) $('#admSmtpPort').value = smtp.port || 465;
488
+ if ($('#admSmtpSecure')) $('#admSmtpSecure').checked = smtp.secure !== false;
489
+ if ($('#admSmtpUser')) $('#admSmtpUser').value = smtp.user || '';
490
+ if ($('#admSmtpPass')) $('#admSmtpPass').value = smtp.pass || '';
491
+ if ($('#admSmtpFrom')) $('#admSmtpFrom').value = smtp.fromEmail || smtp.user || '';
492
+ if ($('#admSmtpSenderName')) $('#admSmtpSenderName').value = smtp.senderName || 'ODANT-WORK';
493
+
494
+ if ($('#admYookassaStatusTag')) {
495
+ $('#admYookassaStatusTag').textContent = yk.live ? '✓ Боевой режим (API v3)' : 'Режим песочницы';
496
+ $('#admYookassaStatusTag').className = yk.live ? 'tag ok' : 'tag';
497
+ }
498
+ if ($('#admYkShopId')) $('#admYkShopId').value = yk.shopId || '';
499
+ if ($('#admYkSecretKey')) $('#admYkSecretKey').value = yk.secretKey || '';
500
+ if ($('#admYkSendReceipt')) $('#admYkSendReceipt').checked = Boolean(yk.sendReceipt);
501
+
502
+ if ($('#admBadgeTgBot')) {
503
+ const tgName = bots.telegramUsername ? `@${bots.telegramUsername}` : (bots.telegramToken ? 'токен задан' : 'не задан');
504
+ $('#admBadgeTgBot').textContent = `TG: ${tgName}`;
505
+ $('#admBadgeTgBot').className = (bots.telegramUsername || bots.telegramToken) ? 'tag ok' : 'tag';
506
+ }
507
+ if ($('#admBadgeMaxBot')) {
508
+ const maxName = bots.maxUsername ? `@${bots.maxUsername}` : (bots.maxToken ? 'токен задан' : 'не задан');
509
+ $('#admBadgeMaxBot').textContent = `MAX: ${maxName}`;
510
+ $('#admBadgeMaxBot').className = (bots.maxUsername || bots.maxToken) ? 'tag ok' : 'tag';
511
+ }
512
+ if ($('#admBotTgToken')) $('#admBotTgToken').value = bots.telegramToken || '';
513
+ if ($('#admBotTgUsername')) $('#admBotTgUsername').value = bots.telegramUsername || '';
514
+ if ($('#admBotMaxToken')) $('#admBotMaxToken').value = bots.maxToken || '';
515
+ if ($('#admBotMaxUsername')) $('#admBotMaxUsername').value = bots.maxUsername || '';
516
+
517
+ if ($('#admLegalCompany')) $('#admLegalCompany').value = legal.companyName || '';
518
+ if ($('#admLegalAuthor')) $('#admLegalAuthor').value = legal.authorName || '';
519
+ if ($('#admLegalStatus')) $('#admLegalStatus').value = legal.legalStatus || '';
520
+ if ($('#admLegalInn')) $('#admLegalInn').value = legal.inn || '';
521
+ if ($('#admLegalOgrn')) $('#admLegalOgrn').value = legal.ogrn || '';
522
+ if ($('#admLegalEmail')) $('#admLegalEmail').value = legal.supportEmail || '';
523
+ if ($('#admLegalPhone')) $('#admLegalPhone').value = legal.phone || '';
524
+ if ($('#admLegalAddress')) $('#admLegalAddress').value = legal.address || '';
525
+ }
526
+
527
+ /* 1. Раскрывающиеся панели клиентов и вложенные серверы/лицензии */
528
+ function renderClientsPanels(clientGroups = []) {
529
+ const root = $('#admClientsList');
530
+ if (!root) return;
531
+ const q = ($('#inpFilterClients')?.value || '').trim().toLowerCase();
532
+ const mode = $('#selFilterClients')?.value || 'all';
533
+ const autoExpand = Boolean(q) || mode !== 'all';
534
+
535
+ const filtered = clientGroups.filter(c => {
536
+ if (mode === 'admins' && !c.isAdmin) return false;
537
+ if (mode === 'base_active' && !c.servers.some(l => l.baseActivated)) return false;
538
+ if (mode === 'base_inactive' && !(c.servers.length === 0 || c.servers.some(l => !l.baseActivated))) return false;
539
+ if (mode === 'hwid_bound' && !c.servers.some(l => Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint))) return false;
335
540
  if (!q) return true;
336
- const hay = [
337
- l.licenseId, l.serverName, l.ownerEmail, l.customer?.name,
338
- l.customer?.inn, l.customer?.email, l.hardware?.hwid, l.hardware?.nodeFingerprint,
339
- ].join(' ').toLowerCase();
340
- return hay.includes(q);
541
+
542
+ const clientHay = [c.email, c.userId, c.name, c.companyName, c.inn, c.kpp, c.phone].join(' ').toLowerCase();
543
+ if (clientHay.includes(q)) return true;
544
+
545
+ return c.servers.some(l => [
546
+ l.licenseId, l.serverName, l.hardware?.hwid, l.hardware?.nodeFingerprint,
547
+ ].join(' ').toLowerCase().includes(q));
341
548
  });
342
549
 
343
- tbody.innerHTML = filtered.map(l => {
344
- const lid = l.licenseId;
345
- const srvName = l.serverName || 'Сервер WORK';
346
- const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
347
- const hwid = l.hardware?.hwid || '—';
348
- const fp = l.hardware?.nodeFingerprint || '';
349
- const ownerEmail = l.ownerEmail || l.customer?.email || '—';
350
-
351
- const baseHtml = l.baseActivated
352
- ? `<span class="tag ok">✓ Активна (${l.baseSeats || 10} вечн. + ИИ)</span>
353
- <div style="margin-top:4px"><button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px;color:var(--danger)" data-adm-base="${esc(lid)}" data-activate="0">Отозвать базовую</button></div>`
354
- : `<span class="tag">Не активирована (1 сессия)</span>
355
- <div style="margin-top:4px"><button class="btn btn-ok btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-base="${esc(lid)}" data-activate="1">Активировать базовую</button></div>`;
356
-
357
- const hwidHtml = hwidBound
358
- ? `<span class="tag ok">Привязан</span> <code>${esc(hwid)}</code>
359
- ${fp ? `<div class="small muted">Узел: <code>${esc(fp.slice(0, 18))}…</code></div>` : ''}
360
- <div style="margin-top:4px"><button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-reset-hwid="${esc(lid)}">Сбросить привязку</button></div>`
361
- : `<span class="small muted">Ожидает привязки токена</span><div class="small muted"><code>${esc(hwid)}</code></div>`;
550
+ if (!filtered.length) {
551
+ root.innerHTML = '<div class="sec-box muted">Клиентов по заданному фильтру не найдено</div>';
552
+ return;
553
+ }
554
+
555
+ root.innerHTML = filtered.map(c => {
556
+ const isOpen = autoExpand || expandedClients.has(c.key);
557
+ const activeBaseCount = c.servers.filter(s => s.baseActivated).length;
558
+ const totalSeats = c.servers.reduce((sum, s) => sum + Number(s.totalSeats || 0), 0);
559
+ const addonSeatsTotal = c.servers.reduce((sum, s) => sum + Number(s.addonSeats || 0), 0);
560
+
561
+ const subParts = [
562
+ c.name ? `${c.name}${c.position ? ` (${c.position})` : ''}` : '',
563
+ c.companyName,
564
+ c.inn ? `ИНН ${c.inn}` : '',
565
+ ].filter(Boolean);
566
+
567
+ const metaItems = [];
568
+ if (c.userId) metaItems.push(`<span>ID: <code>${esc(c.userId)}</code></span>`);
569
+ if (c.name) metaItems.push(`<span>Физ. лицо: <b>${esc(c.name)}</b>${c.position ? ` (${esc(c.position)})` : ''}</span>`);
570
+ if (c.companyName || c.inn) {
571
+ metaItems.push(`<span>Юр. лицо: <b>${esc(c.companyName || '—')}</b>${c.inn ? ` · ИНН <code>${esc(c.inn)}</code>` : ''}${c.kpp ? ` · КПП <code>${esc(c.kpp)}</code>` : ''}</span>`);
572
+ }
573
+ if (c.phone) metaItems.push(`<span>Тел.: <code>${esc(c.phone)}</code></span>`);
574
+ metaItems.push(`<span>Оферта: ${c.ofertaAccepted ? `<b style="color:var(--ok)">✓ ПЭП</b> (<code>${esc(c.ofertaSignatureId)}</code>)` : 'не подписана'}</span>`);
575
+ if (c.lastLoginAt) metaItems.push(`<span>Вход: ${fmtDateTime(c.lastLoginAt)}</span>`);
576
+
577
+ const serversRowsHtml = c.servers.map(l => {
578
+ const lid = l.licenseId;
579
+ const srvName = l.serverName || 'Сервер WORK';
580
+ const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
581
+ const hwid = l.hardware?.hwid || '—';
582
+ const fp = l.hardware?.nodeFingerprint || '';
583
+
584
+ const baseHtml = l.baseActivated
585
+ ? `<span class="tag ok">✓ Активна (${l.baseSeats || 10} вечн. + ИИ)</span>
586
+ <div style="margin-top:4px"><button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px;color:var(--danger)" data-adm-base="${esc(lid)}" data-activate="0">Отозвать базовую</button></div>`
587
+ : `<span class="tag">Не активирована (1 сессия)</span>
588
+ <div style="margin-top:4px"><button class="btn btn-ok btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-base="${esc(lid)}" data-activate="1">Активировать базовую</button></div>`;
589
+
590
+ const hwidHtml = hwidBound
591
+ ? `<span class="tag ok">Привязан</span> <code>${esc(hwid)}</code>
592
+ ${fp ? `<div class="small muted">Узел: <code>${esc(fp.slice(0, 18))}…</code></div>` : ''}
593
+ <div style="margin-top:4px"><button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-reset-hwid="${esc(lid)}">Сбросить привязку</button></div>`
594
+ : `<span class="small muted">Ожидает привязки токена</span><div class="small muted"><code>${esc(hwid)}</code></div>`;
595
+
596
+ return `<tr>
597
+ <td>
598
+ <b>${esc(srvName)}</b>
599
+ <div class="small muted"><code>${esc(lid)}</code> · создан ${fmtDate(l.issuedAt)}</div>
600
+ <div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap">
601
+ <button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-copy-tok="${esc(lid)}">Скопировать токен owk1</button>
602
+ </div>
603
+ </td>
604
+ <td>${hwidHtml}</td>
605
+ <td>${baseHtml}</td>
606
+ <td>
607
+ <div>Всего сессий: <b>${l.totalSeats}</b> (+<b>${l.addonSeats || 0}</b> доп.)</div>
608
+ <div class="small muted">SLA: ${esc(l.support?.tierName || 'Enterprise')} (пакетов: ${(l.activeAddons || []).length})</div>
609
+ <div style="margin-top:4px">
610
+ <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>
611
+ </div>
612
+ </td>
613
+ <td style="text-align:right">
614
+ <button class="btn btn-primary btn-sm" style="padding:6px 10px;font-size:12px" data-cert="${esc(lid)}">.lic.json (${(l.licenseFiles || []).length})</button>
615
+ </td>
616
+ </tr>`;
617
+ }).join('');
362
618
 
363
- return `<tr>
364
- <td>
365
- <b>${esc(srvName)}</b>
366
- <div class="small muted"><code>${esc(lid)}</code> · создан ${fmtDate(l.issuedAt)}</div>
367
- <div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap">
368
- <button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-copy-tok="${esc(lid)}">Скопировать токен owk1</button>
619
+ const serversTableHtml = c.servers.length
620
+ ? `<table class="sec-table">
621
+ <thead><tr>
622
+ <th>Сервер / ID</th>
623
+ <th>Железо (HWID)</th>
624
+ <th>Базовая лицензия</th>
625
+ <th>Доп. места и SLA</th>
626
+ <th style="text-align:right">Файлы</th>
627
+ </tr></thead>
628
+ <tbody>${serversRowsHtml}</tbody>
629
+ </table>`
630
+ : `<div class="small muted" style="padding:8px 4px">У клиента пока нет серверов. Нажмите «+ Добавить сервер», чтобы выпустить лицензию.</div>`;
631
+
632
+ return `<div class="adm-acc-card${isOpen ? ' open' : ''}" data-client-card="${esc(c.key)}">
633
+ <div class="adm-acc-head" data-adm-toggle-client="${esc(c.key)}">
634
+ <div class="adm-acc-ident">
635
+ <span class="adm-acc-chev">▸</span>
636
+ <div>
637
+ <div class="adm-acc-email">${esc(c.email)}</div>
638
+ ${subParts.length ? `<div class="adm-acc-sub">${esc(subParts.join(' · '))}</div>` : ''}
639
+ </div>
369
640
  </div>
370
- </td>
371
- <td>
372
- <b>${esc(l.customer?.name || '—')}</b>
373
- <div class="small muted">ИНН ${esc(l.customer?.inn || '—')}</div>
374
- <div class="small"><code>${esc(ownerEmail)}</code></div>
375
- </td>
376
- <td>${hwidHtml}</td>
377
- <td>${baseHtml}</td>
378
- <td>
379
- <div>Всего сессий: <b>${l.totalSeats}</b> (+<b>${l.addonSeats || 0}</b> доп.)</div>
380
- <div class="small muted">SLA: ${esc(l.support?.tierName || 'Enterprise')} (пакетов: ${(l.activeAddons || []).length})</div>
381
- <div style="margin-top:4px">
382
- <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>
641
+ <div class="adm-acc-badges">
642
+ ${c.isAdmin ? '<span class="tag ok">Администратор</span>' : ''}
643
+ ${c.ofertaAccepted ? `<span class="tag ok" title="ПЭП: ${esc(c.ofertaSignatureId)}">✓ ПЭП</span>` : ''}
644
+ <span class="tag">Серверов: <b>${c.servers.length}</b></span>
645
+ <span class="tag ${activeBaseCount > 0 ? 'ok' : ''}">Сессий: <b>${totalSeats}</b>${addonSeatsTotal > 0 ? ` (+${addonSeatsTotal})` : ''}</span>
646
+ <button type="button" class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-adm-add-srv-email="${esc(c.email)}" data-adm-next-num="${c.servers.length + 1}">+ Сервер</button>
383
647
  </div>
384
- </td>
385
- <td style="text-align:right">
386
- <button class="btn btn-primary btn-sm" style="padding:6px 10px;font-size:12px" data-cert="${esc(lid)}">.lic.json (${(l.licenseFiles || []).length})</button>
387
- </td>
388
- </tr>`;
389
- }).join('') || '<tr><td colspan="6" class="muted">Серверов по заданному фильтру не найдено</td></tr>';
648
+ </div>
649
+ <div class="adm-acc-body">
650
+ <div class="adm-profile-strip">
651
+ <div class="adm-profile-meta">${metaItems.join('')}</div>
652
+ <div class="adm-profile-actions">
653
+ ${c.isAdmin
654
+ ? `<button type="button" class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-toggle-admin="${esc(c.userId || c.email)}" data-make-admin="0">Снять права админа</button>`
655
+ : `<button type="button" class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-toggle-admin="${esc(c.userId || c.email)}" data-make-admin="1">Сделать администратором</button>`}
656
+ <button type="button" class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-adm-add-srv-email="${esc(c.email)}" data-adm-next-num="${c.servers.length + 1}">+ Добавить сервер</button>
657
+ </div>
658
+ </div>
659
+ ${serversTableHtml}
660
+ </div>
661
+ </div>`;
662
+ }).join('');
390
663
  }
391
664
 
392
- /* 2. Таблица франшиз и 12-месячных графиков погашения */
393
- function renderFranchisesTable(franchises = []) {
394
- const tbody = $('#admFranchisesBody');
395
- if (!tbody) return;
396
- const q = ($('#inpFilterFranchises')?.value || '').trim().toLowerCase();
397
- const mode = $('#selFilterFranchises')?.value || 'all';
665
+ /* 2. Раскрывающиеся панели франчайзеров и вложенные регионы со статусами */
666
+ function renderFranchisersPanels(franchises = [], users = []) {
667
+ const root = $('#admFranchisersList');
668
+ if (!root) return;
669
+ const q = ($('#inpFilterFranchisers')?.value || '').trim().toLowerCase();
670
+ const mode = $('#selFilterFranchisers')?.value || 'all';
671
+ const autoExpand = Boolean(q) || mode !== 'all';
398
672
  const now = Date.now();
399
673
 
400
- const filtered = franchises.filter(f => {
674
+ const filteredFranchises = franchises.filter(f => {
401
675
  if (!f) return false;
402
676
  const isClosed = f.status === 'canceled' || f.status === 'expired';
403
677
  const remaining = Number(f.remainingFranchiseFeeRub ?? 0);
@@ -410,74 +684,147 @@
410
684
  if (!q) return true;
411
685
  const hay = [
412
686
  f.reservationId, f.regionName, f.regionCode, f.district,
413
- f.companyName, f.inn, f.email,
687
+ f.companyName, f.inn, f.email, f.phone,
414
688
  ].join(' ').toLowerCase();
415
689
  return hay.includes(q);
416
690
  });
417
691
 
418
- tbody.innerHTML = filtered.map(f => {
419
- const rid = f.reservationId;
420
- const isClosed = f.status === 'canceled' || f.status === 'expired';
421
- const depositRub = Number(f.nonRefundableDepositRub || 100_000);
422
- const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
423
- const paidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
424
- const remRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - paidRub));
425
- const paidMonths = Number(f.paidMonthsCount || 0);
426
- const overdueMonths = Number(f.overdueMonthsCount || 0);
427
- const pct = totalFeeRub > 0 ? Math.min(100, Math.round((paidRub / totalFeeRub) * 100)) : 0;
428
-
429
- const dl = Number(f.depositDeadlineAt || ((f.createdAt || now) + 15 * 60_000));
430
- const remSec = Math.max(0, Math.floor((dl - now) / 1000));
692
+ const groups = buildFranchiserGroups(filteredFranchises, users);
693
+ if (!groups.length) {
694
+ root.innerHTML = '<div class="sec-box muted">Франчайзеров по заданному фильтру не найдено</div>';
695
+ return;
696
+ }
431
697
 
432
- let depositHtml = '';
433
- if (isClosed) {
434
- depositHtml = `<span class="tag bad">${f.status === 'expired' ? 'Истёк (15 мин)' : 'Отменён'}</span>`;
435
- }
436
- else if (f.depositPaid) {
437
- depositHtml = `<span class="tag ok">✓ Оплачен (${rub(depositRub)})</span><div class="small muted">${fmtDateTime(f.paidAt)}</div>`;
438
- }
439
- else {
440
- depositHtml = `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Таймер 15 мин: <b data-adm-deadline="${dl}">${fmtCountdown(remSec)}</b></span>
441
- <div style="margin-top:4px"><button class="btn btn-ok btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-pay-deposit="${esc(rid)}">Зачесть залог (${rub(depositRub)})</button></div>`;
698
+ root.innerHTML = groups.map(fg => {
699
+ const isOpen = autoExpand || expandedFranchisers.has(fg.key);
700
+ let totalPaidGroup = 0;
701
+ let totalRemGroup = 0;
702
+ let pendingDepCount = 0;
703
+ let overdueRegionsCount = 0;
704
+ let activeSchedCount = 0;
705
+ let paidFullCount = 0;
706
+
707
+ for (const f of fg.items) {
708
+ const isClosed = f.status === 'canceled' || f.status === 'expired';
709
+ const depositRub = Number(f.nonRefundableDepositRub || 100_000);
710
+ const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
711
+ const paidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
712
+ const remRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - paidRub));
713
+ if (!isClosed) {
714
+ totalPaidGroup += paidRub;
715
+ totalRemGroup += remRub;
716
+ if (!f.depositPaid) pendingDepCount++;
717
+ else if (remRub <= 0) paidFullCount++;
718
+ else activeSchedCount++;
719
+ if (Number(f.overdueMonthsCount || 0) > 0) overdueRegionsCount++;
720
+ }
442
721
  }
443
722
 
444
- let schedHtml = '';
445
- if (isClosed) {
446
- schedHtml = '<span class="small muted">Резерв закрыт</span>';
447
- }
448
- else {
449
- const overdueBadge = overdueMonths > 0
450
- ? ` <span class="tag bad">Просрочено: ${overdueMonths}</span>`
451
- : (f.depositPaid && remRub <= 0 ? ' <span class="tag ok">Погашен полностью</span>' : '');
452
- schedHtml = `<div><b>${paidMonths} из 12</b> ежемесячных платежей${overdueBadge}</div>
453
- <div class="bar" style="width:160px;height:6px;margin-top:5px"><i style="--p:${pct}%"></i></div>
454
- ${f.nextInstallment && f.depositPaid ? `<div class="small muted" style="margin-top:4px">След. взнос №${f.nextInstallment.monthNumber}: ${rub(f.nextInstallment.amountRub)} до ${fmtDate(f.nextInstallment.dueDate)}</div>` : ''}`;
455
- }
723
+ const subParts = [
724
+ fg.name,
725
+ fg.companyName,
726
+ fg.inn ? `ИНН ${fg.inn}` : '',
727
+ fg.phone,
728
+ ].filter(Boolean);
729
+
730
+ const regionsRowsHtml = fg.items.map(f => {
731
+ const rid = f.reservationId;
732
+ const isClosed = f.status === 'canceled' || f.status === 'expired';
733
+ const depositRub = Number(f.nonRefundableDepositRub || 100_000);
734
+ const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
735
+ const paidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
736
+ const remRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - paidRub));
737
+ const paidMonths = Number(f.paidMonthsCount || 0);
738
+ const overdueMonths = Number(f.overdueMonthsCount || 0);
739
+ const pct = totalFeeRub > 0 ? Math.min(100, Math.round((paidRub / totalFeeRub) * 100)) : 0;
740
+
741
+ const dl = Number(f.depositDeadlineAt || ((f.createdAt || now) + 15 * 60_000));
742
+ const remSec = Math.max(0, Math.floor((dl - now) / 1000));
743
+
744
+ let statusAndDepositHtml = '';
745
+ if (isClosed) {
746
+ statusAndDepositHtml = `<span class="tag bad">${f.status === 'expired' ? 'Истёк таймер залога (15 мин)' : 'Отменён / Отозван'}</span>`;
747
+ }
748
+ else if (!f.depositPaid) {
749
+ statusAndDepositHtml = `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Ожидает залога · <b data-adm-deadline="${dl}">${fmtCountdown(remSec)}</b></span>
750
+ <div style="margin-top:4px"><button class="btn btn-ok btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-pay-deposit="${esc(rid)}">Зачесть залог (${rub(depositRub)})</button></div>`;
751
+ }
752
+ else if (remRub <= 0) {
753
+ statusAndDepositHtml = `<span class="tag ok">✓ Франшиза полностью выкуплена</span>
754
+ <div class="small muted">Залог ${rub(depositRub)} от ${fmtDateTime(f.paidAt)}</div>`;
755
+ }
756
+ else if (overdueMonths > 0) {
757
+ statusAndDepositHtml = `<span class="tag bad">Просрочка (${overdueMonths} мес.)</span>
758
+ <div class="small muted">Залог ✓ (${rub(depositRub)}) · от ${fmtDateTime(f.paidAt)}</div>`;
759
+ }
760
+ else {
761
+ statusAndDepositHtml = `<span class="tag ok">Активна · Погашение по графику</span>
762
+ <div class="small muted">Залог ✓ (${rub(depositRub)}) · от ${fmtDateTime(f.paidAt)}</div>`;
763
+ }
456
764
 
457
- return `<tr>
458
- <td>
459
- <b>${esc(f.regionName)}</b> <span class="tag">${esc(f.district || f.regionCode)}</span>
460
- <div class="small muted"><code>${esc(rid)}</code> · от ${fmtDateTime(f.createdAt)}</div>
461
- </td>
462
- <td>
463
- <b>${esc(f.companyName || '—')}</b>
464
- <div class="small muted">ИНН ${esc(f.inn || '—')} · <code>${esc(f.email || '—')}</code></div>
465
- </td>
466
- <td>${depositHtml}</td>
467
- <td>${schedHtml}</td>
468
- <td>
469
- <div>Паушальный: <b>${rub(totalFeeRub)}</b></div>
470
- <div class="small" style="color:var(--ok)">Оплачено: <b>${rub(paidRub)}</b></div>
471
- <div class="small muted">Остаток: <b>${rub(remRub)}</b></div>
472
- </td>
473
- <td>
474
- <div style="display:flex;gap:6px;flex-wrap:wrap">
475
- <button class="btn btn-primary btn-sm" data-adm-sched="${esc(rid)}">График 12 мес.</button>
476
- ${!isClosed ? `<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-adm-cancel-franchise="${esc(rid)}">Отозвать</button>` : ''}
765
+ let schedHtml = '';
766
+ if (isClosed) {
767
+ schedHtml = '<span class="small muted">Резерв закрыт</span>';
768
+ }
769
+ else {
770
+ schedHtml = `<div><b>${paidMonths} из 12</b> ежемесячных платежей</div>
771
+ <div class="bar" style="width:160px;height:6px;margin-top:5px"><i style="--p:${pct}%"></i></div>
772
+ ${f.nextInstallment && f.depositPaid ? `<div class="small muted" style="margin-top:4px">След. взнос №${f.nextInstallment.monthNumber}: ${rub(f.nextInstallment.amountRub)} до ${fmtDate(f.nextInstallment.dueDate)}</div>` : ''}`;
773
+ }
774
+
775
+ return `<tr>
776
+ <td>
777
+ <b>${esc(f.regionName)}</b> <span class="tag">${esc(f.district || f.regionCode)}</span>
778
+ <div class="small muted"><code>${esc(rid)}</code> · от ${fmtDateTime(f.createdAt)}</div>
779
+ </td>
780
+ <td>${statusAndDepositHtml}</td>
781
+ <td>${schedHtml}</td>
782
+ <td>
783
+ <div>Паушальный: <b>${rub(totalFeeRub)}</b></div>
784
+ <div class="small" style="color:var(--ok)">Оплачено: <b>${rub(paidRub)}</b></div>
785
+ <div class="small muted">Остаток: <b>${rub(remRub)}</b></div>
786
+ </td>
787
+ <td style="text-align:right">
788
+ <div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">
789
+ <button class="btn btn-primary btn-sm" data-adm-sched="${esc(rid)}">График 12 мес.</button>
790
+ ${!isClosed ? `<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-adm-cancel-franchise="${esc(rid)}">Отозвать</button>` : ''}
791
+ </div>
792
+ </td>
793
+ </tr>`;
794
+ }).join('');
795
+
796
+ return `<div class="adm-acc-card${isOpen ? ' open' : ''}" data-franchiser-card="${esc(fg.key)}">
797
+ <div class="adm-acc-head" data-adm-toggle-franchiser="${esc(fg.key)}">
798
+ <div class="adm-acc-ident">
799
+ <span class="adm-acc-chev">▸</span>
800
+ <div>
801
+ <div class="adm-acc-email">${esc(fg.email)}</div>
802
+ ${subParts.length ? `<div class="adm-acc-sub">${esc(subParts.join(' · '))}</div>` : ''}
803
+ </div>
477
804
  </div>
478
- </td>
479
- </tr>`;
480
- }).join('') || '<tr><td colspan="6" class="muted">Франшиз по заданному фильтру не найдено</td></tr>';
805
+ <div class="adm-acc-badges">
806
+ <span class="tag">Регионов: <b>${fg.items.length}</b></span>
807
+ ${pendingDepCount > 0 ? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Ждёт залога: ${pendingDepCount}</span>` : ''}
808
+ ${overdueRegionsCount > 0 ? `<span class="tag bad">Просрочка: ${overdueRegionsCount}</span>` : ''}
809
+ ${activeSchedCount > 0 && overdueRegionsCount === 0 ? `<span class="tag ok">В графике: ${activeSchedCount}</span>` : ''}
810
+ ${paidFullCount > 0 ? `<span class="tag ok">✓ Выкуплено: ${paidFullCount}</span>` : ''}
811
+ <span class="small muted">Оплачено: <b style="color:var(--ok)">${rub(totalPaidGroup)}</b> · Остаток: <b>${rub(totalRemGroup)}</b></span>
812
+ </div>
813
+ </div>
814
+ <div class="adm-acc-body">
815
+ <table class="sec-table">
816
+ <thead><tr>
817
+ <th>Регион РФ / ID</th>
818
+ <th>Текущий статус и залог</th>
819
+ <th>График погашения (12 мес.)</th>
820
+ <th>Оплачено / Остаток</th>
821
+ <th style="text-align:right">Управление</th>
822
+ </tr></thead>
823
+ <tbody>${regionsRowsHtml}</tbody>
824
+ </table>
825
+ </div>
826
+ </div>`;
827
+ }).join('');
481
828
  }
482
829
 
483
830
  /* 3. Таблица заказов и счетов */
@@ -543,41 +890,7 @@
543
890
  }).join('') || '<tr><td colspan="6" class="muted">Заказов по заданному фильтру не найдено</td></tr>';
544
891
  }
545
892
 
546
- /* 4. Таблица клиентов и администраторов */
547
- function renderUsersTable(users = []) {
548
- const tbody = $('#admUsersBody');
549
- if (!tbody) return;
550
- const q = ($('#inpFilterUsers')?.value || '').trim().toLowerCase();
551
-
552
- const filtered = users.filter(u => {
553
- if (!q) return true;
554
- const hay = [u.userId, u.email, u.companyName, u.inn, u.name].join(' ').toLowerCase();
555
- return hay.includes(q);
556
- });
557
-
558
- tbody.innerHTML = filtered.map(u => {
559
- const srvCount = Array.isArray(u.servers) ? u.servers.length : (u.licenseId ? 1 : 0);
560
- const frCount = Array.isArray(u.franchises) ? u.franchises.length : 0;
561
- return `<tr>
562
- <td><b>${esc(u.email)}</b><div class="small muted"><code>${esc(u.userId)}</code></div></td>
563
- <td>
564
- ${u.isAdmin
565
- ? `<span class="tag ok">Администратор</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="0">Снять права</button>`
566
- : `<span class="tag">Клиент</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="1">Сделать администратором</button>`}
567
- </td>
568
- <td><b>${esc(u.companyName || '—')}</b><div class="small muted">ИНН ${esc(u.inn || '—')}</div></td>
569
- <td>Серверов: <b>${srvCount}</b> · Франшиз: <b>${frCount}</b></td>
570
- <td>
571
- ${u.ofertaAccepted
572
- ? `<span class="tag ok">✓ Подписана ПЭП</span><div class="small muted"><code>${esc(u.ofertaSignatureId)}</code></div>`
573
- : '<span class="tag bad">Не подписана</span>'}
574
- </td>
575
- <td class="small muted">${fmtDateTime(u.lastLoginAt || u.createdAt)}</td>
576
- </tr>`;
577
- }).join('') || '<tr><td colspan="6" class="muted">Клиентов не найдено</td></tr>';
578
- }
579
-
580
- /* 5. Таблица тикетов SLA */
893
+ /* 4. Таблица тикетов SLA */
581
894
  function renderTicketsTable(tickets = []) {
582
895
  const tbody = $('#admTicketsBody');
583
896
  if (!tbody) return;
@@ -668,17 +981,28 @@
668
981
  }
669
982
 
670
983
  /* ================= Действия администратора ================= */
984
+ function openCreateServerModal(prefillEmail = '', nextSrvNum = 1) {
985
+ if ($('#admCreateServerStatus')) $('#admCreateServerStatus').textContent = '';
986
+ if ($('#admNewSrvEmail')) $('#admNewSrvEmail').value = prefillEmail || '';
987
+ if ($('#admNewSrvName')) $('#admNewSrvName').value = `Сервер WORK #${Math.max(1, Number(nextSrvNum) || 1)}`;
988
+ if ($('#admNewSrvHwid')) $('#admNewSrvHwid').value = '';
989
+ if ($('#admNewSrvActivateBase')) $('#admNewSrvActivateBase').checked = true;
990
+ $('#admCreateServerModal')?.classList.add('open');
991
+ setTimeout(() => {
992
+ if (prefillEmail) $('#admNewSrvName')?.focus();
993
+ else $('#admNewSrvEmail')?.focus();
994
+ }, 50);
995
+ }
996
+
671
997
  async function adminSubmitCreateServer() {
672
998
  const st = $('#admCreateServerStatus');
673
999
  if (st) st.textContent = '';
674
1000
  const serverName = $('#admNewSrvName')?.value.trim() || 'Сервер WORK #1';
675
- const org = $('#admNewSrvOrg')?.value.trim() || '';
676
- const inn = $('#admNewSrvInn')?.value.trim() || '';
677
1001
  const email = $('#admNewSrvEmail')?.value.trim() || '';
678
1002
  const hwid = $('#admNewSrvHwid')?.value.trim() || '';
679
1003
  const baseActivated = Boolean($('#admNewSrvActivateBase')?.checked);
680
1004
  if (!email || !email.includes('@')) {
681
- if (st) st.textContent = 'Укажите корректный Email владельца сервера.';
1005
+ if (st) st.textContent = 'Укажите корректный Email клиента.';
682
1006
  return;
683
1007
  }
684
1008
  try {
@@ -688,11 +1012,10 @@
688
1012
  baseActivated,
689
1013
  ownerEmail: email,
690
1014
  customer: {
691
- name: org || email,
692
- inn,
693
1015
  email,
694
1016
  },
695
1017
  });
1018
+ expandedClients.add(email.toLowerCase());
696
1019
  $('#admCreateServerModal')?.classList.remove('open');
697
1020
  await refreshRegistry();
698
1021
  }
@@ -870,13 +1193,198 @@
870
1193
  URL.revokeObjectURL(url);
871
1194
  }
872
1195
 
1196
+ async function exportAdminBackup() {
1197
+ try {
1198
+ const bundle = await api('/api/admin/backup/export');
1199
+ const json = JSON.stringify(bundle, null, 2);
1200
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
1201
+ const url = URL.createObjectURL(blob);
1202
+ const a = document.createElement('a');
1203
+ const dayStr = new Date().toISOString().slice(0, 10);
1204
+ a.href = url;
1205
+ a.download = `odant-work-license-backup-${dayStr}.json`;
1206
+ document.body.appendChild(a);
1207
+ a.click();
1208
+ a.remove();
1209
+ URL.revokeObjectURL(url);
1210
+ }
1211
+ catch (err) {
1212
+ alert(err.message);
1213
+ }
1214
+ }
1215
+
1216
+ async function importAdminBackupFile(file) {
1217
+ if (!file) return;
1218
+ try {
1219
+ const text = await file.text();
1220
+ const parsed = JSON.parse(text);
1221
+ const res = await api('/api/admin/backup/import', {
1222
+ backup: parsed,
1223
+ mode: 'merge',
1224
+ });
1225
+ await refreshRegistry();
1226
+ const c = res.counts || {};
1227
+ alert(`Резервная копия успешно объединена с базой!\nПользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
1228
+ }
1229
+ catch (err) {
1230
+ alert('Ошибка импорта резервной копии: ' + (err.message || err));
1231
+ }
1232
+ }
1233
+
1234
+ async function recoverAdminFromNpx() {
1235
+ try {
1236
+ const res = await api('/api/admin/backup/recover', {});
1237
+ await refreshRegistry();
1238
+ const c = res.counts || {};
1239
+ const srcCount = Array.isArray(res.mergedSources) ? res.mergedSources.length : 0;
1240
+ alert(`Поиск по кэшу npx и системным папкам завершён (найдено источников: ${srcCount}).\nВ базе сейчас — пользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
1241
+ }
1242
+ catch (err) {
1243
+ alert(err.message);
1244
+ }
1245
+ }
1246
+
1247
+ function showFeedback(elId, msg, isErr = false) {
1248
+ const el = document.getElementById(elId);
1249
+ if (!el) return;
1250
+ el.textContent = msg;
1251
+ el.style.color = isErr ? 'var(--danger)' : 'var(--ok)';
1252
+ setTimeout(() => {
1253
+ if (el.textContent === msg) el.textContent = '';
1254
+ }, 4500);
1255
+ }
1256
+
1257
+ function applySmtpPreset(preset = '') {
1258
+ const map = {
1259
+ yandex: { host: 'smtp.yandex.ru', port: 465, secure: true },
1260
+ gmail: { host: 'smtp.gmail.com', port: 465, secure: true },
1261
+ mailru: { host: 'smtp.mail.ru', port: 465, secure: true },
1262
+ };
1263
+ const p = map[preset];
1264
+ if (!p) return;
1265
+ if ($('#admSmtpHost')) $('#admSmtpHost').value = p.host;
1266
+ if ($('#admSmtpPort')) $('#admSmtpPort').value = p.port;
1267
+ if ($('#admSmtpSecure')) $('#admSmtpSecure').checked = p.secure;
1268
+ }
1269
+
1270
+ async function saveSmtpSettings() {
1271
+ try {
1272
+ const res = await api('/api/admin/settings/smtp', {
1273
+ host: $('#admSmtpHost')?.value.trim() || '',
1274
+ port: Number($('#admSmtpPort')?.value || 465),
1275
+ secure: Boolean($('#admSmtpSecure')?.checked),
1276
+ user: $('#admSmtpUser')?.value.trim() || '',
1277
+ pass: $('#admSmtpPass')?.value || '',
1278
+ fromEmail: $('#admSmtpFrom')?.value.trim() || '',
1279
+ senderName: $('#admSmtpSenderName')?.value.trim() || 'ODANT-WORK',
1280
+ });
1281
+ if (lastAdminData && res.settings) lastAdminData.settings = res.settings;
1282
+ renderPortalSettings(res.settings || {});
1283
+ showFeedback('admSmtpFeedback', '✓ Настройки SMTP сохранены');
1284
+ }
1285
+ catch (err) {
1286
+ showFeedback('admSmtpFeedback', err.message, true);
1287
+ }
1288
+ }
1289
+
1290
+ async function testSmtpSettings() {
1291
+ try {
1292
+ await saveSmtpSettings();
1293
+ const to = $('#admProfileEmail')?.textContent?.includes('@')
1294
+ ? $('#admProfileEmail').textContent.trim()
1295
+ : ($('#admSmtpFrom')?.value.trim() || $('#admSmtpUser')?.value.trim() || '');
1296
+ const res = await api('/api/admin/settings/smtp/test', { to });
1297
+ showFeedback('admSmtpFeedback', `✓ ${res.message || 'Тестовое письмо отправлено'}`);
1298
+ }
1299
+ catch (err) {
1300
+ showFeedback('admSmtpFeedback', err.message, true);
1301
+ }
1302
+ }
1303
+
1304
+ async function saveYooKassaSettings() {
1305
+ try {
1306
+ const res = await api('/api/admin/settings/yookassa', {
1307
+ shopId: $('#admYkShopId')?.value.trim() || '',
1308
+ secretKey: $('#admYkSecretKey')?.value.trim() || '',
1309
+ sendReceipt: Boolean($('#admYkSendReceipt')?.checked),
1310
+ });
1311
+ if (lastAdminData && res.settings) lastAdminData.settings = res.settings;
1312
+ renderPortalSettings(res.settings || {});
1313
+ showFeedback('admYkFeedback', '✓ Настройки ЮKassa сохранены');
1314
+ }
1315
+ catch (err) {
1316
+ showFeedback('admYkFeedback', err.message, true);
1317
+ }
1318
+ }
1319
+
1320
+ async function saveBotsSettings() {
1321
+ try {
1322
+ const res = await api('/api/admin/settings/bots', {
1323
+ telegramToken: $('#admBotTgToken')?.value.trim() || '',
1324
+ telegramUsername: $('#admBotTgUsername')?.value.trim() || '',
1325
+ maxToken: $('#admBotMaxToken')?.value.trim() || '',
1326
+ maxUsername: $('#admBotMaxUsername')?.value.trim() || '',
1327
+ });
1328
+ if (lastAdminData && res.settings) lastAdminData.settings = res.settings;
1329
+ renderPortalSettings(res.settings || {});
1330
+ showFeedback('admBotsFeedback', '✓ Боты платформы сохранены');
1331
+ }
1332
+ catch (err) {
1333
+ showFeedback('admBotsFeedback', err.message, true);
1334
+ }
1335
+ }
1336
+
1337
+ async function saveLegalSettings() {
1338
+ try {
1339
+ const res = await api('/api/admin/settings/legal', {
1340
+ companyName: $('#admLegalCompany')?.value.trim() || '',
1341
+ authorName: $('#admLegalAuthor')?.value.trim() || '',
1342
+ legalStatus: $('#admLegalStatus')?.value.trim() || '',
1343
+ inn: $('#admLegalInn')?.value.trim() || '',
1344
+ ogrn: $('#admLegalOgrn')?.value.trim() || '',
1345
+ supportEmail: $('#admLegalEmail')?.value.trim() || '',
1346
+ phone: $('#admLegalPhone')?.value.trim() || '',
1347
+ address: $('#admLegalAddress')?.value.trim() || '',
1348
+ });
1349
+ if (lastAdminData && res.settings) lastAdminData.settings = res.settings;
1350
+ renderPortalSettings(res.settings || {});
1351
+ showFeedback('admLegalFeedback', '✓ Юридические реквизиты оферты сохранены');
1352
+ }
1353
+ catch (err) {
1354
+ showFeedback('admLegalFeedback', err.message, true);
1355
+ }
1356
+ }
1357
+
873
1358
  /* ================= Привязка событий ================= */
874
1359
  function bind() {
875
1360
  document.addEventListener('click', (e) => {
876
- const el = e.target.closest('[data-action],[data-adm-tab],[data-cert],[data-adm-dl-licfile],[data-adm-base],[data-adm-reset-hwid],[data-adm-open-grant],[data-adm-copy-tok],[data-adm-sched],[data-adm-pay-deposit],[data-adm-confirm-inst],[data-adm-cancel-franchise],[data-pay-order],[data-cancel-order],[data-reply-ticket],[data-close-ticket],[data-toggle-admin]');
1361
+ const el = e.target.closest('[data-action],[data-adm-tab],[data-smtp-preset],[data-adm-add-srv-email],[data-adm-toggle-client],[data-adm-toggle-franchiser],[data-cert],[data-adm-dl-licfile],[data-adm-base],[data-adm-reset-hwid],[data-adm-open-grant],[data-adm-copy-tok],[data-adm-sched],[data-adm-pay-deposit],[data-adm-confirm-inst],[data-adm-cancel-franchise],[data-pay-order],[data-cancel-order],[data-reply-ticket],[data-close-ticket],[data-toggle-admin]');
877
1362
  if (!el) return;
878
1363
 
879
1364
  if (el.dataset.admTab) { setTab(el.dataset.admTab); return; }
1365
+ if (el.dataset.smtpPreset) { applySmtpPreset(el.dataset.smtpPreset); return; }
1366
+ if (el.dataset.admAddSrvEmail !== undefined) {
1367
+ openCreateServerModal(el.dataset.admAddSrvEmail, el.dataset.admNextNum || 1);
1368
+ return;
1369
+ }
1370
+ if (el.dataset.admToggleClient) {
1371
+ const key = el.dataset.admToggleClient;
1372
+ const card = el.closest('.adm-acc-card');
1373
+ const nowOpen = !card?.classList.contains('open');
1374
+ if (nowOpen) expandedClients.add(key);
1375
+ else expandedClients.delete(key);
1376
+ card?.classList.toggle('open', nowOpen);
1377
+ return;
1378
+ }
1379
+ if (el.dataset.admToggleFranchiser) {
1380
+ const key = el.dataset.admToggleFranchiser;
1381
+ const card = el.closest('.adm-acc-card');
1382
+ const nowOpen = !card?.classList.contains('open');
1383
+ if (nowOpen) expandedFranchisers.add(key);
1384
+ else expandedFranchisers.delete(key);
1385
+ card?.classList.toggle('open', nowOpen);
1386
+ return;
1387
+ }
880
1388
  if (el.dataset.cert) { openCertModal(el.dataset.cert); return; }
881
1389
  if (el.dataset.admDlLicfile !== undefined) { downloadCertLicFile(el.dataset.admDlLicfile); return; }
882
1390
  if (el.dataset.admBase) {
@@ -933,7 +1441,7 @@
933
1441
  case 'close-adm-profile': $('#admProfileModal')?.classList.remove('open'); break;
934
1442
  case 'admin-logout': logoutAdmin(); break;
935
1443
  case 'registry': refreshRegistry(); break;
936
- case 'open-create-server-modal': $('#admCreateServerModal')?.classList.add('open'); break;
1444
+ case 'open-create-server-modal': openCreateServerModal('', 1); break;
937
1445
  case 'close-create-server-modal': $('#admCreateServerModal')?.classList.remove('open'); break;
938
1446
  case 'submit-create-server': adminSubmitCreateServer(); break;
939
1447
  case 'close-grant-modal': $('#admGrantSeatsModal')?.classList.remove('open'); break;
@@ -944,9 +1452,23 @@
944
1452
  navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
945
1453
  break;
946
1454
  case 'cert-download': downloadCurrentCert(); break;
1455
+ case 'admin-export-backup': exportAdminBackup(); break;
1456
+ case 'admin-import-backup': $('#inpAdmBackupFile')?.click(); break;
1457
+ case 'admin-recover-npx': recoverAdminFromNpx(); break;
1458
+ case 'save-smtp-settings': saveSmtpSettings(); break;
1459
+ case 'test-smtp-settings': testSmtpSettings(); break;
1460
+ case 'save-yookassa-settings': saveYooKassaSettings(); break;
1461
+ case 'save-bots-settings': saveBotsSettings(); break;
1462
+ case 'save-legal-settings': saveLegalSettings(); break;
947
1463
  }
948
1464
  });
949
1465
 
1466
+ $('#inpAdmBackupFile')?.addEventListener('change', (e) => {
1467
+ const file = e.target.files?.[0];
1468
+ if (file) importAdminBackupFile(file);
1469
+ e.target.value = '';
1470
+ });
1471
+
950
1472
  $('#btnTheme')?.addEventListener('click', toggleTheme);
951
1473
  $('#adminLoginScreen')?.addEventListener('keydown', (e) => {
952
1474
  if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
@@ -955,7 +1477,7 @@
955
1477
  }
956
1478
  });
957
1479
 
958
- ['inpFilterServers', 'selFilterServers', 'inpFilterFranchises', 'selFilterFranchises', 'inpFilterOrders', 'selFilterOrders', 'inpFilterUsers', 'selFilterTickets'].forEach(id => {
1480
+ ['inpFilterClients', 'selFilterClients', 'inpFilterFranchisers', 'selFilterFranchisers', 'inpFilterOrders', 'selFilterOrders', 'selFilterTickets'].forEach(id => {
959
1481
  const el = document.getElementById(id);
960
1482
  if (!el) return;
961
1483
  el.addEventListener('input', () => lastAdminData && renderAdminPanel(lastAdminData));
@@ -973,7 +1495,7 @@
973
1495
  function start() {
974
1496
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
975
1497
  bind();
976
- setTab('servers');
1498
+ setTab('clients');
977
1499
  if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
978
1500
  checkAdminSession();
979
1501
  }