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 +701 -179
- package/assets/landing.css +158 -0
- package/bin/server.mjs +63 -4
- package/package.json +1 -1
- package/src/admin.js +228 -73
- package/src/index.js +7 -1
- package/src/server.js +199 -21
- package/src/store.js +1012 -30
- package/src/yookassa.js +9 -4
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 = '
|
|
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 || '
|
|
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 ($('#
|
|
310
|
-
if ($('#
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
<
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
</
|
|
385
|
-
<
|
|
386
|
-
<
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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.
|
|
393
|
-
function
|
|
394
|
-
const
|
|
395
|
-
if (!
|
|
396
|
-
const q = ($('#
|
|
397
|
-
const mode = $('#
|
|
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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
<
|
|
475
|
-
<
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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':
|
|
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
|
-
['
|
|
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('
|
|
1498
|
+
setTab('clients');
|
|
977
1499
|
if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
|
|
978
1500
|
checkAdminSession();
|
|
979
1501
|
}
|