'use strict'; // app.js — экраны тестового веба: логин, заявки (+журнал), проекты // (объекты и мастера), приглашения, настройки (Telegram). Никаких // фреймворков: состояние → рендер, маршруты в hash (#/tickets?status=new&offset=20). // ============================== словари ============================== const DICT = { status: { new: 'новая', assigned: 'назначена', in_progress: 'в работе', done: 'сделана', rejected: 'на доработке', closed: 'закрыта', }, category: { electric: 'электрика', plumbing: 'сантехника', climate: 'климат', elevator: 'лифт', other: 'прочее', }, priority: { low: 'низкий', normal: 'обычный', high: 'высокий' }, membership: { invited: 'приглашён', active: 'в составе', declined: 'отказался' }, role: { client: 'клиент', worker: 'мастер', admin: 'админ' }, action: { created: 'создана', assigned: 'назначена', started: 'начата', done: 'сделана', confirmed: 'принята', rejected: 'отклонена', canceled: 'отменена', }, }; const PAGE_SIZE = 20; // ============================== утилиты ============================== const $ = (sel, root) => (root || document).querySelector(sel); const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); function esc(v) { return String(v === null || v === undefined ? '' : v) .replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function fmtTime(iso) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return '—'; return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } // Русские числительные: 1 день / 2 дня / 5 дней (и то же для недель). function plural(n, one, few, many) { const m10 = n % 10; const m100 = n % 100; if (m10 === 1 && m100 !== 11) return one; if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; return many; } // Расписание правила человеческим языком: «каждые 14 дней», // «каждые 3 недели» (период, кратный 7, показываем в неделях). function periodText(days) { if (days % 7 === 0) { const n = days / 7; if (n === 1) return 'каждую неделю'; return `каждые ${n} ${plural(n, 'неделя', 'недели', 'недель')}`; } if (days === 1) return 'каждый день'; return `каждые ${days} ${plural(days, 'день', 'дня', 'дней')}`; } // Значение для input type="datetime-local" — локальная дата-время // в формате YYYY-MM-DDTHH:MM (без секунд и зоны — так требует нативный // контрол; на сервер уходит ISO-строка с зоной). function localDT(d) { const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; } let flashTimer = null; function flash(msg, kind) { const el = $('#flash'); el.textContent = msg; el.className = 'flash ' + (kind || 'ok'); if (flashTimer) clearTimeout(flashTimer); flashTimer = setTimeout(() => el.classList.add('hidden'), 4000); } // Единая обработка ошибок: 401 — сессия мертва, остальное — тост. function onError(e) { if (e && e.status === 401) { showLogin(); flash('Сессия истекла — войдите снова', 'err'); return; } flash((e && e.message) || String(e), 'err'); } function role() { const u = API.user(); return u ? u.role : null; } function statusBadge(status) { return `${esc(DICT.status[status] || status)}`; } function selectOptions(dict, selected) { return '' + Object.entries(dict) .map(([v, l]) => ``) .join(''); } // ============================== сессия ============================== // Экран входа — три карточки на одном view: вход, «забыли пароль» и // сброс по ссылке из письма. Показана ровно одна из них. function showAuthForm(sel) { ['#login-form', '#forgot-form', '#reset-form'].forEach((id) => { $(id).classList.toggle('hidden', id !== sel); }); } function showLogin() { $('#app-view').classList.add('hidden'); $('#login-view').classList.remove('hidden'); $('#login-error').textContent = ''; showAuthForm('#login-form'); } // Форма сброса: токен приходит в хеш-маршруте (#/reset-password?token=…). function showResetForm(token) { $('#app-view').classList.add('hidden'); $('#login-view').classList.remove('hidden'); showAuthForm('#reset-form'); $('#reset-error').textContent = token ? '' : 'Ссылка для сброса не найдена — запросите новую'; } function showApp() { $('#login-view').classList.add('hidden'); $('#app-view').classList.remove('hidden'); const u = API.user(); $('#who').textContent = `${u.email}${DICT.role[u.role] ? ' · ' + DICT.role[u.role] : ''}`; route(); } // ============================== маршруты ============================== function parseHash() { const raw = (location.hash || '').replace(/^#/, '') || '/tickets'; const i = raw.indexOf('?'); const path = i === -1 ? raw : raw.slice(0, i); const qs = i === -1 ? '' : raw.slice(i + 1); return { path, params: new URLSearchParams(qs) }; } // go(path, params) — переход; при совпадении хеша перерисовываем сами // (hashchange в этом случае не сработает). function go(path, params) { const qs = params && params.toString() ? '?' + params.toString() : ''; const target = '#' + path + qs; if (location.hash === target || (target === '#/tickets' && !location.hash)) route(); else location.hash = target; } function renderNav() { const r = role(); const items = [['#/tickets', 'Заявки']]; if (r === 'client' || r === 'admin') items.push(['#/projects', 'Проекты']); // Мастера: админ — всех, клиент — «своих» (граница — на сервере). if (r === 'admin') items.push(['#/workers', 'Мастера'], ['#/clients', 'Клиенты']); else if (r === 'client') items.push(['#/workers', 'Мастера']); if (r === 'worker') items.push(['#/invitations', 'Приглашения']); // Настройки — всем ролям: уведомления в Telegram может включить // любой, кому есть что напоминать (у клиента — свои заявки). items.push(['#/settings', 'Настройки']); const cur = location.hash || '#/tickets'; $('#nav').innerHTML = items .map(([href, label]) => `${label}`) .join(''); } async function route() { const { path, params } = parseHash(); // Ссылка из письма живёт без сессии: показываем её до проверки // входа, иначе неавторизованный пользователь потерял бы токен. if (path === '/reset-password') { showResetForm(params.get('token')); return; } if (!API.user()) { showLogin(); return; } renderNav(); const main = $('#main'); const r = role(); try { if (/^\/tickets\/\d+$/.test(path)) { await ticketView(path.split('/')[2], params); } else if (path === '/invitations') { if (r !== 'worker') { location.hash = '#/tickets'; return; } await invitationsView(params); } else if (/^\/projects\/\d+$/.test(path)) { if (r === 'worker') { location.hash = '#/tickets'; return; } await projectView(path.split('/')[2], params); } else if (path === '/projects') { if (r === 'worker') { location.hash = '#/tickets'; return; } await projectsView(params); } else if (path === '/workers') { if (r !== 'admin' && r !== 'client') { location.hash = '#/tickets'; return; } await workersView(params); } else if (path === '/clients') { if (r !== 'admin') { location.hash = '#/tickets'; return; } await clientsView(params); } else if (path === '/settings') { // Пункт есть у всех ролей — отдельной проверки не требует. await settingsView(); } else { await ticketsView(params); } } catch (e) { onError(e); } } // ============================== список заявок ============================== async function ticketsView(params) { const r = role(); $('#main').innerHTML = `

