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/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
- renderCabinetOrders();
300
- renderCabinetLicenses();
308
+ syncClientServerPicker();
309
+ renderCabinetServers();
310
+ renderCabinetFranchises();
301
311
  moveIndicator();
302
312
  }
303
313
 
304
- function renderCabinetOrders() {
305
- const box = $('#cabinetOrdersBox');
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 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> (чтобы зарезервировать регион).
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
- 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
324
- ? `<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>` : ''}
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
- : `<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>
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
- <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">
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
- <div style="text-align:right">
347
- <div style="font-size:18px;font-weight:800">${rub(o.amountRub)}</div>
348
- ${statusBadge}
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
- ${actionsHtml}
476
+
477
+ ${filesHtml}
478
+ ${pendingOrdersHtml}
352
479
  </div>`;
353
480
  }).join('');
354
481
  }
355
482
 
356
- function renderCabinetLicenses() {
357
- const box = $('#cabinetLicensesBox');
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 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 по токену владельца.
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
- 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>
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
- 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;
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 copyCabinetToken() {
398
- const val = $('#inpCabinetHubToken')?.value || '';
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 = $('#tokenCopyStatus');
706
+ const st = $(`#srvTokStatus-${licenseId}`);
402
707
  if (st) {
403
- st.textContent = 'Токен скопирован в буфер обмена! Вставьте его в локальном WORK → Лицензия.';
404
- setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; }, 5000);
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', () => syncClientSeats($('#inpPakExtraSeatsNum')?.value || 0));
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();