work-license-server 1.0.14 → 1.0.15

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/landing.js CHANGED
@@ -277,6 +277,8 @@
277
277
  catch { /* сессия устарела */ }
278
278
  }
279
279
 
280
+ let franchiseCountdownTimer = null;
281
+
280
282
  function onAuthenticated() {
281
283
  if (!currentUser) return;
282
284
  if ($('#tileAddons')) $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
@@ -288,7 +290,6 @@
288
290
  } catch { /* ignore */ }
289
291
  }
290
292
  if ($('#btnAuthText')) $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
291
- if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = currentUser.hubToken || '';
292
293
  if ($('#authEmail')) $('#authEmail').value = currentUser.email || '';
293
294
  if ($('#authOfertaCheck')) $('#authOfertaCheck').checked = Boolean(currentUser.ofertaAccepted);
294
295
  if ($('#userBadgeBox')) {
@@ -296,90 +297,376 @@
296
297
  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
298
  $('#userBadgeBox').innerHTML = `${bossBadge}${orgInfo}Аккаунт: <b>${esc(currentUser.email)}</b> · <a href="#" data-action="logout" style="color:var(--muted)">Выйти</a>`;
298
299
  }
299
- renderCabinetOrders();
300
- renderCabinetLicenses();
300
+ syncClientServerPicker();
301
+ renderCabinetServers();
302
+ renderCabinetFranchises();
301
303
  moveIndicator();
302
304
  }
303
305
 