Заявки

${r === 'client' ? '' : ''}

Загрузка…

`; $('#filters').addEventListener('submit', (e) => { e.preventDefault(); const p = new URLSearchParams(); for (const [k, v] of new FormData(e.target)) if (v) p.set(k, v); go('/tickets', p); }); $('#reset').addEventListener('click', () => go('/tickets', new URLSearchParams())); if (r === 'client') $('#new-ticket').addEventListener('click', openCreateTicket); await loadTickets(params); } async function loadTickets(params) { const q = new URLSearchParams(params); q.set('limit', String(PAGE_SIZE)); if (!q.get('offset')) q.set('offset', '0'); const page = await API.get('/tickets?' + q.toString()); const items = page.items || []; $('#list').innerHTML = items.length ? ` ${items.map((t) => ` `).join('')}
№СтатусКатегорияПриоритет АдресМастерСоздана
${pagerHTML(page)}` : '

Ничего не найдено. Попробуйте сбросить фильтры.

'; $$('#list tr.link').forEach((tr) => { tr.addEventListener('click', () => go('/tickets/' + tr.dataset.id)); }); wirePager(page, '/tickets', params); } function pagerHTML(page) { const total = page.total || 0; const from = total === 0 ? 0 : page.offset + 1; const to = Math.min(page.offset + (page.items ? page.items.length : 0), total); const hasPrev = page.offset > 0; const hasNext = page.offset + (page.items ? page.items.length : 0) < total; return `
${from}–${to} из ${total}
`; } function wirePager(page, basePath, params) { const set = (off) => { const p = new URLSearchParams(params); p.set('offset', String(Math.max(0, off))); go(basePath, p); }; const prev = $('#prev'); const next = $('#next'); if (prev) prev.addEventListener('click', () => set(page.offset - PAGE_SIZE)); if (next) next.addEventListener('click', () => set(page.offset + PAGE_SIZE)); } // --- создание заявки (только клиент) --- // Форма — компактное модальное окно поверх списка (не карточка на всё // окно): затемнённый фон, узкий диалог по центру. Закрывается крестиком, // Escape и кликом по фону; фон не кликается «насквозь». async function openCreateTicket() { const wrap = $('#create-wrap'); const projects = (await API.get('/projects?limit=100&sort=name')).items .filter((p) => p.is_active); if (!projects.length) { flash('Нет активных проектов — создайте проект на вкладке «Проекты»', 'err'); return; } wrap.innerHTML = `
`; // Состояние повтора: recurring — применённые в окне условия // {dt: 'YYYY-MM-DDTHH:MM', n, unit}, draft — последние значения, // чтобы окно предзаполнялось при повторном открытии; schedOpen — // открыто ли окно условий (Escape сначала закрывает его, а не форму). let recurring = null; let draft = null; let schedOpen = false; // Закрытие: общая точка выхода — убрать оверлей и снять Escape. function close() { wrap.innerHTML = ''; document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key !== 'Escape') return; if (schedOpen) closeSched(); else close(); } async function loadObjects(projectId) { const objs = (await API.get('/projects/' + projectId + '/objects?limit=100&sort=name')).items .filter((o) => o.is_active); $('#ct [name=object_id]').innerHTML = objs.length ? objs.map((o) => ``).join('') : ''; } const projectSelect = $('#ct [name=project_id]'); projectSelect.addEventListener('change', () => loadObjects(projectSelect.value)); await loadObjects(projectSelect.value); // Галочка «Повторяющаяся заявка» открывает отдельное окно с условиями // повтора: дата-время первого запуска (нативный datetime-local) плюс // период в днях/неделях. Галка ставится только после «Применить»; // закрытие окна без применения (крестик, Escape, фон, «Отмена») // возвращает форму в состояние «без повтора». const recChk = $('#ct [name=recurring]'); // Строка под галочкой: применённые условия + ссылка «Изменить». function renderSummary() { const el = $('#ct-rec'); if (!recurring) { el.hidden = true; el.innerHTML = ''; return; } const sched = periodText(recurring.n * recurring.unit) + ' с ' + fmtTime(recurring.dt); el.hidden = false; el.innerHTML = `Расписание: ${sched}. `; $('#ct-rec-edit').addEventListener('click', openSched); } // Окно условий закрыто: если применено — галка остаётся, нет — снимаем. function closeSched() { $('#sched-wrap').innerHTML = ''; schedOpen = false; if (!recurring) recChk.checked = false; renderSummary(); } function openSched() { // Дефолт — сейчас + 7 дней, 10:00: время ориентировочное, // планировщик проверяет расписание раз в сутки. const first = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); first.setHours(10, 0, 0, 0); const src = recurring || draft; const dt = src ? src.dt : localDT(first); const n = src ? src.n : 1; const unit = src ? src.unit : 1; $('#sched-wrap').innerHTML = ` `; schedOpen = true; $('#sched-x').addEventListener('click', closeSched); $('#sched-cancel').addEventListener('click', closeSched); // Клик ровно по фону (не по диалогу) — тоже закрыть. $('#sched-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closeSched(); }); $('#sched').addEventListener('submit', (e) => { e.preventDefault(); const fd = new FormData(e.target); const dtv = fd.get('anchor_at'); let cnt = Number(fd.get('period_n')); let u = Number(fd.get('period_unit')); const period = cnt * u; if (!dtv || Number.isNaN(new Date(dtv).getTime())) { flash('Укажите дату и время первого запуска', 'err'); return; } if (!Number.isInteger(cnt) || cnt < 1 || !Number.isInteger(period) || period < 1 || period > 3650) { flash('Период — от 1 до 3650 дней (максимум 521 неделя)', 'err'); return; } // Показываем период так же, как в подсказке под галочкой: // кратное 7 — в неделях, иначе в днях. if (period % 7 === 0) { u = 7; cnt = period / 7; } recurring = draft = { dt: dtv, n: cnt, unit: u }; closeSched(); }); // Единицы с числительным согласуются («1 день», «2 недели»), // иначе в селекте соседствуют «1 дней» и «1 недель». const nInp = $('#sched [name=period_n]'); const unitSel = $('#sched [name=period_unit]'); function syncUnitLabels() { const cnt = Math.max(1, Number(nInp.value) || 1); unitSel.options[0].text = plural(cnt, 'день', 'дня', 'дней'); unitSel.options[1].text = plural(cnt, 'неделю', 'недели', 'недель'); } nInp.addEventListener('input', syncUnitLabels); unitSel.addEventListener('change', syncUnitLabels); syncUnitLabels(); $('#sched [name=anchor_at]').focus(); } recChk.addEventListener('change', () => { if (recChk.checked) { openSched(); return; } // Снятие галки — без окна: условия гасим, значения оставляем, // чтобы следующее включение начиналось с прошлого выбора. recurring = null; renderSummary(); }); document.addEventListener('keydown', onKey); $('#ct-x').addEventListener('click', close); $('#ct-cancel').addEventListener('click', close); // Клик ровно по фону (не по диалогу) — тоже закрыть. $('#ct-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); }); $('#ct').addEventListener('submit', async (e) => { e.preventDefault(); try { const fd = new FormData(e.target); const body = { object_id: Number(fd.get('object_id')), floor: String(fd.get('floor') || ''), category: fd.get('category'), priority: fd.get('priority'), description: String(fd.get('description') || '').trim(), }; // Галочка повтора: сервер той же операцией создаёт заявку и // правило (период от выбранной даты-времени); условия пользователь // задал в отдельном окне — берём применённое расписание. if (fd.get('recurring')) { if (!recurring) { flash('Задайте условия повторяющейся заявки', 'err'); openSched(); return; } body.recurring = { anchor_at: new Date(recurring.dt).toISOString(), period_days: recurring.n * recurring.unit, }; } const t = await API.post('/tickets', body); close(); flash(t.recurring ? 'Заявка №' + t.id + ' создана — правило повторения включено' : 'Заявка №' + t.id + ' создана'); go('/tickets/' + t.id); } catch (err) { onError(err); } }); } // ============================== детали заявки ============================== async function ticketView(id, params) { const t = await API.get('/tickets/' + id); const r = role(); const me = API.user().id; const can = { // Назначение — только из статуса new (проверка в самом SQL). assign: (r === 'admin' || r === 'client') && t.status === 'new', // Мастер берёт свободную заявку сам — тоже только new. take: r === 'worker' && t.status === 'new', // «Начать работу» — и назначенная, и ушедшая на доработку: после // отклонения (rejected) сервер принимает перезапуск второго круга. start: r === 'worker' && (t.status === 'assigned' || t.status === 'rejected') && t.worker_id === me, done: r === 'worker' && t.status === 'in_progress' && t.worker_id === me, confirm: r === 'client' && t.status === 'done', reject: r === 'client' && t.status === 'done', cancel: (r === 'client' || r === 'admin') && t.status !== 'closed', }; $('#main').innerHTML = `

