work-license-server 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -4,10 +4,17 @@
4
4
 
5
5
  ## Назначение
6
6
 
7
- 1. **Витрина `/`** — вкладки «Платформа», «ИИ-агенты», «Тарифы», «Комплекс», «Докупка лицензий» (только после входа), «Франшиза», «Поддержка»; светлая и тёмная темы, мягкий анимированный фон (графики роста и сеть связей).
7
+ 1. **Витрина `/`** — вкладки «Платформа», «ИИ-агенты», «Тарифы», «Комплекс», «Докупка лицензий» (после регистрации и подписания оферты), «Франшиза», «Поддержка»; светлая и тёмная темы, мягкий анимированный фон (графики роста и сеть связей). На публичной витрине нет ссылок или переходов в админку.
8
+ - **Обязательная регистрация и подписание публичной оферты (ПЭП):** любые действия по оформлению заказа комплекса, докупке годовых мест, оплате через ЮKassa и резервированию региональной франшизы доступны только после регистрации в личном кабинете и акцепта Публичного договора-оферты ODANT-WORK простой электронной подписью (`PEP-...`).
8
9
  - Комплекс: **AMD Ryzen™ AI Max+ PRO 495 · Radeon™ 8065S · 192 ГБ LPDDR5x-8533 · SSD 4 ТБ**.
9
10
  - Внутри: встроенная система учёта, документооборот, управление процессами, ИИ-консультант в каждой форме, мультиагентная система (13 ролевых агентов, 26+ навыков) и 4 типа локальных моделей (языковая, голосовая, генерация изображений, векторный поиск).
10
- - Базовый комплект — `5 000 000 ₽`: **вечная лицензия на 5 одновременных сессий** и **1-й год поддержки**.
11
+ - Базовый комплект — `5 000 000 ₽`: сервер + **вечная лицензия на 10 одновременных сессий** и **1-й год поддержки уровня Enterprise** (далее продление поддержки и докупка мест — по желанию).
12
+ - Отдельно **базовая постоянная лицензия на 10 вечных пользователей (без оборудования)** — `4 000 000 ₽` (для самостоятельно установленных систем).
13
+ - Оплата возможна как онлайн через **ЮKassa**, так и **по договору (счёту) для юридических лиц** (`paymentMethod: 'invoice'`, печатная форма `/api/orders/:orderId/invoice`).
14
+ - В личном кабинете владелец генерирует подписанный токен привязки (`owk1...` с зашитым URL сервера поддержки), по которому локальная система автоматически скачивает файлы своих лицензий (`.lic.json`) в папку `#system`.
15
+ - Подробная документация по установке, автообновлению через `npm`, лицензиям и криптографической защите от подделки `license-server` (`Ed25519 Public Key Pinning`): **[`docs/autonomous-install-and-licensing.md`](../docs/autonomous-install-and-licensing.md)**.
16
+ - Интегрированные на комплексе 4 типа моделей предназначены для базовых ежедневных операций внутри контура; при необходимости более мощных ИИ они подключаются через API из любого источника.
17
+ 2. **Отдельная защищённая админ-панель `/admin`** — запускается как самостоятельная страница (`http://localhost:8020/admin` / `https://work.odant.org/admin`) с обязательной авторизацией администратора (`WORK_ADMIN_LOGIN` / `WORK_ADMIN_PASSWORD`, по умолчанию для локальной отладки `admin` / `odant-admin`). Включает ИИ-агента администратора, выпуск и подпись сертификатов `Ed25519`, управление заказами ЮKassa, резервами и аттестацией партнёров, тикетами SLA и реестром подписанных оферт ПЭП.
11
18
  2. **Годовые лицензии на дополнительных пользователей** со скидкой за количество (5+ мест −5% … 100+ мест −25%):
12
19
  - **Start** — `10 000 ₽ / год` (до 12 обращений в год на место, реакция до 8 рабочих часов);
13
20
  - **Business** — `15 000 ₽ / год` (до 48 обращений, реакция до 2 часов);