304
- function renderCabinetOrders() {
305
- const box = $('#cabinetOrdersBox');
306
+ function getUserServersList() {
307
+ if (Array.isArray(currentUser?.servers) && currentUser.servers.length) {
308
+ return currentUser.servers;
309
+ }
310
+ if (currentUser?.license) {
311
+ return [{
312
+ ...currentUser.license,
313
+ serverName: currentUser.license.serverName || 'Сервер WORK #1',
314
+ hubToken: currentUser.license.hubToken || currentUser.hubToken || '',
315
+ orders: (currentUser.orders || []).filter(o => o.kind !== 'franchise_reservation' && o.kind !== 'franchise_installment'),
316
+ }];
317
+ }
318
+ return [];
319
+ }
320
+
321
+ function syncClientServerPicker(preferredServerId = '') {
322
+ const wrap = $('#clientServerPickerWrap');
323
+ const sel = $('#selClientTargetServer');
324
+ if (!wrap || !sel) return;
325
+ const servers = getUserServersList();
326
+ if (!currentUser || !servers.length) {
327
+ wrap.style.display = 'none';
328
+ return;
329
+ }
330
+ wrap.style.display = '';
331
+ const variant = $('#segPakVariant button.on')?.dataset.variant || 'pak';
332
+ const isBaseBuy = variant === 'pak' || variant === 'license_only';
333
+ const prevVal = preferredServerId || sel.value || '';
334
+
335
+ const options = [];
336
+ for (const s of servers) {
337
+ const statusHint = s.baseActivated
338
+ ? `базовая активна · ${s.totalSeats} мест`
339
+ : 'без базовой лицензии';
340
+ options.push(`<option value="${esc(s.licenseId)}">${esc(s.serverName)} (${esc(s.licenseId)} · ${statusHint})</option>`);
341
+ }
342
+ if (isBaseBuy) {
343
+ options.push(`<option value="NEW">+ Создать новый сервер в реестре</option>`);
344
+ }
345
+ sel.innerHTML = options.join('');
346
+
347
+ if (prevVal && Array.from(sel.options).some(o => o.value === prevVal)) {
348
+ sel.value = prevVal;
349
+ }
350
+ else if (isBaseBuy) {
351
+ const unactivated = servers.find(s => !s.baseActivated);
352
+ sel.value = unactivated ? unactivated.licenseId : 'NEW';
353
+ }
354
+ else {
355
+ sel.value = servers[0]?.licenseId || '';
356
+ }
357
+ }
358
+
359
+ function renderCabinetServers() {
360
+ const box = $('#cabinetServersRegistry');
306
361
  if (!box) return;
307
- const orders = currentUser?.orders || [];
308
- if (!orders.length) {
309
- box.innerHTML = `<div style="padding:18px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
310
- В вашем Личном кабинете пока нет заказов. Перейдите в раздел <a href="#clients" data-stage="clients"><b>«Клиентам»</b></a> (чтобы купить лицензию или ПАК) или <a href="#franchise" data-stage="franchise"><b>«Франшиза»</b></a> (чтобы зарезервировать регион).
362
+ const servers = getUserServersList();
363
+ if ($('#badgeServersCount')) $('#badgeServersCount').textContent = String(servers.length);
364
+
365
+ if (!servers.length) {
366
+ box.innerHTML = `<div style="padding:22px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
367
+ В вашем реестре пока нет добавленных серверов WORK. Введите название сервера выше и нажмите <b>«+ Добавить сервер (устройство)»</b>.
311
368
  </div>`;
312
369
  return;
313
370
  }
314
- box.innerHTML = orders.map(o => {
315
- const isPaid = o.status === 'paid';
316
- const title = (o.items && o.items.length)
317
- ? o.items.map(it => esc(it.title)).join(' + ')
318
- : esc(o.tierName || o.orderId);
319
- const dateStr = o.createdAt ? new Date(o.createdAt).toLocaleString('ru-RU') : '';
320
- const statusBadge = isPaid
321
- ? `<span class="tag ok">✓ Оплачен</span>`
322
- : `<span class="tag">Ожидает оплаты</span>`;
323
- const actionsHtml = isPaid
371
+
372
+ box.innerHTML = servers.map((s, idx) => {
373
+ const lid = s.licenseId;
374
+ const name = s.serverName || `Сервер WORK #${idx + 1}`;
375
+ const tok = s.hubToken || currentUser?.hubToken || '';
376
+ const hwidBound = Boolean(s.hwidBound || s.hardware?.hwidBound || s.hardware?.nodeFingerprint);
377
+ const hwidText = hwidBound
378
+ ? `Железо привязано: <code>${esc(s.hardware?.hwid || '')}</code>${s.hardware?.nodeFingerprint ? ` · узел <code>${esc(s.hardware.nodeFingerprint.slice(0, 16))}…</code>` : ''}`
379
+ : 'Ожидает привязки токена в локальном WORK (раздел WORK → Лицензия)';
380
+
381
+ const baseBadge = s.baseActivated
382
+ ? `<span class="tag ok">✓ Базовая активна · ${s.baseSeats || 10} вечных мест + ИИ-агенты</span>`
383
+ : `<span class="tag">Базовая не активирована (1 сессия BOSS · ИИ отключён)</span>`;
384
+
385
+ const files = s.licenseFiles || [];
386
+ const activeAddons = s.activeAddons || [];
387
+ const serverOrders = (s.orders || []).filter(o => o.status !== 'canceled');
388
+ const pendingOrders = serverOrders.filter(o => o.status !== 'paid');
389
+ const paidOrders = serverOrders.filter(o => o.status === 'paid');
390
+ const canDelete = !s.baseActivated && !activeAddons.length && !paidOrders.length;
391
+
392
+ const baseActionsHtml = !s.baseActivated
324
393
  ? `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
325
- ${o.invoiceUrl ? `<a class="btn btn-ghost btn-sm" href="${esc(o.invoiceUrl)}" target="_blank" rel="noopener">Открыть счёт-договор (${esc(o.invoiceNumber || o.orderId)}) ↗</a>` : ''}
394
+ <button class="btn btn-primary btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="license_only">Купить базовую лицензию (${rub(D.product?.baseLicensePriceRub || 4_000_000)})</button>
395
+ <button class="btn btn-ghost btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="pak">Заказать ПАК с сервером (${rub(D.product?.basePriceRub || 5_000_000)})</button>
326
396
  </div>`
327
- : `<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px;align-items:center">
328
- <button class="btn btn-primary btn-sm" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить через ЮKassa (${rub(o.amountRub)})</button>
329
- <button class="btn btn-ghost btn-sm" data-invoice-toggle="${esc(o.orderId)}">Счёт-договор (для юр. лиц)</button>
330
- <button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}" style="color:var(--danger)">Удалить заказ</button>
331
- </div>
332
- <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:12px;padding:12px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
333
- <div class="small" style="margin-bottom:8px;font-weight:600">Реквизиты организации для выставления счёта-договора:</div>
334
- <div class="row2">
335
- <div class="field"><label>Наименование организации *</label><input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="ООО «Маслоторг»"></div>
336
- <div class="field"><label>ИНН *</label><input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="7701234567" inputmode="numeric"></div>
397
+ : '';
398
+
399
+ const addonsListHtml = activeAddons.length
400
+ ? `<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
401
+ ${activeAddons.map(sub => {
402
+ const exp = sub.expiresAt ? new Date(sub.expiresAt).toLocaleDateString('ru-RU') : '—';
403
+ return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-radius:8px;background:rgba(255,255,255,.03);border:1px solid var(--border)">
404
+ <span><b>+${sub.seats} конк. мест</b> · уровень <b>${esc(sub.tierName || sub.tier)}</b> (${sub.years || 1} г.)</span>
405
+ <span class="small muted">действует до ${esc(exp)}</span>
406
+ </div>`;
407
+ }).join('')}
408
+ </div>`
409
+ : `<div class="small muted" style="margin-top:6px">Дополнительные временные места не подключены.</div>`;
410
+
411
+ const filesHtml = files.length
412
+ ? `<div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border)">
413
+ <div class="small muted" style="margin-bottom:6px">Выпущенные подписанные файлы лицензий этого сервера (автоматически загружаются в <code>#system</code> по токену):</div>
414
+ <div style="display:flex;flex-direction:column;gap:6px">
415
+ ${files.map((f, fIdx) => {
416
+ const lf = f.licenseFile || f;
417
+ const isBase = lf.kind === 'base' || lf.perpetual;
418
+ const label = isBase ? 'Базовая вечная лицензия (10 мест + ИИ)' : `Доп. лицензия (+${lf.seats} мест · ${String(lf.tier || '').toUpperCase()})`;
419
+ return `<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;font-size:12.5px">
420
+ <span><b>${esc(label)}</b> · <code>${esc(f.fileName || lf.fileId)}</code></span>
421
+ <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-download-lic="${esc(lid)}" data-file-idx="${fIdx}">Скачать .lic.json</button>
422
+ </div>`;
423
+ }).join('')}
337
424
  </div>
338
- <button class="btn btn-primary btn-sm" style="margin-top:8px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Сформировать и открыть счёт-договор →</button>
339
- </div>`;
340
- return `<div style="padding:14px 16px;border-radius:12px;border:1px solid var(--border);margin-bottom:10px;">
341
- <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
425
+ </div>`
426
+ : '';
427
+
428
+ const pendingOrdersHtml = pendingOrders.length
429
+ ? `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
430
+ <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--warn);margin-bottom:8px">Ожидают оплаты по этому серверу (${pendingOrders.length})</div>
431
+ ${pendingOrders.map(o => {
432
+ const title = (o.items && o.items.length) ? o.items.map(it => esc(it.title)).join(' + ') : esc(o.tierName || o.orderId);
433
+ return `<div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(245,158,11,.05);margin-bottom:8px">
434
+ <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
435
+ <div>
436
+ <div class="small muted">Заказ <code>${esc(o.orderId)}</code></div>
437
+ <div style="font-weight:700;font-size:14px;margin-top:2px">${title}</div>
438
+ </div>
439
+ <div style="font-size:17px;font-weight:800">${rub(o.amountRub)}</div>
440
+ </div>
441
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center">
442
+ <button class="btn btn-primary btn-sm" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить через ЮKassa (${rub(o.amountRub)})</button>
443
+ <button class="btn btn-ghost btn-sm" data-invoice-toggle="${esc(o.orderId)}">Счёт-договор (для юр. лиц)</button>
444
+ <button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}" style="color:var(--danger)">Отменить заказ</button>
445
+ </div>
446
+ <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:10px;padding:12px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
447
+ <div class="small" style="margin-bottom:8px;font-weight:600">Реквизиты юр. лица для счёта-договора:</div>
448
+ <div class="row2">
449
+ <div class="field"><label>Наименование организации *</label><input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="ООО «Маслоторг»"></div>
450
+ <div class="field"><label>ИНН *</label><input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="7701234567" inputmode="numeric"></div>
451
+ </div>
452
+ <button class="btn btn-primary btn-sm" style="margin-top:8px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Сформировать и открыть счёт-договор →</button>
453
+ </div>
454
+ </div>`;
455
+ }).join('')}
456
+ </div>`
457
+ : '';
458
+
459
+ return `<div style="padding:18px 20px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.015)">
460
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)">
342
461
  <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>
462
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
463
+ <h4 class="h3" style="margin:0;font-size:18px">${esc(name)}</h4>
464
+ <code style="font-size:12px;padding:2px 8px;border-radius:6px;background:rgba(255,255,255,.06)">${esc(lid)}</code>
465
+ ${baseBadge}
466
+ </div>
467
+ <div class="small muted" style="margin-top:6px">${hwidText}</div>
345
468
  </div>
346
- <div style="text-align:right">
347
- <div style="font-size:18px;font-weight:800">${rub(o.amountRub)}</div>
348
- ${statusBadge}
469
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
470
+ <span class="pill" style="padding:4px 12px;font-size:12.5px">Сессий всего: <b>${s.totalSeats}</b> (${s.baseSeats || 0} вечн. + ${s.addonSeats || 0} доп.)</span>
471
+ <button class="btn btn-ghost btn-sm" data-rename-server="${esc(lid)}" data-current-name="${esc(name)}">Переименовать</button>
472
+ ${canDelete ? `<button class="btn btn-ghost btn-sm" data-delete-server="${esc(lid)}" style="color:var(--danger)">Удалить</button>` : ''}
349
473
  </div>
350
474
  </div>
351
- ${actionsHtml}
475
+
476
+ <div class="split" style="margin-top:14px;gap:16px">
477
+ <!-- Токен этого сервера -->
478
+ <div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
479
+ <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--brand-2);margin-bottom:4px">Токен привязки этого сервера (owk1...)</div>
480
+ <p class="small muted" style="margin:0 0 8px">Вставьте этот токен в <b>WORK → Лицензия</b> на данном сервере. Токен привяжет устройство и автоматически скачает все его лицензии в <code>#system</code>.</p>
481
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
482
+ <input type="text" id="srvTok-${esc(lid)}" readonly value="${esc(tok)}" style="flex:1;min-width:180px;font-family:monospace;font-size:12.5px;padding:7px 10px">
483
+ <button class="btn btn-primary btn-sm" data-copy-server-token="${esc(lid)}">Скопировать токен</button>
484
+ <button class="btn btn-ghost btn-sm" data-regen-server-token="${esc(lid)}">Перевыпустить</button>
485
+ </div>
486
+ <div class="small" id="srvTokStatus-${esc(lid)}" style="color:var(--ok);min-height:16px;margin-top:4px"></div>
487
+ </div>
488
+
489
+ <!-- Лицензии этого сервера -->
490
+ <div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
491
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
492
+ <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--brand-2)">Лицензии сервера (базовая и доп.)</div>
493
+ <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-buy-for-server="${esc(lid)}" data-buy-variant="addons_only">+ Докупить рабочие места / SLA</button>
494
+ </div>
495
+ <div class="small" style="margin-top:6px">
496
+ <b>Базовая лицензия:</b> ${s.baseActivated ? '<span style="color:var(--ok)">Активирована (10 вечных мест + ИИ-агенты)</span>' : '<span style="color:var(--muted)">Не активирована</span>'}
497
+ </div>
498
+ ${baseActionsHtml}
499
+ <div class="small" style="margin-top:8px"><b>Дополнительные годовые лицензии:</b></div>
500
+ ${addonsListHtml}
501
+ ${filesHtml}
502
+ </div>
503
+ </div>
504
+
505
+ ${pendingOrdersHtml}
352
506
  </div>`;
353
507
  }).join('');
354
508
  }
355
509
 
356
- function renderCabinetLicenses() {
357
- const box = $('#cabinetLicensesBox');
510
+ function fmtCountdown(sec) {
511
+ const s = Math.max(0, Math.floor(Number(sec) || 0));
512
+ const m = Math.floor(s / 60);
513
+ const r = s % 60;
514
+ return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
515
+ }
516
+
517
+ function startFranchiseCountdowns() {
518
+ if (franchiseCountdownTimer) {
519
+ clearInterval(franchiseCountdownTimer);
520
+ franchiseCountdownTimer = null;
521
+ }
522
+ const nodes = $$('[data-deposit-deadline]');
523
+ if (!nodes.length) return;
524
+ franchiseCountdownTimer = setInterval(() => {
525
+ const now = Date.now();
526
+ let expiredAny = false;
527
+ for (const el of $$('[data-deposit-deadline]')) {
528
+ const deadline = Number(el.dataset.depositDeadline || 0);
529
+ const remSec = Math.max(0, Math.floor((deadline - now) / 1000));
530
+ el.textContent = fmtCountdown(remSec);
531
+ if (remSec <= 0) expiredAny = true;
532
+ }
533
+ if (expiredAny) {
534
+ clearInterval(franchiseCountdownTimer);
535
+ franchiseCountdownTimer = null;
536
+ refreshCabinetUser();
537
+ refreshCatalogRegions();
538
+ }
539
+ }, 1000);
540
+ }
541
+
542
+ function renderCabinetFranchises() {
543
+ const box = $('#cabinetFranchisesRegistry');
358
544
  if (!box) return;
359
- const lic = currentUser?.license;
360
- const files = lic?.licenseFiles || [];
361
- const reservations = (currentUser?.reservations || []).filter(r => r.depositPaid);
362
- if (!files.length && !reservations.length) {
363
- box.innerHTML = `<div style="padding:12px 14px;border-radius:10px;border:1px dashed var(--border);color:var(--muted);">
364
- Активных оплаченных лицензий пока нет. После оплаты заказа выше подписанные файлы лицензий (<code>.lic.json</code>) сразу появятся здесь и автоматически загрузятся в ваш локальный WORK по токену владельца.
545
+ const franchises = currentUser?.franchises || currentUser?.reservations || [];
546
+ const activeList = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
547
+ if ($('#badgeFranchisesCount')) $('#badgeFranchisesCount').textContent = String(activeList.length);
548
+
549
+ if (!activeList.length) {
550
+ box.innerHTML = `<div style="padding:22px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
551
+ У вас пока нет зарезервированных или действующих региональных франшиз. Нажмите <b>«Зарезервировать франшизу на карте РФ»</b> выше, чтобы выбрать субъект РФ.
365
552
  </div>`;
553
+ startFranchiseCountdowns();
366
554
  return;
367
555
  }
368
- const rows = files.map(f => {
369
- const lf = f.licenseFile || f;
370
- const isBase = lf.kind === 'base' || lf.perpetual;
371
- const expText = isBase ? 'Бессрочно (постоянная)' : (lf.expiresAt ? 'до ' + new Date(lf.expiresAt).toLocaleDateString('ru-RU') + ` (${lf.years || 1} г.)` : '—');
372
- const title = isBase ? 'Первичная постоянная лицензия (10 вечных мест + ИИ-агенты)' : `Временная лицензия (+${lf.seats} конк. мест · ${String(lf.tier || '').toUpperCase()})`;
373
- return `<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);align-items:center;">
374
- <div><b>${esc(title)}</b><br><code style="font-size:11.5px;color:var(--muted)">${esc(f.fileName || lf.fileId)}</code></div>
375
- <div style="text-align:right"><span class="pill" style="padding:3px 10px;font-size:11.5px">${esc(expText)}</span></div>
556
+
557
+ const now = Date.now();
558
+ box.innerHTML = activeList.map(f => {
559
+ const rid = f.reservationId;
560
+ const depositRub = Number(f.nonRefundableDepositRub || 100_000);
561
+ const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
562
+ const totalPaidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
563
+ const remainingRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - totalPaidRub));
564
+ const schedule = Array.isArray(f.paymentSchedule) ? f.paymentSchedule : [];
565
+ const paidMonths = Number(f.paidMonthsCount ?? schedule.filter(x => x.status === 'paid').length);
566
+ const pct = totalFeeRub > 0 ? Math.min(100, Math.round((totalPaidRub / totalFeeRub) * 100)) : 0;
567
+
568
+ const deadline = Number(f.depositDeadlineAt || ((f.createdAt || now) + 15 * 60_000));
569
+ const remSec = Math.max(0, Math.floor((deadline - now) / 1000));
570
+
571
+ const statusBadge = !f.depositPaid
572
+ ? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Ожидает оплаты залога · <b data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b></span>`
573
+ : (remainingRub <= 0
574
+ ? `<span class="tag ok">✓ Франшиза полностью оплачена (12/12)</span>`
575
+ : `<span class="tag ok">✓ Залог оплачен · Погашение по графику (${paidMonths}/12 мес.)</span>`);
576
+
577
+ const depositAlertHtml = !f.depositPaid
578
+ ? `<div style="margin-top:12px;padding:14px 16px;border-radius:12px;border:1px solid rgba(245,158,11,.45);background:rgba(245,158,11,.08);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
579
+ <div>
580
+ <div style="font-weight:700;color:var(--warn)">Требуется оплата начального взноса (${rub(depositRub)}) в течение 15 минут</div>
581
+ <div class="small muted" style="margin-top:2px">Осталось времени: <b style="color:var(--text);font-family:monospace;font-size:14px" data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b>. Если оплата через ЮKassa не поступит вовремя, резерв региона автоматически отменится.</div>
582
+ </div>
583
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
584
+ <button class="btn btn-primary btn-sm" data-pay-order="${esc(f.orderId)}" data-pay-method="yookassa">Оплатить начальный взнос ${rub(depositRub)} (ЮKassa)</button>
585
+ <button class="btn btn-ghost btn-sm" data-cancel-franchise="${esc(rid)}" style="color:var(--danger)">Отменить резерв</button>
586
+ </div>
587
+ </div>`
588
+ : '';
589
+
590
+ const nextInst = f.nextInstallment || schedule.find(x => x.status !== 'paid');
591
+ const scheduleControlsHtml = (f.depositPaid && remainingRub > 0)
592
+ ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
593
+ ${nextInst ? `<button class="btn btn-primary btn-sm" data-pay-installment="${esc(rid)}" data-month="${nextInst.monthNumber}">Оплатить платёж №${nextInst.monthNumber} (${rub(nextInst.amountRub)} · ЮKassa)</button>` : ''}
594
+ <button class="btn btn-ghost btn-sm" data-pay-installment="${esc(rid)}" data-early="1">Досрочно погасить весь остаток (${rub(remainingRub)})</button>
595
+ </div>`
596
+ : '';
597
+
598
+ const scheduleRowsHtml = schedule.map(inst => {
599
+ const isPaid = inst.status === 'paid';
600
+ const dueStr = inst.dueDate ? new Date(inst.dueDate).toLocaleDateString('ru-RU') : '—';
601
+ const paidStr = inst.paidAt ? new Date(inst.paidAt).toLocaleDateString('ru-RU') : '';
602
+ let stHtml = '<span class="tag">По графику</span>';
603
+ if (isPaid) {
604
+ stHtml = `<span class="tag ok">✓ Оплачен${paidStr ? ' ' + esc(paidStr) : ''}</span>`;
605
+ }
606
+ else if (inst.displayStatus === 'overdue') {
607
+ stHtml = '<span class="tag bad">Просрочен</span>';
608
+ }
609
+ else if (inst.displayStatus === 'next') {
610
+ stHtml = '<span class="tag" style="color:var(--brand-2)">Ближайший к оплате</span>';
611
+ }
612
+
613
+ const actHtml = (!isPaid && f.depositPaid)
614
+ ? `<div style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap">
615
+ <button class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="yookassa">Оплатить ЮKassa</button>
616
+ <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="invoice">Счёт</button>
617
+ </div>`
618
+ : (isPaid ? `<span class="small muted">Закрыт</span>` : `<span class="small muted">После оплаты залога</span>`);
619
+
620
+ return `<tr>
621
+ <td><b>Месяц ${inst.monthNumber} / 12</b></td>
622
+ <td>${esc(dueStr)}</td>
623
+ <td class="num"><b>${rub(inst.amountRub)}</b></td>
624
+ <td>${stHtml}</td>
625
+ <td style="text-align:right">${actHtml}</td>
626
+ </tr>`;
627
+ }).join('');
628
+
629
+ return `<div style="padding:18px 20px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.015)">
630
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)">
631
+ <div>
632
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
633
+ <h4 class="h3" style="margin:0;font-size:18px">${esc(f.regionName)}</h4>
634
+ <span class="tag">${esc(f.district || f.regionCode)}</span>
635
+ <code style="font-size:12px;color:var(--muted)">${esc(rid)}</code>
636
+ ${statusBadge}
637
+ </div>
638
+ <div class="small muted" style="margin-top:6px">
639
+ Паушальный взнос региона: <b>${rub(totalFeeRub)}</b> · Начальный взнос (залог): <b>${rub(depositRub)}</b> (${f.depositPaid ? 'оплачен' : 'ожидает оплаты'}) · Погашено всего: <b>${rub(totalPaidRub)}</b> · Остаток: <b>${rub(remainingRub)}</b>
640
+ </div>
641
+ </div>
642
+ ${scheduleControlsHtml}
643
+ </div>
644
+
645
+ ${depositAlertHtml}
646
+
647
+ <div style="margin-top:14px">
648
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap">
649
+ <span class="small" style="font-weight:700">Годовой график погашения платежей (12 месяцев, платёж раз в месяц)</span>
650
+ <span class="small muted">Погашено ${pct}% (${rub(totalPaidRub)} из ${rub(totalFeeRub)})</span>
651
+ </div>
652
+ <div class="bar" style="width:100%;height:8px;margin-bottom:12px"><i style="--p:${pct}%"></i></div>
653
+ <div class="table-wrap" style="max-height:340px">
654
+ <table class="t">
655
+ <thead><tr>
656
+ <th>Период</th>
657
+ <th>Дата платежа</th>
658
+ <th class="num">Сумма взноса</th>
659
+ <th>Статус</th>
660
+ <th style="text-align:right">Действие</th>
661
+ </tr></thead>
662
+ <tbody>${scheduleRowsHtml}</tbody>
663
+ </table>
664
+ </div>
665
+ </div>
376
666
  </div>`;
377
667
  }).join('');
378
- const resRows = reservations.map(r => `<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);align-items:center;">
379
- <div><b>Региональная франшиза: ${esc(r.regionName)}</b><br><code style="font-size:11.5px;color:var(--muted)">${esc(r.reservationId)}</code></div>
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;
668
+
669
+ startFranchiseCountdowns();
383
670
  }
384
671
 
385
672
  async function refreshCabinetUser() {
@@ -394,14 +681,176 @@
394
681
  catch { /* ignore */ }
395
682
  }
396
683
 
397
- function copyCabinetToken() {
398
- const val = $('#inpCabinetHubToken')?.value || '';
684
+ async function createCabinetServer() {
685
+ if (!isUserReadyForOrders()) {
686
+ requireAuthAndOferta(() => createCabinetServer());
687
+ return;
688
+ }
689
+ const inp = $('#inpNewServerName');
690
+ const serverName = inp?.value.trim() || '';
691
+ try {
692
+ const res = await api('/api/cabinet/servers/create', {
693
+ sessionKey: currentUser?.sessionKey || '',
694
+ serverName,
695
+ });
696
+ if (inp) inp.value = '';
697
+ if (res.user) {
698
+ currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
699
+ onAuthenticated();
700
+ }
701
+ }
702
+ catch (err) {
703
+ alert(err.message);
704
+ }
705
+ }
706
+
707
+ async function renameCabinetServer(licenseId, currentName = '') {
708
+ const nextName = prompt('Введите новое название для сервера WORK:', currentName || '');
709
+ if (!nextName || nextName.trim() === currentName) return;
710
+ try {
711
+ const res = await api('/api/cabinet/servers/rename', {
712
+ sessionKey: currentUser?.sessionKey || '',
713
+ licenseId,
714
+ serverName: nextName.trim(),
715
+ });
716
+ if (res.user) {
717
+ currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
718
+ onAuthenticated();
719
+ }
720
+ }
721
+ catch (err) {
722
+ alert(err.message);
723
+ }
724
+ }
725
+
726
+ async function deleteCabinetServer(licenseId) {
727
+ if (!confirm('Удалить этот неактивированный сервер из вашего реестра?')) return;
728
+ try {
729
+ const res = await api('/api/cabinet/servers/delete', {
730
+ sessionKey: currentUser?.sessionKey || '',
731
+ licenseId,
732
+ });
733
+ if (res.user) {
734
+ currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
735
+ onAuthenticated();
736
+ }
737
+ }
738
+ catch (err) {
739
+ alert(err.message);
740
+ }
741
+ }
742
+
743
+ function copyServerToken(licenseId) {
744
+ const inp = $(`#srvTok-${licenseId}`);
745
+ const val = inp?.value || '';
399
746
  if (!val) return;
400
747
  navigator.clipboard?.writeText(val);
401
- const st = $('#tokenCopyStatus');
748
+ const st = $(`#srvTokStatus-${licenseId}`);
402
749
  if (st) {
403
- st.textContent = 'Токен скопирован в буфер обмена! Вставьте его в локальном WORK → Лицензия.';
404
- setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; }, 5000);
750
+ st.textContent = '✓ Токен сервера скопирован в буфер обмена!';
751
+ setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; }, 4000);
752
+ }
753
+ }
754
+
755
+ async function regenerateServerToken(licenseId) {
756
+ if (!currentUser?.sessionKey) return;
757
+ try {
758
+ const res = await api('/api/cabinet/token', {
759
+ sessionKey: currentUser.sessionKey,
760
+ licenseId,
761
+ });
762
+ if (res.user) {
763
+ currentUser = { ...res.user, sessionKey: currentUser.sessionKey };
764
+ onAuthenticated();
765
+ }
766
+ const st = $(`#srvTokStatus-${licenseId}`);
767
+ if (st) st.textContent = '✓ Выпущен новый токен для этого сервера.';
768
+ }
769
+ catch (err) {
770
+ alert(err.message);
771
+ }
772
+ }
773
+
774
+ function downloadServerLicFile(licenseId, fileIdx) {
775
+ const srv = getUserServersList().find(s => s.licenseId === licenseId);
776
+ const f = srv?.licenseFiles?.[Number(fileIdx)];
777
+ if (!f) return;
778
+ const json = JSON.stringify(f.licenseFile || f, null, 2);
779
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
780
+ const url = URL.createObjectURL(blob);
781
+ const a = document.createElement('a');
782
+ a.href = url;
783
+ a.download = f.fileName || `${licenseId}.lic.json`;
784
+ document.body.appendChild(a);
785
+ a.click();
786
+ a.remove();
787
+ URL.revokeObjectURL(url);
788
+ }
789
+
790
+ async function buyForSpecificServer(licenseId, variant = 'license_only') {
791
+ if (variant === 'addons_only') {
792
+ $$('#segPakVariant button').forEach(b => b.classList.toggle('on', b.dataset.variant === 'addons_only'));
793
+ syncClientSeats(5);
794
+ syncClientServerPicker(licenseId);
795
+ switchStage('clients');
796
+ return;
797
+ }
798
+ try {
799
+ const data = await api('/api/cabinet/orders/draft', {
800
+ sessionKey: currentUser?.sessionKey || '',
801
+ licenseId,
802
+ includeBasePak: variant === 'pak',
803
+ includeBaseLicenseOnly: variant === 'license_only',
804
+ tier: 'enterprise',
805
+ addonSeats: 0,
806
+ years: 1,
807
+ });
808
+ if (data.user) {
809
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
810
+ onAuthenticated();
811
+ }
812
+ }
813
+ catch (err) {
814
+ alert(err.message);
815
+ }
816
+ }
817
+
818
+ async function payFranchiseInstallment(reservationId, { monthNumber = null, earlyPayoff = false, paymentMethod = 'yookassa' } = {}) {
819
+ try {
820
+ const data = await api('/api/cabinet/franchises/pay-installment', {
821
+ sessionKey: currentUser?.sessionKey || '',
822
+ reservationId,
823
+ monthNumber: monthNumber != null ? Number(monthNumber) : null,
824
+ earlyPayoff: Boolean(earlyPayoff),
825
+ paymentMethod,
826
+ });
827
+ if (data.user) {
828
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
829
+ onAuthenticated();
830
+ }
831
+ const url = data.invoiceUrl || data.confirmationUrl;
832
+ if (url) window.open(url, '_blank', 'noopener');
833
+ }
834
+ catch (err) {
835
+ alert(err.message);
836
+ }
837
+ }
838
+
839
+ async function cancelFranchiseByUser(reservationId) {
840
+ if (!confirm('Отменить резерв этой региональной франшизы?')) return;
841
+ try {
842
+ const data = await api('/api/cabinet/franchises/cancel', {
843
+ sessionKey: currentUser?.sessionKey || '',
844
+ reservationId,
845
+ });
846
+ if (data.user) {
847
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
848
+ onAuthenticated();
849
+ }
850
+ refreshCatalogRegions();
851
+ }
852
+ catch (err) {
853
+ alert(err.message);
405
854
  }
406
855
  }
