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 CHANGED
@@ -1,4 +1,4 @@
1
- /* ODANT-WORK · /admin — клиентский скрипт отдельной защищённой админ-панели центра лицензирования */
1
+ /* ODANT-WORK · /admin — клиентский скрипт рабочей админ-панели центра лицензирования и франшиз */
2
2
  (function () {
3
3
  'use strict';
4
4
 
@@ -12,6 +12,9 @@
12
12
  let adminToken = '';
13
13
  let lastAdminData = null;
14
14
  let currentCertLic = null;
15
+ let currentScheduleResId = '';
16
+ let activeTab = 'servers';
17
+ let admCountdownTimer = null;
15
18
 
16
19
  function getStoredToken() {
17
20
  try {
@@ -70,7 +73,7 @@
70
73
  applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light');
71
74
  }
72
75
 
73
- /* ================= Авторизация администратора (по Email и 6-значному коду из письма) ================= */
76
+ /* ================= Авторизация администратора (по Email и 6-значному коду) ================= */
74
77
  function showLoginGate(errMsg = '') {
75
78
  $('#adminLoginScreen').style.display = '';
76
79
  $('#adminAppShell').style.display = 'none';
@@ -86,10 +89,20 @@
86
89
  setTimeout(() => $('#admLoginEmail')?.focus(), 50);
87
90
  }
88
91
 
92
+ function moveIndicator() {
93
+ const active = $('#tabs .tab.active');
94
+ const ind = $('#tabInd');
95
+ if (!ind) return;
96
+ if (!active) { ind.style.width = '0px'; return; }
97
+ ind.style.left = active.offsetLeft + 'px';
98
+ ind.style.width = active.offsetWidth + 'px';
99
+ }
100
+
89
101
  function showAdminApp(login = 'BOSS') {
90
102
  $('#adminLoginScreen').style.display = 'none';
91
103
  $('#adminAppShell').style.display = '';
92
- if ($('#admUserLabel')) $('#admUserLabel').textContent = `Выйти (${login})`;
104
+ if ($('#admUserLabel')) $('#admUserLabel').textContent = login;
105
+ requestAnimationFrame(moveIndicator);
93
106
  }
94
107
 
95
108
  async function sendAdminOtpCode() {
@@ -195,7 +208,15 @@
195
208
  }
196
209
  }
197
210
 
198
- /* ================= Реестр и таблицы ================= */
211
+ /* ================= Вкладки и форматирование ================= */
212
+ function setTab(tab) {
213
+ activeTab = tab || 'servers';
214
+ $$('[data-adm-tab]').forEach(b => b.classList.toggle('active', b.dataset.admTab === activeTab));
215
+ $$('[data-adm-sec]').forEach(sec => {
216
+ sec.style.display = sec.dataset.admSec === activeTab ? '' : 'none';
217
+ });
218
+ }
219
+
199
220
  function fmtDate(ms) {
200
221
  if (!ms) return '—';
201
222
  try { return new Date(ms).toLocaleDateString('ru-RU'); } catch { return '—'; }
@@ -204,6 +225,35 @@
204
225
  if (!ms) return '—';
205
226
  try { return new Date(ms).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }); } catch { return '—'; }
206
227
  }
228
+ function fmtCountdown(sec) {
229
+ const s = Math.max(0, Math.floor(Number(sec) || 0));
230
+ const m = Math.floor(s / 60);
231
+ const r = s % 60;
232
+ return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
233
+ }
234
+
235
+ function startAdminCountdowns() {
236
+ if (admCountdownTimer) {
237
+ clearInterval(admCountdownTimer);
238
+ admCountdownTimer = null;
239
+ }
240
+ if (!$$('[data-adm-deadline]').length) return;
241
+ admCountdownTimer = setInterval(() => {
242
+ const now = Date.now();
243
+ let expiredAny = false;
244
+ for (const el of $$('[data-adm-deadline]')) {
245
+ const dl = Number(el.dataset.admDeadline || 0);
246
+ const rem = Math.max(0, Math.floor((dl - now) / 1000));
247
+ el.textContent = fmtCountdown(rem);
248
+ if (rem <= 0) expiredAny = true;
249
+ }
250
+ if (expiredAny) {
251
+ clearInterval(admCountdownTimer);
252
+ admCountdownTimer = null;
253
+ refreshRegistry();
254
+ }
255
+ }, 1000);
256
+ }
207
257
 
208
258
  async function refreshRegistry() {
209
259
  if (!adminToken) return;
@@ -212,299 +262,509 @@
212
262
  catch { return; }
213
263
  lastAdminData = data;
214
264
  renderAdminPanel(data);
265
+ if (currentScheduleResId && $('#admFranchiseScheduleModal')?.classList.contains('open')) {
266
+ openFranchiseScheduleModal(currentScheduleResId);
267
+ }
215
268
  }
216
269
 
