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 +602 -299
- package/assets/landing.js +541 -69
- package/package.json +1 -1
- package/src/admin.js +279 -182
- package/src/crypto.js +32 -3
- package/src/landing.js +38 -30
- package/src/server.js +121 -4
- package/src/store.js +810 -58
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
|
-
|
|
232
|
-
|
|
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 =
|
|
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
|
-
$('#
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
<td
|
|
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
|
-
if (
|
|
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
|
-
|
|
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"
|
|
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
|
|
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
|
|
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.
|
|
379
|
-
</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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
396
|
-
const st = $('#
|
|
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
|
-
|
|
400
|
-
|
|
669
|
+
await api('/api/license/issue', {
|
|
670
|
+
serverName,
|
|
671
|
+
hwid,
|
|
672
|
+
baseActivated,
|
|
673
|
+
ownerEmail: email,
|
|
401
674
|
customer: {
|
|
402
|
-
name:
|
|
403
|
-
inn
|
|
404
|
-
email
|
|
675
|
+
name: org || email,
|
|
676
|
+
inn,
|
|
677
|
+
email,
|
|
405
678
|
},
|
|
406
679
|
});
|
|
407
|
-
|
|
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
|
|
688
|
+
async function adminManageServerAction(payload) {
|
|
414
689
|
try {
|
|
415
|
-
await api('/api/
|
|
416
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
727
|
+
navigator.clipboard?.writeText(tok);
|
|
440
728
|
}
|
|
441
729
|
|
|
442
|
-
async function
|
|
730
|
+
async function adminConfirmFranchiseInst(reservationId, { monthNumber = null, earlyPayoff = false } = {}) {
|
|
443
731
|
try {
|
|
444
|
-
await api('/api/admin/
|
|
445
|
-
|
|
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
|
|
451
|
-
|
|
452
|
-
if (st) st.textContent = 'Регистрируем партнёра…';
|
|
744
|
+
async function adminCancelFranchise(reservationId) {
|
|
745
|
+
if (!confirm('Отозвать / отменить эту региональную франшизу? Слот в регионе освободится.')) return;
|
|
453
746
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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/
|
|
471
|
-
|
|
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
|
|
765
|
+
async function adminCancelOrder(orderId) {
|
|
766
|
+
if (!confirm(`Отменить заказ ${orderId}?`)) return;
|
|
483
767
|
try {
|
|
484
|
-
await api('/api/
|
|
485
|
-
|
|
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
|
|
774
|
+
async function adminToggleUserRole(userId, makeAdmin) {
|
|
497
775
|
try {
|
|
498
|
-
await api('/api/
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
827
|
+
function downloadCertLicFile(idx) {
|
|
546
828
|
if (!currentCertLic) return;
|
|
547
|
-
const
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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-
|
|
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
|
-
|
|
608
|
-
if (el.
|
|
609
|
-
if (el.
|
|
610
|
-
if (el.
|
|
611
|
-
if (el.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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 '
|
|
627
|
-
case '
|
|
628
|
-
case '
|
|
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':
|
|
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
|
-
|
|
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')
|
|
645
|
-
|
|
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
|
|