/* «Запись через 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С', medflex: 'Medflex', none: 'заявка', '': 'заявка' }[r.booking] || 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', 'Ждёт оператора'], 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();