work-license-server 1.0.14 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/admin.js +603 -311
- package/assets/landing.css +150 -0
- package/assets/landing.js +504 -71
- package/package.json +1 -1
- package/src/admin.js +241 -165
- package/src/crypto.js +4 -0
- package/src/landing.js +32 -36
- package/src/server.js +120 -4
- package/src/store.js +771 -57
package/assets/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 =
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if ($('#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
</
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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"
|
|
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
|
|
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
|
|
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.
|
|
379
|
-
</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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
396
|
-
const st = $('#
|
|
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
|
-
|
|
400
|
-
|
|
656
|
+
await api('/api/license/issue', {
|
|
657
|
+
serverName,
|
|
658
|
+
hwid,
|
|
659
|
+
baseActivated,
|
|
660
|
+
ownerEmail: email,
|
|
401
661
|
customer: {
|
|
402
|
-
name:
|
|
403
|
-
inn
|
|
404
|
-
email
|
|
662
|
+
name: org || email,
|
|
663
|
+
inn,
|
|
664
|
+
email,
|
|
405
665
|
},
|
|
406
666
|
});
|
|
407
|
-
|
|
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
|
|
675
|
+
async function adminManageServerAction(payload) {
|
|
414
676
|
try {
|
|
415
|
-
await api('/api/
|
|
416
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
714
|
+
navigator.clipboard?.writeText(tok);
|
|
440
715
|
}
|
|
441
716
|
|
|
442
|
-
async function
|
|
717
|
+
async function adminConfirmFranchiseInst(reservationId, { monthNumber = null, earlyPayoff = false } = {}) {
|
|
443
718
|
try {
|
|
444
|
-
await api('/api/admin/
|
|
445
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
if (st) st.textContent = 'Регистрируем партнёра…';
|
|
731
|
+
async function adminCancelFranchise(reservationId) {
|
|
732
|
+
if (!confirm('Отозвать / отменить эту региональную франшизу? Слот в регионе освободится.')) return;
|
|
453
733
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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/
|
|
471
|
-
|
|
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
|
|
752
|
+
async function adminCancelOrder(orderId) {
|
|
753
|
+
if (!confirm(`Отменить заказ ${orderId}?`)) return;
|
|
483
754
|
try {
|
|
484
|
-
await api('/api/
|
|
485
|
-
|
|
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
|
|
761
|
+
async function adminToggleUserRole(userId, makeAdmin) {
|
|
497
762
|
try {
|
|
498
|
-
await api('/api/
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
814
|
+
function downloadCertLicFile(idx) {
|
|
546
815
|
if (!currentCertLic) return;
|
|
547
|
-
const
|
|
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
|
|
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
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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-
|
|
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
|
-
|
|
608
|
-
if (el.
|
|
609
|
-
if (el.
|
|
610
|
-
if (el.
|
|
611
|
-
if (el.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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 '
|
|
627
|
-
case '
|
|
628
|
-
case '
|
|
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':
|
|
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
|
-
|
|
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')
|
|
645
|
-
|
|
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
|
-
|
|
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
|
|