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