axe-web/js/app.js

1945 lines
92 KiB
JavaScript
Raw Normal View History

'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();