Клиент отклонил работу${t.rework_count ? ' — доработка №' + t.rework_count : ''}. Причина отклонения — выше. Нажмите «Начать работу», выполните переделку и снова отметьте «Работа выполнена» — после этого клиент примет заявку.
` : ''}
${t.recurring ? recurringBlock(t) : ''}
Фотоотчёт
Загрузка…
Журнал событий
Загрузка…
`;
await renderActions(t, can);
if (t.recurring) wireRecurring(t, r);
await renderPhotos(t, params);
await loadEvents(t.id, params);
}
// --- блок «Повторяющаяся заявка» на странице заявки ---
// Заявка связана с правилом (создана вместе с ним или самим
// планировщиком) — здесь её расписание и управление им: вкл/выкл
// (PATCH правила) и история срабатываний. Мастеру показываем текст
// без кнопок: API управления закрыт для его роли.
function recurringBlock(t) {
const rc = t.recurring;
const r = role();
const manage = r === 'client' || r === 'admin';
const sched = `${periodText(rc.period_days)} с ${fmtTime(rc.anchor_at)}`;
const next = rc.is_active ? fmtTime(rc.next_run_at) : '—';
const state = rc.is_active
? 'включено'
: 'выключено';
return `
Повторяющаяся заявка ${state}
Расписание
${sched}
Следующий запуск
${next}
Правило создаст такую же заявку ${periodText(rc.period_days)} — но только если прошлая по нему уже закрыта, дублей не будет. Период идёт от ${fmtTime(rc.anchor_at)}, точность до суток: планировщик проверяет расписание раз в сутки.
${manage ? `
` : `
Включением и историей правила управляют клиент и админ.
`}
`;
}
// Обработчики кнопок блока повтора: переключение is_active правилом
// (после — перерисовка страницы, чтобы состояние и кнопка сошлись)
// и модалка истории срабатываний (общая openRuns).
function wireRecurring(t, r) {
const rc = t.recurring;
const manage = r === 'client' || r === 'admin';
if (!manage) return;
$('#rr-toggle').addEventListener('click', async () => {
try {
await API.patch('/recurring/' + rc.rule_id, { is_active: !rc.is_active });
flash(rc.is_active ? 'Правило выключено' : 'Правило включено');
route();
} catch (e) { onError(e); }
});
$('#rr-runs').addEventListener('click', () => openRuns(rc.rule_id, t.description));
}
async function renderActions(t, can) {
const box = $('#actions');
const parts = [];
if (can.assign) parts.push('');
if (can.take) parts.push('');
if (can.start) parts.push('');
if (can.done) parts.push('');
if (can.confirm) parts.push('');
if (can.reject) parts.push('');
if (can.cancel) parts.push('');
if (!parts.length) {
box.innerHTML = 'Нет действий, доступных вашей роли в этом статусе.';
return;
}
box.innerHTML = parts.join('');
// Назначение: выбор из мастеров-участников проекта (только active).
if (can.assign) {
const workers = (await API.get('/projects/' + t.project_id + '/workers?limit=100&sort=name')).items
.filter((w) => w.status === 'active');
$('#assign-box').innerHTML = `
`;
if (workers.length) {
$('#assign-go').addEventListener('click', async () => {
try {
await API.patch('/tickets/' + t.id + '/assign', { worker_id: $('#assignee').value });
flash('Заявка назначена');
route();
} catch (e) { onError(e); }
});
}
}
// Простые переходы без тела.
const simple = { take: 'Заявка взята', start: 'Начата', done: 'Выполнена', confirm: 'Принята клиентом' };
$$('#actions button[data-act]').forEach((btn) => {
const act = btn.dataset.act;
if (simple[act]) {
btn.addEventListener('click', async () => {
try {
await API.patch('/tickets/' + t.id + '/' + act, {});
flash(simple[act]);
route();
} catch (e) { onError(e); }
});
} else if (act === 'reject' || act === 'cancel') {
// Отклонение и отмена требуют причины (min 5 символов).
btn.addEventListener('click', () => {
const label = act === 'reject' ? 'Причина отклонения' : 'Причина отмены';
box.insertAdjacentHTML('beforeend', `
`);
$('#reason-cancel').addEventListener('click', () => $('#reason-form').remove());
$('#reason-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await API.patch('/tickets/' + t.id + '/' + act,
{ reason: String(new FormData(e.target).get('reason')).trim() });
flash(act === 'reject' ? 'Отправлено на доработку' : 'Заявка отменена');
route();
} catch (err) { onError(err); }
});
});
}
});
}
// ============================== фотоотчёт ==============================
// Снимок белого списка форматов сервера — клиентская проверка только
// ради скорости; настоящий арбитр — сервер (сигнатура байтов).
const PHOTO_ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];
const PHOTO_MAX_BYTES = 10 * 1024 * 1024; // дефолт PHOTO_MAX_BYTES
async function renderPhotos(t, params) {
const r = role();
const me = API.user().id;
// Грузить может назначенный мастер в работе или админ — после «Работа
// выполнена» отчёт заморожен; сервер проверяет это в SQL, здесь
// только видимость кнопки.
const canUpload = (r === 'admin' || (r === 'worker' && t.worker_id === me))
&& t.status === 'in_progress';
if (canUpload) {
$('#photo-upload').innerHTML = `
';
wirePager(page, '/clients', params);
wireUserActions();
}
// ============================== мастера ==============================
// Раздел только для админа: список мастеров, серверный поиск &q=
// (мин. 2 символа), пагинация и те же действия, что у клиентов.
async function workersView(params) {
// Поиск по всем мастерам системы (инструмент приглашения) — только
// у админа: у клиента раздел = «мои мастера», глобальная выдача с
// кнопками действий вводила бы в заблуждение — сервер для чужого
// вернёт 404 (граница «состоит в моём проекте» — в SQL).
const canSearch = role() === 'admin';
$('#main').innerHTML = `
';
// Фильтр — локальный: мастеров в проекте десятки, сетевой запрос
// на каждое нажатие клавиши был бы избыточен.
const wFilter = $('#w-filter');
if (wFilter) {
wFilter.addEventListener('input', () => {
const q = wFilter.value.trim().toLowerCase();
$$('#workers tbody tr').forEach((tr) => {
tr.style.display = !q || (tr.dataset.hay || '').includes(q) ? '' : 'none';
});
});
}
$$('.w-del').forEach((btn) => {
btn.addEventListener('click', async () => {
const invited = btn.dataset.status === 'invited';
const ask = invited
? 'Отозвать приглашение для «' + btn.dataset.name + '»?'
: 'Убрать «' + btn.dataset.name + '» из проекта? Он больше не будет получать назначения проекта.';
if (!confirm(ask)) return;
try {
await API.del('/projects/' + projectId + '/workers/' + btn.dataset.wid);
flash(invited ? 'Приглашение отозвано' : 'Мастер убран из проекта');
loadWorkers(projectId, r);
} catch (e) { onError(e); }
});
});
// Отключение аккаунта мастера прямо из проекта (доступно и клиенту —
// сервер разрешает только активных участников своих проектов).
$$('.w-off').forEach((btn) => btn.addEventListener('click', () => {
toggleUser(btn.dataset.wid, false, btn.dataset.name);
}));
$$('.w-on').forEach((btn) => btn.addEventListener('click', () => {
toggleUser(btn.dataset.wid, true, btn.dataset.name);
}));
// Удаление аккаунта (не просто выход из проекта) — то же действие,
// что и в разделе «Мастера»: границы и 409 с предложением отключить
// вместо удаления решает сервер.
$$('.w-userdel').forEach((btn) => {
btn.addEventListener('click', () => deleteUser(btn.dataset.wid, btn.dataset.name));
});
await renderInviteBox(projectId, r, items);
}
// Последний введённый поиск приглашения: переживает перерисовку бокса
// (после приглашения loadWorkers собирает его заново).
let inviteQuery = '';
// Приглашение: поиск по имени/email среди активных мастеров — GET /users
// с &q=. Без запроса API клиенту список не отдаётся (только «свои»),
// поэтому существующего мастера клиент находит именно так, а админ не
// листает вручную всех мастеров системы. Дубликаты в проекте отфильтровываем.
async function renderInviteBox(projectId, r, current) {
const box = $('#invite');
const inProject = new Set(current.map((w) => w.id));
box.innerHTML = `
Пригласить мастера
Введите имя или email — покажем мастеров, которых ещё нет в проекте.
Мастер появится в проекте со статусом «приглашён» — работать сможет
только после того, как сам примет приглашение (учетка мастера → вкладка «Приглашения»).
Приглашений нет. Как только клиент пригласит вас в проект, оно появится здесь.
';
$$('.inv-go').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
await API.post('/projects/' + btn.dataset.pid + '/invitation/' + btn.dataset.act, {});
flash(btn.dataset.act === 'accept'
? 'Вы приняли приглашение — проект может назначать вам заявки'
: 'Приглашение отклонено');
invitationsView();
} catch (e) { onError(e); }
});
});
}
// ============================== настройки ==============================
// Экран «Настройки»: уведомления в Telegram о заявках, пролежавших в
// статусе «новая» больше суток. Три вещи, что тут можно сделать:
// 1. подключить СВОЕГО бота (токен от @BotFather) — уведомления пойдут
// через него, токен хранится на сервере зашифрованным;
// 2. указать, куда слать: личная привязка (одноразовый код боту) либо
// ручная группа/канал;
// 3. отключить уведомления. Пункт меню виден всем ролям.
async function settingsView() {
const st = await API.tgStatus();
// Свой бот. Токен никогда не показывается обратно — сервер отдаёт
// только факт его наличия и имя бота. Без DATA_ENCRYPTION_KEY
// (token_configurable=false) поля нет: хранить токен некуда.
const botBlock = !st.token_configurable ? '' : (st.has_bot_token ? `
Ваш бот
Уведомления идут через @${esc(st.bot_username || '')}. Токен лежит на сервере зашифрованным и никогда не показывается.
` : `
Свой бот для уведомлений
Заведите бота у @BotFather и вставьте его токен: сообщения о заявках придут через вашего бота. Токен шифруется и хранится на сервере.
`);
$('#main').innerHTML = `
Настройки
Уведомления в Telegram
Если заявку не взяли в работу за 24 часа, придёт сообщение в Telegram — и повторно раз в сутки, пока её не возьмут.
${st.enabled ? `
${st.bound
? `Куда слать: ${esc(st.target)}`
: 'Привязки нет — уведомления не приходят.'}
` : ''}
${botBlock}
${st.enabled ? `
Личные уведомления
Возьмите код и отправьте его боту: сообщения начнут приходить в этот чат. Код одноразовый и живёт 15 минут.
Группа или канал работают, только если бот добавлен туда участником.
` : `
${st.token_configurable
? 'Уведомления не настроены: подключите своего бота токеном выше.'
: 'Уведомления не настроены: на сервере не заданы ни TELEGRAM_BOT_TOKEN, ни DATA_ENCRYPTION_KEY.'}
`}
`;
// Свой бот: 400 — токен не прошёл (формат или getMe), 429 — лимит.
const botForm = $('#tg-bot-form');
if (botForm) botForm.addEventListener('submit', async (e) => {
e.preventDefault();
const token = $('#tg-bot-token').value.trim();
if (!token) { flash('Вставьте токен бота', 'err'); return; }
try {
await API.tgSetBot(token);
flash('Бот подключён — уведомления пойдут через него');
settingsView();
} catch (err) {
if (err.status === 400) flash('Токен не принят: проверьте, что он от @BotFather', 'err');
else if (err.status === 429) flash('Слишком много попыток — попробуйте позже', 'err');
else onError(err);
}
});
const botDel = $('#tg-bot-del');
if (botDel) botDel.addEventListener('click', async () => {
try {
await API.tgDelBot();
flash('Свой бот отключён');
settingsView();
} catch (err) { onError(err); }
});
// Код привязки: 429 — лимит выдачи, 400 — бот вообще не настроен.
const codeBtn = $('#tg-code-btn');
if (codeBtn) codeBtn.addEventListener('click', async () => {
try {
const r = await API.tgCode();
$('#tg-code-value').textContent = r.code;
$('#tg-code-link').href = r.link;
$('#tg-code-box').classList.remove('hidden');
} catch (e) {
if (e.status === 429) flash('Слишком много кодов — попробуйте позже', 'err');
else if (e.status === 400) flash('Уведомления не настроены на сервере', 'err');
else onError(e);
}
});
// Ручная цель: 400 — формат не тот либо бот не видит чат
// (не добавлен в группу / человек не писал боту).
const form = $('#tg-form');
if (form) form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await API.tgSet($('#tg-target').value.trim());
flash('Сохранено — уведомления будут приходить по этой цели');
settingsView();
} catch (err) {
if (err.status === 400) {
flash('Бот не видит этот чат: добавьте бота в группу или напишите ему /start', 'err');
} else onError(err);
}
});
const unbind = $('#tg-unbind');
if (unbind) unbind.addEventListener('click', async () => {
try {
await API.tgUnbind();
flash('Уведомления отключены');
settingsView();
} catch (e) { onError(e); }
});
}
// ============================== старт приложения ==============================
function boot() {
// Переключение карточек «вход ↔ забыли пароль» на одном экране.
$('#show-forgot').addEventListener('click', () => {
showAuthForm('#forgot-form');
$('#forgot-error').textContent = '';
$('#forgot-email').value = $('#login-email').value;
$('#forgot-email').focus();
});
$('#back-login').addEventListener('click', () => showLogin());
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('#login-error').textContent = '';
try {
await API.login($('#login-email').value.trim(), $('#login-password').value);
showApp();
} catch (err) {
// 403 — два разных состояний: «отключён» и «ждём подтверждения»
// (сервер отдаёт разные тексты, не путаем их).
$('#login-error').textContent = err.status === 401
? 'Неверный email или пароль'
: err.message === 'account not confirmed'
? 'Аккаунт не подтверждён — подтвердите его в приложении'
: err.message === 'account disabled'
? 'Аккаунт отключён — обратитесь к администратору'
: (err.message || 'Ошибка входа');
}
});
// «Забыли пароль»: ответ одинаковый в любом случае (не перечисляем
// ящики), поэтому после успеха просто возвращаемся ко входу.
$('#forgot-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('#forgot-error').textContent = '';
try {
await API.forgotPassword($('#forgot-email').value.trim());
e.target.reset();
showLogin();
flash('Если аккаунт существует, письмо со ссылкой уже отправлено');
} catch (err) {
$('#forgot-error').textContent = err.status === 429
? 'Слишком много попыток — попробуйте позже'
: (err.message || 'Не удалось отправить письмо');
}
});
// Смена пароля по токену из письма: 400 — либо пароль слабый, либо
// ссылка сгорела/использована (сервер не различает их наружу).
$('#reset-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('#reset-error').textContent = '';
const pass = $('#reset-password').value;
if (pass !== $('#reset-password2').value) {
$('#reset-error').textContent = 'Пароли не совпадают';
return;
}
try {
const { params } = parseHash();
await API.resetPassword(params.get('token') || '', pass);
e.target.reset();
location.hash = '';
showLogin();
flash('Пароль обновлён — войдите с новым');
} catch (err) {
$('#reset-error').textContent = err.status === 429
? 'Слишком много попыток — попробуйте позже'
: err.status === 400
? 'Ссылка недействительна или устарела — запросите новую'
: (err.message || 'Не удалось сменить пароль');
}
});
$('#logout').addEventListener('click', async () => {
await API.logout();
location.hash = '';
showLogin();
});
window.addEventListener('hashchange', () => { route(); });
// Уже есть сессия — сразу в приложение; ссылка из письма
// (#/reset-password?token=…) показывается и без входа.
const start = parseHash();
if (API.user() && start.path !== '/reset-password') showApp();
else route();
}
boot();