qiksy 1.0.0

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.
Files changed (78) hide show
  1. package/ARCHITECTURE.md +78 -0
  2. package/INSTALL-PROMPT.md +53 -0
  3. package/bin/qiksy.mjs +302 -0
  4. package/package.json +32 -0
  5. package/tools/client-finder/vendor/README.md +70 -0
  6. package/tools/client-finder/vendor/find.mjs +121 -0
  7. package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
  8. package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
  9. package/tools/client-finder/vendor/knowledge/assist.md +68 -0
  10. package/tools/client-finder/vendor/knowledge/custom.md +36 -0
  11. package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
  12. package/tools/client-finder/vendor/knowledge/pay.md +26 -0
  13. package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
  14. package/tools/client-finder/vendor/knowledge/travel.md +43 -0
  15. package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
  16. package/tools/client-finder/vendor/lib/access.mjs +108 -0
  17. package/tools/client-finder/vendor/lib/agent.mjs +204 -0
  18. package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
  19. package/tools/client-finder/vendor/lib/browser.mjs +108 -0
  20. package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
  21. package/tools/client-finder/vendor/lib/channels.mjs +54 -0
  22. package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
  23. package/tools/client-finder/vendor/lib/engine.mjs +283 -0
  24. package/tools/client-finder/vendor/lib/formats.mjs +108 -0
  25. package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
  26. package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
  27. package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
  28. package/tools/client-finder/vendor/lib/report.mjs +75 -0
  29. package/tools/client-finder/vendor/lib/scope.mjs +162 -0
  30. package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
  31. package/tools/client-finder/vendor/lib/verify.mjs +96 -0
  32. package/tools/client-finder/vendor/package.json +8 -0
  33. package/tools/client-finder/vendor/server.mjs +390 -0
  34. package/tools/travel/vendor/README.md +58 -0
  35. package/tools/travel/vendor/docs/CONTRACT.md +174 -0
  36. package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
  37. package/tools/travel/vendor/lib/agent.mjs +190 -0
  38. package/tools/travel/vendor/lib/engine.mjs +283 -0
  39. package/tools/travel/vendor/lib/jobs.mjs +125 -0
  40. package/tools/travel/vendor/lib/license.mjs +41 -0
  41. package/tools/travel/vendor/lib/plans.mjs +28 -0
  42. package/tools/travel/vendor/lib/prompts.mjs +394 -0
  43. package/tools/travel/vendor/lib/proposal.mjs +207 -0
  44. package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
  45. package/tools/travel/vendor/lib/store.mjs +75 -0
  46. package/tools/travel/vendor/package.json +12 -0
  47. package/tools/travel/vendor/public/app.js +996 -0
  48. package/tools/travel/vendor/public/index.html +112 -0
  49. package/tools/travel/vendor/public/styles.css +760 -0
  50. package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
  51. package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
  52. package/tools/travel/vendor/server.mjs +755 -0
  53. package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
  54. package/tools/work-finder/vendor/.mcp.json +9 -0
  55. package/tools/work-finder/vendor/CLAUDE.md +24 -0
  56. package/tools/work-finder/vendor/README.md +44 -0
  57. package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
  58. package/tools/work-finder/vendor/lib/agent.mjs +159 -0
  59. package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
  60. package/tools/work-finder/vendor/lib/channels.mjs +73 -0
  61. package/tools/work-finder/vendor/lib/engine.mjs +283 -0
  62. package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
  63. package/tools/work-finder/vendor/lib/license.mjs +60 -0
  64. package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
  65. package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
  66. package/tools/work-finder/vendor/lib/plans.mjs +33 -0
  67. package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
  68. package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
  69. package/tools/work-finder/vendor/lib/store.mjs +125 -0
  70. package/tools/work-finder/vendor/package.json +41 -0
  71. package/tools/work-finder/vendor/public/app.js +1863 -0
  72. package/tools/work-finder/vendor/public/index.html +518 -0
  73. package/tools/work-finder/vendor/public/styles.css +930 -0
  74. package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
  75. package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
  76. package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
  77. package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
  78. package/tools/work-finder/vendor/server.mjs +1006 -0
