1945 lines
92 KiB
JavaScript
1945 lines
92 KiB
JavaScript
|
|
'use strict';
|
|||
|
|
// app.js — экраны тестового веба: логин, заявки (+журнал), проекты
|
|||
|
|
// (объекты и мастера), приглашения, настройки (Telegram). Никаких
|
|||
|
|
// фреймворков: состояние → рендер, маршруты в hash (#/tickets?status=new&offset=20).
|
|||
|
|
|
|||
|
|
// ============================== словари ==============================
|
|||
|
|
const DICT = {
|
|||
|
|
status: {
|
|||
|
|
new: 'новая', assigned: 'назначена', in_progress: 'в работе',
|
|||
|
|
done: 'сделана', rejected: 'на доработке', closed: 'закрыта',
|
|||
|
|
},
|
|||
|
|
category: {
|
|||
|
|
electric: 'электрика', plumbing: 'сантехника', climate: 'климат',
|
|||
|
|
elevator: 'лифт', other: 'прочее',
|
|||
|
|
},
|
|||
|
|
priority: { low: 'низкий', normal: 'обычный', high: 'высокий' },
|
|||
|
|
membership: { invited: 'приглашён', active: 'в составе', declined: 'отказался' },
|
|||
|
|
role: { client: 'клиент', worker: 'мастер', admin: 'админ' },
|
|||
|
|
action: {
|
|||
|
|
created: 'создана', assigned: 'назначена', started: 'начата',
|
|||
|
|
done: 'сделана', confirmed: 'принята', rejected: 'отклонена',
|
|||
|
|
canceled: 'отменена',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const PAGE_SIZE = 20;
|
|||
|
|
|
|||
|
|
// ============================== утилиты ==============================
|
|||
|
|
const $ = (sel, root) => (root || document).querySelector(sel);
|
|||
|
|
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
|
|||
|
|
|
|||
|
|
function esc(v) {
|
|||
|
|
return String(v === null || v === undefined ? '' : v)
|
|||
|
|
.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fmtTime(iso) {
|
|||
|
|
if (!iso) return '—';
|
|||
|
|
const d = new Date(iso);
|
|||
|
|
if (Number.isNaN(d.getTime())) return '—';
|
|||
|
|
return d.toLocaleString('ru-RU', {
|
|||
|
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Русские числительные: 1 день / 2 дня / 5 дней (и то же для недель).
|
|||
|
|
function plural(n, one, few, many) {
|
|||
|
|
const m10 = n % 10;
|
|||
|
|
const m100 = n % 100;
|
|||
|
|
if (m10 === 1 && m100 !== 11) return one;
|
|||
|
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
|
|||
|
|
return many;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Расписание правила человеческим языком: «каждые 14 дней»,
|
|||
|
|
// «каждые 3 недели» (период, кратный 7, показываем в неделях).
|
|||
|
|
function periodText(days) {
|
|||
|
|
if (days % 7 === 0) {
|
|||
|
|
const n = days / 7;
|
|||
|
|
if (n === 1) return 'каждую неделю';
|
|||
|
|
return `каждые ${n} ${plural(n, 'неделя', 'недели', 'недель')}`;
|
|||
|
|
}
|
|||
|
|
if (days === 1) return 'каждый день';
|
|||
|
|
return `каждые ${days} ${plural(days, 'день', 'дня', 'дней')}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Значение для input type="datetime-local" — локальная дата-время
|
|||
|
|
// в формате YYYY-MM-DDTHH:MM (без секунд и зоны — так требует нативный
|
|||
|
|
// контрол; на сервер уходит ISO-строка с зоной).
|
|||
|
|
function localDT(d) {
|
|||
|
|
const p = (n) => String(n).padStart(2, '0');
|
|||
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let flashTimer = null;
|
|||
|
|
function flash(msg, kind) {
|
|||
|
|
const el = $('#flash');
|
|||
|
|
el.textContent = msg;
|
|||
|
|
el.className = 'flash ' + (kind || 'ok');
|
|||
|
|
if (flashTimer) clearTimeout(flashTimer);
|
|||
|
|
flashTimer = setTimeout(() => el.classList.add('hidden'), 4000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Единая обработка ошибок: 401 — сессия мертва, остальное — тост.
|
|||
|
|
function onError(e) {
|
|||
|
|
if (e && e.status === 401) {
|
|||
|
|
showLogin();
|
|||
|
|
flash('Сессия истекла — войдите снова', 'err');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
flash((e && e.message) || String(e), 'err');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function role() {
|
|||
|
|
const u = API.user();
|
|||
|
|
return u ? u.role : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function statusBadge(status) {
|
|||
|
|
return `<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();
|