axe-web/js/app.js
edadmin ca3392157a
Some checks are pending
web / check (push) Waiting to run
web / build-web (push) Waiting to run
web / deploy (push) Blocked by required conditions
feat: prod web image + Gitea Actions, demo chips removed
2026-10-18 22:44:06 +03:00

1945 lines
92 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<span class="badge s-${esc(status)}">${esc(DICT.status[status] || status)}</span>`;
}
function selectOptions(dict, selected) {
return '<option value="">— любое —</option>' + Object.entries(dict)
.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`)
.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]) => `<a class="nav-link${cur === href || cur.startsWith(href + '?') || cur.startsWith(href + '/') ? ' active' : ''}" href="${href}">${label}</a>`)
.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 = `
<section class="card">
<div class="row head">
<h2>Заявки</h2>
${r === 'client' ? '<button class="primary" id="new-ticket" type="button">+ Новая заявка</button>' : ''}
</div>
<form id="filters" class="filters">
<label>Статус
<select name="status">${selectOptions(DICT.status, params.get('status'))}</select>
</label>
<label>Категория
<select name="category">${selectOptions(DICT.category, params.get('category'))}</select>
</label>
<label>Приоритет
<select name="priority">${selectOptions(DICT.priority, params.get('priority'))}</select>
</label>
<label>С даты <input type="date" name="date_from" value="${esc(params.get('date_from') || '')}"></label>
<label>По дату <input type="date" name="date_to" value="${esc(params.get('date_to') || '')}"></label>
<button class="primary" type="submit">Применить</button>
<button class="ghost" id="reset" type="button">Сброс</button>
</form>
<div id="list"><p class="empty">Загрузка…</p></div>
</section>
<div id="create-wrap"></div>`;
$('#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 ? `
<table class="table">
<thead><tr>
<th>№</th><th>Статус</th><th>Категория</th><th>Приоритет</th>
<th>Адрес</th><th>Мастер</th><th>Создана</th>
</tr></thead>
<tbody>${items.map((t) => `
<tr class="link" data-id="${t.id}">
<td>${t.id}</td>
<td>${statusBadge(t.status)}</td>
<td>${esc(DICT.category[t.category] || t.category)}</td>
<td class="p-${esc(t.priority)}">${esc(DICT.priority[t.priority] || t.priority)}</td>
<td>${esc(t.address)}</td>
<td>${esc(t.worker_name || '—')}</td>
<td>${fmtTime(t.created_at)}</td>
</tr>`).join('')}
</tbody>
</table>
${pagerHTML(page)}` : '<p class="empty">Ничего не найдено. Попробуйте сбросить фильтры.</p>';
$$('#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 `<div class="pager">
<button id="prev" type="button" ${hasPrev ? '' : 'disabled'}>← Назад</button>
<span>${from}–${to} из ${total}</span>
<button id="next" type="button" ${hasNext ? '' : 'disabled'}>Вперёд →</button>
</div>`;
}
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 = `
<div class="modal-overlay" id="ct-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ct-title">
<div class="modal-head">
<h3 id="ct-title">Новая заявка</h3>
<button class="modal-close" id="ct-x" type="button" aria-label="Закрыть" title="Закрыть">×</button>
</div>
<form id="ct" class="form">
<label>Проект
<select name="project_id" required>${projects.map((p) => `<option value="${p.id}">${esc(p.name)}</option>`).join('')}</select>
</label>
<label>Объект
<select name="object_id" required><option value="">загрузка…</option></select>
</label>
<label>Этаж <input name="floor" placeholder="например, 2"></label>
<label>Категория
<select name="category">${Object.entries(DICT.category).map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
</label>
<label>Приоритет
<select name="priority">${Object.entries(DICT.priority).map(([v, l]) => `<option value="${v}"${v === 'normal' ? ' selected' : ''}>${l}</option>`).join('')}</select>
</label>
<label class="wide">Описание (мин. 5 символов)
<textarea name="description" required minlength="5" placeholder="Что случилось?"></textarea>
</label>
<label class="wide"><input type="checkbox" name="recurring"> Повторяющаяся заявка — такая же заявка по расписанию</label>
<p class="hint" id="ct-rec" hidden></p>
<div class="row">
<button class="primary" type="submit">Создать заявку</button>
<button class="ghost" id="ct-cancel" type="button">Отмена</button>
</div>
</form>
</div>
</div>
<div id="sched-wrap"></div>`;
// Состояние повтора: 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) => `<option value="${o.id}">${esc(o.name)} — ${esc(o.address)}</option>`).join('')
: '<option value="">— в проекте нет активных объектов —</option>';
}
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}. <button type="button" class="link" id="ct-rec-edit">Изменить</button>`;
$('#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 = `
<div class="modal-overlay" id="sched-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="sched-title">
<div class="modal-head">
<h3 id="sched-title">Повторяющаяся заявка</h3>
<button class="modal-close" id="sched-x" type="button" aria-label="Закрыть" title="Закрыть">×</button>
</div>
<form id="sched" class="form">
<label>Первый запуск
<input type="datetime-local" name="anchor_at" value="${dt}" required>
</label>
<div class="row">
<span class="row-label">Каждые</span>
<label>
<input type="number" name="period_n" min="1" max="3650" value="${n}" style="width:5rem">
</label>
<label>
<select name="period_unit">
<option value="1"${unit === 1 ? ' selected' : ''}>дней</option>
<option value="7"${unit === 7 ? ' selected' : ''}>недель</option>
</select>
</label>
</div>
<p class="hint">Условия: заявка будет создаваться автоматически от этой даты-времени с выбранным периодом — пока прошлая по правилу не закрыта, новая не появляется. Период считается до суток: планировщик проверяет расписание раз в сутки. Управление — на странице заявки.</p>
<div class="row">
<button class="primary" type="submit">Применить</button>
<button class="ghost" id="sched-cancel" type="button">Отмена</button>
</div>
</form>
</div>
</div>`;
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 = `
<section class="card">
<div class="row head">
<h2>Заявка №${t.id} ${statusBadge(t.status)}</h2>
<a class="ghost" href="#/tickets">← к списку</a>
</div>
<div class="detail-grid">
<div><div class="k">Адрес</div>${esc(t.address)}${t.floor ? ', этаж ' + esc(t.floor) : ''}</div>
<div><div class="k">Категория</div>${esc(DICT.category[t.category] || t.category)}</div>
<div><div class="k">Приоритет</div><span class="p-${esc(t.priority)}">${esc(DICT.priority[t.priority] || t.priority)}</span></div>
<div><div class="k">Проект</div>${esc(t.project_name || t.project_id)}</div>
<div><div class="k">Объект</div>${esc(t.object_name || t.object_id)}</div>
<div><div class="k">Клиент</div>${esc(t.client_name || '—')}</div>
<div><div class="k">Мастер</div>${esc(t.worker_name || 'не назначен')}</div>
<div><div class="k">Создана</div>${fmtTime(t.created_at)}</div>
${t.assigned_at ? `<div><div class="k">Назначена</div>${fmtTime(t.assigned_at)}</div>` : ''}
${t.started_at ? `<div><div class="k">Начата</div>${fmtTime(t.started_at)}</div>` : ''}
${t.done_at ? `<div><div class="k">Сделана</div>${fmtTime(t.done_at)}</div>` : ''}
${t.closed_at ? `<div><div class="k">Закрыта</div>${fmtTime(t.closed_at)}</div>` : ''}
${t.rework_count ? `<div><div class="k">Доработок</div>${t.rework_count}</div>` : ''}
</div>
<div class="desc-block">${esc(t.description)}</div>
${t.reject_reason ? `<div class="desc-block"><strong>Причина отклонения:</strong> ${esc(t.reject_reason)}</div>` : ''}
${t.cancel_reason ? `<div class="desc-block"><strong>Причина отмены:</strong> ${esc(t.cancel_reason)}</div>` : ''}
${r === 'worker' && t.status === 'rejected' ? `
<div id="rework-hint" class="notice">Клиент отклонил работу${t.rework_count ? ' — доработка №' + t.rework_count : ''}. Причина отклонения — выше. Нажмите «Начать работу», выполните переделку и снова отметьте «Работа выполнена» — после этого клиент примет заявку.</div>` : ''}
<div id="actions" class="row actions"></div>
</section>
${t.recurring ? recurringBlock(t) : ''}
<section class="card">
<div class="row head">
<h3>Фотоотчёт <span class="hint" id="photos-count" style="margin:0"></span></h3>
</div>
<div id="photo-upload"></div>
<div id="photos"><p class="empty">Загрузка…</p></div>
</section>
<section class="card">
<h3>Журнал событий</h3>
<div id="events"><p class="empty">Загрузка…</p></div>
</section>`;
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
? '<span class="badge m-active">включено</span>'
: '<span class="badge s-closed">выключено</span>';
return `
<section class="card" id="recurring-block">
<div class="row head">
<h3>Повторяющаяся заявка ${state}</h3>
</div>
<div class="detail-grid">
<div><div class="k">Расписание</div>${sched}</div>
<div><div class="k">Следующий запуск</div>${next}</div>
</div>
<p class="hint">Правило создаст такую же заявку ${periodText(rc.period_days)} — но только если прошлая по нему уже закрыта, дублей не будет. Период идёт от ${fmtTime(rc.anchor_at)}, точность до суток: планировщик проверяет расписание раз в сутки.</p>
${manage ? `
<div class="row actions">
<button class="ghost" id="rr-toggle" type="button">${rc.is_active ? 'Выключить' : 'Включить'}</button>
<button class="ghost" id="rr-runs" type="button">История</button>
</div>` : `
<p class="hint">Включением и историей правила управляют клиент и админ.</p>`}
<div id="runs-wrap"></div>
</section>`;
}
// Обработчики кнопок блока повтора: переключение 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('<div id="assign-box" class="row"></div>');
if (can.take) parts.push('<button class="primary" data-act="take" type="button">Взять заявку</button>');
if (can.start) parts.push('<button class="primary" data-act="start" type="button">Начать работу</button>');
if (can.done) parts.push('<button class="primary" data-act="done" type="button">Работа выполнена</button>');
if (can.confirm) parts.push('<button class="primary" data-act="confirm" type="button">Принять работу</button>');
if (can.reject) parts.push('<button data-act="reject" type="button">Отклонить (на доработку)</button>');
if (can.cancel) parts.push('<button class="danger" data-act="cancel" type="button">Отменить заявку</button>');
if (!parts.length) {
box.innerHTML = '<span class="hint" style="margin:0">Нет действий, доступных вашей роли в этом статусе.</span>';
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 = `
<select id="assignee">
${workers.length
? workers.map((w) => `<option value="${w.id}">${esc(w.name)} (${esc(w.email)})</option>`).join('')
: '<option value="">— в проекте нет принявших приглашение мастеров —</option>'}
</select>
<button class="primary" id="assign-go" type="button" ${workers.length ? '' : 'disabled'}>Назначить</button>`;
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', `
<form id="reason-form" class="form" style="flex-basis:100%">
<label class="wide">${label} (мин. 5 символов)
<textarea name="reason" required minlength="5"></textarea>
</label>
<div class="row">
<button class="${act === 'cancel' ? 'danger' : 'primary'}" type="submit">Подтвердить</button>
<button class="ghost" id="reason-cancel" type="button">Не надо</button>
</div>
</form>`);
$('#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 = `
<div class="row photo-actions">
<input type="file" id="photo-input" accept="image/jpeg,image/png,image/webp" multiple>
<button class="primary" id="photo-go" type="button">Загрузить</button>
<span class="hint" style="margin:0">jpeg, png или webp — до 10 МБ на файл</span>
</div>`;
$('#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 = '<p class="empty">Не удалось загрузить список фото.</p>';
return;
}
const items = page.items || [];
count.textContent = items.length + ' / ' + (page.max_per_ticket || '?');
if (!items.length) { box.innerHTML = '<p class="empty">Фото пока нет.</p>'; return; }
// Удаление: админу — любое, мастеру — своё, пока заявка в работе
// (in_progress); в done отчёт заморожен (сервер проверяет это в SQL —
// здесь видимость крестика).
const canDel = (p) => r === 'admin'
|| (r === 'worker' && p.uploader_id === me && t.status === 'in_progress');
box.innerHTML = `<div class="photos">${items.map((p) => `
<figure class="photo" data-id="${p.id}">
${p.url
? `<a href="${esc(p.url)}" target="_blank" rel="noopener">
<img src="${esc(p.url)}" alt="${esc(p.original_name || 'фото')}" loading="lazy">
</a>`
: '<div class="photo-broken">ссылка недоступна</div>'}
<figcaption>
<span class="who" title="${esc(p.uploader_name || 'аккаунт удалён')}">${esc(p.uploader_name || 'аккаунт удалён')}</span>
<span class="when">${fmtTime(p.created_at)}</span>
${canDel(p) ? '<button class="photo-del" type="button" title="Удалить фото" aria-label="Удалить фото">×</button>' : ''}
</figcaption>
</figure>`).join('')}</div>`;
$$('#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)} <span class="arrow">→</span> ` : '';
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 ? `
<ul class="events">${items.map((e) => `
<li>
<span class="when">${fmtTime(e.created_at)}</span>
<span class="who">${esc(e.actor_name || 'система')}</span>
<span>${eventText(e)}</span>
${e.comment ? `<span class="why">«${esc(e.comment)}»</span>` : ''}
</li>`).join('')}</ul>` : '<p class="empty">Событий пока нет.</p>';
}
// ============================== проекты ==============================
async function projectsView(params) {
const r = role();
$('#main').innerHTML = `
<section class="card">
<div class="row head">
<h2>Проекты</h2>
<button class="primary" id="new-project" type="button">+ Проект</button>
</div>
<div id="projects-create"></div>
<div id="list"><p class="empty">Загрузка…</p></div>
</section>`;
$('#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 ? `
<table class="table">
<thead><tr><th>Название</th>${r === 'admin' ? '<th>Клиент</th>' : ''}<th>Статус</th><th>Создан</th></tr></thead>
<tbody>${items.map((p) => `
<tr class="link" data-id="${p.id}">
<td>${esc(p.name)}</td>
${r === 'admin' ? `<td>${esc(p.client_name || p.client_id)}</td>` : ''}
<td>${p.is_active ? '<span class="badge m-active">активен</span>' : '<span class="badge s-closed">выключен</span>'}</td>
<td>${fmtTime(p.created_at)}</td>
</tr>`).join('')}
</tbody>
</table>
${pagerHTML(page)}` : '<p class="empty">Проектов нет — создайте первый.</p>';
$$('#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 = `<label>Клиент
<select name="client_id" required>${clients.map((c) => `<option value="${c.id}">${esc(c.name)} (${esc(c.email)})</option>`).join('')}</select>
</label>`;
}
wrap.innerHTML = `
<form id="np" class="form" style="margin-bottom:12px">
<label>Название (мин. 2 символов) <input name="name" required minlength="2"></label>
${clientSelect}
<button class="primary" type="submit">Создать</button>
<button class="ghost" id="np-cancel" type="button">Отмена</button>
</form>`;
$('#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
? '<span class="badge m-active">активен</span>'
: '<span class="badge s-closed">отключён</span>';
const confirm = u.confirmed_at
? ''
: '<span class="badge s-pending" title="Вход закрыт до подтверждения">не подтверждён</span> ';
return confirm + status;
}
// Ячейка действий строки: «Подтвердить» (неподтверждённому), затем
// «Отключить/Включить», «Сброс пароля» и «Удалить». Сброс — только у
// админа: эндпоинт за RequireRole("admin"), а раздел «Мастера» видит
// и клиент (своих мастеров) — ему сервер вернул бы 403.
function userActionsCell(u) {
const confirmBtn = u.confirmed_at
? ''
: `<button class="primary u-confirm" data-uid="${u.id}" data-name="${esc(u.name)}" type="button">Подтвердить</button>`;
const toggle = u.is_active
? `<button class="ghost u-toggle" data-uid="${u.id}" data-on="0" data-name="${esc(u.name)}" type="button">Отключить</button>`
: `<button class="ghost u-toggle" data-uid="${u.id}" data-on="1" data-name="${esc(u.name)}" type="button">Включить</button>`;
const reset = role() === 'admin'
? `<button class="ghost u-reset" data-uid="${u.id}" data-name="${esc(u.name)}" data-email="${esc(u.email)}" type="button">Сброс пароля</button>`
: '';
return `<td>${confirmBtn}${toggle}${reset}<button class="danger u-del" data-uid="${u.id}" data-name="${esc(u.name)}" type="button">Удалить</button></td>`;
}
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 = `
<div class="modal-overlay" id="pr-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="pr-title">
<div class="modal-head">
<h3 id="pr-title">Сброс пароля: ${esc(name)}</h3>
<button class="modal-close" id="pr-x" type="button" aria-label="Закрыть" title="Закрыть">×</button>
</div>
<form id="pr-link" class="form">
<p class="hint">Способ 1 — ссылка: ${esc(email)} откроет её сам и задаст новый пароль. Ссылка одноразовая, живёт 24 часа; выпуск новой гасит прежние.</p>
<div class="row">
<button class="primary" type="submit">Выпустить ссылку</button>
</div>
<div id="pr-link-box" hidden>
<label class="wide">Ссылка для пользователя
<input id="pr-link-url" readonly autocomplete="off">
</label>
<p class="hint" id="pr-link-exp"></p>
<div class="row">
<button class="ghost" id="pr-copy" type="button">Копировать</button>
</div>
</div>
</form>
<form id="pr-set" class="form">
<p class="hint">Способ 2 — пароль напрямую: задаёте его сами, все сессии пользователя закроются, старые ссылки сброса отменятся.</p>
<label>Новый пароль (мин. 10: буквы и цифры)
<input type="password" name="password" required minlength="10" autocomplete="new-password">
</label>
<label>Повторите пароль
<input type="password" name="password2" required minlength="10" autocomplete="new-password">
</label>
<div class="row">
<button class="primary" type="submit">Задать пароль</button>
<button class="ghost" id="pr-cancel" type="button">Отмена</button>
</div>
</form>
</div>
</div>`;
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 = `
<section class="card">
<div class="row head">
<h2>Клиенты</h2>
<button class="primary" id="new-client" type="button">+ Клиент</button>
</div>
<div id="clients-create"></div>
<div id="list"><p class="empty">Загрузка…</p></div>
<div id="pr-wrap"></div>
</section>`;
$('#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 ? `
<table class="table">
<thead><tr><th>Имя</th><th>Email</th><th>Телефон</th><th>Статус</th><th>Создан</th><th></th></tr></thead>
<tbody>${items.map((u) => `
<tr>
<td>${esc(u.name)}</td>
<td>${esc(u.email)}</td>
<td>${esc(u.phone || '—')}</td>
<td>${userStatusCell(u)}</td>
<td>${fmtTime(u.created_at)}</td>
${userActionsCell(u)}
</tr>`).join('')}
</tbody>
</table>
${pagerHTML(page)}` : '<p class="empty">Клиентов пока нет.</p>';
wirePager(page, '/clients', params);
wireUserActions();
}
// ============================== мастера ==============================
// Раздел только для админа: список мастеров, серверный поиск &q=
// (мин. 2 символа), пагинация и те же действия, что у клиентов.
async function workersView(params) {
// Поиск по всем мастерам системы (инструмент приглашения) — только
// у админа: у клиента раздел = «мои мастера», глобальная выдача с
// кнопками действий вводила бы в заблуждение — сервер для чужого
// вернёт 404 (граница «состоит в моём проекте» — в SQL).
const canSearch = role() === 'admin';
$('#main').innerHTML = `
<section class="card">
<div class="row head">
<h2>Мастера</h2>
</div>
${canSearch ? `
<form id="wq" class="form">
<label class="wide">Поиск по имени или email (мин. 2 символа)
<input name="q" value="${esc(params.get('q') || '')}" placeholder="например, Иван или petrov@" autocomplete="off">
</label>
<div class="row" style="gap:8px;align-items:end">
<button class="primary" type="submit">Найти</button>
${params.get('q') ? '<button class="ghost" id="wq-reset" type="button">Сбросить</button>' : ''}
</div>
</form>` : ''}
<div id="list"><p class="empty">Загрузка…</p></div>
<div id="pr-wrap"></div>
</section>`;
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 ? `
<table class="table">
<thead><tr><th>Имя</th><th>Email</th><th>Телефон</th><th>Статус</th><th>Создан</th><th></th></tr></thead>
<tbody>${items.map((u) => `
<tr>
<td>${esc(u.name)}</td>
<td>${esc(u.email)}</td>
<td>${esc(u.phone || '—')}</td>
<td>${userStatusCell(u)}</td>
<td>${fmtTime(u.created_at)}</td>
${userActionsCell(u)}
</tr>`).join('')}
</tbody>
</table>
${pagerHTML(page)}` : '<p class="empty">Мастеры не найдены.</p>';
wirePager(page, '/workers', params);
wireUserActions();
}
function openCreateClient() {
const wrap = $('#clients-create');
wrap.innerHTML = `
<form id="nc" class="form" style="margin-bottom:12px">
<label>Имя <input name="name" required></label>
<label>Email <input type="email" name="email" required></label>
<label>Телефон <input name="phone"></label>
<label>Пароль (мин. 10 символов, буквы и цифры) <input type="password" name="password" required minlength="10" autocomplete="new-password"></label>
<button class="primary" type="submit">Создать</button>
<button class="ghost" id="nc-cancel" type="button">Отмена</button>
</form>`;
$('#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 = `
<section class="card">
<div class="row head">
<h2>${esc(p.name)}</h2>
<a class="ghost" href="#/projects">← к списку</a>
</div>
<form id="pf" class="form">
<label>Название <input name="name" value="${esc(p.name)}" minlength="2"></label>
<label>Статус
<select name="is_active">
<option value="true"${p.is_active ? ' selected' : ''}>активен</option>
<option value="false"${!p.is_active ? ' selected' : ''}>выключен</option>
</select>
</label>
<button class="primary" type="submit">Сохранить</button>
</form>
${r === 'admin' ? `
<p class="hint">Клиент-владелец: ${esc(p.client_name || p.client_id)}</p>
<button class="danger" id="p-del" type="button">Удалить проект</button>` : ''}
</section>
<section class="card">
<div class="section-head"><h3>Объекты</h3></div>
<form id="no" class="form">
<label>Название <input name="name" required minlength="2" placeholder="Корпус 1"></label>
<label>Адрес <input name="address" required minlength="5" placeholder="ул. Ленина, 1"></label>
<button class="primary" type="submit">Добавить объект</button>
</form>
<div id="objects" style="margin-top:10px"><p class="empty">Загрузка…</p></div>
</section>
<section class="card">
<div class="section-head"><h3>Мастера</h3></div>
<div id="workers"><p class="empty">Загрузка…</p></div>
<div class="invite-box" id="invite"></div>
</section>`;
$('#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 ? `
<table class="table">
<thead><tr><th>Название</th><th>Адрес</th><th>Статус</th><th></th></tr></thead>
<tbody>${items.map((o) => `
<tr>
<td>${esc(o.name)}</td>
<td>${esc(o.address)}</td>
<td>${o.is_active ? '<span class="badge m-active">активен</span>' : '<span class="badge s-closed">выключен</span>'}</td>
<td><button data-oid="${o.id}" data-active="${o.is_active}" class="obj-toggle ghost" type="button">${o.is_active ? 'Выключить' : 'Включить'}</button></td>
</tr>`).join('')}
</tbody>
</table>` : '<p class="empty">Объектов нет.</p>';
$$('.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 = `
<div class="modal-overlay" id="runs-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="runs-title">
<div class="modal-head">
<h3 id="runs-title">История: ${esc(desc)}</h3>
<button class="modal-close" id="runs-x" type="button" aria-label="Закрыть" title="Закрыть">×</button>
</div>
${items.length ? `
<table class="table">
<thead><tr><th>Когда</th><th>Заявка</th><th>Адрес</th></tr></thead>
<tbody>${items.map((x) => `
<tr>
<td>${fmtTime(x.run_at)}</td>
<td>${x.ticket_id ? `<a href="#/tickets/${x.ticket_id}">№${x.ticket_id}</a>` : '<span class="hint">удалена</span>'}</td>
<td>${esc(x.address)}</td>
</tr>`).join('')}
</tbody>
</table>` : '<p class="empty">Правило ещё не срабатывало.</p>'}
</div>
</div>`;
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 ? `
<label class="wide" style="margin-bottom:8px">Быстрый фильтр — имя или email
<input id="w-filter" placeholder="все мастера" autocomplete="off">
</label>
<table class="table">
<thead><tr><th>Имя</th><th>Email</th><th>Статус</th><th>Ответ до</th><th></th></tr></thead>
<tbody>${items.map((w) => `
<tr data-hay="${esc((w.name + ' ' + w.email).toLowerCase())}">
<td>${esc(w.name)}</td>
<td>${esc(w.email)}</td>
<td><span class="badge m-${esc(w.status)}">${esc(DICT.membership[w.status] || w.status)}</span>${w.is_active ? '' : ' <span class="badge s-closed">отключён</span>'}</td>
<td>${w.status === 'invited' ? fmtTime(w.expires_at) : '—'}</td>
<td>
${w.status === 'active' ? (w.is_active
? `<button class="ghost w-off" data-wid="${w.id}" data-name="${esc(w.name)}" type="button">Отключить</button>`
: `<button class="ghost w-on" data-wid="${w.id}" data-name="${esc(w.name)}" type="button">Включить</button>`) : ''}
<button class="danger w-del" data-wid="${w.id}" data-status="${esc(w.status)}" data-name="${esc(w.name)}" type="button">Убрать</button>
<button class="ghost w-userdel" data-wid="${w.id}" data-name="${esc(w.name)}" type="button">Удалить аккаунт</button>
</td>
</tr>`).join('')}
</tbody>
</table>` : '<p class="empty">Мастеров пока нет — пригласите первого ниже.</p>';
// Фильтр — локальный: мастеров в проекте десятки, сетевой запрос
// на каждое нажатие клавиши был бы избыточен.
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 = `
<h4 style="margin:0 0 8px">Пригласить мастера</h4>
<label>Поиск по имени или email (мин. 2 символа)
<input id="w-search" placeholder="например, Иван или petrov@" autocomplete="off" value="${esc(inviteQuery)}">
</label>
<div id="w-results" style="margin-top:8px">
<p class="hint" style="margin:0">Введите имя или email — покажем мастеров, которых ещё нет в проекте.</p>
</div>
<form id="nw" class="form" style="margin-top:12px">
<label>Email <input name="email" type="email" required placeholder="worker@example.com"></label>
<label>Имя <input name="name" required placeholder="Иван Петров"></label>
<label>Пароль (мин. 10: буквы и цифры) <input name="password" type="password" required minlength="10" autocomplete="new-password" placeholder="password123"></label>
<button type="submit">Создать мастера и пригласить</button>
</form>
<p class="hint" style="margin:8px 0 0">Мастер появится в проекте со статусом «приглашён» — работать сможет
только после того, как сам примет приглашение (учетка мастера → вкладка «Приглашения»).</p>`;
const results = $('#w-results');
const idleHint = '<p class="hint" style="margin:0">Введите имя или email — покажем мастеров, которых ещё нет в проекте.</p>';
let timer = null;
let seq = 0; // номер запроса: ответ устаревшего — мимо
function renderRows(itemsFound, q) {
const fresh = itemsFound.filter((w) => !inProject.has(w.id));
if (!itemsFound.length) {
results.innerHTML = `<p class="hint" style="margin:0">По запросу «${esc(q)}» ничего не найдено.</p>`;
return;
}
if (!fresh.length) {
results.innerHTML = `<p class="hint" style="margin:0">Все найденные уже в этом проекте.</p>`;
return;
}
results.innerHTML = `<table class="table"><tbody>${fresh.map((w) => `
<tr>
<td>${esc(w.name)}</td>
<td>${esc(w.email)}</td>
<td><button class="primary inv-go" data-wid="${w.id}" type="button">Пригласить</button></td>
</tr>`).join('')}</tbody></table>`;
$$('.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 = `
<section class="card">
<div class="row head"><h2>Мои приглашения</h2></div>
<div id="inv"><p class="empty">Загрузка…</p></div>
</section>`;
const page = await API.get('/invitations?limit=50');
const items = page.items || [];
$('#inv').innerHTML = items.length ? `
<table class="table">
<thead><tr><th>Проект</th><th>Кто пригласил</th><th>Приглашено</th><th>Ответ до</th><th></th></tr></thead>
<tbody>${items.map((v) => `
<tr>
<td>${esc(v.project_name)}</td>
<td>${esc(v.invited_name || '—')}</td>
<td>${fmtTime(v.invited_at)}</td>
<td>${fmtTime(v.expires_at)}</td>
<td class="row">
<button class="primary inv-go" data-pid="${v.project_id}" data-act="accept" type="button">Принять</button>
<button class="danger inv-go" data-pid="${v.project_id}" data-act="decline" type="button">Отклонить</button>
</td>
</tr>`).join('')}
</tbody>
</table>` : '<p class="empty">Приглашений нет. Как только клиент пригласит вас в проект, оно появится здесь.</p>';
$$('.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 ? `
<div class="invite-box">
<strong>Ваш бот</strong>
<p class="hint">Уведомления идут через <code>@${esc(st.bot_username || '')}</code>. Токен лежит на сервере зашифрованным и никогда не показывается.</p>
<div class="row">
<button type="button" id="tg-bot-del" class="ghost">Отключить бота</button>
</div>
</div>
<form id="tg-bot-form" class="form">
<label>Новый токен бота
<input id="tg-bot-token" type="password" placeholder="123456789:AA..." autocomplete="off">
</label>
<div class="row">
<button type="submit" class="primary">Заменить токен</button>
</div>
</form>
` : `
<div class="invite-box">
<strong>Свой бот для уведомлений</strong>
<p class="hint">Заведите бота у @BotFather и вставьте его токен: сообщения о заявках придут через вашего бота. Токен шифруется и хранится на сервере.</p>
<form id="tg-bot-form" class="form">
<label>Токен бота
<input id="tg-bot-token" type="password" placeholder="123456789:AA..." autocomplete="off">
</label>
<div class="row">
<button type="submit" class="primary">Подключить бота</button>
</div>
</form>
</div>
`);
$('#main').innerHTML = `
<section class="card">
<div class="row head"><h2>Настройки</h2></div>
<h3>Уведомления в Telegram</h3>
<p class="hint">Если заявку не взяли в работу за 24 часа, придёт сообщение в Telegram — и повторно раз в сутки, пока её не возьмут.</p>
${st.enabled ? `
<p>${st.bound
? `Куда слать: <code>${esc(st.target)}</code>`
: 'Привязки нет — уведомления не приходят.'}</p>
` : ''}
${botBlock}
${st.enabled ? `
<div class="invite-box">
<strong>Личные уведомления</strong>
<p class="hint">Возьмите код и отправьте его боту: сообщения начнут приходить в этот чат. Код одноразовый и живёт 15 минут.</p>
<div class="row">
<button type="button" id="tg-code-btn" class="primary">Получить код привязки</button>
</div>
<div id="tg-code-box" class="hidden">
<p>Код: <code id="tg-code-value"></code></p>
<p><a id="tg-code-link" href="#" target="_blank" rel="noopener">Открыть бота и отправить код</a></p>
</div>
</div>
<form id="tg-form" class="form">
<label>Группа или канал
<input id="tg-target" placeholder="@mygroup" value="${st.bound ? esc(st.target) : ''}">
</label>
<div class="row">
<button type="submit" class="primary">Сохранить</button>
${st.bound ? '<button type="button" id="tg-unbind" class="ghost">Отключить уведомления</button>' : ''}
</div>
</form>
<p class="hint">Группа или канал работают, только если бот добавлен туда участником.</p>
` : `
<p class="empty">${st.token_configurable
? 'Уведомления не настроены: подключите своего бота токеном выше.'
: 'Уведомления не настроены: на сервере не заданы ни TELEGRAM_BOT_TOKEN, ни DATA_ENCRYPTION_KEY.'}</p>
`}
</section>`;
// Свой бот: 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();