Заявка №${t.id} ${statusBadge(t.status)}

← к списку
Адрес
${esc(t.address)}${t.floor ? ', этаж ' + esc(t.floor) : ''}
Категория
${esc(DICT.category[t.category] || t.category)}
Приоритет
${esc(DICT.priority[t.priority] || t.priority)}
Проект
${esc(t.project_name || t.project_id)}
Объект
${esc(t.object_name || t.object_id)}
Клиент
${esc(t.client_name || '—')}
Мастер
${esc(t.worker_name || 'не назначен')}
Создана
${fmtTime(t.created_at)}
${t.assigned_at ? `
Назначена
${fmtTime(t.assigned_at)}
` : ''} ${t.started_at ? `
Начата
${fmtTime(t.started_at)}
` : ''} ${t.done_at ? `
Сделана
${fmtTime(t.done_at)}
` : ''} ${t.closed_at ? `
Закрыта
${fmtTime(t.closed_at)}
` : ''} ${t.rework_count ? `
Доработок
${t.rework_count}
` : ''}
${esc(t.description)}
${t.reject_reason ? `
Причина отклонения: ${esc(t.reject_reason)}
` : ''} ${t.cancel_reason ? `
Причина отмены: ${esc(t.cancel_reason)}
` : ''} ${r === 'worker' && t.status === 'rejected' ? `
Клиент отклонил работу${t.rework_count ? ' — доработка №' + t.rework_count : ''}. Причина отклонения — выше. Нажмите «Начать работу», выполните переделку и снова отметьте «Работа выполнена» — после этого клиент примет заявку.
` : ''}
${t.recurring ? recurringBlock(t) : ''}

Фотоотчёт

Загрузка…

Журнал событий

Загрузка…

`; await renderActions(t, can); if (t.recurring) wireRecurring(t, r); await renderPhotos(t, params); await loadEvents(t.id, params); } // --- блок «Повторяющаяся заявка» на странице заявки --- // Заявка связана с правилом (создана вместе с ним или самим // планировщиком) — здесь её расписание и управление им: вкл/выкл // (PATCH правила) и история срабатываний. Мастеру показываем текст // без кнопок: API управления закрыт для его роли. function recurringBlock(t) { const rc = t.recurring; const r = role(); const manage = r === 'client' || r === 'admin'; const sched = `${periodText(rc.period_days)} с ${fmtTime(rc.anchor_at)}`; const next = rc.is_active ? fmtTime(rc.next_run_at) : '—'; const state = rc.is_active ? 'включено' : 'выключено'; return `

Повторяющаяся заявка ${state}

Расписание
${sched}
Следующий запуск
${next}

Правило создаст такую же заявку ${periodText(rc.period_days)} — но только если прошлая по нему уже закрыта, дублей не будет. Период идёт от ${fmtTime(rc.anchor_at)}, точность до суток: планировщик проверяет расписание раз в сутки.