407
856
 
@@ -493,8 +942,11 @@
493
942
  const st = $('#pakOrderStatus');
494
943
  if (st) st.textContent = 'Добавляем заказ в ваш Личный кабинет…';
495
944
  try {
945
+ const pickedLicId = $('#selClientTargetServer')?.value || '';
496
946
  const data = await api('/api/cabinet/orders/draft', {
497
947
  sessionKey: currentUser?.sessionKey || '',
948
+ licenseId: pickedLicId,
949
+ createNewServer: pickedLicId === 'NEW',
498
950
  includeBasePak,
499
951
  includeBaseLicenseOnly,
500
952
  tier,
@@ -1236,7 +1688,7 @@
1236
1688
  /* ================= Привязка событий ================= */
1237
1689
  function bind() {
1238
1690
  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');
1691
+ const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-copy-server-token],[data-regen-server-token],[data-rename-server],[data-delete-server],[data-download-lic],[data-buy-for-server],[data-pay-installment],[data-cancel-franchise],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1240
1692
  if (!el) return;
1241
1693
  if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
1242
1694
  if (el.matches('[data-pay-order]')) { e.stopPropagation(); payCabinetOrder(el.dataset.payOrder, el.dataset.payMethod || 'yookassa'); return; }
@@ -1247,6 +1699,22 @@
1247
1699
  return;
1248
1700
  }
1249
1701
  if (el.matches('[data-cancel-order]')) { e.stopPropagation(); cancelCabinetOrder(el.dataset.cancelOrder); return; }
1702
+ if (el.matches('[data-copy-server-token]')) { e.stopPropagation(); copyServerToken(el.dataset.copyServerToken); return; }
1703
+ if (el.matches('[data-regen-server-token]')) { e.stopPropagation(); regenerateServerToken(el.dataset.regenServerToken); return; }
1704
+ if (el.matches('[data-rename-server]')) { e.stopPropagation(); renameCabinetServer(el.dataset.renameServer, el.dataset.currentName || ''); return; }
1705
+ if (el.matches('[data-delete-server]')) { e.stopPropagation(); deleteCabinetServer(el.dataset.deleteServer); return; }
1706
+ if (el.matches('[data-download-lic]')) { e.stopPropagation(); downloadServerLicFile(el.dataset.downloadLic, el.dataset.fileIdx || 0); return; }
1707
+ if (el.matches('[data-buy-for-server]')) { e.stopPropagation(); buyForSpecificServer(el.dataset.buyForServer, el.dataset.buyVariant || 'license_only'); return; }
1708
+ if (el.matches('[data-pay-installment]')) {
1709
+ e.stopPropagation();
1710
+ payFranchiseInstallment(el.dataset.payInstallment, {
1711
+ monthNumber: el.dataset.month ? Number(el.dataset.month) : null,
1712
+ earlyPayoff: el.dataset.early === '1',
1713
+ paymentMethod: el.dataset.payMethod || 'yookassa',
1714
+ });
1715
+ return;
1716
+ }
1717
+ if (el.matches('[data-cancel-franchise]')) { e.stopPropagation(); cancelFranchiseByUser(el.dataset.cancelFranchise); return; }
1250
1718
  if (el.matches('#regionsTableBody tr')) { selectRegion(el.dataset.code, { zoom: true }); $('#mapBox').scrollIntoView({ block: 'nearest', behavior: 'smooth' }); return; }
1251
1719
  if (el.matches('th[data-sort]')) {
1252
1720
  SORT.dir = SORT.key === el.dataset.sort ? -SORT.dir : (el.dataset.sort === 'name' || el.dataset.sort === 'district' ? 1 : -1);
@@ -1291,11 +1759,12 @@
1291
1759
  $$('#segPakVariant button').forEach(b => b.classList.toggle('on', b.dataset.variant === 'addons_only'));
1292
1760
  }
1293
1761
  syncClientSeats(5);
1762
+ syncClientServerPicker();
1294
1763
  buyClientConfig();
1295
1764
  break;
1296
1765
  case 'buy-client-config': buyClientConfig(); break;
1766
+ case 'create-server': createCabinetServer(); break;
1297
1767
  case 'gen-hub-token': generateCabinetToken(); break;
1298
- case 'copy-hub-token': copyCabinetToken(); break;
1299
1768
  case 'refresh-cabinet': refreshCabinetUser(); break;
1300
1769
  case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
1301
1770
  case 'cert-close': $('#certModal')?.classList.remove('open'); break;
@@ -1325,7 +1794,10 @@
1325
1794
  $('#rngPartnerSales')?.addEventListener('input', recalcPartner);
1326
1795
  $('#inpRegionFilter')?.addEventListener('input', renderRegionsTable);
1327
1796
  bindSeg('segPakTier', recalcBaseOrder);
1328
- bindSeg('segPakVariant', () => syncClientSeats($('#inpPakExtraSeatsNum')?.value || 0));
1797
+ bindSeg('segPakVariant', () => {
1798
+ syncClientSeats($('#inpPakExtraSeatsNum')?.value || 0);
1799
+ syncClientServerPicker();
1800
+ });
1329
1801
  window.addEventListener('message', (e) => {
1330
1802
  if (e.data?.type === 'odant:order-paid') {
1331
1803
  refreshCabinetUser();