work-license-server 1.0.22 → 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,104 +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})`;
314
458
  if ($('#admDataDirLabel') && data.dataDir) {
315
459
  $('#admDataDirLabel').textContent = `Папка БД: ${data.dataDir}`;
316
460
  }
317
461
 
318
- renderServersTable(licenses);
319
- renderFranchisesTable(franchises);
462
+ renderClientsPanels(clientGroups);
463
+ renderFranchisersPanels(franchises, users);
320
464
  renderOrdersTable(orders);
321
- renderUsersTable(users);
322
465
  renderTicketsTable(tickets);
466
+ renderPortalSettings(data.settings || {});
323
467
  startAdminCountdowns();
324
468
  }
325
469
 
326
- /* 1. Таблица серверов и лицензий */
327
- function renderServersTable(licenses = []) {
328
- const tbody = $('#admLicensesBody');
329
- if (!tbody) return;
330
- const q = ($('#inpFilterServers')?.value || '').trim().toLowerCase();
331
- const mode = $('#selFilterServers')?.value || 'all';
332
-
333
- const filtered = licenses.filter(l => {
334
- const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
335
- if (mode === 'base_active' && !l.baseActivated) return false;
336
- if (mode === 'base_inactive' && l.baseActivated) return false;
337
- 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;
338
540
  if (!q) return true;
339
- const hay = [
340
- l.licenseId, l.serverName, l.ownerEmail, l.customer?.name,
341
- l.customer?.inn, l.customer?.email, l.hardware?.hwid, l.hardware?.nodeFingerprint,
342
- ].join(' ').toLowerCase();
343
- 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));
344
548
  });
345
549
 
346
- tbody.innerHTML = filtered.map(l => {
347
- const lid = l.licenseId;
348
- const srvName = l.serverName || 'Сервер WORK';
349
- const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
350
- const hwid = l.hardware?.hwid || '—';
351
- const fp = l.hardware?.nodeFingerprint || '';
352
- const ownerEmail = l.ownerEmail || l.customer?.email || '—';
353
-
354
- const baseHtml = l.baseActivated
355
- ? `<span class="tag ok">✓ Активна (${l.baseSeats || 10} вечн. + ИИ)</span>
356
- <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>`
357
- : `<span class="tag">Не активирована (1 сессия)</span>
358
- <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>`;
359
-
360
- const hwidHtml = hwidBound
361
- ? `<span class="tag ok">Привязан</span> <code>${esc(hwid)}</code>
362
- ${fp ? `<div class="small muted">Узел: <code>${esc(fp.slice(0, 18))}…</code></div>` : ''}
363
- <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>`
364
- : `<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('');
365
618
 
366
- return `<tr>
367
- <td>
368
- <b>${esc(srvName)}</b>
369
- <div class="small muted"><code>${esc(lid)}</code> · создан ${fmtDate(l.issuedAt)}</div>
370
- <div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap">
371
- <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>
372
640
  </div>
373
- </td>
374
- <td>
375
- <b>${esc(l.customer?.name || '—')}</b>
376
- <div class="small muted">ИНН ${esc(l.customer?.inn || '—')}</div>
377
- <div class="small"><code>${esc(ownerEmail)}</code></div>
378
- </td>
379
- <td>${hwidHtml}</td>
380
- <td>${baseHtml}</td>
381
- <td>
382
- <div>Всего сессий: <b>${l.totalSeats}</b> (+<b>${l.addonSeats || 0}</b> доп.)</div>
383
- <div class="small muted">SLA: ${esc(l.support?.tierName || 'Enterprise')} (пакетов: ${(l.activeAddons || []).length})</div>
384
- <div style="margin-top:4px">
385
- <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>
386
647
  </div>
387
- </td>
388
- <td style="text-align:right">
389
- <button class="btn btn-primary btn-sm" style="padding:6px 10px;font-size:12px" data-cert="${esc(lid)}">.lic.json (${(l.licenseFiles || []).length})</button>
390
- </td>
391
- </tr>`;
392
- }).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('');
393
663
  }