${manage ? `
` : `

Включением и историей правила управляют клиент и админ.

`}
`; } // Обработчики кнопок блока повтора: переключение is_active правилом // (после — перерисовка страницы, чтобы состояние и кнопка сошлись) // и модалка истории срабатываний (общая openRuns). function wireRecurring(t, r) { const rc = t.recurring; const manage = r === 'client' || r === 'admin'; if (!manage) return; $('#rr-toggle').addEventListener('click', async () => { try { await API.patch('/recurring/' + rc.rule_id, { is_active: !rc.is_active }); flash(rc.is_active ? 'Правило выключено' : 'Правило включено'); route(); } catch (e) { onError(e); } }); $('#rr-runs').addEventListener('click', () => openRuns(rc.rule_id, t.description)); } async function renderActions(t, can) { const box = $('#actions'); const parts = []; if (can.assign) parts.push('
'); if (can.take) parts.push(''); if (can.start) parts.push(''); if (can.done) parts.push(''); if (can.confirm) parts.push(''); if (can.reject) parts.push(''); if (can.cancel) parts.push(''); if (!parts.length) { box.innerHTML = 'Нет действий, доступных вашей роли в этом статусе.'; return; } box.innerHTML = parts.join(''); // Назначение: выбор из мастеров-участников проекта (только active). if (can.assign) { const workers = (await API.get('/projects/' + t.project_id + '/workers?limit=100&sort=name')).items .filter((w) => w.status === 'active'); $('#assign-box').innerHTML = ` `; if (workers.length) { $('#assign-go').addEventListener('click', async () => { try { await API.patch('/tickets/' + t.id + '/assign', { worker_id: $('#assignee').value }); flash('Заявка назначена'); route(); } catch (e) { onError(e); } }); } } // Простые переходы без тела. const simple = { take: 'Заявка взята', start: 'Начата', done: 'Выполнена', confirm: 'Принята клиентом' }; $$('#actions button[data-act]').forEach((btn) => { const act = btn.dataset.act; if (simple[act]) { btn.addEventListener('click', async () => { try { await API.patch('/tickets/' + t.id + '/' + act, {}); flash(simple[act]); route(); } catch (e) { onError(e); } }); } else if (act === 'reject' || act === 'cancel') { // Отклонение и отмена требуют причины (min 5 символов). btn.addEventListener('click', () => { const label = act === 'reject' ? 'Причина отклонения' : 'Причина отмены'; box.insertAdjacentHTML('beforeend', `
`); $('#reason-cancel').addEventListener('click', () => $('#reason-form').remove()); $('#reason-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await API.patch('/tickets/' + t.id + '/' + act, { reason: String(new FormData(e.target).get('reason')).trim() }); flash(act === 'reject' ? 'Отправлено на доработку' : 'Заявка отменена'); route(); } catch (err) { onError(err); } }); }); } }); } // ============================== фотоотчёт ============================== // Снимок белого списка форматов сервера — клиентская проверка только // ради скорости; настоящий арбитр — сервер (сигнатура байтов). const PHOTO_ACCEPT = ['image/jpeg', 'image/png', 'image/webp']; const PHOTO_MAX_BYTES = 10 * 1024 * 1024; // дефолт PHOTO_MAX_BYTES async function renderPhotos(t, params) { const r = role(); const me = API.user().id; // Грузить может назначенный мастер в работе или админ — после «Работа // выполнена» отчёт заморожен; сервер проверяет это в SQL, здесь // только видимость кнопки. const canUpload = (r === 'admin' || (r === 'worker' && t.worker_id === me)) && t.status === 'in_progress'; if (canUpload) { $('#photo-upload').innerHTML = `
jpeg, png или webp — до 10 МБ на файл
`; $('#photo-go').addEventListener('click', () => uploadPhotos(t, r, me, params)); } await loadPhotos(t, r, me, params); } async function uploadPhotos(t, r, me, params) { const input = $('#photo-input'); const files = Array.from(input.files || []); if (!files.length) { flash('Выберите файлы', 'err'); return; } const bad = files.find((f) => PHOTO_ACCEPT.indexOf(f.type) === -1 || f.size > PHOTO_MAX_BYTES); if (bad) { flash(PHOTO_ACCEPT.indexOf(bad.type) === -1 ? 'Нужны jpeg, png или webp' : 'Файл больше 10 МБ', 'err'); return; } const btn = $('#photo-go'); btn.disabled = true; let done = 0; try { for (const f of files) { const fd = new FormData(); fd.append('file', f, f.name); await API.upload('/tickets/' + t.id + '/photos', fd); done++; } } catch (e) { onError(e); // серверная ошибка (лимит, статус) — тост с текстом API } finally { btn.disabled = false; input.value = ''; } if (done) { flash(done === files.length ? 'Фото загружены' : 'Загружено ' + done + ' из ' + files.length); await loadEvents(t.id, params); // журнал — сразу, без F5 } await loadPhotos(t, r, me, params); } async function loadPhotos(t, r, me, params) { const box = $('#photos'); const count = $('#photos-count'); let page; try { page = await API.get('/tickets/' + t.id + '/photos?limit=100'); } catch (e) { box.innerHTML = '

Не удалось загрузить список фото.

'; return; } const items = page.items || []; count.textContent = items.length + ' / ' + (page.max_per_ticket || '?'); if (!items.length) { box.innerHTML = '

Фото пока нет.

'; return; } // Удаление: админу — любое, мастеру — своё, пока заявка в работе // (in_progress); в done отчёт заморожен (сервер проверяет это в SQL — // здесь видимость крестика). const canDel = (p) => r === 'admin' || (r === 'worker' && p.uploader_id === me && t.status === 'in_progress'); box.innerHTML = `
${items.map((p) => `
${p.url ? ` ${esc(p.original_name || 'фото')} ` : '
ссылка недоступна
'}
${esc(p.uploader_name || 'аккаунт удалён')} ${fmtTime(p.created_at)} ${canDel(p) ? '' : ''}
`).join('')}
`; $$('#photos .photo-del').forEach((btn) => { btn.addEventListener('click', async () => { const id = btn.closest('.photo').dataset.id; if (!window.confirm('Удалить фото без возможности вернуть?')) return; btn.disabled = true; try { await API.del('/tickets/' + t.id + '/photos/' + id); flash('Фото удалено'); } catch (e) { onError(e); } await loadPhotos(t, r, me, params); await loadEvents(t.id, params); // журнал — сразу, без F5 }); }); } // Текст события в журнале: у фото-действий статус не менялся — // важнее «что сделали», а не «куда перешли». function eventText(e) { if (e.action === 'photo_added') return 'добавлено фото'; if (e.action === 'photo_removed') return 'удалено фото'; const from = e.from_status ? `${esc(DICT.status[e.from_status] || e.from_status)} → ` : ''; return `${from}${esc(DICT.status[e.to_status] || e.to_status)}`; } async function loadEvents(ticketId, params) { const q = new URLSearchParams(params); q.set('limit', '50'); q.set('offset', '0'); const page = await API.get('/tickets/' + ticketId + '/events?' + q.toString()); const items = page.items || []; $('#events').innerHTML = items.length ? ` ` : '

Событий пока нет.

'; } // ============================== проекты ============================== async function projectsView(params) { const r = role(); $('#main').innerHTML = `

Проекты

Загрузка…

