282 lines
16 KiB
JavaScript
282 lines
16 KiB
JavaScript
/* «Запись через 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();
|