@@ -0,0 +1,572 @@
1
+ /* ODANT-WORK · /admin — клиентский скрипт отдельной защищённой админ-панели центра лицензирования */
2
+ (function () {
3
+ 'use strict';
4
+
5
+ const D = window.__ODANT_ADMIN__ || {};
6
+ const $ = (sel, root = document) => root.querySelector(sel);
7
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
8
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[ch]));
9
+ const rub = (n) => Number(n || 0).toLocaleString('ru-RU') + ' ₽';
10
+
11
+ const TOKEN_KEY = 'odant_work_admin_token';
12
+ let adminToken = '';
13
+ let lastAdminData = null;
14
+ let currentCertLic = null;
15
+
16
+ function getStoredToken() {
17
+ try { return sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY) || ''; }
18
+ catch { return ''; }
19
+ }
20
+ function setStoredToken(tok) {
21
+ adminToken = tok || '';
22
+ try {
23
+ if (tok) {
24
+ sessionStorage.setItem(TOKEN_KEY, tok);
25
+ localStorage.setItem(TOKEN_KEY, tok);
26
+ }
27
+ else {
28
+ sessionStorage.removeItem(TOKEN_KEY);
29
+ localStorage.removeItem(TOKEN_KEY);
30
+ }
31
+ }
32
+ catch { /* приватный режим */ }
33
+ }
34
+
35
+ async function api(path, body, method) {
36
+ const headers = {};
37
+ if (body) headers['Content-Type'] = 'application/json';
38
+ if (adminToken) headers['Authorization'] = 'Bearer ' + adminToken;
39
+ const res = await fetch(path, {
40
+ method: method || (body ? 'POST' : 'GET'),
41
+ headers,
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ });
44
+ let data = {};
45
+ try { data = await res.json(); } catch { /* пустой ответ */ }
46
+ if (res.status === 401) {
47
+ setStoredToken('');
48
+ showLoginGate('Сессия администратора истекла или требуется вход');
49
+ throw new Error(data.error || 'Требуется авторизация администратора');
50
+ }
51
+ if (!res.ok)
52
+ throw new Error(data.error || ('Ошибка ' + res.status));
53
+ return data;
54
+ }
55
+
56
+ /* ================= Тема ================= */
57
+ function applyTheme(theme) {
58
+ document.documentElement.setAttribute('data-theme', theme);
59
+ try { localStorage.setItem('odant_work_theme', theme); } catch { /* ignore */ }
60
+ }
61
+ function toggleTheme() {
62
+ applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light');
63
+ }
64
+
65
+ /* ================= Авторизация администратора ================= */
66
+ function showLoginGate(errMsg = '') {
67
+ $('#adminLoginScreen').style.display = '';
68
+ $('#adminAppShell').style.display = 'none';
69
+ if ($('#admLoginError')) $('#admLoginError').textContent = errMsg;
70
+ setTimeout(() => $('#admLoginPass')?.focus(), 50);
71
+ }
72
+
73
+ function showAdminApp(login = 'admin') {
74
+ $('#adminLoginScreen').style.display = 'none';
75
+ $('#adminAppShell').style.display = '';
76
+ if ($('#admUserLabel')) $('#admUserLabel').textContent = `Выйти (${login})`;
77
+ }
78
+
79
+ async function submitAdminLogin() {
80
+ const login = $('#admLoginUser')?.value.trim() || 'admin';
81
+ const password = $('#admLoginPass')?.value || '';
82
+ const errBox = $('#admLoginError');
83
+ if (errBox) errBox.textContent = '';
84
+ try {
85
+ const res = await fetch('/api/admin/login', {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ login, password }),
89
+ });
90
+ const data = await res.json().catch(() => ({}));
91
+ if (!res.ok || !data.token) {
92
+ throw new Error(data.error || 'Неверный логин или ключ администратора');
93
+ }
94
+ setStoredToken(data.token);
95
+ if ($('#admLoginPass')) $('#admLoginPass').value = '';
96
+ showAdminApp(data.login || login);
97
+ await refreshRegistry();
98
+ }
99
+ catch (err) {
100
+ if (errBox) errBox.textContent = err.message || String(err);
101
+ }
102
+ }
103
+
104
+ async function logoutAdmin() {
105
+ try { await api('/api/admin/logout', {}); } catch { /* ignore */ }
106
+ setStoredToken('');
107
+ showLoginGate('');
108
+ }
109
+
110
+ async function checkAdminSession() {
111
+ const saved = getStoredToken();
112
+ if (!saved) {
113
+ showLoginGate('');
114
+ return;
115
+ }
116
+ adminToken = saved;
117
+ try {
118
+ const me = await api('/api/admin/me');
119
+ if (me.authenticated) {
120
+ showAdminApp(me.login || 'admin');
121
+ await refreshRegistry();
122
+ }
123
+ else {
124
+ showLoginGate('');
125
+ }
126
+ }
127
+ catch {
128
+ showLoginGate('');
129
+ }
130
+ }
131
+
132
+ /* ================= Реестр и таблицы ================= */
133
+ function fmtDate(ms) {
134
+ if (!ms) return '—';
135
+ try { return new Date(ms).toLocaleDateString('ru-RU'); } catch { return '—'; }
136
+ }
137
+ function fmtDateTime(ms) {
138
+ if (!ms) return '—';
139
+ try { return new Date(ms).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }); } catch { return '—'; }
140
+ }
141
+
142
+ async function refreshRegistry() {
143
+ if (!adminToken) return;
144
+ let data;
145
+ try { data = await api('/api/admin/overview'); }
146
+ catch { return; }
147
+ lastAdminData = data;
148
+ renderAdminPanel(data);
149
+ }
150
+
151
+ function renderAdminPanel(data = {}) {
152
+ const licenses = data.licenses || [];
153
+ const orders = data.orders || [];
154
+ const partners = data.partners || [];
155
+ const reservations = (data.reservations || []).filter(r => r.status !== 'canceled');
156
+ const tickets = data.tickets || [];
157
+ const users = data.users || [];
158
+
159
+ const totalSeats = licenses.reduce((s, l) => s + (l.totalSeats || 0), 0);
160
+ const paidOrders = orders.filter(o => o.status === 'paid');
161
+ const pendingOrders = orders.filter(o => o.status === 'pending');
162
+ const paidRub = paidOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
163
+ const pendingRub = pendingOrders.reduce((s, o) => s + (Number(o.amountRub) || 0), 0);
164
+ const openTickets = tickets.filter(t => t.status !== 'answered' && t.status !== 'closed');
165
+ const activeReservations = reservations.filter(r => !r.convertedToPartnerId);
166
+ const signedUsers = users.filter(u => u.ofertaAccepted);
167
+
168
+ if ($('#admStatLic')) {
169
+ $('#admStatLic').textContent = String(licenses.length);
170
+ $('#admStatLicSub').textContent = `лицензий ПАК · ${totalSeats} активных мест`;
171
+ $('#admStatPaid').textContent = rub(paidRub);
172
+ $('#admStatPaidSub').textContent = `оплачено заказов: ${paidOrders.length}`;
173
+ $('#admStatPending').textContent = rub(pendingRub);
174
+ $('#admStatPendingSub').textContent = `ожидают оплаты: ${pendingOrders.length}`;
175
+ $('#admStatPartners').textContent = `${partners.length} / ${activeReservations.length}`;
176
+ if ($('#admStatUsers')) {
177
+ $('#admStatUsers').textContent = String(users.length);
178
+ $('#admStatUsersSub').textContent = `клиентов (оферт ПЭП: ${signedUsers.length})`;
179
+ }
180
+ $('#admStatTickets').textContent = String(openTickets.length);
181
+ $('#admStatTicketsSub').textContent = `открытых из ${tickets.length} обращений`;
182
+ }
183
+
184
+ // 1. Лицензии
185
+ if ($('#admLicensesBody')) {
186
+ $('#admLicensesBody').innerHTML = licenses.map(l => {
187
+ const hwid = l.hardware?.hwid || l.boundHwid || 'не привязан';
188
+ const fp = l.hardware?.nodeFingerprint || l.boundNodeFingerprint || '';
189
+ const bindCode = l.support?.bindCode || l.bindingCode || l.licenseId;
190
+ const expAt = l.baseSupportUntil || l.support?.expiresAt;
191
+ const partnerInfo = l.partner?.companyName
192
+ ? `<div class="small" style="color:var(--brand-2)">Партнёр: ${esc(l.partner.companyName)} (${esc(l.partner.regionName || '')})</div>`
193
+ : '';
194
+ return `<tr>
195
+ <td><b>${esc(l.licenseId)}</b><div class="small muted">Код бота: <code>${esc(bindCode)}</code></div></td>
196
+ <td><b>${esc(l.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(l.customer?.inn || '—')} · ${esc(l.customer?.email || '—')}</div></td>
197
+ <td><code>${esc(hwid)}</code><div class="small muted">${esc(fp ? fp.slice(0, 22) : 'ожидает синхронизации узла')}</div>${partnerInfo}</td>
198
+ <td class="num"><b>${l.totalSeats}</b> <span class="muted">(${l.baseSeats ?? 10} вечн. + ${l.addonSeats || 0} доп.)</span></td>
199
+ <td><span class="tag ok">${esc(l.support?.tierName || 'Enterprise')}</span><div class="small muted">до ${fmtDate(expAt)} · ${l.support?.ticketsRemaining ?? 0}/${l.support?.ticketsQuota ?? 0} обращ.</div></td>
200
+ <td><div style="display:flex;gap:6px;flex-wrap:wrap">
201
+ <button class="btn btn-ghost btn-sm" data-cert="${esc(l.licenseId)}">Сертификат JSON</button>
202
+ </div></td>
203
+ </tr>`;
204
+ }).join('') || '<tr><td colspan="6" class="muted">Лицензий пока нет — нажмите «+ Демо-лицензия ПАК» или выпустите выше</td></tr>';
205
+ }
206
+
207
+ // 2. Заказы ЮKassa
208
+ if ($('#admOrdersBody')) {
209
+ $('#admOrdersBody').innerHTML = orders.map(o => {
210
+ const stTag = o.status === 'paid' ? 'ok' : (o.status === 'canceled' ? 'bad' : '');
211
+ const stLabel = o.status === 'paid' ? 'оплачен' : (o.status === 'canceled' ? 'отменён' : 'ожидает оплаты');
212
+ const purposeSub = o.includeBasePak
213
+ ? `Комплекс ПАК${o.addonSeats ? ` + ${o.addonSeats} доп. мест` : ''}`
214
+ : (o.kind === 'franchise_reservation' ? `Залог франшизы (${esc(o.regionName || o.regionCode || '')})` : `Доп. места: ${o.addonSeats || 0} × ${o.years || 1} г.`);
215
+ return `<tr>
216
+ <td><b>${esc(o.orderId)}</b><div class="small muted">${esc(o.paymentId || '—')}</div></td>
217
+ <td><b>${esc(o.tierName || o.kind || 'Заказ')}</b><div class="small muted">${purposeSub}</div></td>
218
+ <td><b>${esc(o.customer?.name || '—')}</b><div class="small muted">ИНН ${esc(o.customer?.inn || '—')} · ${esc(o.customer?.email || '—')}</div></td>
219
+ <td class="num"><b>${rub(o.amountRub)}</b></td>
220
+ <td><span class="tag ${stTag}">${stLabel}</span></td>
221
+ <td><div style="display:flex;gap:6px;flex-wrap:wrap">
222
+ ${o.status === 'pending' ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(o.orderId)}">Провести оплату</button>` : ''}
223
+ ${o.status === 'pending' ? `<button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}">Отменить</button>` : ''}
224
+ <a class="btn btn-ghost btn-sm" href="/pay/sandbox/${encodeURIComponent(o.orderId)}" target="_blank" rel="noopener">Песочница</a>
225
+ </div></td>
226
+ </tr>`;
227
+ }).join('') || '<tr><td colspan="6" class="muted">Заказов пока нет</td></tr>';
228
+ }
229
+
230
+ // 3. Резервы франшиз и действующие партнёры
231
+ if ($('#admPartnersBody')) {
232
+ const resRows = reservations.map(r => `<tr>
233
+ <td><b>${esc(r.reservationId)}</b><div class="small muted">Резерв франшизы</div></td>
234
+ <td><b>${esc(r.regionName)}</b> <span class="muted">(${esc(r.regionCode)})</span></td>
235
+ <td><b>${esc(r.companyName)}</b><div class="small muted">ИНН ${esc(r.inn || '—')} · ${esc(r.email || '—')}</div></td>
236
+ <td>Паушальный взнос <b>${rub(r.franchiseFeeRub)}</b><div class="small muted">Залог ${rub(r.nonRefundableDepositRub || 100000)} · остаток за 1 год</div></td>
237
+ <td><span class="tag ${r.depositPaid ? 'ok' : ''}">${r.convertedToPartnerId ? 'конвертирован в партнёра' : (r.depositPaid ? 'залог оплачен' : 'ждёт оплаты залога')}</span></td>
238
+ <td><div style="display:flex;gap:6px;flex-wrap:wrap">
239
+ ${!r.depositPaid && r.orderId ? `<button class="btn btn-ok btn-sm" data-pay-order="${esc(r.orderId)}">Оплатить залог</button>` : ''}
240
+ ${!r.convertedToPartnerId ? `<button class="btn btn-primary btn-sm" data-activate-res="${esc(r.reservationId)}">Активировать партнёра</button>` : `<span class="small muted">Партнёр ${esc(r.convertedToPartnerId)}</span>`}
241
+ </div></td>
242
+ </tr>`);
243
+
244
+ const partRows = partners.map(p => {
245
+ const certs = p.certifiedSpecialists || [];
246
+ const certCodes = certs.map(c => c.trackCode).join(', ');
247
+ return `<tr>
248
+ <td><b>${esc(p.partnerId)}</b><div class="small muted">Действующий партнёр</div></td>
249
+ <td><b>${esc(p.regionName)}</b> <span class="muted">(${esc(p.regionCode)})</span></td>
250
+ <td><b>${esc(p.companyName)}</b><div class="small muted">ИНН ${esc(p.inn || '—')} · ${esc(p.email || '—')}</div></td>
251
+ <td>Скидка <b>${p.discountPercent}%</b> · ${esc(p.statusLevel)}<div class="small muted">Продажи: ${rub(p.annualSalesRub || 0)}</div></td>
252
+ <td>
253
+ <span class="tag ${p.certifiedStatus ? 'ok' : ''}">${p.certifiedStatus ? '✓ Сертифицированный франчайзи' : `сертификатов: ${certs.length} из 3`}</span>
254
+ ${certCodes ? `<div class="small muted">${esc(certCodes)}</div>` : ''}
255
+ </td>
256
+ <td><div style="display:flex;gap:6px;flex-wrap:wrap">
257
+ <button class="btn btn-ghost btn-sm" data-certify-partner="${esc(p.partnerId)}">+ Аттестовать инженера</button>
258
+ <button class="btn btn-primary btn-sm" data-partner-issue="${esc(p.partnerId)}">+ Выдать ПАК (продажа)</button>
259
+ </div></td>
260
+ </tr>`;
261
+ });
262
+ $('#admPartnersBody').innerHTML = [...resRows, ...partRows].join('') || '<tr><td colspan="6" class="muted">Резервов и партнёров пока нет</td></tr>';
263
+ }
264
+
265
+ // 4. Тикеты SLA
266
+ if ($('#admTicketsBody')) {
267
+ $('#admTicketsBody').innerHTML = tickets.map(t => {
268
+ const msgs = (t.messages || []).map(m => {
269
+ const isSupport = m.from === 'support' || m.role === 'engineer';
270
+ const author = isSupport ? (m.author || 'Инженер ODANT') : (m.author || t.userName || 'Клиент');
271
+ return `<div class="small"><b style="color:${isSupport ? 'var(--brand-2)' : 'var(--text)'}">${esc(author)}:</b> ${esc(m.text)}</div>`;
272
+ }).join('');
273
+ const slaStatus = t.status === 'closed'
274
+ ? '<span class="tag">закрыт</span>'
275
+ : t.slaMet === true
276
+ ? '<span class="tag ok">✓ SLA выполнен</span>'
277
+ : t.slaMet === false
278
+ ? '<span class="tag bad">SLA просрочен</span>'
279
+ : '<span class="tag">ожидает ответа</span>';
280
+ return `<tr>
281
+ <td><b>${esc(t.ticketId)}</b><div><span class="tag">${esc(String(t.channel || '').toUpperCase())}</span> ${slaStatus}</div></td>
282
+ <td><b>${esc(t.customerName || t.userName || t.chatId)}</b><div class="small muted">Лицензия: ${esc(t.licenseId || 'не привязана')}</div></td>
283
+ <td><span class="tag ok">${esc(t.tierName || 'Start')}</span><div class="small muted">${esc(t.reactionLabel || (`реакция ≤ ${t.reactionMinutes || 480} мин`))}</div></td>
284
+ <td style="white-space:normal;max-width:360px">${msgs}</td>
285
+ <td>
286
+ ${t.status !== 'closed' ? `<div style="display:flex;gap:6px;flex-wrap:wrap">
287
+ <input type="text" id="reply-${esc(t.ticketId)}" placeholder="Ответ инженера…" style="min-width:180px;padding:7px 10px;font-size:12.5px">
288
+ <button class="btn btn-primary btn-sm" data-reply-ticket="${esc(t.ticketId)}">Ответить</button>
289
+ <button class="btn btn-ghost btn-sm" data-close-ticket="${esc(t.ticketId)}">Закрыть</button>
290
+ </div>` : '<span class="small muted">Обращение закрыто</span>'}
291
+ </td>
292
+ </tr>`;
293
+ }).join('') || '<tr><td colspan="5" class="muted">Обращений пока нет</td></tr>';
294
+ }
295
+
296
+ // 5. Клиенты и оферты (ПЭП)
297
+ if ($('#admUsersBody')) {
298
+ $('#admUsersBody').innerHTML = users.map(u => `<tr>
299
+ <td><b>${esc(u.userId)}</b><div class="small muted">${esc(u.email)}</div></td>
300
+ <td><b>${esc(u.companyName || '—')}</b><div class="small muted">ИНН ${esc(u.inn || '—')}</div></td>
301
+ <td><b>${esc(u.name || '—')}</b><div class="small muted">${esc(u.position || '')} ${esc(u.phone || '')}</div></td>
302
+ <td><code>${esc(u.licenseId || '—')}</code></td>
303
+ <td>
304
+ ${u.ofertaAccepted
305
+ ? `<span class="tag ok">✓ Подписана ПЭП</span><div class="small muted"><code>${esc(u.ofertaSignatureId)}</code> · ${esc(u.ofertaVersion)}</div>`
306
+ : '<span class="tag bad">Не подписана</span>'}
307
+ </td>
308
+ <td class="small muted">${fmtDateTime(u.ofertaAcceptedAt || u.createdAt)}</td>
309
+ </tr>`).join('') || '<tr><td colspan="6" class="muted">Зарегистрированных клиентов пока нет</td></tr>';
310
+ }
311
+ }
312
+
313
+ /* ================= Действия администратора ================= */
314
+ async function adminIssueLicense() {
315
+ const st = $('#admIssueStatus');
316
+ st.textContent = 'Выпускаем сертификат…';
317
+ try {
318
+ const lic = await api('/api/license/issue', {
319
+ hwid: $('#admLicHwid').value.trim(),
320
+ customer: {
321
+ name: $('#admLicOrg').value.trim(),
322
+ inn: $('#admLicInn').value.trim(),
323
+ email: $('#admLicEmail').value.trim(),
324
+ },
325
+ });
326
+ st.textContent = `Выпущена лицензия ${lic.licenseId} (${lic.totalSeats} мест, подпись Ed25519)`;
327
+ refreshRegistry();
328
+ }
329
+ catch (err) { st.textContent = err.message; }
330
+ }
331
+
332
+ async function createDemoPak() {
333
+ try {
334
+ await api('/api/license/issue', {
335
+ customer: { name: 'ООО «Маслоторг»', inn: '7701234567', email: 'admin@maslotorg.ru' },
336
+ });
337
+ }
338
+ catch (err) { alert(err.message); }
339
+ refreshRegistry();
340
+ }
341
+
342
+ async function adminConfirmOrder(orderId) {
343
+ const order = (lastAdminData?.orders || []).find(o => o.orderId === orderId);
344
+ if (!order) return;
345
+ try {
346
+ await api('/api/payments/yookassa/webhook', {
347
+ type: 'notification',
348
+ event: 'payment.succeeded',
349
+ object: {
350
+ id: order.paymentId,
351
+ status: 'succeeded',
352
+ amount: { value: Number(order.amountRub || 0).toFixed(2), currency: 'RUB' },
353
+ metadata: { orderId: order.orderId, licenseId: order.licenseId || '', reservationId: order.reservationId || '' },
354
+ },
355
+ });
356
+ refreshRegistry();
357
+ }
358
+ catch (err) { alert(err.message); }
359
+ }
360
+
361
+ async function adminCancelOrder(orderId) {
362
+ try {
363
+ await api('/api/admin/orders/' + encodeURIComponent(orderId) + '/cancel', {});
364
+ refreshRegistry();
365
+ }
366
+ catch (err) { alert(err.message); }
367
+ }
368
+
369
+ async function adminRegisterPartner() {
370
+ const st = $('#admPartnerStatus');
371
+ if (st) st.textContent = 'Регистрируем партнёра…';
372
+ try {
373
+ const partner = await api('/api/partners/register', {
374
+ regionCode: $('#admPartRegion').value,
375
+ companyName: $('#admPartCompany').value.trim(),
376
+ inn: $('#admPartInn').value.trim(),
377
+ email: $('#admPartEmail').value.trim(),
378
+ });
379
+ if (st) st.textContent = `Зарегистрирован партнёр ${partner.partnerId} (${partner.regionName}, скидка ${partner.discountPercent}%)`;
380
+ refreshRegistry();
381
+ }
382
+ catch (err) { if (st) st.textContent = err.message; }
383
+ }
384
+
385
+ async function adminActivateReservation(reservationId) {
386
+ const r = (lastAdminData?.reservations || []).find(x => x.reservationId === reservationId);
387
+ if (!r) return;
388
+ try {
389
+ await api('/api/partners/register', {
390
+ reservationId: r.reservationId,
391
+ regionCode: r.regionCode,
392
+ companyName: r.companyName,
393
+ inn: r.inn,
394
+ email: r.email,
395
+ });
396
+ refreshRegistry();
397
+ }
398
+ catch (err) { alert(err.message); }
399
+ }
400
+
401
+ async function adminCertifyPartner(partnerId) {
402
+ try {
403
+ await api('/api/partners/certify', {
404
+ partnerId,
405
+ specialistName: 'Ведущий инженер ПАК',
406
+ email: 'engineer@partner.ru',
407
+ trackId: 'next',
408
+ score: 96,
409
+ });
410
+ refreshRegistry();
411
+ }
412
+ catch (err) { alert(err.message); }
413
+ }
414
+
415
+ async function adminPartnerIssueLicense(partnerId) {
416
+ try {
417
+ await api('/api/partners/issue-license', {
418
+ partnerId,
419
+ customer: { name: 'Заказчик регионального партнёра', inn: '7710998877' },
420
+ hardwareWarrantyYears: 1,
421
+ partnerAddons: {
422
+ nasBackup: true,
423
+ techSupportContract: true,
424
+ },
425
+ });
426
+ refreshRegistry();
427
+ }
428
+ catch (err) { alert(err.message); }
429
+ }
430
+
431
+ async function adminReplyTicket(ticketId) {
432
+ const inp = document.getElementById('reply-' + ticketId);
433
+ const text = inp?.value.trim();
434
+ if (!text) return;
435
+ try {
436
+ await api('/api/tickets/' + encodeURIComponent(ticketId) + '/reply', {
437
+ engineerName: 'Дежурный инженер ODANT',
438
+ text,
439
+ });
440
+ inp.value = '';
441
+ refreshRegistry();
442
+ }
443
+ catch (err) { alert(err.message); }
444
+ }
445
+
446
+ async function adminCloseTicket(ticketId) {
447
+ try {
448
+ await api('/api/admin/tickets/' + encodeURIComponent(ticketId) + '/close', {});
449
+ refreshRegistry();
450
+ }
451
+ catch (err) { alert(err.message); }
452
+ }
453
+
454
+ function openCertModal(licenseId) {
455
+ const lic = (lastAdminData?.licenses || []).find(l => l.licenseId === licenseId);
456
+ if (!lic) return;
457
+ currentCertLic = lic;
458
+ $('#certTitle').textContent = `Сертификат ${lic.licenseId}`;
459
+ $('#certSub').textContent = `${lic.customer?.name || ''} · Ed25519 (WORK-LIC-v1)`;
460
+ $('#certJsonBox').textContent = JSON.stringify(lic.certificate || lic, null, 2);
461
+ $('#certModal').classList.add('open');
462
+ }
463
+
464
+ function downloadCurrentCert() {
465
+ if (!currentCertLic) return;
466
+ const json = JSON.stringify(currentCertLic.certificate || currentCertLic, null, 2);
467
+ const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
468
+ const url = URL.createObjectURL(blob);
469
+ const a = document.createElement('a');
470
+ a.href = url;
471
+ a.download = `${currentCertLic.licenseId || 'work-license'}.json`;
472
+ document.body.appendChild(a);
473
+ a.click();
474
+ a.remove();
475
+ URL.revokeObjectURL(url);
476
+ }
477
+
478
+ function switchAdminTab(tab = 'all') {
479
+ $$('[data-adm-tab]').forEach(b => b.classList.toggle('on', b.dataset.admTab === tab));
480
+ const ws = $('#adminWorkspace');
481
+ if (ws) ws.classList.toggle('only-agent', tab === 'agent');
482
+ $$('[data-adm-sec]').forEach(sec => {
483
+ sec.style.display = (tab === 'all' || tab === sec.dataset.admSec) ? '' : 'none';
484
+ });
485
+ if (tab === 'agent') setTimeout(() => $('#admAiInput')?.focus(), 50);
486
+ }
487
+
488
+ function md(text) {
489
+ return esc(text)
490
+ .replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
491
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
492
+ .replace(/(^|\s)\*(.+?)\*(?=\s|$)/g, '$1<i>$2</i>');
493
+ }
494
+
495
+ async function sendAdminAi(text) {
496
+ const inp = $('#admAiInput');
497
+ text = (text ?? inp?.value ?? '').trim();
498
+ if (!text) return;
499
+ if (inp) inp.value = '';
500
+ const box = $('#admAiMessages');
501
+ if (!box) return;
502
+ 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>`);
503
+ box.scrollTop = box.scrollHeight;
504
+ let data;
505
+ try { data = await api('/api/agent/chat', { message: text }); }
506
+ catch (err) { data = { reply: err.message, actions: [] }; }
507
+ $('#admAiTyping')?.remove();
508
+ box.insertAdjacentHTML('beforeend', `<div class="msg bot">${md(data.reply || '')}</div>`);
509
+ box.scrollTop = box.scrollHeight;
510
+ for (const act of data.actions || []) {
511
+ if (act.type === 'refresh_admin') {
512
+ refreshRegistry();
513
+ }
514
+ else if (act.type === 'admin_tab' && act.tab) {
515
+ switchAdminTab(act.tab);
516
+ }
517
+ }
518
+ }
519
+
520
+ function bind() {
521
+ $('#adminLoginForm')?.addEventListener('submit', (e) => {
522
+ e.preventDefault();
523
+ submitAdminLogin();
524
+ });
525
+ $('#btnTheme')?.addEventListener('click', toggleTheme);
526
+
527
+ document.addEventListener('click', (e) => {
528
+ const el = e.target.closest('[data-action],[data-adm-tab],[data-adm-ask],[data-cert],[data-pay-order],[data-cancel-order],[data-activate-res],[data-certify-partner],[data-partner-issue],[data-reply-ticket],[data-close-ticket]');
529
+ if (!el) return;
530
+ if (el.matches('[data-adm-tab]')) { switchAdminTab(el.dataset.admTab); return; }
531
+ if (el.matches('[data-adm-ask]')) { sendAdminAi(el.dataset.admAsk); return; }
532
+ if (el.matches('[data-cert]')) { openCertModal(el.dataset.cert); return; }
533
+ if (el.matches('[data-pay-order]')) { adminConfirmOrder(el.dataset.payOrder); return; }
534
+ if (el.matches('[data-cancel-order]')) { adminCancelOrder(el.dataset.cancelOrder); return; }
535
+ if (el.matches('[data-activate-res]')) { adminActivateReservation(el.dataset.activateRes); return; }
536
+ if (el.matches('[data-certify-partner]')) { adminCertifyPartner(el.dataset.certifyPartner); return; }
537
+ if (el.matches('[data-partner-issue]')) { adminPartnerIssueLicense(el.dataset.partnerIssue); return; }
538
+ if (el.matches('[data-reply-ticket]')) { adminReplyTicket(el.dataset.replyTicket); return; }
539
+ if (el.matches('[data-close-ticket]')) { adminCloseTicket(el.dataset.closeTicket); return; }
540
+
541
+ switch (el.dataset.action) {
542
+ case 'registry': refreshRegistry(); break;
543
+ case 'demo-pak': createDemoPak(); break;
544
+ case 'admin-logout': logoutAdmin(); break;
545
+ case 'adm-issue-lic': adminIssueLicense(); break;
546
+ case 'adm-register-partner': adminRegisterPartner(); break;
547
+ case 'adm-ai-send': sendAdminAi(); break;
548
+ case 'cert-close': $('#certModal')?.classList.remove('open'); break;
549
+ case 'cert-copy': navigator.clipboard?.writeText($('#certJsonBox')?.textContent || ''); break;
550
+ case 'cert-download': downloadCurrentCert(); break;
551
+ }
552
+ });
553
+
554
+ $('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
555
+ document.addEventListener('keydown', (e) => {
556
+ if (e.key === 'Escape') $('#certModal')?.classList.remove('open');
557
+ if (e.key === 'Enter' && e.target?.id?.startsWith('reply-')) {
558
+ adminReplyTicket(e.target.id.slice('reply-'.length));
559
+ }
560
+ });
561
+ $('#admAiInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAdminAi(); });
562
+ }
563
+
564
+ function start() {
565
+ applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
566
+ bind();
567
+ checkAdminSession();
568
+ }
569
+
570
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
571
+ else start();
572
+ })();