`; $('#new-project').addEventListener('click', () => openCreateProject(r)); const q = new URLSearchParams(params); q.set('limit', String(PAGE_SIZE)); if (!q.get('offset')) q.set('offset', '0'); const page = await API.get('/projects?' + q.toString()); const items = page.items || []; $('#list').innerHTML = items.length ? ` ${r === 'admin' ? '' : ''}${items.map((p) => ` ${r === 'admin' ? `` : ''} `).join('')}
НазваниеКлиентСтатусСоздан
${pagerHTML(page)}` : '

Проектов нет — создайте первый.

'; $$('#list tr.link').forEach((tr) => { tr.addEventListener('click', () => go('/projects/' + tr.dataset.id)); }); wirePager(page, '/projects', params); } async function openCreateProject(r) { const wrap = $('#projects-create'); // Админ создаёт проект для клиента — ему нужен список клиентов. let clientSelect = ''; if (r === 'admin') { const clients = (await API.get('/users?role=client&limit=100&sort=name')).items; clientSelect = ``; } wrap.innerHTML = `
${clientSelect}
`; $('#np-cancel').addEventListener('click', () => { wrap.innerHTML = ''; }); $('#np').addEventListener('submit', async (e) => { e.preventDefault(); try { const fd = new FormData(e.target); const body = { name: String(fd.get('name')).trim() }; if (fd.get('client_id')) body.client_id = fd.get('client_id'); const p = await API.post('/projects', body); flash('Проект «' + p.name + '» создан'); go('/projects/' + p.id); } catch (err) { onError(err); } }); } // ============ действия админа над пользователями (общие) ============ // Для разделов «Клиенты» и «Мастера»: включение/отключение и удаление // с фолбэком. Отключение гасит все сессии пользователя; у мастера флаг // общий на все проекты. Удаляется только «чистый» аккаунт — при 409 // (есть заявки/проекты/участия) предлагаем отключить вместо удаления. // Ячейка статуса: отдельный бейдж подтверждения (0010) — до подтверждения // вход закрыт, поэтому он виден даже у активного аккаунта. function userStatusCell(u) { const status = u.is_active ? 'активен' : 'отключён'; const confirm = u.confirmed_at ? '' : 'не подтверждён '; return confirm + status; } // Ячейка действий строки: «Подтвердить» (неподтверждённому), затем // «Отключить/Включить», «Сброс пароля» и «Удалить». Сброс — только у // админа: эндпоинт за RequireRole("admin"), а раздел «Мастера» видит // и клиент (своих мастеров) — ему сервер вернул бы 403. function userActionsCell(u) { const confirmBtn = u.confirmed_at ? '' : ``; const toggle = u.is_active ? `` : ``; const reset = role() === 'admin' ? `` : ''; return `${confirmBtn}${toggle}${reset}`; } function wireUserActions() { $$('.u-toggle').forEach((btn) => btn.addEventListener('click', () => { toggleUser(btn.dataset.uid, btn.dataset.on === '1', btn.dataset.name); })); $$('.u-del').forEach((btn) => btn.addEventListener('click', () => { deleteUser(btn.dataset.uid, btn.dataset.name); })); $$('.u-confirm').forEach((btn) => btn.addEventListener('click', () => { confirmUser(btn.dataset.uid, btn.dataset.name); })); $$('.u-reset').forEach((btn) => btn.addEventListener('click', () => { openPasswordReset(btn.dataset.uid, btn.dataset.name, btn.dataset.email); })); } // Подтверждение аккаунта: сервер шлёт пользователю письмо «аккаунт // подтверждён» — после этого он сможет войти. Границы прав (чужой, // уже подтверждён) держит сервер: 404/409 показываем текстом. async function confirmUser(id, name) { try { await API.post('/users/' + id + '/confirm', {}); flash('Аккаунт «' + name + '» подтверждён — пользователю отправлено письмо'); route(); } catch (e) { onError(e); } } // Удаление: сначала DELETE; 409 «есть история» — диалог «отключить // вместо удаления», история заявок при этом остаётся. Текст первого // подтверждения зависит от роли: клиенту вместе с аккаунтом уходят // членства в его проектах, админу удаляется только «чистый» аккаунт. async function deleteUser(id, name) { const ask = role() === 'client' ? 'Удалить «' + name + '»? Аккаунт и участие мастера в ваших проектах будут удалены.' : 'Удалить «' + name + '»? Удаляется только аккаунт без истории.'; if (!confirm(ask)) return; try { await API.del('/users/' + id); flash('Пользователь «' + name + '» удалён'); route(); } catch (e) { if (e.status !== 409) { onError(e); return; } if (!confirm('У «' + name + '» есть связанные записи (заявки, проекты). Отключить аккаунт вместо удаления?')) return; try { await API.patch('/users/' + id, { is_active: false }); flash('Аккаунт «' + name + '» отключён — история сохранена'); route(); } catch (e2) { onError(e2); } } } // Включение/отключение аккаунта (админ — любой, клиент — свой мастер // в проекте; границу держит сервер). async function toggleUser(id, on, name) { if (!on && !confirm('Выключить «' + name + '»? Он потеряет доступ, все сессии закроются.')) return; try { await API.patch('/users/' + id, { is_active: on }); flash(on ? '«' + name + '» включён' : '«' + name + '» отключён'); route(); } catch (e) { onError(e); } } // ==================== сброс пароля админом ==================== // Окно с двумя способами (кнопка есть только у админа): // 1) выпустить одноразовую ссылку — пользователь сам откроет её и // задаст пароль (токен живёт сутки, копируется кнопкой); // 2) задать пароль напрямую — сервер ставит его сразу. // Оба варианта гасят refresh-сессии пользователя, прямая установка // заодно отменяет неиспользованные токены сброса — старые ссылки // перестают работать. async function openPasswordReset(id, name, email) { const wrap = $('#pr-wrap'); wrap.innerHTML = ` `; function close() { wrap.innerHTML = ''; document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') close(); } $('#pr-x').addEventListener('click', close); $('#pr-cancel').addEventListener('click', close); $('#pr-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); }); document.addEventListener('keydown', onKey); // Способ 1: адрес страницы сброса собираем сами — в ответе только токен. $('#pr-link').addEventListener('submit', async (e) => { e.preventDefault(); try { const r = await API.post('/users/' + id + '/password-reset', {}); $('#pr-link-url').value = location.origin + '/#/reset-password?token=' + encodeURIComponent(r.token); $('#pr-link-exp').textContent = 'Действует до ' + fmtTime(r.expires_at) + '. Передайте ссылку пользователю любым каналом.'; $('#pr-link-box').hidden = false; } catch (err) { onError(err); } }); $('#pr-copy').addEventListener('click', async () => { const input = $('#pr-link-url'); try { await navigator.clipboard.writeText(input.value); flash('Ссылка скопирована'); } catch (_) { // Без HTTPS/разрешения буфера — выделяем текст, копирует Ctrl+C. input.select(); flash('Ссылка выделена — Ctrl+C'); } }); // Способ 2: пароль должен совпасть в двух полях до ухода на сервер. $('#pr-set').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); const pass = String(fd.get('password')); if (pass !== String(fd.get('password2'))) { flash('Пароли не совпадают', 'err'); return; } try { await API.post('/users/' + id + '/password-set', { password: pass }); flash('Пароль «' + name + '» обновлён — все его сессии закрыты'); close(); } catch (err) { onError(err); } }); $('#pr-set [name=password]').focus(); } // ============================== клиенты ============================== // Раздел только для админа: список клиентов и создание аккаунта — // единственный способ завести клиента (саморегистрации нет). // include_inactive=1 — отключённые остаются в списке (иначе «Включить» // после отключения оказалось бы тупиком). async function clientsView(params) { $('#main').innerHTML = `

