dana-medical-site/internal/api/adminui/onec.js
2026-10-22 07:40:00 +00:00

282 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/* «Запись через 1С»: статус синхронизации, привязка врачей 1С к карточкам сайта,
сетка окон на день и записи, сделанные с сайта. */
const ocEl = (t, p = {}, kids = []) => { const n = el(t, p); n.append(...kids); return n; };
const Onec = {
data: null,
siteDoctors: [],
gridDate: '',
busy: false,
fmtTime(iso, opts = { hour: '2-digit', minute: '2-digit' }) {
return new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', ...opts }).format(new Date(iso));
},
fmtDateTime(iso) {
return iso ? Onec.fmtTime(iso, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—';
},
ago(iso) {
if (!iso) return 'ещё не было';
const m = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'только что';
if (m < 60) return `${m} мин назад`;
return Onec.fmtDateTime(iso);
},
docName(d) {
return [d.last_name, d.first_name, d.middle_name].filter(Boolean).join(' ');
},
async open() {
if (!Onec.gridDate) Onec.gridDate = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Moscow' });
$('#oc-date').value = Onec.gridDate;
try {
const [main, docs, booking] = await Promise.all([api('/admin/onec'), api('/admin/doctors'), api('/admin/booking-settings')]);
Onec.data = main.data;
Onec.booking = booking.data;
Onec.siteDoctors = (docs.data || []).sort((a, b) => Onec.docName(a).localeCompare(Onec.docName(b), 'ru'));
} catch (e) {
toast(e.message, true);
return;
}
Onec.renderStatus();
Onec.renderConfirm();
Onec.renderDoctors();
Onec.loadGrid();
Onec.loadAppointments();
},
renderStatus() {
const d = Onec.data, s = d.status || {};
const box = $('#oc-status');
box.textContent = '';
if (!d.configured) {
box.append(ocEl('p', { className: 'oc-warn', textContent: 'Связь с 1С не настроена: на сервере не заданы ONEC_API_URL и ONEC_API_KEY. Пока запись работает как обычная заявка.' }));
$('#oc-sync').disabled = true;
return;
}
$('#oc-sync').disabled = false;
const state = s.last_error ? ['off', 'Ошибка'] : (s.last_ok ? ['', 'Работает'] : ['wait', 'Ждём первую синхронизацию']);
const stale = s.last_ok && Date.now() - new Date(s.last_ok).getTime() > 30 * 60000;
if (stale) { state[0] = 'off'; state[1] = 'Данные устарели — окна на сайте скрыты'; }
const stat = (label, value) => ocEl('div', { className: 'oc-stat' }, [ocEl('b', { textContent: value }), ocEl('span', { textContent: label })]);
box.append(
ocEl('div', { className: 'oc-line' }, [
ocEl('span', { className: 'pill dot ' + state[0], textContent: state[1] }),
ocEl('span', { className: 'muted', textContent: `1С: ${d.host} · последняя удачная синхронизация: ${Onec.ago(s.last_ok)}` }),
]),
ocEl('div', { className: 'oc-stats' }, [
stat('врачей в 1С', s.doctors ?? 0),
stat('привязано к карточкам', s.linked ?? 0),
stat('свободных окон', s.free_slots ?? 0),
stat('записей с сайта', s.appointments ?? 0),
]),
);
if (s.last_error) box.append(ocEl('p', { className: 'oc-warn', textContent: 'Последняя ошибка: ' + s.last_error }));
},
// Подтверждение записи оператором в Битрикс24 и проверочный звонок пациента.
renderConfirm() {
const b = Onec.booking || {}, s = b.settings || {};
const box = $('#oc-confirm');
box.textContent = '';
box.append(b.confirm_enabled
? ocEl('div', { className: 'oc-line' }, [
ocEl('span', { className: 'pill dot', textContent: 'Включено' }),
ocEl('span', { className: 'muted', textContent: 'Запись сразу занимает окно в 1С и создаёт лид в Битрикс24 («Не обработан»). «Клиент записался» — подтверждена, любая провальная стадия — запись в 1С отменяется.' }),
])
: ocEl('p', { className: 'oc-warn', textContent: b.bitrix_configured
? 'Не работает: нет связи с 1С.'
: 'Не работает: на сервере не задан BITRIX_WEBHOOK_URL. Записи с сайта не попадают в Битрикс24 и не требуют подтверждения.' }));
const num = (value, max, label) => ocEl('input', { type: 'number', min: '0', max: String(max), step: '1', value: String(value ?? 0), className: 'oc-num', 'aria-label': label });
const after = num(s.autocancel_hours, 168, 'Часов после записи');
const before = num(s.cancel_before_hours, 48, 'Часов до приёма');
const phone = ocEl('input', { type: 'checkbox', checked: !!s.phone_check, disabled: !b.smsc_configured });
const perDoc = num(s.max_per_doctor_day, 50, 'Неподтверждённых к врачу в день');
const perHour = num(s.max_per_hour, 1000, 'Онлайн-записей за час');
const perPhone = num(s.max_active_per_phone, 20, 'Будущих записей на один номер');
const save = ocEl('button', { type: 'button', className: 'btn', textContent: 'Сохранить' });
save.onclick = () => Onec.saveConfirm({
phone_check: phone.checked,
autocancel_hours: Number(after.value) || 0,
cancel_before_hours: Number(before.value) || 0,
max_per_doctor_day: Number(perDoc.value) || 0,
max_per_hour: Number(perHour.value) || 0,
max_active_per_phone: Number(perPhone.value) || 0,
}, save);
box.append(
ocEl('p', {}, [
'Отменять неподтверждённую запись через ', after, ' ч после записи или за ', before,
' ч до приёма — что наступит раньше (но не раньше чем через 30 минут после записи). 0 — правило выключено.',
]),
ocEl('label', { className: 'oc-line' }, [phone, ocEl('span', {
textContent: 'Требовать проверочный звонок от пациента перед записью' + (b.smsc_configured ? '' : ' (недоступно: не настроен SMSC)'),
})]),
ocEl('p', { className: 'muted', textContent: 'Защита расписания от ботов (0 — правило выключено). Обычно пациент записывается без звонка. '
+ 'Если сработало любое правило ниже, сайт просит проверочный звонок: настоящий пациент записывается дальше, бот останавливается.'
+ (b.smsc_configured ? '' : ' Сейчас SMSC не настроен — вместо звонка такая запись придёт оператору заявкой, без окна в 1С.') }),
ocEl('p', {}, ['Просить звонок, если за последний час уже ', perHour, ' онлайн-записей,']),
ocEl('p', {}, ['или у врача на этот день уже ', perDoc, ' неподтверждённых записей,']),
ocEl('p', {}, ['или на этот номер уже ', perPhone, ' будущих записей.']),
ocEl('div', {}, [save]),
);
},
async saveConfirm(body, btn) {
btn.disabled = true;
try {
Onec.booking = (await api('/admin/booking-settings', { method: 'PUT', body: JSON.stringify(body) })).data;
toast('Настройки записи сохранены');
Onec.renderConfirm();
} catch (e) {
toast(e.message, true);
btn.disabled = false;
}
},
renderDoctors() {
const tb = $('#oc-doctors'); tb.textContent = '';
const rows = Onec.data.doctors || [];
$('#oc-doctors-count').textContent = `${rows.length} ${plural(rows.length, 'врач', 'врача', 'врачей')}`;
if (!rows.length) {
const tr = ocEl('tr'); tr.append(ocEl('td', { colSpan: 5, className: 'muted dr-empty', textContent: 'Врачей из 1С пока нет — дождитесь синхронизации.' })); tb.append(tr);
return;
}
const takenBy = new Map(rows.filter((r) => r.doctor_id).map((r) => [r.doctor_id, r.onec_id]));
for (const r of rows) {
const tr = ocEl('tr');
if (!r.active) tr.className = 'oc-off';
const name = ocEl('td');
name.append(ocEl('b', { textContent: r.full_name }), ocEl('div', { className: 'muted', textContent: (r.specialties || []).join(', ') + (r.active ? '' : ' · в 1С не работает') }));
const sel = ocEl('select', { 'aria-label': 'Карточка врача на сайте' });
sel.append(ocEl('option', { value: '', textContent: '— не привязан —' }));
for (const sd of Onec.siteDoctors) {
const other = takenBy.get(sd.id);
const o = ocEl('option', { value: sd.id, textContent: Onec.docName(sd) + (other && other !== r.onec_id ? ' (занят другим врачом 1С)' : '') });
if (sd.id === r.doctor_id) o.selected = true;
sel.append(o);
}
sel.onchange = () => Onec.link(r, sel.value ? Number(sel.value) : null);
const link = ocEl('td'); link.append(sel);
const way = ocEl('td');
const bookingNative = r.booking === 'native';
if (!r.doctor_id) {
way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' }));
} else {
const label = { native: '1С', none: 'заявка', '': 'заявка' }[r.booking] || 'заявка';
way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label }));
const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' });
b.onclick = () => Onec.setBooking(r, !bookingNative);
way.append(' ', b);
}
tr.append(name, link, way, ocEl('td', { textContent: String(r.free_slots ?? 0) }));
tb.append(tr);
}
},
async link(r, doctorID) {
try {
await api(`/admin/onec/doctors/${encodeURIComponent(r.onec_id)}/link`, { method: 'PUT', body: JSON.stringify({ doctor_id: doctorID }) });
toast(doctorID ? 'Врач привязан к карточке. Включите запись через 1С.' : 'Привязка снята');
} catch (e) { toast(e.message, true); }
Onec.open();
},
async setBooking(r, enabled) {
try {
await api(`/admin/onec/doctors/${encodeURIComponent(r.onec_id)}/booking`, { method: 'PUT', body: JSON.stringify({ enabled }) });
toast(enabled ? 'Запись через 1С включена: на карточке врача появятся окна' : 'Запись через 1С отключена: остаётся обычная заявка');
// кэш врачей на других вкладках админки устарел
if (typeof loadAdminDoctors === 'function') loadAdminDoctors().catch(() => {});
} catch (e) { toast(e.message, true); }
Onec.open();
},
async sync() {
if (Onec.busy) return;
Onec.busy = true;
const b = $('#oc-sync'); b.disabled = true; b.textContent = 'Синхронизируем…';
try {
await api('/admin/onec/sync', { method: 'POST' });
toast('Расписание обновлено');
} catch (e) { toast(e.message, true); }
Onec.busy = false; b.disabled = false; b.textContent = 'Синхронизировать сейчас';
Onec.open();
},
async loadGrid() {
const box = $('#oc-grid'); box.textContent = '';
let rows;
try { rows = (await api('/admin/onec/grid?date=' + encodeURIComponent(Onec.gridDate))).data || []; } catch (e) { toast(e.message, true); return; }
if (!rows.length) { box.append(ocEl('p', { className: 'muted', textContent: 'На эту дату окон в расписании нет.' })); return; }
const byDoc = new Map();
for (const r of rows) {
const key = r.doctor_onec_id + '|' + r.branch;
if (!byDoc.has(key)) byDoc.set(key, { name: r.doctor_name, branch: r.branch, cabinet: r.cabinet, slots: [] });
byDoc.get(key).slots.push(r);
}
for (const g of byDoc.values()) {
const row = ocEl('div', { className: 'oc-grid-row' });
const head = ocEl('div', { className: 'oc-grid-doc' }, [ocEl('b', { textContent: g.name }), ocEl('span', { className: 'muted', textContent: g.branch })]);
const cells = ocEl('div', { className: 'oc-cells' });
for (const s of g.slots) {
cells.append(ocEl('span', { className: 'oc-cell ' + (s.status === 'free' ? 'free' : 'busy'), textContent: Onec.fmtTime(s.start), title: s.status === 'free' ? 'Свободно' : 'Занято' }));
}
row.append(head, cells);
box.append(row);
}
},
async loadAppointments() {
const tb = $('#oc-appts'); tb.textContent = '';
let rows;
try { rows = (await api('/admin/onec/appointments?limit=100')).data || []; } catch (e) { toast(e.message, true); return; }
if (!rows.length) {
const tr = ocEl('tr'); tr.append(ocEl('td', { colSpan: 7, className: 'muted dr-empty', textContent: 'Записей с сайта через 1С пока нет.' })); tb.append(tr);
return;
}
const STATUS = { booked: ['', 'Записан'], pending: ['wait', 'Отправляется'], failed: ['off', 'Не записан'], cancelled: ['grey', 'Отменён'], arrived: ['', 'Пришёл'] };
const CONFIRM = {
waiting: ['wait', 'Ждёт оператора'], cancel_pending: ['wait', 'Ожидает отмены в 1С'], confirmed: ['', 'Подтверждена'], cancelled: ['grey', 'Отменена оператором'],
expired: ['off', 'Не подтвердили — отменена'], missed: ['grey', 'Приём без подтверждения'],
lead_only: ['wait', 'Отправляется в Битрикс'], lead_sent: ['', 'Заявка в Битриксе'],
};
for (const a of rows) {
const st = STATUS[a.status] || ['grey', a.status];
const tr = ocEl('tr');
const patient = ocEl('td');
patient.append(ocEl('b', { textContent: a.patient_name }), ocEl('div', { className: 'muted', textContent: a.patient_phone }));
const doc = ocEl('td');
doc.append(ocEl('span', { textContent: a.doctor_name || a.doctor_onec_id }), ocEl('div', { className: 'muted', textContent: a.branch || '' }));
const state = ocEl('td');
state.append(ocEl('span', { className: 'pill ' + st[0], textContent: st[1] }));
if (a.error) state.append(ocEl('div', { className: 'muted oc-err', textContent: a.error, title: a.error }));
const confirm = ocEl('td');
const cs = CONFIRM[a.confirm_state];
if (cs) {
confirm.append(ocEl('span', { className: 'pill ' + cs[0], textContent: cs[1] }));
if (a.bitrix_lead_id) confirm.append(ocEl('div', { className: 'muted', textContent: 'лид № ' + a.bitrix_lead_id }));
if (a.confirm_error) confirm.append(ocEl('div', { className: 'muted oc-err', textContent: a.confirm_error, title: a.confirm_error }));
} else {
confirm.append(ocEl('span', { className: 'muted', textContent: '—' }));
}
tr.append(
ocEl('td', { textContent: Onec.fmtDateTime(a.created_at) }),
patient, doc,
ocEl('td', { textContent: Onec.fmtDateTime(a.start) }),
state, confirm,
ocEl('td', { className: 'muted', textContent: a.onec_appointment_id || '—' }),
);
tb.append(tr);
}
},
init() {
$('#oc-sync').onclick = Onec.sync;
$('#oc-date').onchange = (e) => { Onec.gridDate = e.target.value; Onec.loadGrid(); };
},
};
Onec.init();