@@ -0,0 +1,996 @@
1
+ /* Travel Assistant UI — hash-роутинг, поллинг джобов, рендер через innerHTML (каждая вставка через esc). */
2
+
3
+ let state = null;
4
+ let view = 'trips';
5
+ let currentTripId = null;
6
+ let jobsPanelOpen = false;
7
+ let lastJobsFingerprint = '';
8
+ let idleTicks = 0;
9
+ const revealedPassports = new Set();
10
+
11
+ /* EMBED MODE: хаб хостит приложение в iframe со своей единой шапкой.
12
+ При ?embed=1 прячем собственный .topbar (styles.css: body.embed .topbar),
13
+ а навигацию отдаём хабу через postMessage (см. слушатель ниже и route()). */
14
+ const IN_EMBED = new URLSearchParams(location.search).get('embed') === '1';
15
+ const HUB_TABS = [
16
+ { id: 'trips', label: 'Поездки' },
17
+ { id: 'team', label: 'Команда' },
18
+ { id: 'settings', label: 'Настройки' },
19
+ ];
20
+ function postToHub(msg) {
21
+ if (!IN_EMBED) return;
22
+ try { window.parent.postMessage(msg, '*'); } catch (e) {}
23
+ }
24
+
25
+ /* MODE-SYNC: хаб транслирует свою light/dark моду в iframe — через ?theme= в URL
26
+ и сообщения {qiksy:"mode",mode} (первичный хендшейк + каждое переключение).
27
+ applyTheme дёргает только наш собственный механизм (data-theme на <html>), без
28
+ перезагрузки. Standalone без embed и без theme= остаётся светлым — тема просто
29
+ не выставляется, базовые :root-токены работают как раньше. */
30
+ function applyTheme(mode) {
31
+ if (mode === 'dark') document.documentElement.dataset.theme = 'dark';
32
+ else delete document.documentElement.dataset.theme;
33
+ }
34
+ applyTheme(new URLSearchParams(location.search).get('theme'));
35
+
36
+ const $ = sel => document.querySelector(sel);
37
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
38
+ /* URL из ответа агента идёт в href — пускаем только http(s), иначе javascript: выполнится в origin с доступом к /api/state (паспорта) */
39
+ const safeUrl = u => /^https?:\/\//i.test(String(u ?? '')) ? String(u) : '';
40
+
41
+ const userIsTyping = () => {
42
+ const el = document.activeElement;
43
+ return el && (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && el.type === 'text') || (el.tagName === 'INPUT' && !el.type));
44
+ };
45
+
46
+ const STATUS_LABELS = {
47
+ draft: 'черновик', searching: 'идёт поиск', options: 'варианты готовы',
48
+ booking: 'оформляется', ready_to_pay: 'ждёт оплаты', paid: 'оплачено', cancelled: 'отменена',
49
+ };
50
+ const PIPELINE = ['draft', 'searching', 'options', 'booking', 'ready_to_pay', 'paid'];
51
+ const PIPE_LABELS = {
52
+ draft: 'Черновик', searching: 'Поиск', options: 'Варианты',
53
+ booking: 'Оформление', ready_to_pay: 'К оплате', paid: 'Оплачено',
54
+ };
55
+ const TRANSFER_LABELS = { auto: 'авто', yes: 'да', no: 'нет' };
56
+ const JOB_TYPE_LABELS = { search: 'Поиск вариантов', book: 'Оформление', finalize: 'Подготовка документов' };
57
+ const JOB_STATUS_LABELS = { queued: 'в очереди', running: 'работает', done: 'готово', error: 'ошибка', cancelled: 'отменён' };
58
+ const BLOCKS_META = [
59
+ ['letter', 'Письмо путнику'],
60
+ ['costTable', 'Сводка стоимости'],
61
+ ['checklist', 'Чеклист перед поездкой'],
62
+ ];
63
+ const CORPORATE_FIELDS = [
64
+ 'legalEntity', 'travelAgency', 'geniusLevel', 'airlineCodes', 'insurance', 'transferVendor',
65
+ 'flightClass', 'trainClass', 'hotelStars', 'perDiem', 'hrTimezone', 'currency',
66
+ ];
67
+ const CUR = { EUR: '€', USD: '$', UAH: '₴', PLN: 'zł', GBP: '£', CZK: 'Kč', CHF: 'CHF' };
68
+ const MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
69
+
70
+ const ICONS = {
71
+ plane: '<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
72
+ train: '<rect x="4" y="3" width="16" height="12" rx="2"/><path d="M4 10h16"/><circle cx="9" cy="12.5" r="0.6"/><circle cx="15" cy="12.5" r="0.6"/><path d="M8 15l-3 6M16 15l3 6M6.5 18h11"/>',
73
+ bus: '<rect x="4" y="4" width="16" height="13" rx="2"/><path d="M4 11h16M9 4v7M15 4v7"/><circle cx="8" cy="19" r="1.5"/><circle cx="16" cy="19" r="1.5"/>',
74
+ hotel: '<rect x="5" y="3" width="14" height="18" rx="1.5"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2M10 21v-3h4v3"/>',
75
+ pin: '<path d="M12 21s-7-5.3-7-11a7 7 0 0 1 14 0c0 5.7-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
76
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
77
+ calendar: '<rect x="4" y="5" width="16" height="16" rx="2"/><path d="M4 10h16M8 3v4M16 3v4"/>',
78
+ external: '<path d="M14 4h6v6"/><path d="M20 4 11 13"/><path d="M20 14v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/>',
79
+ copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
80
+ car: '<path d="M5 11l1.5-4.5A2 2 0 0 1 8.4 5h7.2a2 2 0 0 1 1.9 1.5L19 11"/><rect x="3" y="11" width="18" height="7" rx="2"/><circle cx="7.5" cy="18" r="1.5"/><circle cx="16.5" cy="18" r="1.5"/>',
81
+ };
82
+ const icon = (name, cls = '') =>
83
+ `<svg class="ico ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ''}</svg>`;
84
+
85
+ /* ─────────── утилиты ─────────── */
86
+
87
+ async function api(path, opts = {}, silent = false) {
88
+ const res = await fetch(path, {
89
+ headers: { 'Content-Type': 'application/json' },
90
+ ...opts,
91
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
92
+ });
93
+ const data = await res.json().catch(() => ({}));
94
+ if (!res.ok) {
95
+ if (!silent) showToast(data.error || res.statusText);
96
+ throw new Error(data.error || res.statusText);
97
+ }
98
+ return data;
99
+ }
100
+
101
+ function plural(n, one, few, many) {
102
+ const a = Math.abs(n) % 100, b = a % 10;
103
+ if (a > 10 && a < 20) return many;
104
+ if (b > 1 && b < 5) return few;
105
+ if (b === 1) return one;
106
+ return many;
107
+ }
108
+
109
+ /* даты приходят строками YYYY-MM-DD — парсим руками, чтобы не ловить сдвиг таймзоны */
110
+ function fmtDate(iso) {
111
+ if (!iso) return '';
112
+ const [, m, d] = iso.split('-');
113
+ if (!m || !d) return iso;
114
+ return `${Number(d)} ${MONTHS[Number(m) - 1] || ''}`;
115
+ }
116
+
117
+ function nightsOf(t) {
118
+ if (!t.departDate || !t.returnDate) return 0;
119
+ const n = Math.round((Date.parse(t.returnDate) - Date.parse(t.departDate)) / 86_400_000);
120
+ return n > 0 ? n : 0;
121
+ }
122
+
123
+ function fmtRange(t) {
124
+ if (!t.returnDate) return `${fmtDate(t.departDate)} · в одну сторону`;
125
+ const n = nightsOf(t);
126
+ return `${fmtDate(t.departDate)} → ${fmtDate(t.returnDate)}${n ? ` · ${n} ${plural(n, 'ночь', 'ночи', 'ночей')}` : ''}`;
127
+ }
128
+
129
+ const fmtTime = ts => ts ? new Date(ts).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
130
+ const fmtReviews = n => n == null ? '' : Number(n).toLocaleString('ru-RU');
131
+ const fmtMoney = (amount, currency) => amount == null ? '' : `${CUR[currency] || (currency ? currency + ' ' : '')}${amount}`;
132
+ const maskPassport = p => {
133
+ const s = String(p || '');
134
+ return s.length > 5 ? `${s.slice(0, 3)}***${s.slice(-2)}` : s;
135
+ };
136
+
137
+ const tripById = id => (state?.trips || []).find(t => t.id === id);
138
+ const memberById = id => (state?.team || []).find(m => m.id === id);
139
+ const tripTitle = t => t ? (t.title || `${t.from} → ${t.to}`) : 'поездка';
140
+ const activeJob = tripId => (state?.jobs || []).find(j => j.tripId === tripId && (j.status === 'running' || j.status === 'queued'));
141
+ const lastJobOf = tripId => (state?.jobs || []).find(j => j.tripId === tripId);
142
+ const logTailText = j => Array.isArray(j.logTail) ? j.logTail.join('\n') : String(j.logTail || '');
143
+
144
+ /* ─────────── toast + копирование (fallback-цепочка из CLAUDE.md ассистента) ─────────── */
145
+
146
+ let toastTimer = null;
147
+ function showToast(msg) {
148
+ const t = $('#toast');
149
+ t.textContent = msg;
150
+ t.classList.add('show');
151
+ clearTimeout(toastTimer);
152
+ toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
153
+ }
154
+
155
+ function legacyCopy(text) {
156
+ const ta = document.createElement('textarea');
157
+ ta.value = text;
158
+ ta.setAttribute('readonly', '');
159
+ ta.style.cssText = 'position:fixed;left:-9999px;top:0';
160
+ document.body.appendChild(ta);
161
+ ta.select();
162
+ ta.setSelectionRange(0, text.length);
163
+ let ok = false;
164
+ try { ok = document.execCommand('copy'); } catch (e) {}
165
+ document.body.removeChild(ta);
166
+ return ok;
167
+ }
168
+
169
+ async function copyToClipboard(text, selectEl) {
170
+ let ok = false;
171
+ try {
172
+ if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); ok = true; }
173
+ } catch (e) {}
174
+ if (!ok) ok = legacyCopy(text);
175
+ if (ok) { showToast('Скопировано — Cmd+V куда нужно'); return; }
176
+ if (selectEl) {
177
+ const range = document.createRange();
178
+ range.selectNodeContents(selectEl);
179
+ const sel = window.getSelection();
180
+ sel.removeAllRanges();
181
+ sel.addRange(range);
182
+ }
183
+ showToast('Не пустило в буфер — текст выделен, нажми Cmd+C');
184
+ }
185
+
186
+ /* ─────────── refresh / рендер ─────────── */
187
+
188
+ async function refresh(force = false) {
189
+ state = await api('/api/state', {}, true);
190
+ renderTopbar();
191
+ if (force || !userIsTyping()) renderAll(force);
192
+ }
193
+
194
+ function renderAll(force = false) {
195
+ renderTrips();
196
+ renderTripDetail();
197
+ renderTeam();
198
+ renderSettings(force);
199
+ }
200
+
201
+ function renderTopbar() {
202
+ if (!state) return;
203
+ const pill = $('#browser-pill');
204
+ const alive = !!state.browserAlive;
205
+ pill.classList.toggle('on', alive);
206
+ pill.querySelector('.lbl').textContent = alive ? 'браузер подключен' : 'браузер выключен';
207
+ renderJobsWidget();
208
+ }
209
+
210
+ function renderJobsWidget() {
211
+ const jobs = state.jobs || [];
212
+ const active = jobs.filter(j => j.status === 'running' || j.status === 'queued');
213
+ const recent = jobs.filter(j => j.finishedAt && Date.now() - j.finishedAt < 3 * 60_000);
214
+ const shown = [...active, ...recent.filter(j => !active.includes(j))];
215
+ const w = $('#jobs-widget');
216
+ w.hidden = !shown.length;
217
+ if (!shown.length) { jobsPanelOpen = false; return; }
218
+
219
+ $('#jobs-toggle').innerHTML = active.length
220
+ ? `<span class="spinner"></span> Агенты: ${esc(active.length)}`
221
+ : 'Агенты: готово';
222
+
223
+ const panel = $('#jobs-panel');
224
+ panel.hidden = !jobsPanelOpen;
225
+ panel.innerHTML = [
226
+ active.length ? '<div class="progress"><i></i></div>' : '',
227
+ ...shown.map(j => `
228
+ <div class="job-row" data-trip="${esc(j.tripId || '')}">
229
+ <div class="job-row-head">
230
+ ${j.status === 'running' ? '<span class="spinner"></span>' : ''}
231
+ <span class="job-label">${esc(JOB_TYPE_LABELS[j.type] || j.type)} — ${esc(tripTitle(tripById(j.tripId)))}</span>
232
+ <span class="st st-job-${esc(j.status)}">${esc(JOB_STATUS_LABELS[j.status] || j.status)}</span>
233
+ </div>
234
+ ${j.error ? `<pre class="job-log">${esc(j.error)}</pre>` : ''}
235
+ ${j.status === 'running' && logTailText(j) ? `<pre class="job-log">${esc(logTailText(j).split('\n').slice(-4).join('\n'))}</pre>` : ''}
236
+ </div>`),
237
+ ].join('');
238
+ }
239
+
240
+ /* ── список поездок ── */
241
+
242
+ function tripCard(t) {
243
+ const names = (t.travelerIds || [])
244
+ .map(id => memberById(id)?.nameRu?.split(' ').slice(0, 2).join(' '))
245
+ .filter(Boolean);
246
+ const n = nightsOf(t);
247
+ const nightsTxt = t.returnDate
248
+ ? (n ? `${n} ${plural(n, 'ночь', 'ночи', 'ночей')}` : '')
249
+ : 'в одну сторону';
250
+ return `
251
+ <div class="trip-card" data-trip="${esc(t.id)}">
252
+ <div class="bp-route">
253
+ <div class="bp-eyebrow">маршрут</div>
254
+ <div class="bp-cities">
255
+ <div class="bp-end">
256
+ <div class="bp-city">${esc(t.from)}</div>
257
+ <div class="bp-sub">откуда</div>
258
+ </div>
259
+ <div class="bp-path">${icon('plane', 'bp-plane')}</div>
260
+ <div class="bp-end bp-end-to">
261
+ <div class="bp-city">${esc(t.to)}</div>
262
+ <div class="bp-sub">куда</div>
263
+ </div>
264
+ </div>
265
+ <div class="bp-dates">${icon('calendar', 'ico-14')} ${esc(fmtRange(t))}</div>
266
+ </div>
267
+ <div class="bp-perf" aria-hidden="true"></div>
268
+ <div class="bp-stub">
269
+ <span class="st st-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>
270
+ ${names.length ? `<div class="bp-line">${icon('pin', 'ico-14')} <span>${esc(names.join(', '))}</span></div>` : ''}
271
+ ${nightsTxt ? `<div class="bp-line">${icon('clock', 'ico-14')} <span>${esc(nightsTxt)}</span></div>` : ''}
272
+ </div>
273
+ </div>`;
274
+ }
275
+
276
+ function renderTrips() {
277
+ if (!state) return;
278
+ const list = [...(state.trips || [])].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
279
+ $('#trips-list').innerHTML = list.map(tripCard).join('')
280
+ || '<div class="empty">Пока нет поездок.<br>Нажмите «Новая поездка», чтобы начать.</div>';
281
+ }
282
+
283
+ /* ── деталь поездки ── */
284
+
285
+ function heroBlock(t) {
286
+ const members = (t.travelerIds || []).map(memberById).filter(Boolean);
287
+ const label = members.length === 1
288
+ ? ['КОМАНДИРОВКА', members[0].nameRu, members[0].position].filter(Boolean).join(' · ')
289
+ : members.length
290
+ ? ['КОМАНДИРОВКА', ...members.map(m => m.nameRu)].join(' · ')
291
+ : 'КОМАНДИРОВКА';
292
+ const meta = [
293
+ `<span>${icon('calendar')} ${esc(fmtRange(t))}</span>`,
294
+ t.meetingAt ? `<span>${icon('clock')} ${esc(t.meetingAt)}</span>` : '',
295
+ `<span>${icon('car')} трансфер: ${esc(TRANSFER_LABELS[t.needTransfer] || t.needTransfer || 'авто')}</span>`,
296
+ ].filter(Boolean).join('');
297
+ return `
298
+ <section class="hero">
299
+ <div class="hero-label">${esc(label)}</div>
300
+ <h1 class="hero-title">${esc(t.from)} <span class="arrow">→</span> ${esc(t.to)}</h1>
301
+ <div class="hero-meta">${meta}</div>
302
+ </section>`;
303
+ }
304
+
305
+ function pipelineBlock(t) {
306
+ if (t.status === 'cancelled') {
307
+ return `<div class="pipe"><span class="st st-cancelled">${esc(STATUS_LABELS.cancelled)}</span></div>`;
308
+ }
309
+ const idx = PIPELINE.indexOf(t.status);
310
+ return `<div class="pipe">${PIPELINE.map((s, i) =>
311
+ `<span class="pipe-step ${i < idx ? 'done' : ''} ${i === idx ? 'current' : ''}">${esc(PIPE_LABELS[s])}</span>`,
312
+ ).join('<span class="pipe-sep"></span>')}</div>`;
313
+ }
314
+
315
+ function jobLogBlock(job, title) {
316
+ return `
317
+ <div class="card joblog-card">
318
+ <div class="joblog-head"><span class="spinner"></span> ${esc(title)}</div>
319
+ <div class="progress"><i></i></div>
320
+ <pre class="joblog" id="joblog">${esc(logTailText(job))}</pre>
321
+ </div>`;
322
+ }
323
+
324
+ function errorNote(t) {
325
+ const j = lastJobOf(t.id);
326
+ return j && j.status === 'error' && j.error
327
+ ? `<div class="error-note">Агент упал с ошибкой: ${esc(j.error)}</div>`
328
+ : '';
329
+ }
330
+
331
+ function sectionHead(iconName, label) {
332
+ return `<div class="sec-head"><span class="sec-ico">${icon(iconName)}</span><span class="sec-title">${esc(label)}</span><span class="sec-line"></span></div>`;
333
+ }
334
+
335
+ function timeline(o) {
336
+ const dep = o.depart || {}, arr = o.arrive || {};
337
+ const stops = Array.isArray(o.stops) ? o.stops : [];
338
+ const dots = stops.map((s, i) =>
339
+ `<span class="tl-stop" style="left:${((i + 1) / (stops.length + 1) * 100).toFixed(1)}%" title="${esc(s.place || '')}"></span>`,
340
+ ).join('');
341
+ const note = o.direct
342
+ ? '<div class="tl-direct">Прямой</div>'
343
+ : stops.length
344
+ ? `<div class="tl-stops">${stops.map(s => `пересадка ${esc(s.place || '')}${s.wait ? ` · ${esc(s.wait)}` : ''}`).join('; ')}</div>`
345
+ : '';
346
+ return `
347
+ <div class="timeline">
348
+ <div class="tl-end"><div class="tl-time">${esc(dep.time || '')}</div><div class="tl-place">${esc(dep.place || '')}</div></div>
349
+ <div class="tl-mid">
350
+ ${o.duration ? `<span class="tl-chip">${esc(o.duration)}</span>` : ''}
351
+ <div class="tl-line">${dots}</div>
352
+ ${note}
353
+ </div>
354
+ <div class="tl-end tl-right"><div class="tl-time">${esc(arr.time || '')}</div><div class="tl-place">${esc(arr.place || '')}</div></div>
355
+ </div>`;
356
+ }
357
+
358
+ function priceBlock(o) {
359
+ const p = o.price || {};
360
+ const small = p.unit === 'per_night' ? 'за ночь' : 'итого';
361
+ const big = fmtMoney(p.amount, p.currency);
362
+ let extra = '';
363
+ if (o.kind === 'hotel' && o.totalPrice != null) {
364
+ const n = o.nights ? ` · ${o.nights} ${plural(o.nights, 'ночь', 'ночи', 'ночей')}` : '';
365
+ extra = `<div class="price-extra">итого ${esc(fmtMoney(o.totalPrice, p.currency))}${esc(n)}</div>`;
366
+ }
367
+ if (!big && !extra) return '';
368
+ return `<div class="opt-price"><div class="price-small">${esc(small)}</div><div class="price-big">${esc(big)}</div>${extra}</div>`;
369
+ }
370
+
371
+ function optionBody(o) {
372
+ if (['flight', 'train', 'bus'].includes(o.kind)) {
373
+ return [
374
+ timeline(o),
375
+ o.baggage ? `<div class="opt-row">Багаж: ${esc(o.baggage)}</div>` : '',
376
+ o.notes ? `<div class="opt-row muted">${esc(o.notes)}</div>` : '',
377
+ ].join('');
378
+ }
379
+ if (o.kind === 'hotel') {
380
+ return [
381
+ `<div class="hotel-row">
382
+ ${o.rating ? `<span class="rate-plate">${esc(o.rating.score ?? '')} <small>· ${esc(fmtReviews(o.rating.reviews))}</small></span>` : ''}
383
+ ${o.address ? `<span class="opt-addr">${esc(o.address)}</span>` : ''}
384
+ </div>`,
385
+ `<div class="chips">
386
+ ${o.distanceKm != null ? `<span class="chip">${icon('pin')} ${esc(o.distanceKm)} км от центра</span>` : ''}
387
+ ${(Array.isArray(o.amenities) ? o.amenities : []).map(a => `<span class="chip ${a.green ? 'chip-green' : ''}">${esc(a.label)}</span>`).join('')}
388
+ </div>`,
389
+ o.notes ? `<div class="opt-row muted">${esc(o.notes)}</div>` : '',
390
+ ].join('');
391
+ }
392
+ if (o.kind === 'transfer') {
393
+ return [
394
+ o.service ? `<div class="opt-row">${esc(o.service)}</div>` : '',
395
+ o.pickupAt ? `<div class="opt-row">${icon('pin', 'ico-14')} Подача: ${esc(o.pickupAt)}</div>` : '',
396
+ o.vehicle ? `<div class="opt-row">${icon('car', 'ico-14')} Машина: ${esc(o.vehicle)}</div>` : '',
397
+ o.notes ? `<div class="opt-row muted">${esc(o.notes)}</div>` : '',
398
+ ].join('');
399
+ }
400
+ return o.notes ? `<div class="opt-row muted">${esc(o.notes)}</div>` : '';
401
+ }
402
+
403
+ function optionCard(o, enabled) {
404
+ const badge = o.badge === 'recommended'
405
+ ? '<span class="pill pill-green">⭐ рекомендую</span>'
406
+ : o.badge === 'cheapest'
407
+ ? '<span class="pill pill-gold">самый дешёвый</span>'
408
+ : '';
409
+ const stars = o.kind === 'hotel' && o.stars
410
+ ? `<span class="stars">${'★'.repeat(Math.max(1, Math.min(5, o.stars)))}</span>`
411
+ : '';
412
+ const link = safeUrl(o.link);
413
+ return `
414
+ <div class="card opt-card">
415
+ <div class="opt-main">
416
+ <div class="opt-num">${esc(o.n)}</div>
417
+ <div class="opt-body">
418
+ <div class="opt-title">${esc(o.title)}${stars}${o.brand && o.brand !== o.title ? `<span class="opt-brand">· ${esc(o.brand)}</span>` : ''}${badge}</div>
419
+ ${optionBody(o)}
420
+ </div>
421
+ ${priceBlock(o)}
422
+ </div>
423
+ <div class="opt-actions">
424
+ ${link ? `<a class="ext-link" href="${esc(link)}" target="_blank" rel="noopener">Открыть ${icon('external')}</a>` : '<span></span>'}
425
+ <button class="btn primary sm" data-book="${esc(o.n)}" ${enabled ? '' : 'disabled'}>Оформить №${esc(o.n)}</button>
426
+ </div>
427
+ </div>`;
428
+ }
429
+
430
+ function optionSections(t, enabled, booked = new Set()) {
431
+ const opts = [...(t.options || [])].sort((a, b) => (a.n || 0) - (b.n || 0));
432
+ if (!opts.length) return '';
433
+ const known = new Set(['flight', 'train', 'bus', 'hotel', 'transfer']);
434
+ const groups = [
435
+ { label: 'Транспорт', icon: opts.some(o => o.kind === 'flight') ? 'plane' : 'train', items: opts.filter(o => ['flight', 'train', 'bus'].includes(o.kind)) },
436
+ { label: 'Отель', icon: 'hotel', items: opts.filter(o => o.kind === 'hotel') },
437
+ { label: 'Трансфер', icon: 'car', items: opts.filter(o => o.kind === 'transfer') },
438
+ { label: 'Другое', icon: 'pin', items: opts.filter(o => !known.has(o.kind)) },
439
+ ].filter(g => g.items.length);
440
+ return groups.map(g => sectionHead(g.icon, g.label) + g.items.map(o => optionCard(o, enabled && !booked.has(o.n))).join('')).join('');
441
+ }
442
+
443
+ function recPlate(t, enabled) {
444
+ const r = t.recommendation || {};
445
+ const ids = r.ids || [];
446
+ if (!r.text && !ids.length) return '';
447
+ return `
448
+ <div class="rec-plate">
449
+ <div class="rec-top">
450
+ <span class="rec-badge">⭐ Рекомендованная связка</span>
451
+ ${r.comboTotal ? `<span class="rec-total">${esc(r.comboTotal)}</span>` : ''}
452
+ </div>
453
+ ${r.text ? `<div class="rec-text">${esc(r.text)}</div>` : ''}
454
+ <div class="rec-foot">
455
+ <span class="rec-ids">${esc(ids.map(n => `№${n}`).join(' + '))}</span>
456
+ <button class="btn primary sm" data-book-combo ${enabled && ids.length ? '' : 'disabled'}>Оформить связку</button>
457
+ </div>
458
+ </div>`;
459
+ }
460
+
461
+ function reportCard(r) {
462
+ const filled = Array.isArray(r.filled) ? r.filled.filter(Boolean) : [];
463
+ const manualLink = safeUrl(r.manualLink);
464
+ return `
465
+ <div class="card report-card">
466
+ <div class="report-head">
467
+ <h3>Отчёт оформления — вариант №${esc(r.optionN ?? '?')}</h3>
468
+ ${r.at ? `<span class="muted">${esc(fmtTime(r.at))}</span>` : ''}
469
+ </div>
470
+ ${filled.length ? `<div class="report-row"><b>Заполнено:</b> ${filled.map(x => esc(x)).join(' · ')}</div>` : ''}
471
+ ${!filled.length && r.text ? `<div class="report-row">${esc(r.text)}</div>` : ''}
472
+ ${r.stoppedAt ? `<div class="report-row"><b>Остановился:</b> ${esc(r.stoppedAt)}</div>` : ''}
473
+ ${r.totalShown ? `<div class="report-row"><b>Сумма на экране:</b> <span class="report-total">${esc(r.totalShown)}</span></div>` : ''}
474
+ ${(r.warnings || []).length ? `<div class="report-warnings">${r.warnings.map(w => `<div class="warn-row">${esc(w)}</div>`).join('')}</div>` : ''}
475
+ ${manualLink ? `<div class="report-row"><a class="ext-link" href="${esc(manualLink)}" target="_blank" rel="noopener">Продолжить вручную ${icon('external')}</a></div>` : ''}
476
+ </div>`;
477
+ }
478
+
479
+ function blocksSection(t) {
480
+ const b = t.blocks || {};
481
+ const finJob = activeJob(t.id);
482
+ if (!b.letter && !b.costTable && !b.checklist) {
483
+ return finJob
484
+ ? jobLogBlock(finJob, 'Агент готовит письмо, смету и чеклист…')
485
+ : '<div class="card"><p class="muted" style="margin:0">Документы ещё не готовы.</p></div>';
486
+ }
487
+ return BLOCKS_META.map(([key, title]) => b[key] ? `
488
+ <div class="card block-card">
489
+ <div class="block-head">
490
+ <h3>${esc(title)}</h3>
491
+ <button class="btn ghost sm copy-btn" data-copy-block="${esc(key)}">${icon('copy')} Копировать</button>
492
+ </div>
493
+ <pre class="block-pre" id="block-${esc(key)}">${esc(b[key])}</pre>
494
+ </div>` : '').join('');
495
+ }
496
+
497
+ function draftProps(t) {
498
+ const needs = [
499
+ `транспорт: ${t.needTransport ? 'да' : 'нет'}`,
500
+ `отель: ${t.needHotel ? 'да' : 'нет'}`,
501
+ `трансфер: ${TRANSFER_LABELS[t.needTransfer] || t.needTransfer || 'авто'}`,
502
+ ].join(' · ');
503
+ return `
504
+ <div class="card">
505
+ <dl class="trip-props">
506
+ ${t.budget ? `<div><dt>Бюджет</dt><dd>${esc(t.budget)}</dd></div>` : ''}
507
+ ${t.meetingAt ? `<div><dt>Встреча</dt><dd>${esc(t.meetingAt)}</dd></div>` : ''}
508
+ <div><dt>Что нужно</dt><dd>${esc(needs)}</dd></div>
509
+ ${t.criteria ? `<div><dt>Критерии</dt><dd>${esc(t.criteria)}</dd></div>` : ''}
510
+ </dl>
511
+ </div>`;
512
+ }
513
+
514
+ function tripActions(t) {
515
+ const editable = ['draft', 'options'].includes(t.status);
516
+ const btns = [
517
+ t.status === 'draft' ? '<button class="btn primary" data-search>Искать варианты</button>' : '',
518
+ t.status === 'options' ? '<button class="btn ghost" data-search>Искать заново</button>' : '',
519
+ editable ? '<button class="btn ghost" data-edit-trip>Редактировать</button>' : '',
520
+ t.status !== 'cancelled' && t.status !== 'paid' ? '<button class="btn danger" data-cancel-trip>Отменить поездку</button>' : '',
521
+ ['draft', 'cancelled', 'paid'].includes(t.status) ? '<button class="btn danger" data-del-trip>Удалить</button>' : '',
522
+ ].filter(Boolean);
523
+ return btns.length ? `<div class="actions-row">${btns.join('')}</div>` : '';
524
+ }
525
+
526
+ function renderTripDetail() {
527
+ const box = $('#trip-detail');
528
+ if (!state || view !== 'trip') return;
529
+ const t = tripById(currentTripId);
530
+ if (!t) {
531
+ box.innerHTML = '<p class="empty">Поездка не найдена.</p>';
532
+ return;
533
+ }
534
+ const job = activeJob(t.id);
535
+ const parts = [heroBlock(t), pipelineBlock(t), errorNote(t)];
536
+
537
+ if (t.status === 'draft') {
538
+ parts.push(draftProps(t));
539
+ } else if (t.status === 'searching') {
540
+ parts.push(job
541
+ ? jobLogBlock(job, 'Агент ищет варианты…')
542
+ : '<div class="card"><p class="muted" style="margin:0">Джоб поиска не найден — возможно, сервер перезапускался. Отмените поездку или дождитесь обновления.</p></div>');
543
+ if ((t.options || []).length) {
544
+ parts.push(`<p class="muted">Найдено пока: ${esc(t.options.length)}</p>`, optionSections(t, false));
545
+ }
546
+ } else if (t.status === 'options') {
547
+ parts.push(recPlate(t, true));
548
+ if (t.summary) parts.push(`<p class="summary">${esc(t.summary)}</p>`);
549
+ if (t.notFound) parts.push(`<div class="notfound">${esc(t.notFound)}</div>`);
550
+ parts.push(optionSections(t, true));
551
+ } else if (t.status === 'booking') {
552
+ parts.push(job
553
+ ? jobLogBlock(job, 'Агент оформляет — дойдёт до экрана оплаты и остановится…')
554
+ : '<div class="card"><p class="muted" style="margin:0">Джоб оформления не найден — возможно, сервер перезапускался.</p></div>');
555
+ if ((t.bookingReports || []).length) parts.push((t.bookingReports || []).map(reportCard).join(''));
556
+ parts.push(optionSections(t, false));
557
+ } else if (t.status === 'ready_to_pay') {
558
+ parts.push((t.bookingReports || []).map(reportCard).join(''));
559
+ parts.push('<div class="pay-note"><b>Карту вводит только HR</b> — агент никогда не жмёт Pay.</div>');
560
+ parts.push('<div class="actions-row"><button class="btn primary" data-paid>Оплачено ✓</button></div>');
561
+ const booked = new Set((t.bookingReports || []).map(r => r.optionN));
562
+ parts.push(optionSections(t, true, booked));
563
+ } else if (t.status === 'paid') {
564
+ parts.push(blocksSection(t));
565
+ if ((t.bookingReports || []).length) parts.push((t.bookingReports || []).map(reportCard).join(''));
566
+ } else if (t.status === 'cancelled') {
567
+ parts.push('<div class="card"><p class="muted" style="margin:0">Поездка отменена.</p></div>');
568
+ }
569
+
570
+ parts.push(tripActions(t));
571
+ box.innerHTML = parts.filter(Boolean).join('');
572
+
573
+ const log = $('#joblog');
574
+ if (log) log.scrollTop = log.scrollHeight;
575
+ }
576
+
577
+ /* ── команда ── */
578
+
579
+ function memberCard(m) {
580
+ const revealed = revealedPassports.has(m.id);
581
+ const passport = revealed ? m.passportNo : maskPassport(m.passportNo);
582
+ return `
583
+ <div class="card member-card">
584
+ <div class="member-name">${esc(m.nameRu)}${m.demo ? '<span class="badge-demo">демо</span>' : ''}</div>
585
+ <div class="member-pos">${esc(m.position || '')}</div>
586
+ <dl class="member-props">
587
+ ${m.nameEn ? `<div><dt>Латиницей</dt><dd>${esc(m.nameEn)}</dd></div>` : ''}
588
+ ${m.email || m.phone ? `<div><dt>Контакты</dt><dd>${esc([m.email, m.phone].filter(Boolean).join(' · '))}</dd></div>` : ''}
589
+ ${m.passportNo ? `<div><dt>Паспорт</dt><dd><span class="mono">${esc(passport)}</span>
590
+ <button class="btn-link" data-toggle-passport="${esc(m.id)}" type="button">${revealed ? 'скрыть' : 'показать'}</button>
591
+ ${m.passportValidUntil ? `<span class="muted"> · до ${esc(m.passportValidUntil)}</span>` : ''}</dd></div>` : ''}
592
+ ${m.loyaltyAir ? `<div><dt>Авиа</dt><dd>${esc(m.loyaltyAir)}</dd></div>` : ''}
593
+ ${m.loyaltyHotel ? `<div><dt>Отели</dt><dd>${esc(m.loyaltyHotel)}</dd></div>` : ''}
594
+ ${m.notes ? `<div><dt>Особенности</dt><dd>${esc(m.notes)}</dd></div>` : ''}
595
+ </dl>
596
+ <div class="member-actions">
597
+ <button class="btn ghost sm" data-edit-member="${esc(m.id)}" type="button">Править</button>
598
+ <button class="btn danger sm" data-del-member="${esc(m.id)}" type="button">Удалить</button>
599
+ </div>
600
+ </div>`;
601
+ }
602
+
603
+ function renderTeam() {
604
+ if (!state) return;
605
+ $('#team-list').innerHTML = (state.team || []).map(memberCard).join('')
606
+ || '<div class="empty">Команда пуста.<br>Добавьте первого сотрудника.</div>';
607
+ }
608
+
609
+ /* ── настройки ── */
610
+
611
+ function renderSettings(force = false) {
612
+ if (!state) return;
613
+ const f = $('#settings-form');
614
+ if (!force && f.contains(document.activeElement)) return;
615
+ const c = state.corporate || {};
616
+ for (const k of CORPORATE_FIELDS) f.elements[k].value = c[k] ?? '';
617
+ const s = state.settings || {};
618
+ f.elements.model.value = s.model || 'sonnet';
619
+ f.elements.browserSearch.checked = !!s.browserSearch;
620
+ f.elements.extraInstructions.value = s.extraInstructions || '';
621
+ renderLicenseStatus();
622
+ }
623
+
624
+ /* ── тариф / лицензия (local-first: платные планы включаются ключом) ── */
625
+ const LICENSE_API = 'https://qiksy-licenses.qiksy.workers.dev';
626
+ const buyUrl = plan => `${LICENSE_API}/buy?product=travel&plan=${plan}`;
627
+ const billing = () => (state && state.billing) || {};
628
+ const planId = () => billing().plan || 'free';
629
+
630
+ function renderLicenseStatus() {
631
+ const st = $('#license-status');
632
+ if (!st) return;
633
+ const lic = billing().license || {};
634
+ const p = planId();
635
+ if (lic.invalid) { st.textContent = 'Ключ недействителен для Travel Assistant (или истёк).'; st.className = 'muted wide err'; return; }
636
+ if (lic.set && (p === 'start' || p === 'pro')) {
637
+ const until = lic.paidUntil ? new Date(lic.paidUntil).toLocaleDateString('ru-RU') : '';
638
+ st.textContent = `Лицензия ${p.charAt(0).toUpperCase() + p.slice(1)} активна${until ? ' · до ' + until : ''}.`;
639
+ st.className = 'muted wide ok';
640
+ } else if (lic.set) {
641
+ st.textContent = 'Проверяем ключ…'; st.className = 'muted wide';
642
+ } else {
643
+ st.textContent = 'Free — до 3 путников (family). Pro ($99/год): путники без лимита.'; st.className = 'muted wide';
644
+ }
645
+ }
646
+
647
+ async function applyLicense() {
648
+ const inp = $('#settings-form').licenseKey;
649
+ const key = (inp && inp.value || '').trim();
650
+ const st = $('#license-status');
651
+ if (st) { st.textContent = 'Проверяем ключ…'; st.className = 'muted wide'; }
652
+ try { await api('/api/license', { method: 'POST', body: { key } }); } catch (e) { /* surfaced via billing */ }
653
+ if (inp) inp.value = '';
654
+ await refresh(true);
655
+ const lic = billing().license || {};
656
+ const p = planId();
657
+ if (key && lic.invalid) showToast('Ключ недействителен — проверьте и попробуйте снова.');
658
+ else if (key && (p === 'start' || p === 'pro')) showToast(`${p.charAt(0).toUpperCase() + p.slice(1)} подключён ✓`);
659
+ else if (!key) showToast('Лицензия убрана — снова Free.');
660
+ }
661
+
662
+ /* ─────────── модалка + формы ─────────── */
663
+
664
+ function openModal(html) {
665
+ $('#modal-body').innerHTML = html;
666
+ $('#modal').hidden = false;
667
+ $('#modal-body').querySelector('input,select,textarea')?.focus();
668
+ }
669
+
670
+ function closeModal() {
671
+ $('#modal').hidden = true;
672
+ $('#modal-body').innerHTML = '';
673
+ }
674
+
675
+ function tripFormHtml(t) {
676
+ const team = state?.team || [];
677
+ const sel = t?.needTransfer || 'auto';
678
+ return `
679
+ <form id="trip-form" data-id="${esc(t?.id || '')}">
680
+ <h2>${t ? 'Поездка' : 'Новая поездка'}</h2>
681
+ <div class="form-grid">
682
+ <label>Откуда <input name="from" type="text" required value="${esc(t?.from || '')}" placeholder="Львов"></label>
683
+ <label>Куда <input name="to" type="text" required value="${esc(t?.to || '')}" placeholder="Берлин"></label>
684
+ <label>Дата выезда <input name="departDate" type="date" required value="${esc(t?.departDate || '')}"></label>
685
+ <label>Дата возврата <input name="returnDate" type="date" value="${esc(t?.returnDate || '')}"></label>
686
+ </div>
687
+ <fieldset>
688
+ <legend>Кто едет</legend>
689
+ ${team.map(m => `
690
+ <label class="check"><input type="checkbox" name="travelers" value="${esc(m.id)}" ${t?.travelerIds?.includes(m.id) ? 'checked' : ''}> ${esc(m.nameRu)}</label>`).join('')
691
+ || '<p class="muted" style="margin:0">Команда пуста — сначала добавьте сотрудников во вкладке «Команда».</p>'}
692
+ </fieldset>
693
+ <div class="form-grid">
694
+ <label>Встреча <input name="meetingAt" type="text" value="${esc(t?.meetingAt || '')}" placeholder="встреча 10:00 16 августа"></label>
695
+ <label>Бюджет <input name="budget" type="text" value="${esc(t?.budget || '')}" placeholder="до 400€ на всё"></label>
696
+ </div>
697
+ <fieldset>
698
+ <legend>Что нужно</legend>
699
+ <label class="check"><input type="checkbox" name="needTransport" ${t ? (t.needTransport ? 'checked' : '') : 'checked'}> Транспорт</label>
700
+ <label class="check"><input type="checkbox" name="needHotel" ${t ? (t.needHotel ? 'checked' : '') : 'checked'}> Отель</label>
701
+ <label class="inline">Трансфер
702
+ <select name="needTransfer">
703
+ <option value="auto" ${sel === 'auto' ? 'selected' : ''}>авто</option>
704
+ <option value="yes" ${sel === 'yes' ? 'selected' : ''}>да</option>
705
+ <option value="no" ${sel === 'no' ? 'selected' : ''}>нет</option>
706
+ </select>
707
+ </label>
708
+ </fieldset>
709
+ <label class="wide">Критерии <textarea name="criteria" rows="3" placeholder="без пересадок, отель ближе к Alexanderplatz">${esc(t?.criteria || '')}</textarea></label>
710
+ <div class="form-actions">
711
+ <button class="btn ghost" type="button" data-modal-close>Отмена</button>
712
+ <button class="btn primary" type="submit">${t ? 'Сохранить' : 'Создать'}</button>
713
+ </div>
714
+ </form>`;
715
+ }
716
+
717
+ function memberFormHtml(m) {
718
+ return `
719
+ <form id="member-form" data-id="${esc(m?.id || '')}">
720
+ <h2>${m ? 'Карточка сотрудника' : 'Новый сотрудник'}</h2>
721
+ <div class="form-grid">
722
+ <label>ФИО (RU) <input name="nameRu" type="text" required value="${esc(m?.nameRu || '')}" placeholder="Иванов Алексей Сергеевич"></label>
723
+ <label>Name (EN, как в паспорте) <input name="nameEn" type="text" value="${esc(m?.nameEn || '')}" placeholder="Ivanov Oleksii"></label>
724
+ <label>Должность <input name="position" type="text" value="${esc(m?.position || '')}" placeholder="Senior Backend Developer"></label>
725
+ <label>Дата рождения <input name="dob" type="text" value="${esc(m?.dob || '')}" placeholder="14.03.1990"></label>
726
+ <label>Пол
727
+ <select name="gender">
728
+ <option value="М" ${(m?.gender || 'М') === 'М' ? 'selected' : ''}>М</option>
729
+ <option value="Ж" ${m?.gender === 'Ж' ? 'selected' : ''}>Ж</option>
730
+ </select>
731
+ </label>
732
+ <label>Гражданство <input name="citizenship" type="text" value="${esc(m?.citizenship || '')}" placeholder="Ukraine"></label>
733
+ <label>Загранпаспорт № <input name="passportNo" type="text" value="${esc(m?.passportNo || '')}" placeholder="FK123456"></label>
734
+ <label>Паспорт действителен до <input name="passportValidUntil" type="text" value="${esc(m?.passportValidUntil || '')}" placeholder="22.08.2030"></label>
735
+ <label>Email <input name="email" type="email" value="${esc(m?.email || '')}" placeholder="a.ivanov@company.com"></label>
736
+ <label>Телефон <input name="phone" type="text" value="${esc(m?.phone || '')}" placeholder="+380 67 111 22 33"></label>
737
+ <label>Лояльность авиа <input name="loyaltyAir" type="text" value="${esc(m?.loyaltyAir || '')}" placeholder="Lufthansa Miles&More 992100012345"></label>
738
+ <label>Лояльность отелей <input name="loyaltyHotel" type="text" value="${esc(m?.loyaltyHotel || '')}" placeholder="Booking Genius (level 2)"></label>
739
+ </div>
740
+ <label class="wide">Особенности <textarea name="notes" rows="3" placeholder="окно, не любит ранние рейсы до 7:00">${esc(m?.notes || '')}</textarea></label>
741
+ <div class="form-actions">
742
+ <button class="btn ghost" type="button" data-modal-close>Отмена</button>
743
+ <button class="btn primary" type="submit">${m ? 'Сохранить' : 'Добавить'}</button>
744
+ </div>
745
+ </form>`;
746
+ }
747
+
748
+ /* ─────────── события ─────────── */
749
+
750
+ document.addEventListener('click', async e => {
751
+ const el = e.target.closest('[data-modal-close],[data-toggle-passport],[data-edit-member],[data-del-member],[data-copy-block],[data-search],[data-book],[data-book-combo],[data-paid],[data-cancel-trip],[data-del-trip],[data-edit-trip],[data-trip]');
752
+ if (!el) return;
753
+ const d = el.dataset;
754
+
755
+ try {
756
+ if (d.modalClose !== undefined) { closeModal(); return; }
757
+
758
+ if (d.togglePassport) {
759
+ if (revealedPassports.has(d.togglePassport)) revealedPassports.delete(d.togglePassport);
760
+ else revealedPassports.add(d.togglePassport);
761
+ renderTeam();
762
+ return;
763
+ }
764
+ if (d.editMember) {
765
+ openModal(memberFormHtml(memberById(d.editMember)));
766
+ return;
767
+ }
768
+ if (d.delMember) {
769
+ const m = memberById(d.delMember);
770
+ if (!confirm(`Удалить карточку «${m?.nameRu || ''}»?`)) return;
771
+ await api(`/api/team/${d.delMember}`, { method: 'DELETE' });
772
+ refresh(true);
773
+ return;
774
+ }
775
+
776
+ if (d.copyBlock !== undefined) {
777
+ const t = tripById(currentTripId);
778
+ const text = t?.blocks?.[d.copyBlock] || '';
779
+ await copyToClipboard(text, $(`#block-${d.copyBlock}`));
780
+ return;
781
+ }
782
+
783
+ if (d.search !== undefined) {
784
+ const t = tripById(currentTripId);
785
+ if (t?.status === 'options' && !confirm('Повторный поиск перезапишет текущие варианты. Продолжить?')) return;
786
+ await api(`/api/trips/${currentTripId}/search`, { method: 'POST' });
787
+ showToast('Поиск запущен');
788
+ refresh(true);
789
+ return;
790
+ }
791
+ if (d.book) {
792
+ const n = Number(d.book);
793
+ if (!confirm(`Агент заполнит данные путника и дойдёт до экрана оплаты варианта №${n}, но платить не будет — карту вводит HR. Продолжить?`)) return;
794
+ await api(`/api/trips/${currentTripId}/book`, { method: 'POST', body: { n } });
795
+ showToast(`Оформление №${n} запущено`);
796
+ refresh(true);
797
+ return;
798
+ }
799
+ if (d.bookCombo !== undefined) {
800
+ const t = tripById(currentTripId);
801
+ const ids = t?.recommendation?.ids || [];
802
+ if (!ids.length) return;
803
+ if (!confirm(`Оформить связку ${ids.map(n => `№${n}`).join(' + ')}? Агент дойдёт до экрана оплаты и остановится — карту вводит HR.`)) return;
804
+ // бэкенд принимает по одному n за раз — шлём первый, остальные HR запускает после
805
+ await api(`/api/trips/${currentTripId}/book`, { method: 'POST', body: { n: ids[0] } });
806
+ showToast(ids.length > 1
807
+ ? `Оформляю №${ids[0]}. Остальные (${ids.slice(1).map(n => `№${n}`).join(', ')}) запустите после завершения этого оформления.`
808
+ : `Оформление №${ids[0]} запущено`);
809
+ refresh(true);
810
+ return;
811
+ }
812
+ if (d.paid !== undefined) {
813
+ if (!confirm('Отметить как оплачено? Агент сгенерирует письмо путнику, смету и чеклист.')) return;
814
+ await api(`/api/trips/${currentTripId}/paid`, { method: 'POST' });
815
+ showToast('Отмечено как оплачено — готовлю документы');
816
+ refresh(true);
817
+ return;
818
+ }
819
+ if (d.cancelTrip !== undefined) {
820
+ if (!confirm('Отменить поездку?')) return;
821
+ await api(`/api/trips/${currentTripId}/cancel`, { method: 'POST' });
822
+ refresh(true);
823
+ return;
824
+ }
825
+ if (d.delTrip !== undefined) {
826
+ if (!confirm('Удалить поездку насовсем?')) return;
827
+ await api(`/api/trips/${currentTripId}`, { method: 'DELETE' });
828
+ location.hash = '#trips';
829
+ refresh(true);
830
+ return;
831
+ }
832
+ if (d.editTrip !== undefined) {
833
+ openModal(tripFormHtml(tripById(currentTripId)));
834
+ return;
835
+ }
836
+
837
+ if (d.trip) {
838
+ jobsPanelOpen = false;
839
+ location.hash = `#trip/${d.trip}`;
840
+ return;
841
+ }
842
+ } catch (err) { /* тост об ошибке уже показан в api() */ }
843
+ });
844
+
845
+ document.addEventListener('submit', async e => {
846
+ const f = e.target;
847
+
848
+ if (f.id === 'trip-form') {
849
+ e.preventDefault();
850
+ const travelerIds = [...f.querySelectorAll('input[name="travelers"]:checked')].map(i => i.value);
851
+ if (!travelerIds.length) { showToast('Выберите хотя бы одного путника'); return; }
852
+ const body = {
853
+ from: f.elements.from.value.trim(),
854
+ to: f.elements.to.value.trim(),
855
+ departDate: f.elements.departDate.value,
856
+ returnDate: f.elements.returnDate.value,
857
+ meetingAt: f.elements.meetingAt.value.trim(),
858
+ budget: f.elements.budget.value.trim(),
859
+ needTransport: f.elements.needTransport.checked,
860
+ needHotel: f.elements.needHotel.checked,
861
+ needTransfer: f.elements.needTransfer.value,
862
+ criteria: f.elements.criteria.value.trim(),
863
+ travelerIds,
864
+ };
865
+ try {
866
+ const id = f.dataset.id;
867
+ const resp = id
868
+ ? await api(`/api/trips/${id}`, { method: 'PUT', body })
869
+ : await api('/api/trips', { method: 'POST', body });
870
+ closeModal();
871
+ await refresh(true);
872
+ const newId = resp?.id || resp?.trip?.id;
873
+ if (!id && newId) location.hash = `#trip/${newId}`;
874
+ showToast(id ? 'Поездка обновлена' : 'Поездка создана');
875
+ } catch (err) {}
876
+ return;
877
+ }
878
+
879
+ if (f.id === 'member-form') {
880
+ e.preventDefault();
881
+ const body = {};
882
+ for (const k of ['nameRu', 'nameEn', 'position', 'dob', 'gender', 'citizenship', 'passportNo', 'passportValidUntil', 'email', 'phone', 'loyaltyAir', 'loyaltyHotel', 'notes']) {
883
+ body[k] = f.elements[k].value.trim();
884
+ }
885
+ try {
886
+ const id = f.dataset.id;
887
+ if (id) await api(`/api/team/${id}`, { method: 'PUT', body });
888
+ else await api('/api/team', { method: 'POST', body });
889
+ closeModal();
890
+ showToast(id ? 'Карточка обновлена' : 'Сотрудник добавлен');
891
+ refresh(true);
892
+ } catch (err) {}
893
+ return;
894
+ }
895
+
896
+ if (f.id === 'settings-form') {
897
+ e.preventDefault();
898
+ const corporate = {};
899
+ for (const k of CORPORATE_FIELDS) corporate[k] = f.elements[k].value.trim();
900
+ try {
901
+ await api('/api/corporate', { method: 'PUT', body: corporate });
902
+ await api('/api/settings', { method: 'PUT', body: {
903
+ model: f.elements.model.value,
904
+ browserSearch: f.elements.browserSearch.checked,
905
+ extraInstructions: f.elements.extraInstructions.value,
906
+ } });
907
+ showToast('Настройки сохранены');
908
+ refresh(true);
909
+ } catch (err) {}
910
+ }
911
+ });
912
+
913
+ $('#new-trip').addEventListener('click', () => openModal(tripFormHtml(null)));
914
+ $('#new-member').addEventListener('click', () => openModal(memberFormHtml(null)));
915
+ $('#license-apply').addEventListener('click', applyLicense);
916
+ $('#buy-pro').addEventListener('click', () => window.open(buyUrl('pro'), '_blank', 'noopener'));
917
+ $('#modal-close').addEventListener('click', closeModal);
918
+ $('#modal-backdrop').addEventListener('click', closeModal);
919
+ $('#jobs-toggle').addEventListener('click', () => {
920
+ jobsPanelOpen = !jobsPanelOpen;
921
+ renderJobsWidget();
922
+ });
923
+ document.addEventListener('keydown', e => {
924
+ if (e.key === 'Escape' && !$('#modal').hidden) closeModal();
925
+ });
926
+
927
+ /* ─────────── hash-роутинг ─────────── */
928
+
929
+ function route() {
930
+ const h = (location.hash.slice(1) || 'trips').split('/');
931
+ if (h[0] === 'trip' && h[1]) {
932
+ view = 'trip';
933
+ currentTripId = h[1];
934
+ } else {
935
+ view = ['trips', 'team', 'settings'].includes(h[0]) ? h[0] : 'trips';
936
+ currentTripId = null;
937
+ }
938
+ const navKey = view === 'trip' ? 'trips' : view;
939
+ document.querySelectorAll('.nav-tab').forEach(a => a.classList.toggle('active', a.dataset.tab === navKey));
940
+ document.querySelectorAll('main.view').forEach(v => { v.hidden = v.id !== `view-${view}`; });
941
+ if (state) renderAll(true);
942
+ postToHub({ qiksy: 'tab', id: navKey });
943
+ }
944
+
945
+ window.addEventListener('hashchange', route);
946
+
947
+ /* Сообщения из хаба:
948
+ {qiksy:"mode", mode:"dark"|"light"} → applyTheme вживую (без перезагрузки);
949
+ {qiksy:"nav", to:"trips|team|settings"} → меняем hash,
950
+ существующий hashchange-роутер перерисовывает без перезагрузки. */
951
+ window.addEventListener('message', e => {
952
+ const d = e.data;
953
+ if (!d) return;
954
+ if (d.qiksy === 'mode') { applyTheme(d.mode); return; }
955
+ if (d.qiksy !== 'nav') return;
956
+ if (d.to === 'trips' || d.to === 'team' || d.to === 'settings') {
957
+ location.hash = `#${d.to}`;
958
+ }
959
+ });
960
+
961
+ /* ─────────── поллинг ───────────
962
+ Раз в 2.5 с — лёгкий GET /api/jobs с fingerprint id+status; полный /api/state
963
+ только при изменении fingerprint или пока есть running (партиалы options
964
+ прилетают live). Пока есть running — интервал 1 с. */
965
+
966
+ async function pollTick() {
967
+ let delay = 2500;
968
+ try {
969
+ const jobs = await api('/api/jobs', {}, true);
970
+ const running = jobs.some(j => j.status === 'running');
971
+ if (running) delay = 1000;
972
+ const fp = jobs.map(j => j.id + j.status).join('|');
973
+ if (fp !== lastJobsFingerprint) {
974
+ lastJobsFingerprint = fp;
975
+ idleTicks = 0;
976
+ await refresh();
977
+ } else if (running) {
978
+ idleTicks = 0;
979
+ await refresh();
980
+ } else if (++idleTicks >= 6) {
981
+ // без джобов fingerprint не меняется — раз в ~15с тянем полный /api/state, чтобы обновить browserAlive и правки из других вкладок
982
+ idleTicks = 0;
983
+ await refresh();
984
+ }
985
+ } catch (err) { /* сервер перезапускается */ }
986
+ setTimeout(pollTick, delay);
987
+ }
988
+
989
+ if (IN_EMBED) document.body.classList.add('embed');
990
+ route();
991
+ refresh(true).catch(() => {});
992
+ setTimeout(pollTick, 2500);
993
+
994
+ if (IN_EMBED) {
995
+ postToHub({ qiksy: 'ready', tabs: HUB_TABS, active: view === 'trip' ? 'trips' : view });
996
+ }