Клиенты

Загрузка…

`; $('#new-client').addEventListener('click', () => openCreateClient()); const q = new URLSearchParams(params); q.set('role', 'client'); q.set('include_inactive', '1'); q.set('limit', String(PAGE_SIZE)); if (!q.get('offset')) q.set('offset', '0'); const page = await API.get('/users?' + q.toString()); const items = page.items || []; $('#list').innerHTML = items.length ? ` ${items.map((u) => ` ${userActionsCell(u)} `).join('')}
ИмяEmailТелефонСтатусСоздан
${esc(u.name)} ${esc(u.email)} ${esc(u.phone || '—')} ${userStatusCell(u)} ${fmtTime(u.created_at)}
${pagerHTML(page)}` : '

Клиентов пока нет.

'; wirePager(page, '/clients', params); wireUserActions(); } // ============================== мастера ============================== // Раздел только для админа: список мастеров, серверный поиск &q= // (мин. 2 символа), пагинация и те же действия, что у клиентов. async function workersView(params) { // Поиск по всем мастерам системы (инструмент приглашения) — только // у админа: у клиента раздел = «мои мастера», глобальная выдача с // кнопками действий вводила бы в заблуждение — сервер для чужого // вернёт 404 (граница «состоит в моём проекте» — в SQL). const canSearch = role() === 'admin'; $('#main').innerHTML = `

Мастера

${canSearch ? `
${params.get('q') ? '' : ''}
` : ''}

Загрузка…

`; if ($('#wq')) { $('#wq').addEventListener('submit', (e) => { e.preventDefault(); const qv = String(new FormData(e.target).get('q') || '').trim(); const p = new URLSearchParams(); if (qv) p.set('q', qv); go('/workers', p); }); const reset = $('#wq-reset'); if (reset) reset.addEventListener('click', () => go('/workers')); } const q = new URLSearchParams(params); q.set('role', 'worker'); q.set('include_inactive', '1'); q.set('limit', String(PAGE_SIZE)); if (!q.get('offset')) q.set('offset', '0'); const page = await API.get('/users?' + q.toString()); const items = page.items || []; $('#list').innerHTML = items.length ? ` ${items.map((u) => ` ${userActionsCell(u)} `).join('')}
ИмяEmailТелефонСтатусСоздан
${esc(u.name)} ${esc(u.email)} ${esc(u.phone || '—')} ${userStatusCell(u)} ${fmtTime(u.created_at)}
${pagerHTML(page)}` : '

Мастеры не найдены.

'; wirePager(page, '/workers', params); wireUserActions(); } function openCreateClient() { const wrap = $('#clients-create'); wrap.innerHTML = `
`; $('#nc-cancel').addEventListener('click', () => { wrap.innerHTML = ''; }); $('#nc').addEventListener('submit', async (e) => { e.preventDefault(); try { const fd = new FormData(e.target); const body = { name: String(fd.get('name')).trim(), email: String(fd.get('email')).trim(), password: String(fd.get('password')), role: 'client', }; if (fd.get('phone')) body.phone = String(fd.get('phone')).trim(); const u = await API.post('/users', body); flash('Клиент «' + u.name + '» создан'); route(); } catch (err) { onError(err); } }); } // --- детали проекта: правка, объекты, мастера --- async function projectView(id, params) { const p = await API.get('/projects/' + id); const r = role(); $('#main').innerHTML = `

${esc(p.name)}

← к списку
${r === 'admin' ? `

Клиент-владелец: ${esc(p.client_name || p.client_id)}

` : ''}

Объекты

Загрузка…

Мастера

Загрузка…