217
270
  function renderAdminPanel(data = {}) {
218
271
  const licenses = data.licenses || [];
272
+ const franchises = data.franchises || data.reservations || [];
219
273
  const orders = data.orders || [];
220
- const partners = data.partners || [];
221
- const reservations = (data.reservations || []).filter(r => r.status !== 'canceled');
222
274
  const tickets = data.tickets || [];
223
275
  const users = data.users || [];
224
276
 
225
- const totalSeats = licenses.reduce((s, l) => s + (l.totalSeats || 0), 0);
226
- const paidOrders = orders.filter(o => o.status === 'paid');
227
- const pendingOrders = orders.filter(o => o.status === 'pending');
228
- const paidRub = paidOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
229
- const pendingRub = pendingOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
277
+ const activeFranchises = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
230
278
  const openTickets = tickets.filter(t => t.status !== 'answered' && t.status !== 'closed');
231
- const activeReservations = reservations.filter(r => !r.convertedToPartnerId);
232
- const signedUsers = users.filter(u => u.ofertaAccepted);
233
-
234
- if ($('#admStatLic')) {
235
- $('#admStatLic').textContent = String(licenses.length);
236
- $('#admStatLicSub').textContent = `лицензий ПАК · ${totalSeats} активных мест`;
237
- $('#admStatPaid').textContent = rub(paidRub);
238
- $('#admStatPaidSub').textContent = `оплачено заказов: ${paidOrders.length}`;
239
- $('#admStatPending').textContent = rub(pendingRub);
240
- $('#admStatPendingSub').textContent = `ожидают оплаты: ${pendingOrders.length}`;
241
- $('#admStatPartners').textContent = `${partners.length} / ${activeReservations.length}`;
242
- if ($('#admStatUsers')) {
243
- $('#admStatUsers').textContent = String(users.length);
244
- $('#admStatUsersSub').textContent = `клиентов (оферт ПЭП: ${signedUsers.length})`;
279
+
280
+ if ($('#navAdmServers')) $('#navAdmServers').textContent = `Серверы и лицензии (${licenses.length})`;
281
+ if ($('#navAdmFranchises')) $('#navAdmFranchises').textContent = `Франшизы (${activeFranchises.length})`;
282
+ if ($('#navAdmOrders')) $('#navAdmOrders').textContent = `Заказы и счета (${orders.length})`;
283
+ if ($('#navAdmUsers')) $('#navAdmUsers').textContent = `Клиенты и роли (${users.length})`;
284
+ if ($('#navAdmTickets')) $('#navAdmTickets').textContent = `Тикеты поддержки (${openTickets.length})`;
285
+
286
+ renderServersTable(licenses);
287
+ renderFranchisesTable(franchises);
288
+ renderOrdersTable(orders);
289
+ renderUsersTable(users);
290
+ renderTicketsTable(tickets);
291
+ startAdminCountdowns();
292
+ }
293
+
294
+ /* 1. Таблица серверов и лицензий */
295
+ function renderServersTable(licenses = []) {
296
+ const tbody = $('#admLicensesBody');
297
+ if (!tbody) return;
298
+ const q = ($('#inpFilterServers')?.value || '').trim().toLowerCase();
299
+ const mode = $('#selFilterServers')?.value || 'all';
300
+
301
+ const filtered = licenses.filter(l => {
302
+ const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
303
+ if (mode === 'base_active' && !l.baseActivated) return false;
304
+ if (mode === 'base_inactive' && l.baseActivated) return false;
305
+ if (mode === 'hwid_bound' && !hwidBound) return false;
306
+ if (!q) return true;
307
+ const hay = [
308
+ l.licenseId, l.serverName, l.ownerEmail, l.customer?.name,
309
+ l.customer?.inn, l.customer?.email, l.hardware?.hwid, l.hardware?.nodeFingerprint,
310
+ ].join(' ').toLowerCase();
311
+ return hay.includes(q);
312
+ });
313
+
314
+ tbody.innerHTML = filtered.map(l => {
315
+ const lid = l.licenseId;
316
+ const srvName = l.serverName || 'Сервер WORK';
317
+ const hwidBound = Boolean(l.hwidBound || l.hardware?.hwidBound || l.hardware?.nodeFingerprint);
318
+ const hwid = l.hardware?.hwid || '—';
319
+ const fp = l.hardware?.nodeFingerprint || '';
320
+ const ownerEmail = l.ownerEmail || l.customer?.email || '—';
321
+
322
+ const baseHtml = l.baseActivated
323
+ ? `<span class="tag ok">✓ Активна (${l.baseSeats || 10} вечн. + ИИ)</span>
324
+ <div style="margin-top:4px"><button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px;color:var(--danger)" data-adm-base="${esc(lid)}" data-activate="0">Отозвать базовую</button></div>`
325
+ : `<span class="tag">Не активирована (1 сессия BOSS)</span>
326
+ <div style="margin-top:4px"><button class="btn btn-ok btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-base="${esc(lid)}" data-activate="1">Активировать базовую</button></div>`;
327
+
328
+ const hwidHtml = hwidBound
329
+ ? `<span class="tag ok">Привязан</span> <code>${esc(hwid)}</code>
330
+ ${fp ? `<div class="small muted">Узел: <code>${esc(fp.slice(0, 18))}…</code></div>` : ''}
331
+ <div style="margin-top:4px"><button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-reset-hwid="${esc(lid)}">Сбросить привязку</button></div>`
332
+ : `<span class="small muted">Ожидает привязки токена</span><div class="small muted"><code>${esc(hwid)}</code></div>`;
333
+
334
+ return `<tr>
335
+ <td>
336
+ <b>${esc(srvName)}</b>
337
+ <div class="small muted"><code>${esc(lid)}</code> · создан ${fmtDate(l.issuedAt)}</div>
338
+ <div style="margin-top:4px;display:flex;gap:6px;flex-wrap:wrap">
339
+ <button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-copy-tok="${esc(lid)}">Скопировать токен owk1</button>
340
+ </div>
341
+ </td>
342
+ <td>
343
+ <b>${esc(l.customer?.name || '—')}</b>
344
+ <div class="small muted">ИНН ${esc(l.customer?.inn || '—')}</div>
345
+ <div class="small"><code>${esc(ownerEmail)}</code></div>
346
+ </td>
347
+ <td>${hwidHtml}</td>
348
+ <td>${baseHtml}</td>
349
+ <td>
350
+ <div>Всего сессий: <b>${l.totalSeats}</b> (+<b>${l.addonSeats || 0}</b> доп.)</div>
351
+ <div class="small muted">SLA: ${esc(l.support?.tierName || 'Enterprise')} (пакетов: ${(l.activeAddons || []).length})</div>
352
+ <div style="margin-top:4px">
353
+ <button class="btn btn-ghost btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-open-grant="${esc(lid)}" data-srv-name="${esc(srvName)}">+ Начислить доп. места</button>
354
+ </div>
355
+ </td>
356
+ <td style="text-align:right">
357
+ <button class="btn btn-primary btn-sm" style="padding:6px 10px;font-size:12px" data-cert="${esc(lid)}">.lic.json (${(l.licenseFiles || []).length})</button>
358
+ </td>
359
+ </tr>`;
360
+ }).join('') || '<tr><td colspan="6" class="muted">Серверов по заданному фильтру не найдено</td></tr>';
361
+ }
362
+
363
+ /* 2. Таблица франшиз и 12-месячных графиков погашения */
364
+ function renderFranchisesTable(franchises = []) {
365
+ const tbody = $('#admFranchisesBody');
366
+ if (!tbody) return;
367
+ const q = ($('#inpFilterFranchises')?.value || '').trim().toLowerCase();
368
+ const mode = $('#selFilterFranchises')?.value || 'all';
369
+ const now = Date.now();
370
+
371
+ const filtered = franchises.filter(f => {
372
+ if (!f) return false;
373
+ const isClosed = f.status === 'canceled' || f.status === 'expired';
374
+ const remaining = Number(f.remainingFranchiseFeeRub ?? 0);
375
+ if (mode === 'all' && isClosed) return false;
376
+ if (mode === 'pending_deposit' && (isClosed || f.depositPaid)) return false;
377
+ if (mode === 'deposit_paid' && (isClosed || !f.depositPaid || remaining <= 0)) return false;
378
+ if (mode === 'overdue' && (isClosed || !(f.overdueMonthsCount > 0))) return false;
379
+ if (mode === 'paid_in_full' && (isClosed || !f.depositPaid || remaining > 0)) return false;
380
+ if (mode === 'closed' && !isClosed) return false;
381
+ if (!q) return true;
382
+ const hay = [
383
+ f.reservationId, f.regionName, f.regionCode, f.district,
384
+ f.companyName, f.inn, f.email,
385
+ ].join(' ').toLowerCase();
386
+ return hay.includes(q);
387
+ });
388
+
389
+ tbody.innerHTML = filtered.map(f => {
390
+ const rid = f.reservationId;
391
+ const isClosed = f.status === 'canceled' || f.status === 'expired';
392
+ const depositRub = Number(f.nonRefundableDepositRub || 100_000);
393
+ const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
394
+ const paidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
395
+ const remRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - paidRub));
396
+ const paidMonths = Number(f.paidMonthsCount || 0);
397
+ const overdueMonths = Number(f.overdueMonthsCount || 0);
398
+ const pct = totalFeeRub > 0 ? Math.min(100, Math.round((paidRub / totalFeeRub) * 100)) : 0;
399
+
400
+ const dl = Number(f.depositDeadlineAt || ((f.createdAt || now) + 15 * 60_000));
401
+ const remSec = Math.max(0, Math.floor((dl - now) / 1000));
402
+
403
+ let depositHtml = '';
404
+ if (isClosed) {
405
+ depositHtml = `<span class="tag bad">${f.status === 'expired' ? 'Истёк (15 мин)' : 'Отменён'}</span>`;
245
406
  }
246
- $('#admStatTickets').textContent = String(openTickets.length);
247
- $('#admStatTicketsSub').textContent = `открытых из ${tickets.length} обращений`;
248
- }
249
-
250
- // 1. Лицензии
251
- if ($('#admLicensesBody')) {
252
- $('#admLicensesBody').innerHTML = licenses.map(l => {
253
- const hwid = l.hardware?.hwid || l.boundHwid || 'не привязан';
254
- const fp = l.hardware?.nodeFingerprint || l.boundNodeFingerprint || '';
255
- const bindCode = l.support?.bindCode || l.bindingCode || l.licenseId;
256
- const expAt = l.baseSupportUntil || l.support?.expiresAt;
257
- const partnerInfo = l.partner?.companyName
258
- ? `<div class="small" style="color:var(--brand-2)">Партнёр: ${esc(l.partner.companyName)} (${esc(l.partner.regionName || '')})</div>`
259
- : '';
260
- return `<tr>
261
- <td><b>${esc(l.licenseId)}</b><div class="small muted">Код бота: <code>${esc(bindCode)}</code></div></td>
262
- <td><b>${esc(l.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(l.customer?.inn || '—')} · ${esc(l.customer?.email || '—')}</div></td>
263
- <td><code>${esc(hwid)}</code><div class="small muted">${esc(fp ? fp.slice(0, 22) : 'ожидает синхронизации узла')}</div>${partnerInfo}</td>
264
- <td class="num"><b>${l.totalSeats}</b> <span class="muted">(${l.baseSeats ?? 10} вечн. + ${l.addonSeats || 0} доп.)</span></td>
265
- <td><span class="tag ok">${esc(l.support?.tierName || 'Enterprise')}</span><div class="small muted">до ${fmtDate(expAt)} · ${l.support?.ticketsRemaining ?? 0}/${l.support?.ticketsQuota ?? 0} обращ.</div></td>
266
- <td><div style="display:flex;gap:6px;flex-wrap:wrap">
267
- <button class="btn btn-ghost btn-sm" data-cert="${esc(l.licenseId)}">Сертификат JSON</button>
268
- </div></td>
269
- </tr>`;
270
- }).join('') || '<tr><td colspan="6" class="muted">Лицензий пока нет — нажмите «+ Демо-лицензия ПАК» или выпустите выше</td></tr>';
271
- }
272
-
273
- // 2. Заказы ЮKassa
274
- if ($('#admOrdersBody')) {
275
- $('#admOrdersBody').innerHTML = orders.map(o => {
276
- const stTag = o.status === 'paid' ? 'ok' : (o.status === 'canceled' ? 'bad' : '');
277
- const stLabel = o.status === 'paid' ? 'оплачен' : (o.status === 'canceled' ? 'отменён' : 'ожидает оплаты');
278
- const purposeSub = o.includeBasePak
279
- ? `Комплекс ПАК${o.addonSeats ? ` + ${o.addonSeats} доп. мест` : ''}`
280
- : (o.kind === 'franchise_reservation' ? `Залог франшизы (${esc(o.regionName || o.regionCode || '')})` : `Доп. места: ${o.addonSeats || 0} × ${o.years || 1} г.`);
281
- return `<tr>
282
- <td><b>${esc(o.orderId)}</b><div class="small muted">${esc(o.paymentId || '—')}</div></td>
283
- <td><b>${esc(o.tierName || o.kind || 'Заказ')}</b><div class="small muted">${purposeSub}</div></td>
284
- <td><b>${esc(o.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(o.customer?.inn || '—')} · ${esc(o.customer?.email || '—')}</div></td>
285
- <td class="num"><b>${rub(o.amountRub)}</b></td>
286
- <td><span class="tag ${stTag}">${stLabel}</span></td>
287
- <td><div style="display:flex;gap:6px;flex-wrap:wrap">
288
- ${o.status === 'pending' ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(o.orderId)}">Провести оплату</button>` : ''}
289
- ${o.status === 'pending' ? `<button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}">Отменить</button>` : ''}
290
- <a class="btn btn-ghost btn-sm" href="/pay/sandbox/${encodeURIComponent(o.orderId)}" target="_blank" rel="noopener">Песочница</a>
291
- </div></td>
292
- </tr>`;
293
- }).join('') || '<tr><td colspan="6" class="muted">Заказов пока нет</td></tr>';
294
- }
295
-
296
- // 3. Резервы франшиз и действующие партнёры
297
- if ($('#admPartnersBody')) {
298
- const resRows = reservations.map(r => `<tr>
299
- <td><b>${esc(r.reservationId)}</b><div class="small muted">Резерв франшизы</div></td>
300
- <td><b>${esc(r.regionName)}</b> <span class="muted">(${esc(r.regionCode)})</span></td>
301
- <td><b>${esc(r.companyName)}</b><div class="small muted">ИНН ${esc(r.inn || '—')} · ${esc(r.email || '—')}</div></td>
302
- <td>Паушальный взнос <b>${rub(r.franchiseFeeRub)}</b><div class="small muted">Залог ${rub(r.nonRefundableDepositRub || 100000)} · остаток за 1 год</div></td>
303
- <td><span class="tag ${r.depositPaid ? 'ok' : ''}">${r.convertedToPartnerId ? 'конвертирован в партнёра' : (r.depositPaid ? 'залог оплачен' : 'ждёт оплаты залога')}</span></td>
304
- <td><div style="display:flex;gap:6px;flex-wrap:wrap">
305
- ${!r.depositPaid && r.orderId ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(r.orderId)}">Оплатить залог</button>` : ''}
306
- ${!r.convertedToPartnerId ? `<button class="btn btn-primary btn-sm" data-activate-res="${esc(r.reservationId)}">Активировать партнёра</button>` : `<span class="small muted">Партнёр ${esc(r.convertedToPartnerId)}</span>`}
307
- </div></td>
308
- </tr>`);
309
-
310
- const partRows = partners.map(p => {
311
- const certs = p.certifiedSpecialists || [];
312
- const certCodes = certs.map(c => c.trackCode).join(', ');
313
- return `<tr>
314
- <td><b>${esc(p.partnerId)}</b><div class="small muted">Действующий партнёр</div></td>
315
- <td><b>${esc(p.regionName)}</b> <span class="muted">(${esc(p.regionCode)})</span></td>
316
- <td><b>${esc(p.companyName)}</b><div class="small muted">ИНН ${esc(p.inn || '—')} · ${esc(p.email || '—')}</div></td>
317
- <td>Скидка <b>${p.discountPercent}%</b> · ${esc(p.statusLevel)}<div class="small muted">Продажи: ${rub(p.annualSalesRub || 0)}</div></td>
318
- <td>
319
- <span class="tag ${p.certifiedStatus ? 'ok' : ''}">${p.certifiedStatus ? '✓ Сертифицированный франчайзи' : `сертификатов: ${certs.length} из 3`}</span>
320
- ${certCodes ? `<div class="small muted">${esc(certCodes)}</div>` : ''}
321
- </td>
322
- <td><div style="display:flex;gap:6px;flex-wrap:wrap">
323
- <button class="btn btn-ghost btn-sm" data-certify-partner="${esc(p.partnerId)}">+ Аттестовать инженера</button>
324
- <button class="btn btn-primary btn-sm" data-partner-issue="${esc(p.partnerId)}">+ Выдать ПАК (продажа)</button>
325
- </div></td>
326
- </tr>`;
327
- });
328
- $('#admPartnersBody').innerHTML = [...resRows, ...partRows].join('') || '<tr><td colspan="6" class="muted">Резервов и партнёров пока нет</td></tr>';
329
- }
330
-
331
- // 4. Тикеты SLA
332
- if ($('#admTicketsBody')) {
333
- $('#admTicketsBody').innerHTML = tickets.map(t => {
334
- const msgs = (t.messages || []).map(m => {
335
- const isSupport = m.from === 'support' || m.role === 'engineer';
336
- const author = isSupport ? (m.author || 'Инженер ODANT') : (m.author || t.userName || 'Клиент');
337
- return `<div class="small"><b style="color:${isSupport ? 'var(--brand-2)' : 'var(--text)'}">${esc(author)}:</b> ${esc(m.text)}</div>`;
338
- }).join('');
339
- const slaStatus = t.status === 'closed'
340
- ? '<span class="tag">закрыт</span>'
341
- : t.slaMet === true
342
- ? '<span class="tag ok">✓ SLA выполнен</span>'
343
- : t.slaMet === false
344
- ? '<span class="tag bad">SLA просрочен</span>'
345
- : '<span class="tag">ожидает ответа</span>';
346
- return `<tr>
347
- <td><b>${esc(t.ticketId)}</b><div><span class="tag">${esc(String(t.channel || '').toUpperCase())}</span> ${slaStatus}</div></td>
348
- <td><b>${esc(t.customerName || t.userName || t.chatId)}</b><div class="small muted">Лицензия: ${esc(t.licenseId || 'не привязана')}</div></td>
349
- <td><span class="tag ok">${esc(t.tierName || 'Start')}</span><div class="small muted">${esc(t.reactionLabel || (`реакция ≤ ${t.reactionMinutes || 480} мин`))}</div></td>
350
- <td style="white-space:normal;max-width:360px">${msgs}</td>
351
- <td>
352
- ${t.status !== 'closed' ? `<div style="display:flex;gap:6px;flex-wrap:wrap">
353
- <input type="text" id="reply-${esc(t.ticketId)}" placeholder="Ответ инженера…" style="min-width:180px;padding:7px 10px;font-size:12.5px">
354
- <button class="btn btn-primary btn-sm" data-reply-ticket="${esc(t.ticketId)}">Ответить</button>
355
- <button class="btn btn-ghost btn-sm" data-close-ticket="${esc(t.ticketId)}">Закрыть</button>
356
- </div>` : '<span class="small muted">Обращение закрыто</span>'}
357
- </td>
358
- </tr>`;
359
- }).join('') || '<tr><td colspan="5" class="muted">Обращений пока нет</td></tr>';
360
- }
361
-
362
- // 5. Клиенты, роли BOSS и оферты (ПЭП)
363
- if ($('#admUsersBody')) {
364
- $('#admUsersBody').innerHTML = users.map(u => `<tr>
365
- <td><b>${esc(u.userId)}</b><div class="small muted">${esc(u.email)}</div></td>
407
+ else if (f.depositPaid) {
408
+ depositHtml = `<span class="tag ok">✓ Оплачен (${rub(depositRub)})</span><div class="small muted">${fmtDateTime(f.paidAt)}</div>`;
409
+ }
410
+ else {
411
+ depositHtml = `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Таймер 15 мин: <b data-adm-deadline="${dl}">${fmtCountdown(remSec)}</b></span>
412
+ <div style="margin-top:4px"><button class="btn btn-ok btn-sm" style="padding:3px 8px;font-size:11.5px" data-adm-pay-deposit="${esc(rid)}">Зачесть залог (${rub(depositRub)})</button></div>`;
413
+ }
414
+
415
+ let schedHtml = '';
416
+ if (isClosed) {
417
+ schedHtml = '<span class="small muted">Резерв закрыт</span>';
418
+ }
419
+ else {
420
+ const overdueBadge = overdueMonths > 0
421
+ ? ` <span class="tag bad">Просрочено: ${overdueMonths}</span>`
422
+ : (f.depositPaid && remRub <= 0 ? ' <span class="tag ok">Погашен полностью</span>' : '');
423
+ schedHtml = `<div><b>${paidMonths} из 12</b> ежемесячных платежей${overdueBadge}</div>
424
+ <div class="bar" style="width:160px;height:6px;margin-top:5px"><i style="--p:${pct}%"></i></div>
425
+ ${f.nextInstallment && f.depositPaid ? `<div class="small muted" style="margin-top:4px">След. взнос №${f.nextInstallment.monthNumber}: ${rub(f.nextInstallment.amountRub)} до ${fmtDate(f.nextInstallment.dueDate)}</div>` : ''}`;
426
+ }
427
+
428
+ return `<tr>
429
+ <td>
430
+ <b>${esc(f.regionName)}</b> <span class="tag">${esc(f.district || f.regionCode)}</span>
431
+ <div class="small muted"><code>${esc(rid)}</code> · от ${fmtDateTime(f.createdAt)}</div>
432
+ </td>
433
+ <td>
434
+ <b>${esc(f.companyName || '—')}</b>
435
+ <div class="small muted">ИНН ${esc(f.inn || '—')} · <code>${esc(f.email || '—')}</code></div>
436
+ </td>
437
+ <td>${depositHtml}</td>
438
+ <td>${schedHtml}</td>
439
+ <td>
440
+ <div>Паушальный: <b>${rub(totalFeeRub)}</b></div>
441
+ <div class="small" style="color:var(--ok)">Оплачено: <b>${rub(paidRub)}</b></div>
442
+ <div class="small muted">Остаток: <b>${rub(remRub)}</b></div>
443
+ </td>
444
+ <td>
445
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
446
+ <button class="btn btn-primary btn-sm" data-adm-sched="${esc(rid)}">График 12 мес.</button>
447
+ ${!isClosed ? `<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-adm-cancel-franchise="${esc(rid)}">Отозвать</button>` : ''}
448
+ </div>
449
+ </td>
450
+ </tr>`;
451
+ }).join('') || '<tr><td colspan="6" class="muted">Франшиз по заданному фильтру не найдено</td></tr>';
452
+ }
453
+
454
+ /* 3. Таблица заказов и счетов */
455
+ function renderOrdersTable(orders = []) {
456
+ const tbody = $('#admOrdersBody');
457
+ if (!tbody) return;
458
+ const q = ($('#inpFilterOrders')?.value || '').trim().toLowerCase();
459
+ const mode = $('#selFilterOrders')?.value || 'all';
460
+
461
+ const filtered = orders.filter(o => {
462
+ if (!o) return false;
463
+ const isPending = o.status === 'pending' || o.status === 'awaiting_invoice';
464
+ if (mode === 'pending' && !isPending) return false;
465
+ if (mode === 'paid' && o.status !== 'paid') return false;
466
+ if (mode === 'canceled' && o.status !== 'canceled') return false;
467
+ if (!q) return true;
468
+ const hay = [
469
+ o.orderId, o.invoiceNumber, o.contractNumber, o.licenseId,
470
+ o.reservationId, o.tierName, o.customer?.name, o.customer?.inn, o.customer?.email,
471
+ ].join(' ').toLowerCase();
472
+ return hay.includes(q);
473
+ });
474
+
475
+ tbody.innerHTML = filtered.map(o => {
476
+ const isPaid = o.status === 'paid';
477
+ const isCanceled = o.status === 'canceled';
478
+ const isPending = !isPaid && !isCanceled;
479
+ const stTag = isPaid ? 'ok' : (isCanceled ? 'bad' : '');
480
+ const stLabel = isPaid ? '✓ Оплачен' : (isCanceled ? 'Отменён' : (o.paymentMethod === 'invoice' ? 'Ждёт оплаты по счёту' : 'Ожидает ЮKassa'));
481
+
482
+ let targetInfo = `Сервер: <code>${esc(o.licenseId || '—')}</code>`;
483
+ if (o.kind === 'franchise_reservation') {
484
+ targetInfo = `Залог франшизы (15 мин): <b>${esc(o.regionName || o.regionCode)}</b> (<code>${esc(o.reservationId)}</code>)`;
485
+ }
486
+ else if (o.kind === 'franchise_installment') {
487
+ targetInfo = `Погашение франшизы: <b>${esc(o.regionName || o.regionCode)}</b> (<code>${esc(o.reservationId)}</code>)`;
488
+ }
489
+
490
+ return `<tr>
491
+ <td>
492
+ <b>${esc(o.orderId)}</b>
493
+ ${o.invoiceNumber ? `<div class="small">Счёт: <code>${esc(o.invoiceNumber)}</code></div>` : ''}
494
+ <div class="small muted">${fmtDateTime(o.createdAt)}</div>
495
+ </td>
496
+ <td>
497
+ <b>${esc(o.tierName || o.kind || 'Заказ')}</b>
498
+ <div class="small muted">${targetInfo}</div>
499
+ </td>
500
+ <td>
501
+ <b>${esc(o.customer?.name || '—')}</b>
502
+ <div class="small muted">ИНН ${esc(o.customer?.inn || '—')} · ${esc(o.customer?.email || '—')}</div>
503
+ </td>
504
+ <td class="num"><b>${rub(o.amountRub)}</b><div class="small muted">${o.paymentMethod === 'invoice' ? 'Безнал / Счёт' : 'ЮKassa'}</div></td>
505
+ <td><span class="tag ${stTag}">${stLabel}</span>${o.paidAt ? `<div class="small muted">${fmtDateTime(o.paidAt)}</div>` : ''}</td>
506
+ <td>
507
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
508
+ ${isPending ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(o.orderId)}">Подтвердить оплату</button>` : ''}
509
+ ${isPending ? `<button class="btn btn-ghost btn-sm" style="color:var(--danger)" data-cancel-order="${esc(o.orderId)}">Отменить</button>` : ''}
510
+ ${o.invoiceUrl ? `<a class="btn btn-ghost btn-sm" href="${esc(o.invoiceUrl)}" target="_blank" rel="noopener">Счёт ↗</a>` : ''}
511
+ </div>
512
+ </td>
513
+ </tr>`;
514
+ }).join('') || '<tr><td colspan="6" class="muted">Заказов по заданному фильтру не найдено</td></tr>';
515
+ }
516
+
517
+ /* 4. Таблица клиентов и ролей BOSS */
518
+ function renderUsersTable(users = []) {
519
+ const tbody = $('#admUsersBody');
520
+ if (!tbody) return;
521
+ const q = ($('#inpFilterUsers')?.value || '').trim().toLowerCase();
522
+
523
+ const filtered = users.filter(u => {
524
+ if (!q) return true;
525
+ const hay = [u.userId, u.email, u.companyName, u.inn, u.name].join(' ').toLowerCase();
526
+ return hay.includes(q);
527
+ });
528
+
529
+ tbody.innerHTML = filtered.map(u => {
530
+ const srvCount = Array.isArray(u.servers) ? u.servers.length : (u.licenseId ? 1 : 0);
531
+ const frCount = Array.isArray(u.franchises) ? u.franchises.length : 0;
532
+ return `<tr>
533
+ <td><b>${esc(u.email)}</b><div class="small muted"><code>${esc(u.userId)}</code></div></td>
366
534
  <td>
367
535
  ${u.isAdmin
368
536
  ? `<span class="tag ok">BOSS · Администратор</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="0">Снять права</button>`
369
- : `<span class="tag">Клиент</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="1">Сделать админом</button>`}
537
+ : `<span class="tag">Клиент</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="1">Назначить BOSS</button>`}
370
538
  </td>
371
539
  <td><b>${esc(u.companyName || '—')}</b><div class="small muted">ИНН ${esc(u.inn || '—')}</div></td>
372
- <td><code>${esc(u.licenseId || '—')}</code></td>
540
+ <td>Серверов: <b>${srvCount}</b> · Франшиз: <b>${frCount}</b></td>
373
541
  <td>
374
542
  ${u.ofertaAccepted
375
- ? `<span class="tag ok">✓ Подписана ПЭП</span><div class="small muted"><code>${esc(u.ofertaSignatureId)}</code> · ${esc(u.ofertaVersion)}</div>`
543
+ ? `<span class="tag ok">✓ Подписана ПЭП</span><div class="small muted"><code>${esc(u.ofertaSignatureId)}</code></div>`
376
544
  : '<span class="tag bad">Не подписана</span>'}
377
545
  </td>
378
- <td class="small muted">${fmtDateTime(u.ofertaAcceptedAt || u.createdAt)}</td>
379
- </tr>`).join('') || '<tr><td colspan="6" class="muted">Зарегистрированных клиентов пока нет</td></tr>';
380
- }
381
- }
546
+ <td class="small muted">${fmtDateTime(u.lastLoginAt || u.createdAt)}</td>
547
+ </tr>`;
548
+ }).join('') || '<tr><td colspan="6" class="muted">Клиентов не найдено</td></tr>';
549
+ }
550
+
551
+ /* 5. Таблица тикетов SLA */
552
+ function renderTicketsTable(tickets = []) {
553
+ const tbody = $('#admTicketsBody');
554
+ if (!tbody) return;
555
+ const mode = $('#selFilterTickets')?.value || 'all';
556
+
557
+ const filtered = tickets.filter(t => {
558
+ if (mode === 'open' && t.status !== 'open') return false;
559
+ if (mode === 'answered' && t.status !== 'answered') return false;
560
+ if (mode === 'closed' && t.status !== 'closed') return false;
561
+ return true;
562
+ });
382
563
 
383
- async function adminToggleUserRole(userId, makeAdmin) {
384
- try {
385
- await api('/api/admin/users/role', {
386
- userId,
387
- isAdmin: Boolean(makeAdmin),
388
- });
389
- refreshRegistry();
390
- }
391
- catch (err) { alert(err.message); }
564
+ tbody.innerHTML = filtered.map(t => {
565
+ const msgs = (t.messages || []).map(m => {
566
+ const isSupport = m.from === 'support' || m.role === 'engineer';
567
+ const author = isSupport ? (m.author || 'Инженер ODANT') : (m.author || t.userName || 'Клиент');
568
+ return `<div class="small"><b style="color:${isSupport ? 'var(--brand-2)' : 'var(--text)'}">${esc(author)}:</b> ${esc(m.text)}</div>`;
569
+ }).join('');
570
+ const slaStatus = t.status === 'closed'
571
+ ? '<span class="tag">закрыт</span>'
572
+ : t.slaMet === true
573
+ ? '<span class="tag ok">✓ SLA выполнен</span>'
574
+ : t.slaMet === false
575
+ ? '<span class="tag bad">SLA просрочен</span>'
576
+ : '<span class="tag">ожидает ответа</span>';
577
+ return `<tr>
578
+ <td><b>${esc(t.ticketId)}</b><div><span class="tag">${esc(String(t.channel || '').toUpperCase())}</span> ${slaStatus}</div></td>
579
+ <td><b>${esc(t.customerName || t.userName || t.chatId)}</b><div class="small muted">Сервер: <code>${esc(t.licenseId || '—')}</code></div></td>
580
+ <td><span class="tag ok">${esc(t.tierName || 'Start')}</span><div class="small muted">${esc(t.reactionLabel || '')}</div></td>
581
+ <td style="white-space:normal;max-width:360px">${msgs}</td>
582
+ <td>
583
+ ${t.status !== 'closed' ? `<div style="display:flex;gap:6px;flex-wrap:wrap">
584
+ <input type="text" id="reply-${esc(t.ticketId)}" placeholder="Ответ инженера…" style="min-width:180px;padding:7px 10px;font-size:12.5px">
585
+ <button class="btn btn-primary btn-sm" data-reply-ticket="${esc(t.ticketId)}">Ответить</button>
586
+ <button class="btn btn-ghost btn-sm" data-close-ticket="${esc(t.ticketId)}">Закрыть</button>
587
+ </div>` : '<span class="small muted">Обращение закрыто</span>'}
588
+ </td>
589
+ </tr>`;
590
+ }).join('') || '<tr><td colspan="5" class="muted">Обращений нет</td></tr>';
591
+ }
592
+
593
+ /* ================= Модалка 12-месячного графика погашения франшизы ================= */
594
+ function openFranchiseScheduleModal(reservationId) {
595
+ const franchises = lastAdminData?.franchises || lastAdminData?.reservations || [];
596
+ const f = franchises.find(x => x.reservationId === reservationId);
597
+ if (!f) return;
598
+ currentScheduleResId = reservationId;
599
+ $('#admSchedTitle').textContent = `График погашения франшизы · ${f.regionName}`;
600
+ $('#admSchedSub').textContent = `${f.companyName || f.email || ''} · ${f.reservationId}`;
601
+
602
+ const depositRub = Number(f.nonRefundableDepositRub || 100_000);
603
+ const totalFeeRub = Number(f.franchiseFeeRub || 500_000);
604
+ const paidRub = Number(f.totalPaidRub ?? (f.depositPaid ? depositRub : 0));
605
+ const remRub = Number(f.remainingFranchiseFeeRub ?? Math.max(0, totalFeeRub - paidRub));
606
+
607
+ $('#admSchedSummary').innerHTML = `<div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap">
608
+ <div>Паушальный взнос: <b>${rub(totalFeeRub)}</b></div>
609
+ <div>Начальный взнос (залог): <b>${rub(depositRub)}</b> (${f.depositPaid ? '<span style="color:var(--ok)">оплачен</span>' : '<span style="color:var(--warn)">не оплачен</span>'})</div>
610
+ <div>Оплачено всего: <b style="color:var(--ok)">${rub(paidRub)}</b></div>
611
+ <div>Остаток к погашению: <b>${rub(remRub)}</b></div>
612
+ </div>`;
613
+
614
+ const schedule = Array.isArray(f.paymentSchedule) ? f.paymentSchedule : [];
615
+ $('#admSchedBody').innerHTML = schedule.map(inst => {
616
+ const isPaid = inst.status === 'paid';
617
+ const stHtml = isPaid
618
+ ? `<span class="tag ok">✓ Оплачен ${fmtDate(inst.paidAt)}</span>`
619
+ : (inst.displayStatus === 'overdue'
620
+ ? '<span class="tag bad">Просрочен</span>'
621
+ : (inst.displayStatus === 'next' ? '<span class="tag">Очередной к оплате</span>' : '<span class="tag">По графику</span>'));
622
+ const actHtml = !isPaid
623
+ ? `<button class="btn btn-ok btn-sm" style="padding:4px 10px;font-size:12px" data-adm-confirm-inst="${esc(f.reservationId)}" data-month="${inst.monthNumber}">Зачесть оплату (${rub(inst.amountRub)})</button>`
624
+ : `<span class="small muted">Заказ ${esc(inst.orderId || '—')}</span>`;
625
+ return `<tr>
626
+ <td><b>Месяц ${inst.monthNumber} / 12</b></td>
627
+ <td>${fmtDate(inst.dueDate)}</td>
628
+ <td class="num"><b>${rub(inst.amountRub)}</b></td>
629
+ <td>${stHtml}</td>
630
+ <td style="text-align:right">${actHtml}</td>
631
+ </tr>`;
632
+ }).join('');
633
+
634
+ $('#admSchedEarlyBox').innerHTML = remRub > 0
635
+ ? `<button class="btn btn-primary btn-sm" data-adm-confirm-inst="${esc(f.reservationId)}" data-early="1">Досрочно зачесть весь остаток (${rub(remRub)})</button>`
636
+ : `<span class="tag ok">✓ Все 12 платежей полностью погашены</span>`;
637
+
638
+ $('#admFranchiseScheduleModal').classList.add('open');
392
639
  }
393
640
 
394
641
  /* ================= Действия администратора ================= */
395
- async function adminIssueLicense() {
396
- const st = $('#admIssueStatus');
397
- st.textContent = 'Выпускаем сертификат…';
642
+ async function adminSubmitCreateServer() {
643
+ const st = $('#admCreateServerStatus');
644
+ if (st) st.textContent = '';
645
+ const serverName = $('#admNewSrvName')?.value.trim() || 'Сервер WORK #1';
646
+ const org = $('#admNewSrvOrg')?.value.trim() || '';
647
+ const inn = $('#admNewSrvInn')?.value.trim() || '';
648
+ const email = $('#admNewSrvEmail')?.value.trim() || '';
649
+ const hwid = $('#admNewSrvHwid')?.value.trim() || '';
650
+ const baseActivated = Boolean($('#admNewSrvActivateBase')?.checked);
651
+ if (!email || !email.includes('@')) {
652
+ if (st) st.textContent = 'Укажите корректный Email владельца сервера.';
653
+ return;
654
+ }
398
655
  try {
399
- const lic = await api('/api/license/issue', {
400
- hwid: $('#admLicHwid').value.trim(),
656
+ await api('/api/license/issue', {
657
+ serverName,
658
+ hwid,
659
+ baseActivated,
660
+ ownerEmail: email,
401
661
  customer: {
402
- name: $('#admLicOrg').value.trim(),
403
- inn: $('#admLicInn').value.trim(),
404
- email: $('#admLicEmail').value.trim(),
662
+ name: org || email,
663
+ inn,
664
+ email,
405
665
  },
406
666
  });
407
- st.textContent = `Выпущена лицензия ${lic.licenseId} (${lic.totalSeats} мест, подпись Ed25519)`;
408
- refreshRegistry();
667
+ $('#admCreateServerModal')?.classList.remove('open');
668
+ await refreshRegistry();
669
+ }
670
+ catch (err) {
671
+ if (st) st.textContent = err.message;
409
672
  }
410
- catch (err) { st.textContent = err.message; }
411
673
  }
412
674
 
413
- async function createDemoPak() {
675
+ async function adminManageServerAction(payload) {
414
676
  try {
415
- await api('/api/license/issue', {
416
- customer: { name: 'ООО «Маслоторг»', inn: '7701234567', email: 'admin@maslotorg.ru' },
417
- });
677
+ await api('/api/admin/servers/manage', payload);
678
+ await refreshRegistry();
679
+ }
680
+ catch (err) {
681
+ alert(err.message);
418
682
  }
419
- catch (err) { alert(err.message); }
420
- refreshRegistry();
421
683
  }
422
684
 
423
- async function adminConfirmOrder(orderId) {
424
- const order = (lastAdminData?.orders || []).find(o => o.orderId === orderId);
425
- if (!order) return;
426
- try {
427
- await api('/api/payments/yookassa/webhook', {
428
- type: 'notification',
429
- event: 'payment.succeeded',
430
- object: {
431
- id: order.paymentId,
432
- status: 'succeeded',
433
- amount: { value: Number(order.amountRub || 0).toFixed(2), currency: 'RUB' },
434
- metadata: { orderId: order.orderId, licenseId: order.licenseId || '', reservationId: order.reservationId || '' },
435
- },
436
- });
437
- refreshRegistry();
685
+ function openGrantSeatsModal(licenseId, serverName = '') {
686
+ $('#admGrantLicId').value = licenseId;
687
+ $('#admGrantTitle').textContent = 'Начислить доп. рабочие места';
688
+ $('#admGrantSub').textContent = `${serverName || 'Сервер WORK'} (${licenseId})`;
689
+ $('#admGrantSeatsModal')?.classList.add('open');
690
+ }
691
+
692
+ async function submitGrantSeats() {
693
+ const licenseId = $('#admGrantLicId')?.value || '';
694
+ const addonSeats = Math.max(1, Number($('#admGrantSeatsCount')?.value) || 5);
695
+ const addonYears = Math.max(1, Math.min(5, Number($('#admGrantYears')?.value) || 1));
696
+ const addonTier = $('#admGrantTier')?.value || 'business';
697
+ if (!licenseId) return;
698
+ await adminManageServerAction({
699
+ licenseId,
700
+ addonSeats,
701
+ addonYears,
702
+ addonTier,
703
+ });
704
+ $('#admGrantSeatsModal')?.classList.remove('open');
705
+ }
706
+
707
+ function copyAdminServerToken(licenseId) {
708
+ const lic = (lastAdminData?.licenses || []).find(l => l.licenseId === licenseId);
709
+ const tok = lic?.hubToken || '';
710
+ if (!tok) {
711
+ alert('Токен для этого сервера не найден');
712
+ return;
438
713
  }
439
- catch (err) { alert(err.message); }
714
+ navigator.clipboard?.writeText(tok);
440
715
  }
441
716
 
442
- async function adminCancelOrder(orderId) {
717
+ async function adminConfirmFranchiseInst(reservationId, { monthNumber = null, earlyPayoff = false } = {}) {
443
718
  try {
444
- await api('/api/admin/orders/' + encodeURIComponent(orderId) + '/cancel', {});
445
- refreshRegistry();
719
+ await api('/api/admin/franchises/confirm-installment', {
720
+ reservationId,
721
+ monthNumber,
722
+ earlyPayoff,
723
+ });
724
+ await refreshRegistry();
725
+ }
726
+ catch (err) {
727
+ alert(err.message);
446
728
  }
447
- catch (err) { alert(err.message); }
448
729
  }
449
730
 
450
- async function adminRegisterPartner() {
451
- const st = $('#admPartnerStatus');
452
- if (st) st.textContent = 'Регистрируем партнёра…';
731
+ async function adminCancelFranchise(reservationId) {
732
+ if (!confirm('Отозвать / отменить эту региональную франшизу? Слот в регионе освободится.')) return;
453
733
  try {
454
- const partner = await api('/api/partners/register', {
455
- regionCode: $('#admPartRegion').value,
456
- companyName: $('#admPartCompany').value.trim(),
457
- inn: $('#admPartInn').value.trim(),
458
- email: $('#admPartEmail').value.trim(),
459
- });
460
- if (st) st.textContent = `Зарегистрирован партнёр ${partner.partnerId} (${partner.regionName}, скидка ${partner.discountPercent}%)`;
461
- refreshRegistry();
734
+ await api('/api/admin/franchises/cancel', { reservationId });
735
+ await refreshRegistry();
736
+ }
737
+ catch (err) {
738
+ alert(err.message);
462
739
  }
463
- catch (err) { if (st) st.textContent = err.message; }
464
740
  }
465
741
 
466
- async function adminActivateReservation(reservationId) {
467
- const r = (lastAdminData?.reservations || []).find(x => x.reservationId === reservationId);
468
- if (!r) return;
742
+ async function adminConfirmOrder(orderId) {
469
743
  try {
470
- await api('/api/partners/register', {
471
- reservationId: r.reservationId,
472
- regionCode: r.regionCode,
473
- companyName: r.companyName,
474
- inn: r.inn,
475
- email: r.email,
744
+ await api('/api/admin/orders/' + encodeURIComponent(orderId) + '/confirm-invoice', {
745
+ paymentOrderNumber: 'ADM-' + Date.now(),
476
746
  });
477
- refreshRegistry();
747
+ await refreshRegistry();
478
748
  }
479
749
  catch (err) { alert(err.message); }
480
750
  }
481
751
 
482
- async function adminCertifyPartner(partnerId) {
752
+ async function adminCancelOrder(orderId) {
753
+ if (!confirm(`Отменить заказ ${orderId}?`)) return;
483
754
  try {
484
- await api('/api/partners/certify', {
485
- partnerId,
486
- specialistName: 'Ведущий инженер ПАК',
487
- email: 'engineer@partner.ru',
488
- trackId: 'next',
489
- score: 96,
490
- });
491
- refreshRegistry();
755
+ await api('/api/admin/orders/' + encodeURIComponent(orderId) + '/cancel', {});
756
+ await refreshRegistry();
492
757
  }
493
758
  catch (err) { alert(err.message); }
494
759
  }
495
760
 
496
- async function adminPartnerIssueLicense(partnerId) {
761
+ async function adminToggleUserRole(userId, makeAdmin) {
497
762
  try {
498
- await api('/api/partners/issue-license', {
499
- partnerId,
500
- customer: { name: 'Заказчик регионального партнёра', inn: '7710998877' },
501
- hardwareWarrantyYears: 1,
502
- partnerAddons: {
503
- nasBackup: true,
504
- techSupportContract: true,
505
- },
763
+ await api('/api/admin/users/role', {
764
+ userId,
765
+ isAdmin: Boolean(makeAdmin),
506
766
  });
507
- refreshRegistry();
767
+ await refreshRegistry();
508
768
  }
509
769
  catch (err) { alert(err.message); }
510
770
  }
@@ -519,7 +779,7 @@
519
779
  text,
520
780
  });
521
781
  inp.value = '';
522
- refreshRegistry();
782
+ await refreshRegistry();
523
783
  }
524
784
  catch (err) { alert(err.message); }
525
785
  }
@@ -527,7 +787,7 @@
527
787
  async function adminCloseTicket(ticketId) {
528
788
  try {
529
789
  await api('/api/admin/tickets/' + encodeURIComponent(ticketId) + '/close', {});
530
- refreshRegistry();
790
+ await refreshRegistry();
531
791
  }
532
792
  catch (err) { alert(err.message); }
533
793
  }
@@ -536,102 +796,127 @@
536
796
  const lic = (lastAdminData?.licenses || []).find(l => l.licenseId === licenseId);
537
797
  if (!lic) return;
538
798
  currentCertLic = lic;
539
- $('#certTitle').textContent = `Сертификат ${lic.licenseId}`;
540
- $('#certSub').textContent = `${lic.customer?.name || ''} · Ed25519 (WORK-LIC-v1)`;
799
+ $('#certTitle').textContent = `Сертификат и файлы .lic.json · ${lic.serverName || lic.licenseId}`;
800
+ $('#certSub').textContent = `${lic.customer?.name || ''} (${lic.licenseId}) · Ed25519 (WORK-LIC-v1)`;
801
+ const files = lic.licenseFiles || [];
802
+ if ($('#certFilesList')) {
803
+ $('#certFilesList').innerHTML = files.length
804
+ ? files.map((f, idx) => `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-radius:8px;border:1px solid var(--border);margin-bottom:6px;font-size:12.5px">
805
+ <code>${esc(f.fileName)}</code>
806
+ <button class="btn btn-ok btn-sm" style="padding:3px 10px;font-size:12px" data-adm-dl-licfile="${idx}">Скачать .lic.json</button>
807
+ </div>`).join('')
808
+ : '<div class="small muted">Нет активных .lic.json файлов (базовая лицензия не активирована).</div>';
809
+ }
541
810
  $('#certJsonBox').textContent = JSON.stringify(lic.certificate || lic, null, 2);
542
811
  $('#certModal').classList.add('open');
543
812
  }
544
813
 
545
- function downloadCurrentCert() {
814
+ function downloadCertLicFile(idx) {
546
815
  if (!currentCertLic) return;
547
- const json = JSON.stringify(currentCertLic.certificate || currentCertLic, null, 2);
816
+ const f = currentCertLic.licenseFiles?.[Number(idx)];
817
+ if (!f) return;
818
+ const json = JSON.stringify(f.licenseFile || f, null, 2);
548
819
  const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
549
820
  const url = URL.createObjectURL(blob);
550
821
  const a = document.createElement('a');
551
822
  a.href = url;
552
- a.download = `${currentCertLic.licenseId || 'work-license'}.json`;
823
+ a.download = f.fileName || `${currentCertLic.licenseId}.lic.json`;
553
824
  document.body.appendChild(a);
554
825
  a.click();
555
826
  a.remove();
556
827
  URL.revokeObjectURL(url);
557
828
  }
558
829
 
559
- function switchAdminTab(tab = 'all') {
560
- $$('[data-adm-tab]').forEach(b => b.classList.toggle('on', b.dataset.admTab === tab));
561
- const ws = $('#adminWorkspace');
562
- if (ws) ws.classList.toggle('only-agent', tab === 'agent');
563
- $$('[data-adm-sec]').forEach(sec => {
564
- sec.style.display = (tab === 'all' || tab === sec.dataset.admSec) ? '' : 'none';
565
- });
566
- if (tab === 'agent') setTimeout(() => $('#admAiInput')?.focus(), 50);
567
- }
568
-
569
- function md(text) {
570
- return esc(text)
571
- .replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
572
- .replace(/`([^`]+)`/g, '<code>$1</code>')
573
- .replace(/(^|\s)\*(.+?)\*(?=\s|$)/g, '$1<i>$2</i>');
574
- }
575
-
576
- async function sendAdminAi(text) {
577
- const inp = $('#admAiInput');
578
- text = (text ?? inp?.value ?? '').trim();
579
- if (!text) return;
580
- if (inp) inp.value = '';
581
- const box = $('#admAiMessages');
582
- if (!box) return;
583
- box.insertAdjacentHTML('beforeend', `<div class="msg me">${esc(text)}</div><div class="msg bot" id="admAiTyping"><span class="typing"><i></i><i></i><i></i></span></div>`);
584
- box.scrollTop = box.scrollHeight;
585
- let data;
586
- try { data = await api('/api/agent/chat', { message: text }); }
587
- catch (err) { data = { reply: err.message, actions: [] }; }
588
- $('#admAiTyping')?.remove();
589
- box.insertAdjacentHTML('beforeend', `<div class="msg bot">${md(data.reply || '')}</div>`);
590
- box.scrollTop = box.scrollHeight;
591
- for (const act of data.actions || []) {
592
- if (act.type === 'refresh_admin') {
593
- refreshRegistry();
594
- }
595
- else if (act.type === 'admin_tab' && act.tab) {
596
- switchAdminTab(act.tab);
597
- }
598
- }
830
+ function downloadCurrentCert() {
831
+ if (!currentCertLic) return;
832
+ const json = JSON.stringify(currentCertLic.certificate || currentCertLic, null, 2);
833
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
834
+ const url = URL.createObjectURL(blob);
835
+ const a = document.createElement('a');
836
+ a.href = url;
837
+ a.download = `${currentCertLic.licenseId}.cert.json`;
838
+ document.body.appendChild(a);
839
+ a.click();
840
+ a.remove();
841
+ URL.revokeObjectURL(url);
599
842
  }
600
843
 
844
+ /* ================= Привязка событий ================= */
601
845
  function bind() {
602
- $('#btnTheme')?.addEventListener('click', toggleTheme);
603
-
604
846
  document.addEventListener('click', (e) => {
605
- const el = e.target.closest('[data-action],[data-adm-tab],[data-adm-ask],[data-cert],[data-pay-order],[data-cancel-order],[data-activate-res],[data-certify-partner],[data-partner-issue],[data-reply-ticket],[data-close-ticket],[data-toggle-admin]');
847
+ const el = e.target.closest('[data-action],[data-adm-tab],[data-cert],[data-adm-dl-licfile],[data-adm-base],[data-adm-reset-hwid],[data-adm-open-grant],[data-adm-copy-tok],[data-adm-sched],[data-adm-pay-deposit],[data-adm-confirm-inst],[data-adm-cancel-franchise],[data-pay-order],[data-cancel-order],[data-reply-ticket],[data-close-ticket],[data-toggle-admin]');
606
848
  if (!el) return;
607
- if (el.matches('[data-adm-tab]')) { switchAdminTab(el.dataset.admTab); return; }
608
- if (el.matches('[data-adm-ask]')) { sendAdminAi(el.dataset.admAsk); return; }
609
- if (el.matches('[data-cert]')) { openCertModal(el.dataset.cert); return; }
610
- if (el.matches('[data-pay-order]')) { adminConfirmOrder(el.dataset.payOrder); return; }
611
- if (el.matches('[data-cancel-order]')) { adminCancelOrder(el.dataset.cancelOrder); return; }
612
- if (el.matches('[data-activate-res]')) { adminActivateReservation(el.dataset.activateRes); return; }
613
- if (el.matches('[data-certify-partner]')) { adminCertifyPartner(el.dataset.certifyPartner); return; }
614
- if (el.matches('[data-partner-issue]')) { adminPartnerIssueLicense(el.dataset.partnerIssue); return; }
615
- if (el.matches('[data-reply-ticket]')) { adminReplyTicket(el.dataset.replyTicket); return; }
616
- if (el.matches('[data-close-ticket]')) { adminCloseTicket(el.dataset.closeTicket); return; }
617
- if (el.matches('[data-toggle-admin]')) { adminToggleUserRole(el.dataset.toggleAdmin, el.dataset.makeAdmin === '1'); return; }
849
+
850
+ if (el.dataset.admTab) { setTab(el.dataset.admTab); return; }
851
+ if (el.dataset.cert) { openCertModal(el.dataset.cert); return; }
852
+ if (el.dataset.admDlLicfile !== undefined) { downloadCertLicFile(el.dataset.admDlLicfile); return; }
853
+ if (el.dataset.admBase) {
854
+ adminManageServerAction({
855
+ licenseId: el.dataset.admBase,
856
+ baseActivated: el.dataset.activate === '1',
857
+ });
858
+ return;
859
+ }
860
+ if (el.dataset.admResetHwid) {
861
+ if (confirm('Сбросить привязку оборудования (HWID) для этого сервера?')) {
862
+ adminManageServerAction({ licenseId: el.dataset.admResetHwid, resetHwid: true });
863
+ }
864
+ return;
865
+ }
866
+ if (el.dataset.admOpenGrant) {
867
+ openGrantSeatsModal(el.dataset.admOpenGrant, el.dataset.srvName || '');
868
+ return;
869
+ }
870
+ if (el.dataset.admCopyTok) {
871
+ copyAdminServerToken(el.dataset.admCopyTok);
872
+ return;
873
+ }
874
+ if (el.dataset.admSched) {
875
+ openFranchiseScheduleModal(el.dataset.admSched);
876
+ return;
877
+ }
878
+ if (el.dataset.admPayDeposit) {
879
+ adminConfirmFranchiseInst(el.dataset.admPayDeposit, { monthNumber: null, earlyPayoff: false });
880
+ return;
881
+ }
882
+ if (el.dataset.admConfirmInst) {
883
+ adminConfirmFranchiseInst(el.dataset.admConfirmInst, {
884
+ monthNumber: el.dataset.month ? Number(el.dataset.month) : null,
885
+ earlyPayoff: el.dataset.early === '1',
886
+ });
887
+ return;
888
+ }
889
+ if (el.dataset.admCancelFranchise) {
890
+ adminCancelFranchise(el.dataset.admCancelFranchise);
891
+ return;
892
+ }
893
+ if (el.dataset.payOrder) { adminConfirmOrder(el.dataset.payOrder); return; }
894
+ if (el.dataset.cancelOrder) { adminCancelOrder(el.dataset.cancelOrder); return; }
895
+ if (el.dataset.replyTicket) { adminReplyTicket(el.dataset.replyTicket); return; }
896
+ if (el.dataset.closeTicket) { adminCloseTicket(el.dataset.closeTicket); return; }
897
+ if (el.dataset.toggleAdmin) { adminToggleUserRole(el.dataset.toggleAdmin, el.dataset.makeAdmin === '1'); return; }
618
898
 
619
899
  switch (el.dataset.action) {
620
900
  case 'adm-send-code': sendAdminOtpCode(); break;
621
901
  case 'adm-verify-code': verifyAdminOtpCode(); break;
622
902
  case 'adm-reset-step': resetAdmStep(); break;
623
- case 'registry': refreshRegistry(); break;
624
- case 'demo-pak': createDemoPak(); break;
625
903
  case 'admin-logout': logoutAdmin(); break;
626
- case 'adm-issue-lic': adminIssueLicense(); break;
627
- case 'adm-register-partner': adminRegisterPartner(); break;
628
- case 'adm-ai-send': sendAdminAi(); break;
904
+ case 'registry': refreshRegistry(); break;
905
+ case 'open-create-server-modal': $('#admCreateServerModal')?.classList.add('open'); break;
906
+ case 'close-create-server-modal': $('#admCreateServerModal')?.classList.remove('open'); break;
907
+ case 'submit-create-server': adminSubmitCreateServer(); break;
908
+ case 'close-grant-modal': $('#admGrantSeatsModal')?.classList.remove('open'); break;
909
+ case 'submit-grant-seats': submitGrantSeats(); break;
910
+ case 'close-sched-modal': $('#admFranchiseScheduleModal')?.classList.remove('open'); break;
629
911
  case 'cert-close': $('#certModal')?.classList.remove('open'); break;
630
- case 'cert-copy': navigator.clipboard?.writeText($('#certJsonBox')?.textContent || ''); break;
912
+ case 'cert-copy':
913
+ navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
914
+ break;
631
915
  case 'cert-download': downloadCurrentCert(); break;
632
916
  }
633
917
  });
634
918
 
919
+ $('#btnTheme')?.addEventListener('click', toggleTheme);
635
920
  $('#adminLoginScreen')?.addEventListener('keydown', (e) => {
636
921
  if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
637
922
  if ($('#admStepCode')?.style.display !== 'none') verifyAdminOtpCode();
@@ -639,19 +924,26 @@
639
924
  }
640
925
  });
641
926
 
642
- $('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
927
+ ['inpFilterServers', 'selFilterServers', 'inpFilterFranchises', 'selFilterFranchises', 'inpFilterOrders', 'selFilterOrders', 'inpFilterUsers', 'selFilterTickets'].forEach(id => {
928
+ const el = document.getElementById(id);
929
+ if (!el) return;
930
+ el.addEventListener('input', () => lastAdminData && renderAdminPanel(lastAdminData));
931
+ el.addEventListener('change', () => lastAdminData && renderAdminPanel(lastAdminData));
932
+ });
933
+
643
934
  document.addEventListener('keydown', (e) => {
644
- if (e.key === 'Escape') $('#certModal')?.classList.remove('open');
645
- if (e.key === 'Enter' && e.target?.id?.startsWith('reply-')) {
646
- adminReplyTicket(e.target.id.slice('reply-'.length));
935
+ if (e.key === 'Escape') {
936
+ $$('.modal.open').forEach(m => m.classList.remove('open'));
647
937
  }
648
938
  });
649
- $('#admAiInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAdminAi(); });
939
+ window.addEventListener('resize', moveIndicator);
650
940
  }
651
941
 
652
942
  function start() {
653
943
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
654
944
  bind();
945
+ setTab('servers');
946
+ if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
655
947
  checkAdminSession();
656
948
  }
657
949