work-license-server 1.0.14 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/admin.js +603 -311
- package/assets/landing.css +150 -0
- package/assets/landing.js +504 -71
- package/package.json +1 -1
- package/src/admin.js +241 -165
- package/src/crypto.js +4 -0
- package/src/landing.js +32 -36
- package/src/server.js +120 -4
- package/src/store.js +771 -57
package/assets/landing.js
CHANGED
|
@@ -277,6 +277,16 @@
|
|
|
277
277
|
catch { /* сессия устарела */ }
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
let franchiseCountdownTimer = null;
|
|
281
|
+
let activeCabinetTab = 'servers';
|
|
282
|
+
|
|
283
|
+
function switchCabinetTab(tab = 'servers') {
|
|
284
|
+
activeCabinetTab = tab === 'franchises' ? 'franchises' : 'servers';
|
|
285
|
+
$$('[data-cab-tab]').forEach(b => b.classList.toggle('active', b.dataset.cabTab === activeCabinetTab));
|
|
286
|
+
if ($('#cabSecServers')) $('#cabSecServers').style.display = activeCabinetTab === 'servers' ? '' : 'none';
|
|
287
|
+
if ($('#cabSecFranchises')) $('#cabSecFranchises').style.display = activeCabinetTab === 'franchises' ? '' : 'none';
|
|
288
|
+
}
|
|
289
|
+
|
|
280
290
|
function onAuthenticated() {
|
|
281
291
|
if (!currentUser) return;
|
|
282
292
|
if ($('#tileAddons')) $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
|
|
@@ -288,7 +298,6 @@
|
|
|
288
298
|
} catch { /* ignore */ }
|
|
289
299
|
}
|
|
290
300
|
if ($('#btnAuthText')) $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
|
|
291
|
-
if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = currentUser.hubToken || '';
|
|
292
301
|
if ($('#authEmail')) $('#authEmail').value = currentUser.email || '';
|
|
293
302
|
if ($('#authOfertaCheck')) $('#authOfertaCheck').checked = Boolean(currentUser.ofertaAccepted);
|
|
294
303
|
if ($('#userBadgeBox')) {
|
|
@@ -296,90 +305,326 @@
|
|
|
296
305
|
const bossBadge = currentUser.isAdmin ? `<span class="tag ok" style="margin-right:6px">BOSS · Администратор</span> <a href="/admin" style="font-weight:700;margin-right:8px">Открыть Админ-панель →</a> · ` : '';
|
|
297
306
|
$('#userBadgeBox').innerHTML = `${bossBadge}${orgInfo}Аккаунт: <b>${esc(currentUser.email)}</b> · <a href="#" data-action="logout" style="color:var(--muted)">Выйти</a>`;
|
|
298
307
|
}
|
|
299
|
-
|
|
300
|
-
|
|
308
|
+
syncClientServerPicker();
|
|
309
|
+
renderCabinetServers();
|
|
310
|
+
renderCabinetFranchises();
|
|
301
311
|
moveIndicator();
|
|
302
312
|
}
|
|
303
313
|
|
|
304
|
-
function
|
|
305
|
-
|
|
314
|
+
function getUserServersList() {
|
|
315
|
+
if (Array.isArray(currentUser?.servers) && currentUser.servers.length) {
|
|
316
|
+
return currentUser.servers;
|
|
317
|
+
}
|
|
318
|
+
if (currentUser?.license) {
|
|
319
|
+
return [{
|
|
320
|
+
...currentUser.license,
|
|
321
|
+
serverName: currentUser.license.serverName || 'Сервер WORK #1',
|
|
322
|
+
hubToken: currentUser.license.hubToken || currentUser.hubToken || '',
|
|
323
|
+
orders: (currentUser.orders || []).filter(o => o.kind !== 'franchise_reservation' && o.kind !== 'franchise_installment'),
|
|
324
|
+
}];
|
|
325
|
+
}
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function syncClientServerPicker(preferredServerId = '') {
|
|
330
|
+
const wrap = $('#clientServerPickerWrap');
|
|
331
|
+
const sel = $('#selClientTargetServer');
|
|
332
|
+
if (!wrap || !sel) return;
|
|
333
|
+
const servers = getUserServersList();
|
|
334
|
+
if (!currentUser || !servers.length) {
|
|
335
|
+
wrap.style.display = 'none';
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
wrap.style.display = '';
|
|
339
|
+
const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
|
|
340
|
+
const isBaseBuy = variant === 'pak' || variant === 'license_only';
|
|
341
|
+
const prevVal = preferredServerId || sel.value || '';
|
|
342
|
+
|
|
343
|
+
const options = [];
|
|
344
|
+
for (const s of servers) {
|
|
345
|
+
const statusHint = s.baseActivated
|
|
346
|
+
? `базовая активна · ${s.totalSeats} мест`
|
|
347
|
+
: 'без базовой лицензии';
|
|
348
|
+
options.push(`<option value="${esc(s.licenseId)}">${esc(s.serverName)} (${esc(s.licenseId)} · ${statusHint})</option>`);
|
|
349
|
+
}
|
|
350
|
+
if (isBaseBuy) {
|
|
351
|
+
options.push(`<option value="NEW">+ Создать новый сервер в реестре</option>`);
|
|
352
|
+
}
|
|
353
|
+
sel.innerHTML = options.join('');
|
|
354
|
+
|
|
355
|
+
if (prevVal && Array.from(sel.options).some(o => o.value === prevVal)) {
|
|
356
|
+
sel.value = prevVal;
|
|
357
|
+
}
|
|
358
|
+
else if (isBaseBuy) {
|
|
359
|
+
const unactivated = servers.find(s => !s.baseActivated);
|
|
360
|
+
sel.value = unactivated ? unactivated.licenseId : 'NEW';
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
sel.value = servers[0]?.licenseId || '';
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderCabinetServers() {
|
|
368
|
+
const box = $('#cabinetServersRegistry');
|
|
306
369
|
if (!box) return;
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
370
|
+
const servers = getUserServersList();
|
|
371
|
+
if ($('#navCabServers')) $('#navCabServers').textContent = `Серверы и устройства (${servers.length})`;
|
|
372
|
+
|
|
373
|
+
if (!servers.length) {
|
|
374
|
+
box.innerHTML = `<div style="padding:18px;color:var(--muted);text-align:center;">
|
|
375
|
+
В реестре пока нет серверов WORK. Введите название нового сервера справа вверху и нажмите <b>«+ Добавить сервер»</b>.
|
|
311
376
|
</div>`;
|
|
312
377
|
return;
|
|
313
378
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
?
|
|
322
|
-
:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
379
|
+
|
|
380
|
+
box.innerHTML = servers.map((s, idx) => {
|
|
381
|
+
const lid = s.licenseId;
|
|
382
|
+
const name = s.serverName || `Сервер WORK #${idx + 1}`;
|
|
383
|
+
const tok = s.hubToken || currentUser?.hubToken || '';
|
|
384
|
+
const hwidBound = Boolean(s.hwidBound || s.hardware?.hwidBound || s.hardware?.nodeFingerprint);
|
|
385
|
+
const hwidText = hwidBound
|
|
386
|
+
? `HWID: <code>${esc(s.hardware?.hwid || '')}</code>`
|
|
387
|
+
: 'ожидает привязки токена в WORK → Лицензия';
|
|
388
|
+
|
|
389
|
+
const baseBadge = s.baseActivated
|
|
390
|
+
? `<span class="tag ok">✓ Базовая: ${s.baseSeats || 10} вечн. мест + ИИ</span>`
|
|
391
|
+
: `<span class="tag">Базовая не активна (1 сессия BOSS)</span>`;
|
|
392
|
+
|
|
393
|
+
const files = s.licenseFiles || [];
|
|
394
|
+
const activeAddons = s.activeAddons || [];
|
|
395
|
+
const serverOrders = (s.orders || []).filter(o => o.status !== 'canceled');
|
|
396
|
+
const pendingOrders = serverOrders.filter(o => o.status !== 'paid');
|
|
397
|
+
const paidOrders = serverOrders.filter(o => o.status === 'paid');
|
|
398
|
+
const canDelete = !s.baseActivated && !activeAddons.length && !paidOrders.length;
|
|
399
|
+
|
|
400
|
+
const baseActionsHtml = !s.baseActivated
|
|
401
|
+
? `<button class="btn btn-primary btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="license_only">Купить базовую (${rub(D.product?.baseLicensePriceRub || 4_000_000)})</button>
|
|
402
|
+
<button class="btn btn-ghost btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="pak">Купить ПАК с сервером (${rub(D.product?.basePriceRub || 5_000_000)})</button>`
|
|
403
|
+
: '';
|
|
404
|
+
|
|
405
|
+
const addonsSummaryHtml = activeAddons.length
|
|
406
|
+
? activeAddons.map(sub => {
|
|
407
|
+
const exp = sub.expiresAt ? new Date(sub.expiresAt).toLocaleDateString('ru-RU') : '—';
|
|
408
|
+
return `<span class="tag ok" style="margin-right:6px">+${sub.seats} мест · ${esc(sub.tierName || sub.tier)} до ${esc(exp)}</span>`;
|
|
409
|
+
}).join('')
|
|
410
|
+
: `<span class="small muted">Доп. места не подключены</span>`;
|
|
411
|
+
|
|
412
|
+
const filesHtml = files.length
|
|
413
|
+
? `<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
|
414
|
+
<span class="small muted">Файлы лицензий (<code>#system</code>):</span>
|
|
415
|
+
${files.map((f, fIdx) => `<button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-download-lic="${esc(lid)}" data-file-idx="${fIdx}">↓ ${esc(f.fileName || 'license.lic.json')}</button>`).join('')}
|
|
326
416
|
</div>`
|
|
327
|
-
:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
417
|
+
: '';
|
|
418
|
+
|
|
419
|
+
const pendingOrdersHtml = pendingOrders.length
|
|
420
|
+
? `<div style="margin-top:10px;padding:10px 12px;border-radius:8px;border:1px solid var(--line-strong);background:color-mix(in srgb, var(--warn) 6%, transparent)">
|
|
421
|
+
${pendingOrders.map(o => {
|
|
422
|
+
const title = (o.items && o.items.length) ? o.items.map(it => esc(it.title)).join(' + ') : esc(o.tierName || o.orderId);
|
|
423
|
+
return `<div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
|
|
424
|
+
<div class="small"><b>Ожидает оплаты:</b> ${title} · <b>${rub(o.amountRub)}</b></div>
|
|
425
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
426
|
+
<button class="btn btn-primary btn-sm" style="padding:5px 10px;font-size:12px" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить ЮKassa</button>
|
|
427
|
+
<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-invoice-toggle="${esc(o.orderId)}">Счёт юрлицу</button>
|
|
428
|
+
<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px;color:var(--danger)" data-cancel-order="${esc(o.orderId)}">Отменить</button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
<div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)">
|
|
432
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
|
433
|
+
<input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="Организация (ООО «Маслоторг»)" style="width:240px;padding:6px 10px;font-size:13px">
|
|
434
|
+
<input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="ИНН" style="width:150px;padding:6px 10px;font-size:13px">
|
|
435
|
+
<button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12.5px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Открыть счёт-договор →</button>
|
|
436
|
+
</div>
|
|
437
|
+
</div>`;
|
|
438
|
+
}).join('')}
|
|
439
|
+
</div>`
|
|
440
|
+
: '';
|
|
441
|
+
|
|
442
|
+
return `<div class="sec-item">
|
|
443
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
|
444
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
445
|
+
<b style="font-size:15px">${esc(name)}</b>
|
|
446
|
+
<span class="small muted">· <code>${esc(lid)}</code> · ${hwidText}</span>
|
|
447
|
+
${baseBadge}
|
|
337
448
|
</div>
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
<div>
|
|
343
|
-
<div style="font-size:12px;color:var(--muted)">Заказ <code>${esc(o.orderId)}</code> · ${esc(dateStr)}</div>
|
|
344
|
-
<div style="font-weight:700;font-size:15px;margin-top:2px">${title}</div>
|
|
449
|
+
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
|
450
|
+
<span class="small muted" style="margin-right:4px">Сессий: <b>${s.totalSeats}</b></span>
|
|
451
|
+
<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-rename-server="${esc(lid)}" data-current-name="${esc(name)}">Переименовать</button>
|
|
452
|
+
${canDelete ? `<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px;color:var(--danger)" data-delete-server="${esc(lid)}">Удалить</button>` : ''}
|
|
345
453
|
</div>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<!-- Строка токена привязки этого сервера -->
|
|
457
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px">
|
|
458
|
+
<span class="small muted" style="min-width:115px">Токен привязки:</span>
|
|
459
|
+
<input type="text" id="srvTok-${esc(lid)}" readonly value="${esc(tok)}" style="flex:1;min-width:200px;max-width:440px;padding:6px 10px;font-family:monospace;font-size:12px;border-radius:8px">
|
|
460
|
+
<button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12px" data-copy-server-token="${esc(lid)}">Скопировать токен</button>
|
|
461
|
+
<button class="btn btn-ghost btn-sm" style="padding:6px 10px;font-size:12px" data-regen-server-token="${esc(lid)}">Перевыпустить</button>
|
|
462
|
+
<span class="small" id="srvTokStatus-${esc(lid)}" style="color:var(--ok)"></span>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<!-- Строка лицензий и докупки мест -->
|
|
466
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px">
|
|
467
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
468
|
+
<span class="small muted" style="min-width:115px">Доп. лицензии:</span>
|
|
469
|
+
${addonsSummaryHtml}
|
|
470
|
+
</div>
|
|
471
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
472
|
+
${baseActionsHtml}
|
|
473
|
+
<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-buy-for-server="${esc(lid)}" data-buy-variant="addons_only">+ Докупить рабочие места</button>
|
|
349
474
|
</div>
|
|
350
475
|
</div>
|
|
351
|
-
|
|
476
|
+
|
|
477
|
+
${filesHtml}
|
|
478
|
+
${pendingOrdersHtml}
|
|
352
479
|
</div>`;
|
|
353
480
|
}).join('');
|
|
354
481
|
}
|
|
355
482
|
|
|
356
|
-
function
|
|
357
|
-
const
|
|
483
|
+
function fmtCountdown(sec) {
|
|
484
|
+
const s = Math.max(0, Math.floor(Number(sec) || 0));
|
|
485
|
+
const m = Math.floor(s / 60);
|
|
486
|
+
const r = s % 60;
|
|
487
|
+
return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function startFranchiseCountdowns() {
|
|
491
|
+
if (franchiseCountdownTimer) {
|
|
492
|
+
clearInterval(franchiseCountdownTimer);
|
|
493
|
+
franchiseCountdownTimer = null;
|
|
494
|
+
}
|
|
495
|
+
const nodes = $$('[data-deposit-deadline]');
|
|
496
|
+
if (!nodes.length) return;
|
|
497
|
+
franchiseCountdownTimer = setInterval(() => {
|
|
498
|
+
const now = Date.now();
|
|
499
|
+
let expiredAny = false;
|
|
500
|
+
for (const el of $$('[data-deposit-deadline]')) {
|
|
501
|
+
const deadline = Number(el.dataset.depositDeadline || 0);
|
|
502
|
+
const remSec = Math.max(0, Math.floor((deadline - now) / 1000));
|
|
503
|
+
el.textContent = fmtCountdown(remSec);
|
|
504
|
+
if (remSec <= 0) expiredAny = true;
|
|
505
|
+
}
|
|
506
|
+
if (expiredAny) {
|
|
507
|
+
clearInterval(franchiseCountdownTimer);
|
|
508
|
+
franchiseCountdownTimer = null;
|
|
509
|
+
refreshCabinetUser();
|
|
510
|
+
refreshCatalogRegions();
|
|
511
|
+
}
|
|
512
|
+
}, 1000);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function renderCabinetFranchises() {
|
|
516
|
+
const box = $('#cabinetFranchisesRegistry');
|
|
358
517
|
if (!box) return;
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
518
|
+
const franchises = currentUser?.franchises || currentUser?.reservations || [];
|
|
519
|
+
const activeList = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
|
|
520
|
+
if ($('#navCabFranchises')) $('#navCabFranchises').textContent = `Региональные франшизы (${activeList.length})`;
|
|
521
|
+
|
|
522
|
+
if (!activeList.length) {
|
|
523
|
+
box.innerHTML = `<div style="padding:18px;color:var(--muted);text-align:center;">
|
|
524
|
+
У вас пока нет зарезервированных или действующих региональных франшиз. Нажмите <b>«+ Выбрать регион на карте РФ»</b> справа вверху.
|
|
365
525
|
</div>`;
|
|
526
|
+
startFranchiseCountdowns();
|
|
366
527
|
return;
|
|
367
528
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
529
|
+
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
box.innerHTML = activeList.map(f => {
|
|
532
|
+
const rid = f.reservationId;
|
|
533
|
+
const depositRub = Number(f.nonRefundableDepositRub || 100_000);
|
|
534
|
+
const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
|
|
535
|
+
const totalPaidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
|
|
536
|
+
const remainingRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - totalPaidRub));
|
|
537
|
+
const schedule = Array.isArray(f.paymentSchedule) ? f.paymentSchedule : [];
|
|
538
|
+
const paidMonths = Number(f.paidMonthsCount ?? schedule.filter(x => x.status === 'paid').length);
|
|
539
|
+
const pct = totalFeeRub > 0 ? Math.min(100, Math.round((totalPaidRub / totalFeeRub) * 100)) : 0;
|
|
540
|
+
|
|
541
|
+
const deadline = Number(f.depositDeadlineAt || ((f.createdAt || now) + 15 * 60_000));
|
|
542
|
+
const remSec = Math.max(0, Math.floor((deadline - now) / 1000));
|
|
543
|
+
|
|
544
|
+
const statusBadge = !f.depositPaid
|
|
545
|
+
? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Оплата залога: <b data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b></span>`
|
|
546
|
+
: (remainingRub <= 0
|
|
547
|
+
? `<span class="tag ok">✓ Погашена полностью (12/12)</span>`
|
|
548
|
+
: `<span class="tag ok">✓ Залог оплачен · График ${paidMonths}/12 мес.</span>`);
|
|
549
|
+
|
|
550
|
+
const depositAlertHtml = !f.depositPaid
|
|
551
|
+
? `<div style="margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid rgba(245,158,11,.45);background:rgba(245,158,11,.08);display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
|
552
|
+
<div class="small">
|
|
553
|
+
<b>Оплатите начальный взнос (${rub(depositRub)}) через ЮKassa в течение <span style="font-family:monospace;font-size:13.5px" data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</span></b>, иначе резерв автоматически отменится.
|
|
554
|
+
</div>
|
|
555
|
+
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
556
|
+
<button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12px" data-pay-order="${esc(f.orderId)}" data-pay-method="yookassa">Оплатить ${rub(depositRub)} (ЮKassa)</button>
|
|
557
|
+
<button class="btn btn-ghost btn-sm" style="padding:6px 10px;font-size:12px;color:var(--danger)" data-cancel-franchise="${esc(rid)}">Отменить резерв</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>`
|
|
560
|
+
: '';
|
|
561
|
+
|
|
562
|
+
const nextInst = f.nextInstallment || schedule.find(x => x.status !== 'paid');
|
|
563
|
+
const scheduleControlsHtml = (f.depositPaid && remainingRub > 0)
|
|
564
|
+
? `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
|
565
|
+
${nextInst ? `<button class="btn btn-primary btn-sm" style="padding:5px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${nextInst.monthNumber}">Оплатить месяц №${nextInst.monthNumber} (${rub(nextInst.amountRub)})</button>` : ''}
|
|
566
|
+
<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-early="1">Погасить досрочно (${rub(remainingRub)})</button>
|
|
567
|
+
</div>`
|
|
568
|
+
: '';
|
|
569
|
+
|
|
570
|
+
const scheduleRowsHtml = schedule.map(inst => {
|
|
571
|
+
const isPaid = inst.status === 'paid';
|
|
572
|
+
const dueStr = inst.dueDate ? new Date(inst.dueDate).toLocaleDateString('ru-RU') : '—';
|
|
573
|
+
const paidStr = inst.paidAt ? new Date(inst.paidAt).toLocaleDateString('ru-RU') : '';
|
|
574
|
+
let stHtml = '<span class="tag">По графику</span>';
|
|
575
|
+
if (isPaid) {
|
|
576
|
+
stHtml = `<span class="tag ok">✓ Оплачен${paidStr ? ' ' + esc(paidStr) : ''}</span>`;
|
|
577
|
+
}
|
|
578
|
+
else if (inst.displayStatus === 'overdue') {
|
|
579
|
+
stHtml = '<span class="tag bad">Просрочен</span>';
|
|
580
|
+
}
|
|
581
|
+
else if (inst.displayStatus === 'next') {
|
|
582
|
+
stHtml = '<span class="tag" style="color:var(--brand-2)">К оплате</span>';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const actHtml = (!isPaid && f.depositPaid)
|
|
586
|
+
? `<div style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap">
|
|
587
|
+
<button class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="yookassa">ЮKassa</button>
|
|
588
|
+
<button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="invoice">Счёт</button>
|
|
589
|
+
</div>`
|
|
590
|
+
: (isPaid ? `<span class="small muted">Закрыт</span>` : `<span class="small muted">После залога</span>`);
|
|
591
|
+
|
|
592
|
+
return `<tr>
|
|
593
|
+
<td><b>Месяц ${inst.monthNumber} / 12</b></td>
|
|
594
|
+
<td>${esc(dueStr)}</td>
|
|
595
|
+
<td><b>${rub(inst.amountRub)}</b></td>
|
|
596
|
+
<td>${stHtml}</td>
|
|
597
|
+
<td style="text-align:right">${actHtml}</td>
|
|
598
|
+
</tr>`;
|
|
599
|
+
}).join('');
|
|
600
|
+
|
|
601
|
+
return `<div class="sec-item">
|
|
602
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
|
603
|
+
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
604
|
+
<b style="font-size:15px">${esc(f.regionName)}</b>
|
|
605
|
+
<span class="small muted">· ${esc(f.district || f.regionCode)} · <code>${esc(rid)}</code></span>
|
|
606
|
+
${statusBadge}
|
|
607
|
+
</div>
|
|
608
|
+
${scheduleControlsHtml}
|
|
609
|
+
</div>
|
|
610
|
+
<div class="small muted" style="margin-top:4px">
|
|
611
|
+
Паушальный взнос: <b>${rub(totalFeeRub)}</b> · Залог: <b>${rub(depositRub)}</b> · Оплачено: <b>${rub(totalPaidRub)}</b> (${pct}%) · Остаток: <b>${rub(remainingRub)}</b>
|
|
612
|
+
</div>
|
|
613
|
+
${depositAlertHtml}
|
|
614
|
+
<table class="sec-table" style="margin-top:10px">
|
|
615
|
+
<thead><tr>
|
|
616
|
+
<th>Период</th>
|
|
617
|
+
<th>Дата платежа</th>
|
|
618
|
+
<th>Сумма</th>
|
|
619
|
+
<th>Статус</th>
|
|
620
|
+
<th style="text-align:right">Оплата</th>
|
|
621
|
+
</tr></thead>
|
|
622
|
+
<tbody>${scheduleRowsHtml}</tbody>
|
|
623
|
+
</table>
|
|
376
624
|
</div>`;
|
|
377
625
|
}).join('');
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
<div style="text-align:right"><span class="pill" style="padding:3px 10px;font-size:11.5px">Залог оплачен</span></div>
|
|
381
|
-
</div>`).join('');
|
|
382
|
-
box.innerHTML = (files.length ? `<div>Всего доступно одновременных сессий: <b style="color:var(--text)">${lic.totalSeats}</b> (${lic.baseSeats} вечных + ${lic.addonSeats} временных)</div>` : '') + rows + resRows;
|
|
626
|
+
|
|
627
|
+
startFranchiseCountdowns();
|
|
383
628
|
}
|
|
384
629
|
|
|
385
630
|
async function refreshCabinetUser() {
|
|
@@ -394,14 +639,176 @@
|
|
|
394
639
|
catch { /* ignore */ }
|
|
395
640
|
}
|
|
396
641
|
|
|
397
|
-
function
|
|
398
|
-
|
|
642
|
+
async function createCabinetServer() {
|
|
643
|
+
if (!isUserReadyForOrders()) {
|
|
644
|
+
requireAuthAndOferta(() => createCabinetServer());
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const inp = $('#inpNewServerName');
|
|
648
|
+
const serverName = inp?.value.trim() || '';
|
|
649
|
+
try {
|
|
650
|
+
const res = await api('/api/cabinet/servers/create', {
|
|
651
|
+
sessionKey: currentUser?.sessionKey || '',
|
|
652
|
+
serverName,
|
|
653
|
+
});
|
|
654
|
+
if (inp) inp.value = '';
|
|
655
|
+
if (res.user) {
|
|
656
|
+
currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
|
|
657
|
+
onAuthenticated();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
alert(err.message);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function renameCabinetServer(licenseId, currentName = '') {
|
|
666
|
+
const nextName = prompt('Введите новое название для сервера WORK:', currentName || '');
|
|
667
|
+
if (!nextName || nextName.trim() === currentName) return;
|
|
668
|
+
try {
|
|
669
|
+
const res = await api('/api/cabinet/servers/rename', {
|
|
670
|
+
sessionKey: currentUser?.sessionKey || '',
|
|
671
|
+
licenseId,
|
|
672
|
+
serverName: nextName.trim(),
|
|
673
|
+
});
|
|
674
|
+
if (res.user) {
|
|
675
|
+
currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
|
|
676
|
+
onAuthenticated();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
alert(err.message);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function deleteCabinetServer(licenseId) {
|
|
685
|
+
if (!confirm('Удалить этот неактивированный сервер из вашего реестра?')) return;
|
|
686
|
+
try {
|
|
687
|
+
const res = await api('/api/cabinet/servers/delete', {
|
|
688
|
+
sessionKey: currentUser?.sessionKey || '',
|
|
689
|
+
licenseId,
|
|
690
|
+
});
|
|
691
|
+
if (res.user) {
|
|
692
|
+
currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
|
|
693
|
+
onAuthenticated();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
alert(err.message);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function copyServerToken(licenseId) {
|
|
702
|
+
const inp = $(`#srvTok-${licenseId}`);
|
|
703
|
+
const val = inp?.value || '';
|
|
399
704
|
if (!val) return;
|
|
400
705
|
navigator.clipboard?.writeText(val);
|
|
401
|
-
const st = $(
|
|
706
|
+
const st = $(`#srvTokStatus-${licenseId}`);
|
|
402
707
|
if (st) {
|
|
403
|
-
st.textContent = 'Токен скопирован в буфер обмена!
|
|
404
|
-
setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; },
|
|
708
|
+
st.textContent = '✓ Токен сервера скопирован в буфер обмена!';
|
|
709
|
+
setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; }, 4000);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function regenerateServerToken(licenseId) {
|
|
714
|
+
if (!currentUser?.sessionKey) return;
|
|
715
|
+
try {
|
|
716
|
+
const res = await api('/api/cabinet/token', {
|
|
717
|
+
sessionKey: currentUser.sessionKey,
|
|
718
|
+
licenseId,
|
|
719
|
+
});
|
|
720
|
+
if (res.user) {
|
|
721
|
+
currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
|
|
722
|
+
onAuthenticated();
|
|
723
|
+
}
|
|
724
|
+
const st = $(`#srvTokStatus-${licenseId}`);
|
|
725
|
+
if (st) st.textContent = '✓ Выпущен новый токен для этого сервера.';
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
alert(err.message);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function downloadServerLicFile(licenseId, fileIdx) {
|
|
733
|
+
const srv = getUserServersList().find(s => s.licenseId === licenseId);
|
|
734
|
+
const f = srv?.licenseFiles?.[Number(fileIdx)];
|
|
735
|
+
if (!f) return;
|
|
736
|
+
const json = JSON.stringify(f.licenseFile || f, null, 2);
|
|
737
|
+
const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
|
|
738
|
+
const url = URL.createObjectURL(blob);
|
|
739
|
+
const a = document.createElement('a');
|
|
740
|
+
a.href = url;
|
|
741
|
+
a.download = f.fileName || `${licenseId}.lic.json`;
|
|
742
|
+
document.body.appendChild(a);
|
|
743
|
+
a.click();
|
|
744
|
+
a.remove();
|
|
745
|
+
URL.revokeObjectURL(url);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function buyForSpecificServer(licenseId, variant = 'license_only') {
|
|
749
|
+
if (variant === 'addons_only') {
|
|
750
|
+
$$('#segPakVariant button').forEach(b => b.classList.toggle('on', b.dataset.variant === 'addons_only'));
|
|
751
|
+
syncClientSeats(5);
|
|
752
|
+
syncClientServerPicker(licenseId);
|
|
753
|
+
switchStage('clients');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const data = await api('/api/cabinet/orders/draft', {
|
|
758
|
+
sessionKey: currentUser?.sessionKey || '',
|
|
759
|
+
licenseId,
|
|
760
|
+
includeBasePak: variant === 'pak',
|
|
761
|
+
includeBaseLicenseOnly: variant === 'license_only',
|
|
762
|
+
tier: 'enterprise',
|
|
763
|
+
addonSeats: 0,
|
|
764
|
+
years: 1,
|
|
765
|
+
});
|
|
766
|
+
if (data.user) {
|
|
767
|
+
currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
|
|
768
|
+
onAuthenticated();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
alert(err.message);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function payFranchiseInstallment(reservationId, { monthNumber = null, earlyPayoff = false, paymentMethod = 'yookassa' } = {}) {
|
|
777
|
+
try {
|
|
778
|
+
const data = await api('/api/cabinet/franchises/pay-installment', {
|
|
779
|
+
sessionKey: currentUser?.sessionKey || '',
|
|
780
|
+
reservationId,
|
|
781
|
+
monthNumber: monthNumber != null ? Number(monthNumber) : null,
|
|
782
|
+
earlyPayoff: Boolean(earlyPayoff),
|
|
783
|
+
paymentMethod,
|
|
784
|
+
});
|
|
785
|
+
if (data.user) {
|
|
786
|
+
currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
|
|
787
|
+
onAuthenticated();
|
|
788
|
+
}
|
|
789
|
+
const url = data.invoiceUrl || data.confirmationUrl;
|
|
790
|
+
if (url) window.open(url, '_blank', 'noopener');
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
alert(err.message);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function cancelFranchiseByUser(reservationId) {
|
|
798
|
+
if (!confirm('Отменить резерв этой региональной франшизы?')) return;
|
|
799
|
+
try {
|
|
800
|
+
const data = await api('/api/cabinet/franchises/cancel', {
|
|
801
|
+
sessionKey: currentUser?.sessionKey || '',
|
|
802
|
+
reservationId,
|
|
803
|
+
});
|
|
804
|
+
if (data.user) {
|
|
805
|
+
currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
|
|
806
|
+
onAuthenticated();
|
|
807
|
+
}
|
|
808
|
+
refreshCatalogRegions();
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
alert(err.message);
|
|
405
812
|
}
|
|
406
813
|
}
|
|
407
814
|
|
|
@@ -493,8 +900,11 @@
|
|
|
493
900
|
const st = $('#pakOrderStatus');
|
|
494
901
|
if (st) st.textContent = 'Добавляем заказ в ваш Личный кабинет…';
|
|
495
902
|
try {
|
|
903
|
+
const pickedLicId = $('#selClientTargetServer')?.value || '';
|
|
496
904
|
const data = await api('/api/cabinet/orders/draft', {
|
|
497
905
|
sessionKey: currentUser?.sessionKey || '',
|
|
906
|
+
licenseId: pickedLicId,
|
|
907
|
+
createNewServer: pickedLicId === 'NEW',
|
|
498
908
|
includeBasePak,
|
|
499
909
|
includeBaseLicenseOnly,
|
|
500
910
|
tier,
|
|
@@ -506,6 +916,7 @@
|
|
|
506
916
|
onAuthenticated();
|
|
507
917
|
}
|
|
508
918
|
if (st) st.textContent = '';
|
|
919
|
+
switchCabinetTab('servers');
|
|
509
920
|
switchStage('addons');
|
|
510
921
|
}
|
|
511
922
|
catch (err) {
|
|
@@ -976,6 +1387,7 @@
|
|
|
976
1387
|
}
|
|
977
1388
|
if (msg) msg.textContent = '';
|
|
978
1389
|
refreshCatalogRegions();
|
|
1390
|
+
switchCabinetTab('franchises');
|
|
979
1391
|
switchStage('addons');
|
|
980
1392
|
}
|
|
981
1393
|
catch (err) {
|
|
@@ -1236,8 +1648,9 @@
|
|
|
1236
1648
|
/* ================= Привязка событий ================= */
|
|
1237
1649
|
function bind() {
|
|
1238
1650
|
document.addEventListener('click', (e) => {
|
|
1239
|
-
const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
|
|
1651
|
+
const el = e.target.closest('[data-stage],[data-cab-tab],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-copy-server-token],[data-regen-server-token],[data-rename-server],[data-delete-server],[data-download-lic],[data-buy-for-server],[data-pay-installment],[data-cancel-franchise],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
|
|
1240
1652
|
if (!el) return;
|
|
1653
|
+
if (el.matches('[data-cab-tab]')) { e.preventDefault(); switchCabinetTab(el.dataset.cabTab); return; }
|
|
1241
1654
|
if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
|
|
1242
1655
|
if (el.matches('[data-pay-order]')) { e.stopPropagation(); payCabinetOrder(el.dataset.payOrder, el.dataset.payMethod || 'yookassa'); return; }
|
|
1243
1656
|
if (el.matches('[data-invoice-toggle]')) {
|
|
@@ -1247,6 +1660,22 @@
|
|
|
1247
1660
|
return;
|
|
1248
1661
|
}
|
|
1249
1662
|
if (el.matches('[data-cancel-order]')) { e.stopPropagation(); cancelCabinetOrder(el.dataset.cancelOrder); return; }
|
|
1663
|
+
if (el.matches('[data-copy-server-token]')) { e.stopPropagation(); copyServerToken(el.dataset.copyServerToken); return; }
|
|
1664
|
+
if (el.matches('[data-regen-server-token]')) { e.stopPropagation(); regenerateServerToken(el.dataset.regenServerToken); return; }
|
|
1665
|
+
if (el.matches('[data-rename-server]')) { e.stopPropagation(); renameCabinetServer(el.dataset.renameServer, el.dataset.currentName || ''); return; }
|
|
1666
|
+
if (el.matches('[data-delete-server]')) { e.stopPropagation(); deleteCabinetServer(el.dataset.deleteServer); return; }
|
|
1667
|
+
if (el.matches('[data-download-lic]')) { e.stopPropagation(); downloadServerLicFile(el.dataset.downloadLic, el.dataset.fileIdx || 0); return; }
|
|
1668
|
+
if (el.matches('[data-buy-for-server]')) { e.stopPropagation(); buyForSpecificServer(el.dataset.buyForServer, el.dataset.buyVariant || 'license_only'); return; }
|
|
1669
|
+
if (el.matches('[data-pay-installment]')) {
|
|
1670
|
+
e.stopPropagation();
|
|
1671
|
+
payFranchiseInstallment(el.dataset.payInstallment, {
|
|
1672
|
+
monthNumber: el.dataset.month ? Number(el.dataset.month) : null,
|
|
1673
|
+
earlyPayoff: el.dataset.early === '1',
|
|
1674
|
+
paymentMethod: el.dataset.payMethod || 'yookassa',
|
|
1675
|
+
});
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (el.matches('[data-cancel-franchise]')) { e.stopPropagation(); cancelFranchiseByUser(el.dataset.cancelFranchise); return; }
|
|
1250
1679
|
if (el.matches('#regionsTableBody tr')) { selectRegion(el.dataset.code, { zoom: true }); $('#mapBox').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
|
|
1251
1680
|
if (el.matches('th[data-sort]')) {
|
|
1252
1681
|
SORT.dir = SORT.key === el.dataset.sort ? -SORT.dir : (el.dataset.sort === 'name' || el.dataset.sort === 'district' ? 1 : -1);
|
|
@@ -1291,11 +1720,12 @@
|
|
|
1291
1720
|
$$('#segPakVariant button').forEach(b => b.classList.toggle('on', b.dataset.variant === 'addons_only'));
|
|
1292
1721
|
}
|
|
1293
1722
|
syncClientSeats(5);
|
|
1723
|
+
syncClientServerPicker();
|
|
1294
1724
|
buyClientConfig();
|
|
1295
1725
|
break;
|
|
1296
1726
|
case 'buy-client-config': buyClientConfig(); break;
|
|
1727
|
+
case 'create-server': createCabinetServer(); break;
|
|
1297
1728
|
case 'gen-hub-token': generateCabinetToken(); break;
|
|
1298
|
-
case 'copy-hub-token': copyCabinetToken(); break;
|
|
1299
1729
|
case 'refresh-cabinet': refreshCabinetUser(); break;
|
|
1300
1730
|
case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
|
|
1301
1731
|
case 'cert-close': $('#certModal')?.classList.remove('open'); break;
|
|
@@ -1325,7 +1755,10 @@
|
|
|
1325
1755
|
$('#rngPartnerSales')?.addEventListener('input', recalcPartner);
|
|
1326
1756
|
$('#inpRegionFilter')?.addEventListener('input', renderRegionsTable);
|
|
1327
1757
|
bindSeg('segPakTier', recalcBaseOrder);
|
|
1328
|
-
bindSeg('segPakVariant', () =>
|
|
1758
|
+
bindSeg('segPakVariant', () => {
|
|
1759
|
+
syncClientSeats($('#inpPakExtraSeatsNum')?.value || 0);
|
|
1760
|
+
syncClientServerPicker();
|
|
1761
|
+
});
|
|
1329
1762
|
window.addEventListener('message', (e) => {
|
|
1330
1763
|
if (e.data?.type === 'odant:order-paid') {
|
|
1331
1764
|
refreshCabinetUser();
|