394
664
 
395
- /* 2. Таблица франшиз и 12-месячных графиков погашения */
396
- function renderFranchisesTable(franchises = []) {
397
- const tbody = $('#admFranchisesBody');
398
- if (!tbody) return;
399
- const q = ($('#inpFilterFranchises')?.value || '').trim().toLowerCase();
400
- 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';
401
672
  const now = Date.now();
402
673
 
403
- const filtered = franchises.filter(f => {
674
+ const filteredFranchises = franchises.filter(f => {
404
675
  if (!f) return false;
405
676
  const isClosed = f.status === 'canceled' || f.status === 'expired';
406
677
  const remaining = Number(f.remainingFranchiseFeeRub ?? 0);
@@ -413,74 +684,147 @@
413
684
  if (!q) return true;
414
685
  const hay = [
415
686
  f.reservationId, f.regionName, f.regionCode, f.district,
416
- f.companyName, f.inn, f.email,
687
+ f.companyName, f.inn, f.email, f.phone,
417
688
  ].join(' ').toLowerCase();
418
689
  return hay.includes(q);
419
690
  });
420
691
 
421
- tbody.innerHTML = filtered.map(f => {
422
- const rid = f.reservationId;
423
- const isClosed = f.status === 'canceled' || f.status === 'expired';
424
- const depositRub = Number(f.nonRefundableDepositRub || 100_000);
425
- const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
426
- const paidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
427
- const remRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - paidRub));
428
- const paidMonths = Number(f.paidMonthsCount || 0);
429
- const overdueMonths = Number(f.overdueMonthsCount || 0);
430
- const pct = totalFeeRub > 0 ? Math.min(100, Math.round((paidRub / totalFeeRub) * 100)) : 0;
431
-
432
- const dl = Number(f.depositDeadlineAt || ((f.createdAt || now) + 15 * 60_000));
433
- const remSec = Math.max(0, Math.floor((dl - now) / 1000));
434
-
435
- let depositHtml = '';
436
- if (isClosed) {
437
- depositHtml = `<span class="tag bad">${f.status === 'expired' ? 'Истёк (15 мин)' : 'Отменён'}</span>`;
438
- }
439
- else if (f.depositPaid) {
440
- depositHtml = `<span class="tag ok">✓ Оплачен (${rub(depositRub)})</span><div class="small muted">${fmtDateTime(f.paidAt)}</div>`;
441
- }
442
- else {
443
- depositHtml = `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Таймер 15 мин: <b data-adm-deadline="${dl}">${fmtCountdown(remSec)}</b></span>
444
- <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>`;
445
- }
692
+ const groups = buildFranchiserGroups(filteredFranchises, users);
693
+ if (!groups.length) {
694
+ root.innerHTML = '<div class="sec-box muted">Франчайзеров по заданному фильтру не найдено</div>';
695
+ return;
696
+ }
446
697
 
447
- let schedHtml = '';
448
- if (isClosed) {
449
- schedHtml = '<span class="small muted">Резерв закрыт</span>';
450
- }
451
- else {
452
- const overdueBadge = overdueMonths > 0
453
- ? ` <span class="tag bad">Просрочено: ${overdueMonths}</span>`
454
- : (f.depositPaid && remRub <= 0 ? ' <span class="tag ok">Погашен полностью</span>' : '');
455
- schedHtml = `<div><b>${paidMonths} из 12</b> ежемесячных платежей${overdueBadge}</div>
456
- <div class="bar" style="width:160px;height:6px;margin-top:5px"><i style="--p:${pct}%"></i></div>
457
- ${f.nextInstallment && f.depositPaid ? `<div class="small muted" style="margin-top:4px">След. взнос №${f.nextInstallment.monthNumber}: ${rub(f.nextInstallment.amountRub)} до ${fmtDate(f.nextInstallment.dueDate)}</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
+ }
458
721
  }
459
722
 
460
- return `<tr>
461
- <td>
462
- <b>${esc(f.regionName)}</b> <span class="tag">${esc(f.district || f.regionCode)}</span>
463
- <div class="small muted"><code>${esc(rid)}</code> · от ${fmtDateTime(f.createdAt)}</div>
464
- </td>
465
- <td>
466
- <b>${esc(f.companyName || '—')}</b>
467
- <div class="small muted">ИНН ${esc(f.inn || '—')} · <code>${esc(f.email || '—')}</code></div>
468
- </td>
469
- <td>${depositHtml}</td>
470
- <td>${schedHtml}</td>
471
- <td>
472
- <div>Паушальный: <b>${rub(totalFeeRub)}</b></div>
473
- <div class="small" style="color:var(--ok)">Оплачено: <b>${rub(paidRub)}</b></div>
474
- <div class="small muted">Остаток: <b>${rub(remRub)}</b></div>
475
- </td>
476
- <td>
477
- <div style="display:flex;gap:6px;flex-wrap:wrap">
478
- <button class="btn btn-primary btn-sm" data-adm-sched="${esc(rid)}">График 12 мес.</button>
479
- ${!isClosed ? `<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-adm-cancel-franchise="${esc(rid)}">Отозвать</button>` : ''}
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
+ }
764
+
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>
480
804
  </div>
481
- </td>
482
- </tr>`;
483
- }).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('');
484
828
  }