`; $('#pf').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { await API.patch('/projects/' + id, { name: String(fd.get('name')).trim(), is_active: fd.get('is_active') === 'true', }); flash('Проект сохранён'); route(); } catch (err) { onError(err); } }); $('#no').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { await API.post('/projects/' + id + '/objects', { name: String(fd.get('name')).trim(), address: String(fd.get('address')).trim(), }); flash('Объект добавлен'); route(); } catch (err) { onError(err); } }); // Удаление проекта — только у админа (роут закрыт RequireRole): // вместе с проектом уходят объекты, участия и все заявки — предупреждаем честно. const pDel = $('#p-del'); if (pDel) { pDel.addEventListener('click', async () => { const ask = 'Удалить проект «' + p.name + '»? Вместе с ним будут удалены все объекты, участия мастеров и заявки — без возможности вернуть.'; if (!confirm(ask)) return; try { await API.del('/projects/' + id); flash('Проект удалён'); go('/projects'); } catch (err) { onError(err); } }); } await Promise.all([loadObjects(id), loadWorkers(id, r)]); } async function loadObjects(projectId) { const page = await API.get('/projects/' + projectId + '/objects?limit=100&sort=name'); const items = page.items || []; $('#objects').innerHTML = items.length ? ` ${items.map((o) => ` `).join('')}
НазваниеАдресСтатус
${esc(o.name)} ${esc(o.address)} ${o.is_active ? 'активен' : 'выключен'}
` : '

Объектов нет.

'; $$('.obj-toggle').forEach((btn) => { btn.addEventListener('click', async () => { try { await API.patch('/objects/' + btn.dataset.oid, { is_active: btn.dataset.active !== 'true' }); flash('Статус объекта изменён'); loadObjects(projectId); } catch (e) { onError(e); } }); }); } // История срабатываний правила — модалка по образцу «Новая заявка»: // какие заявки создавались по расписанию и когда. Заявку показываем // ссылкой: она живёт, пока её не заберёт автоочистка — тогда строка // истории уходит следом за заявкой. async function openRuns(ruleId, desc) { const wrap = $('#runs-wrap'); if (!wrap) return; const page = await API.get('/recurring/' + ruleId + '/runs?limit=50&sort=run_at'); const items = page.items || []; wrap.innerHTML = ` `; function close() { wrap.innerHTML = ''; document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') close(); } document.addEventListener('keydown', onKey); $('#runs-x').addEventListener('click', close); $('#runs-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); }); // Переход к заявке — модалка больше не нужна. $$('#runs-wrap a').forEach((a) => a.addEventListener('click', close)); } async function loadWorkers(projectId, r) { const page = await API.get('/projects/' + projectId + '/workers?limit=100&sort=name'); const items = page.items || []; $('#workers').innerHTML = items.length ? ` ${items.map((w) => ` `).join('')}
ИмяEmailСтатусОтвет до
${esc(w.name)} ${esc(w.email)} ${esc(DICT.membership[w.status] || w.status)}${w.is_active ? '' : ' отключён'} ${w.status === 'invited' ? fmtTime(w.expires_at) : '—'} ${w.status === 'active' ? (w.is_active ? `` : ``) : ''}
` : '

Мастеров пока нет — пригласите первого ниже.

'; // Фильтр — локальный: мастеров в проекте десятки, сетевой запрос // на каждое нажатие клавиши был бы избыточен. const wFilter = $('#w-filter'); if (wFilter) { wFilter.addEventListener('input', () => { const q = wFilter.value.trim().toLowerCase(); $$('#workers tbody tr').forEach((tr) => { tr.style.display = !q || (tr.dataset.hay || '').includes(q) ? '' : 'none'; }); }); } $$('.w-del').forEach((btn) => { btn.addEventListener('click', async () => { const invited = btn.dataset.status === 'invited'; const ask = invited ? 'Отозвать приглашение для «' + btn.dataset.name + '»?' : 'Убрать «' + btn.dataset.name + '» из проекта? Он больше не будет получать назначения проекта.'; if (!confirm(ask)) return; try { await API.del('/projects/' + projectId + '/workers/' + btn.dataset.wid); flash(invited ? 'Приглашение отозвано' : 'Мастер убран из проекта'); loadWorkers(projectId, r); } catch (e) { onError(e); } }); }); // Отключение аккаунта мастера прямо из проекта (доступно и клиенту — // сервер разрешает только активных участников своих проектов). $$('.w-off').forEach((btn) => btn.addEventListener('click', () => { toggleUser(btn.dataset.wid, false, btn.dataset.name); })); $$('.w-on').forEach((btn) => btn.addEventListener('click', () => { toggleUser(btn.dataset.wid, true, btn.dataset.name); })); // Удаление аккаунта (не просто выход из проекта) — то же действие, // что и в разделе «Мастера»: границы и 409 с предложением отключить // вместо удаления решает сервер. $$('.w-userdel').forEach((btn) => { btn.addEventListener('click', () => deleteUser(btn.dataset.wid, btn.dataset.name)); }); await renderInviteBox(projectId, r, items); } // Последний введённый поиск приглашения: переживает перерисовку бокса // (после приглашения loadWorkers собирает его заново). let inviteQuery = ''; // Приглашение: поиск по имени/email среди активных мастеров — GET /users // с &q=. Без запроса API клиенту список не отдаётся (только «свои»), // поэтому существующего мастера клиент находит именно так, а админ не // листает вручную всех мастеров системы. Дубликаты в проекте отфильтровываем. async function renderInviteBox(projectId, r, current) { const box = $('#invite'); const inProject = new Set(current.map((w) => w.id)); box.innerHTML = `

Пригласить мастера

Введите имя или email — покажем мастеров, которых ещё нет в проекте.

Мастер появится в проекте со статусом «приглашён» — работать сможет только после того, как сам примет приглашение (учетка мастера → вкладка «Приглашения»).

`; const results = $('#w-results'); const idleHint = '

Введите имя или email — покажем мастеров, которых ещё нет в проекте.

'; let timer = null; let seq = 0; // номер запроса: ответ устаревшего — мимо function renderRows(itemsFound, q) { const fresh = itemsFound.filter((w) => !inProject.has(w.id)); if (!itemsFound.length) { results.innerHTML = `

По запросу «${esc(q)}» ничего не найдено.

`; return; } if (!fresh.length) { results.innerHTML = `

Все найденные уже в этом проекте.

`; return; } results.innerHTML = `${fresh.map((w) => ` `).join('')}
${esc(w.name)} ${esc(w.email)}
`; $$('.inv-go', results).forEach((btn) => { btn.addEventListener('click', async () => { try { await API.post('/projects/' + projectId + '/workers', { worker_id: btn.dataset.wid }); flash('Приглашение отправлено'); await loadWorkers(projectId, r); // перерисует бокс и вернёт запрос из inviteQuery } catch (e) { onError(e); } }); }); } async function search(q) { const my = ++seq; try { const page = await API.get('/users?role=worker&limit=20&q=' + encodeURIComponent(q)); if (my !== seq) return; // уже ввели что-то другое renderRows(page.items || [], q); } catch (e) { onError(e); } } const input = $('#w-search'); input.addEventListener('input', () => { const q = input.value.trim(); inviteQuery = q; clearTimeout(timer); if (q.length < 2) { seq++; // гасим запрос в полёте results.innerHTML = idleHint; return; } timer = setTimeout(() => search(q), 300); }); // Восстановленный после перерисовки запрос — сразу выполняем снова, // чтобы в списке не оставался только что приглашённый мастер. if (inviteQuery.length >= 2) search(inviteQuery); $('#nw').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { const w = await API.post('/users', { email: String(fd.get('email')).trim(), name: String(fd.get('name')).trim(), password: String(fd.get('password')), role: 'worker', }); await API.post('/projects/' + projectId + '/workers', { worker_id: w.id }); flash('Мастер создан и приглашён'); inviteQuery = ''; // в боксе снова пустой поиск — таблица обновится через loadWorkers loadWorkers(projectId, r); } catch (err) { onError(err); } }); } // ============================== приглашения (мастер) ============================== async function invitationsView() { $('#main').innerHTML = `

Мои приглашения

Загрузка…

`; const page = await API.get('/invitations?limit=50'); const items = page.items || []; $('#inv').innerHTML = items.length ? ` ${items.map((v) => ` `).join('')}
ПроектКто пригласилПриглашеноОтвет до
${esc(v.project_name)} ${esc(v.invited_name || '—')} ${fmtTime(v.invited_at)} ${fmtTime(v.expires_at)}
` : '

Приглашений нет. Как только клиент пригласит вас в проект, оно появится здесь.

'; $$('.inv-go').forEach((btn) => { btn.addEventListener('click', async () => { try { await API.post('/projects/' + btn.dataset.pid + '/invitation/' + btn.dataset.act, {}); flash(btn.dataset.act === 'accept' ? 'Вы приняли приглашение — проект может назначать вам заявки' : 'Приглашение отклонено'); invitationsView(); } catch (e) { onError(e); } }); }); } // ============================== настройки ============================== // Экран «Настройки»: уведомления в Telegram о заявках, пролежавших в // статусе «новая» больше суток. Три вещи, что тут можно сделать: // 1. подключить СВОЕГО бота (токен от @BotFather) — уведомления пойдут // через него, токен хранится на сервере зашифрованным; // 2. указать, куда слать: личная привязка (одноразовый код боту) либо // ручная группа/канал; // 3. отключить уведомления. Пункт меню виден всем ролям. async function settingsView() { const st = await API.tgStatus(); // Свой бот. Токен никогда не показывается обратно — сервер отдаёт // только факт его наличия и имя бота. Без DATA_ENCRYPTION_KEY // (token_configurable=false) поля нет: хранить токен некуда. const botBlock = !st.token_configurable ? '' : (st.has_bot_token ? `
Ваш бот