485
829
 
486
830
  /* 3. Таблица заказов и счетов */
@@ -546,41 +890,7 @@
546
890
  }).join('') || '<tr><td colspan="6" class="muted">Заказов по заданному фильтру не найдено</td></tr>';
547
891
  }
548
892
 
549
- /* 4. Таблица клиентов и администраторов */
550
- function renderUsersTable(users = []) {
551
- const tbody = $('#admUsersBody');
552
- if (!tbody) return;
553
- const q = ($('#inpFilterUsers')?.value || '').trim().toLowerCase();
554
-
555
- const filtered = users.filter(u => {
556
- if (!q) return true;
557
- const hay = [u.userId, u.email, u.companyName, u.inn, u.name].join(' ').toLowerCase();
558
- return hay.includes(q);
559
- });
560
-
561
- tbody.innerHTML = filtered.map(u => {
562
- const srvCount = Array.isArray(u.servers) ? u.servers.length : (u.licenseId ? 1 : 0);
563
- const frCount = Array.isArray(u.franchises) ? u.franchises.length : 0;
564
- return `<tr>
565
- <td><b>${esc(u.email)}</b><div class="small muted"><code>${esc(u.userId)}</code></div></td>
566
- <td>
567
- ${u.isAdmin
568
- ? `<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>`
569
- : `<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>`}
570
- </td>
571
- <td><b>${esc(u.companyName || '—')}</b><div class="small muted">ИНН ${esc(u.inn || '—')}</div></td>
572
- <td>Серверов: <b>${srvCount}</b> · Франшиз: <b>${frCount}</b></td>
573
- <td>
574
- ${u.ofertaAccepted
575
- ? `<span class="tag ok">✓ Подписана ПЭП</span><div class="small muted"><code>${esc(u.ofertaSignatureId)}</code></div>`
576
- : '<span class="tag bad">Не подписана</span>'}
577
- </td>
578
- <td class="small muted">${fmtDateTime(u.lastLoginAt || u.createdAt)}</td>
579
- </tr>`;
580
- }).join('') || '<tr><td colspan="6" class="muted">Клиентов не найдено</td></tr>';
581
- }
582
-
583
- /* 5. Таблица тикетов SLA */
893
+ /* 4. Таблица тикетов SLA */
584
894
  function renderTicketsTable(tickets = []) {
585
895
  const tbody = $('#admTicketsBody');
586
896
  if (!tbody) return;
@@ -671,17 +981,28 @@
671
981
  }
672
982
 
673
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
+
674
997
  async function adminSubmitCreateServer() {
675
998
  const st = $('#admCreateServerStatus');
676
999
  if (st) st.textContent = '';
677
1000
  const serverName = $('#admNewSrvName')?.value.trim() || 'Сервер WORK #1';
678
- const org = $('#admNewSrvOrg')?.value.trim() || '';
679
- const inn = $('#admNewSrvInn')?.value.trim() || '';
680
1001
  const email = $('#admNewSrvEmail')?.value.trim() || '';
681
1002
  const hwid = $('#admNewSrvHwid')?.value.trim() || '';
682
1003
  const baseActivated = Boolean($('#admNewSrvActivateBase')?.checked);
683
1004
  if (!email || !email.includes('@')) {
684
- if (st) st.textContent = 'Укажите корректный Email владельца сервера.';
1005
+ if (st) st.textContent = 'Укажите корректный Email клиента.';
685
1006
  return;
686
1007
  }
687
1008
  try {
@@ -691,11 +1012,10 @@
691
1012
  baseActivated,
692
1013
  ownerEmail: email,
693
1014
  customer: {
694
- name: org || email,
695
- inn,
696
1015
  email,
697
1016
  },
698
1017
  });
1018
+ expandedClients.add(email.toLowerCase());
699
1019
  $('#admCreateServerModal')?.classList.remove('open');
700
1020
  await refreshRegistry();
701
1021
  }
@@ -924,13 +1244,147 @@
924
1244
  }
925
1245
  }
926
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
+
927
1358
  /* ================= Привязка событий ================= */
928
1359
  function bind() {
929
1360
  document.addEventListener('click', (e) => {
930
- 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]');
931
1362
  if (!el) return;
932
1363
 
933
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
+ }
934
1388
  if (el.dataset.cert) { openCertModal(el.dataset.cert); return; }
935
1389
  if (el.dataset.admDlLicfile !== undefined) { downloadCertLicFile(el.dataset.admDlLicfile); return; }
936
1390
  if (el.dataset.admBase) {
@@ -987,7 +1441,7 @@
987
1441
  case 'close-adm-profile': $('#admProfileModal')?.classList.remove('open'); break;
988
1442
  case 'admin-logout': logoutAdmin(); break;
989
1443
  case 'registry': refreshRegistry(); break;
990
- case 'open-create-server-modal': $('#admCreateServerModal')?.classList.add('open'); break;
1444
+ case 'open-create-server-modal': openCreateServerModal('', 1); break;
991
1445
  case 'close-create-server-modal': $('#admCreateServerModal')?.classList.remove('open'); break;
992
1446
  case 'submit-create-server': adminSubmitCreateServer(); break;
993
1447
  case 'close-grant-modal': $('#admGrantSeatsModal')?.classList.remove('open'); break;
@@ -1001,6 +1455,11 @@
1001
1455
  case 'admin-export-backup': exportAdminBackup(); break;
1002
1456
  case 'admin-import-backup': $('#inpAdmBackupFile')?.click(); break;
1003
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;
1004
1463
  }
1005
1464
  });
1006
1465
 
@@ -1018,7 +1477,7 @@
1018
1477
  }
1019
1478
  });
1020
1479
 
1021
- ['inpFilterServers', 'selFilterServers', 'inpFilterFranchises', 'selFilterFranchises', 'inpFilterOrders', 'selFilterOrders', 'inpFilterUsers', 'selFilterTickets'].forEach(id => {
1480
+ ['inpFilterClients', 'selFilterClients', 'inpFilterFranchisers', 'selFilterFranchisers', 'inpFilterOrders', 'selFilterOrders', 'selFilterTickets'].forEach(id => {
1022
1481
  const el = document.getElementById(id);
1023
1482
  if (!el) return;
1024
1483
  el.addEventListener('input', () => lastAdminData && renderAdminPanel(lastAdminData));
@@ -1036,7 +1495,7 @@
1036
1495
  function start() {
1037
1496
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
1038
1497
  bind();
1039
- setTab('servers');
1498
+ setTab('clients');
1040
1499
  if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
1041
1500
  checkAdminSession();
1042
1501
  }