Уведомления идут через @${esc(st.bot_username || '')}. Токен лежит на сервере зашифрованным и никогда не показывается.

` : `
Свой бот для уведомлений

Заведите бота у @BotFather и вставьте его токен: сообщения о заявках придут через вашего бота. Токен шифруется и хранится на сервере.

`); $('#main').innerHTML = `

Настройки

Уведомления в Telegram

Если заявку не взяли в работу за 24 часа, придёт сообщение в Telegram — и повторно раз в сутки, пока её не возьмут.

${st.enabled ? `

${st.bound ? `Куда слать: ${esc(st.target)}` : 'Привязки нет — уведомления не приходят.'}

` : ''} ${botBlock} ${st.enabled ? `
Личные уведомления

Возьмите код и отправьте его боту: сообщения начнут приходить в этот чат. Код одноразовый и живёт 15 минут.

${st.bound ? '' : ''}

Группа или канал работают, только если бот добавлен туда участником.

` : `

${st.token_configurable ? 'Уведомления не настроены: подключите своего бота токеном выше.' : 'Уведомления не настроены: на сервере не заданы ни TELEGRAM_BOT_TOKEN, ни DATA_ENCRYPTION_KEY.'}

`}
`; // Свой бот: 400 — токен не прошёл (формат или getMe), 429 — лимит. const botForm = $('#tg-bot-form'); if (botForm) botForm.addEventListener('submit', async (e) => { e.preventDefault(); const token = $('#tg-bot-token').value.trim(); if (!token) { flash('Вставьте токен бота', 'err'); return; } try { await API.tgSetBot(token); flash('Бот подключён — уведомления пойдут через него'); settingsView(); } catch (err) { if (err.status === 400) flash('Токен не принят: проверьте, что он от @BotFather', 'err'); else if (err.status === 429) flash('Слишком много попыток — попробуйте позже', 'err'); else onError(err); } }); const botDel = $('#tg-bot-del'); if (botDel) botDel.addEventListener('click', async () => { try { await API.tgDelBot(); flash('Свой бот отключён'); settingsView(); } catch (err) { onError(err); } }); // Код привязки: 429 — лимит выдачи, 400 — бот вообще не настроен. const codeBtn = $('#tg-code-btn'); if (codeBtn) codeBtn.addEventListener('click', async () => { try { const r = await API.tgCode(); $('#tg-code-value').textContent = r.code; $('#tg-code-link').href = r.link; $('#tg-code-box').classList.remove('hidden'); } catch (e) { if (e.status === 429) flash('Слишком много кодов — попробуйте позже', 'err'); else if (e.status === 400) flash('Уведомления не настроены на сервере', 'err'); else onError(e); } }); // Ручная цель: 400 — формат не тот либо бот не видит чат // (не добавлен в группу / человек не писал боту). const form = $('#tg-form'); if (form) form.addEventListener('submit', async (e) => { e.preventDefault(); try { await API.tgSet($('#tg-target').value.trim()); flash('Сохранено — уведомления будут приходить по этой цели'); settingsView(); } catch (err) { if (err.status === 400) { flash('Бот не видит этот чат: добавьте бота в группу или напишите ему /start', 'err'); } else onError(err); } }); const unbind = $('#tg-unbind'); if (unbind) unbind.addEventListener('click', async () => { try { await API.tgUnbind(); flash('Уведомления отключены'); settingsView(); } catch (e) { onError(e); } }); } // ============================== старт приложения ============================== function boot() { // Переключение карточек «вход ↔ забыли пароль» на одном экране. $('#show-forgot').addEventListener('click', () => { showAuthForm('#forgot-form'); $('#forgot-error').textContent = ''; $('#forgot-email').value = $('#login-email').value; $('#forgot-email').focus(); }); $('#back-login').addEventListener('click', () => showLogin()); $('#login-form').addEventListener('submit', async (e) => { e.preventDefault(); $('#login-error').textContent = ''; try { await API.login($('#login-email').value.trim(), $('#login-password').value); showApp(); } catch (err) { // 403 — два разных состояний: «отключён» и «ждём подтверждения» // (сервер отдаёт разные тексты, не путаем их). $('#login-error').textContent = err.status === 401 ? 'Неверный email или пароль' : err.message === 'account not confirmed' ? 'Аккаунт не подтверждён — подтвердите его в приложении' : err.message === 'account disabled' ? 'Аккаунт отключён — обратитесь к администратору' : (err.message || 'Ошибка входа'); } }); // «Забыли пароль»: ответ одинаковый в любом случае (не перечисляем // ящики), поэтому после успеха просто возвращаемся ко входу. $('#forgot-form').addEventListener('submit', async (e) => { e.preventDefault(); $('#forgot-error').textContent = ''; try { await API.forgotPassword($('#forgot-email').value.trim()); e.target.reset(); showLogin(); flash('Если аккаунт существует, письмо со ссылкой уже отправлено'); } catch (err) { $('#forgot-error').textContent = err.status === 429 ? 'Слишком много попыток — попробуйте позже' : (err.message || 'Не удалось отправить письмо'); } }); // Смена пароля по токену из письма: 400 — либо пароль слабый, либо // ссылка сгорела/использована (сервер не различает их наружу). $('#reset-form').addEventListener('submit', async (e) => { e.preventDefault(); $('#reset-error').textContent = ''; const pass = $('#reset-password').value; if (pass !== $('#reset-password2').value) { $('#reset-error').textContent = 'Пароли не совпадают'; return; } try { const { params } = parseHash(); await API.resetPassword(params.get('token') || '', pass); e.target.reset(); location.hash = ''; showLogin(); flash('Пароль обновлён — войдите с новым'); } catch (err) { $('#reset-error').textContent = err.status === 429 ? 'Слишком много попыток — попробуйте позже' : err.status === 400 ? 'Ссылка недействительна или устарела — запросите новую' : (err.message || 'Не удалось сменить пароль'); } }); $('#logout').addEventListener('click', async () => { await API.logout(); location.hash = ''; showLogin(); }); window.addEventListener('hashchange', () => { route(); }); // Уже есть сессия — сразу в приложение; ссылка из письма // (#/reset-password?token=…) показывается и без входа. const start = parseHash(); if (API.user() && start.path !== '/reset-password') showApp(); else route(); } boot();