fix(booking): select doctor before showing real 1C slots

This commit is contained in:
Dana Medical Developer 2026-10-22 08:18:17 +00:00
parent 6e30d4cb5f
commit 97fc02778e
4 changed files with 183 additions and 240 deletions

View File

@ -0,0 +1,101 @@
// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок.
import assert from 'node:assert/strict';
import { parseHTML } from 'linkedom';
import { createServer } from 'vite';
const { window } = parseHTML('<html><body><div id="root"></div></body></html>');
Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
window.location = new URL('https://booking-test.invalid/');
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
const doctors = [
{ id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' } },
{ id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' } },
{ id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' } },
];
const pending = [];
api.doctors = async () => ({ data: doctors });
api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject }));
api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
const container = document.getElementById('root');
const root = createRoot(container);
const render = async (props = {}) => act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(LeadModal, { ...props, onClose() {} }))));
const text = () => container.textContent;
const times = () => [...container.querySelectorAll('.slot-chip--time')].map((e) => e.textContent);
async function chooseDoctor(id) {
const select = container.querySelector('select');
await act(async () => {
// linkedom exposes select.value as read-only; option.selected sets the value.
for (const option of select.options) option.selected = false;
[...select.options].find((option) => option.value === String(id)).selected = true;
select.dispatchEvent(new window.Event('change', { bubbles: true }));
});
}
const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' });
async function reply(request, slots, enabled = true) {
await act(async () => request.resolve({ data: { enabled, slots, services: [] } }));
}
try {
await render();
assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor');
assert.equal(container.querySelector('input[type="date"]'), null);
assert.equal(container.querySelector('input[type="tel"]'), null);
assert.deepEqual(times(), []);
assert.match(text(), /Сначала выберите врача/);
await chooseDoctor(1);
assert.equal(pending[0].slug, 'first');
assert.match(text(), /Загружаем свободное время/);
await reply(pending[0], [free('09:15'), { ...free('10:00'), status: 'busy' }, { ...free('11:00'), start: '2000-01-01T11:00:00+03:00' }, { ...free('12:00'), start: 'invalid' }]);
assert.deepEqual(times(), ['09:15'], 'Only actual free future slots appear');
await act(async () => container.querySelector('.slot-chip--time').dispatchEvent(new window.Event('click', { bubbles: true })));
assert.ok(container.querySelector('input[type="tel"]'), 'Contacts follow an actual slot');
await chooseDoctor(2);
assert.deepEqual(times(), []);
assert.equal(container.querySelector('input[type="tel"]'), null, 'Switching doctor clears the chosen slot');
assert.equal(pending[1].slug, 'second');
await chooseDoctor(1);
assert.equal(pending[2].slug, 'first');
await reply(pending[1], [], false);
assert.match(text(), /Загружаем свободное время/, 'Late response for another doctor must be ignored');
await reply(pending[2], [free('15:45')]);
assert.deepEqual(times(), ['15:45']);
await chooseDoctor('');
assert.deepEqual(times(), []);
assert.equal(pending.length, 3, 'Clearing selection must not request an all-doctors schedule');
await chooseDoctor(3);
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.deepEqual(times(), []);
assert.equal(container.querySelector('input[type="date"]'), null, 'Fallback cannot offer invented dates');
assert.ok(container.querySelector('input[type="tel"]'));
await chooseDoctor(2);
await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable')));
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.deepEqual(times(), []);
await act(async () => root.render(null));
await render({ doctor: doctors[0], slot: free('10:00') });
await reply(pending[4], [free('16:15')]);
assert.deepEqual(times(), ['16:15']);
assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected');
await act(async () => root.render(null));
await render({ form: 'callback' });
assert.ok(container.querySelector('input[type="tel"]'));
assert.equal(container.querySelector('select'), null);
await act(async () => root.render(null));
await render({ form: 'review' });
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
assert.ok(container.querySelector('select'));
console.log('PASS: doctor-first selection, actual slots, switching/reset, stale responses, fallback, callback and review');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

@ -144,24 +144,6 @@
pointer-events: none;
}
.lead-field__select--date img {
right: 14px;
}
/* нативный календарь оставляем, но его значок прячем под иконкой из макета */
.lead-field__select--date input::-webkit-calendar-picker-indicator {
position: absolute;
right: 10px;
width: 26px;
height: 26px;
opacity: 0;
cursor: pointer;
}
.lead-field__select--date input {
padding: 0 44px 0 14px;
}
.lead-rating {
display: flex;
gap: 6px;
@ -301,124 +283,23 @@
}
}
/* слоты по часам: выбор удобного часа приёма */
.lead-slots {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.lead-slot {
height: 40px;
padding: 0 4px;
background: #fff;
border: 1px solid var(--chip-border);
border-radius: var(--r-12);
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--navy);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.lead-slot:hover:not(:disabled) {
border-color: var(--coral);
}
.lead-slot--on {
background: var(--coral);
border-color: var(--coral);
color: #fff;
}
.lead-slot:disabled {
opacity: 0.35;
cursor: not-allowed;
text-decoration: line-through;
}
.lead-slots__hint {
.lead-modal__availability {
margin: 0;
padding: 14px 16px;
background: var(--chip);
border-radius: var(--r-12);
font-size: 13px;
color: var(--muted, #7a8699);
line-height: 1.6;
color: var(--navy);
}
@media (max-width: 600px) {
.lead-slots {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* ---------- запись на приём: широкое окно в две колонки ----------
24 слота по 30 минут в одну колонку не помещались на экран ноутбука — окно приходилось прокручивать. */
/* Расширяем окно только для реального расписания выбранного врача. */
.lead-modal--wide {
width: 920px;
}
.lead-modal__form--split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'contacts date'
'doctor time'
'comment time';
align-items: start;
column-gap: 32px;
row-gap: 18px;
}
/* всё остальное (согласие, кнопка, подсказки) — на всю ширину под колонками */
.lead-modal__form--split > * {
grid-column: 1 / -1;
}
.lead-modal__form--split > .lead-modal__contacts {
grid-area: contacts;
/* в половине окна имя и телефон рядом не помещаются — друг под другом */
grid-template-columns: 1fr;
row-gap: 18px;
}
.lead-modal__form--split > .lead-field--doctor {
grid-area: doctor;
}
.lead-modal__form--split > .lead-field--comment {
grid-area: comment;
}
.lead-modal__form--split > .lead-field--date {
grid-area: date;
}
.lead-modal__form--split > .lead-field--time {
grid-area: time;
}
.lead-modal__form--split .lead-slot {
height: 36px;
}
/* узкий экран: снова одна колонка */
@media (max-width: 960px) {
.lead-modal--wide {
width: 650px;
}
.lead-modal__form--split {
display: flex;
flex-direction: column;
}
}
/* телефон: слоты по 6 в ряд — 4 ряда вместо 6 */
@media (max-width: 600px) {
.lead-modal__form--split .lead-slots {
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.lead-modal__form--split .lead-slot {
height: 36px;
font-size: 13px;
}
}

View File

@ -2,7 +2,6 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom';
import closeIcon from '../assets/figma/icon-close.svg';
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
import calendarIcon from '../assets/figma/icon-calendar.svg';
import checkIcon from '../assets/figma/icon-check.svg';
import dotGreen from '../assets/figma/icon-dot-green.svg';
import starIcon from '../assets/figma/icon-star.svg';
@ -15,7 +14,7 @@ import './LeadModal.css';
const COPY = {
appointment: {
title: 'Записаться на приём',
subtitle: 'Оставьте контакты — администратор поможет выбрать врача и время',
subtitle: 'Выберите врача, затем свободную дату и время приёма',
},
callback: {
title: 'Обратный звонок',
@ -31,78 +30,51 @@ const COPY = {
},
};
// Часы работы из настроек сайта («Пн–Вс: 08:00–20:00») → слоты по часам: 08:00 … 19:00.
// Пока нет расписания 1С, это пожелание пациента; администратор подтверждает время звонком.
function hourSlots(hours) {
const m = /(\d{1,2}):\d{2}\s*[–—-]\s*(\d{1,2}):\d{2}/.exec(hours || '');
let from = m ? Number(m[1]) : 8;
let to = m ? Number(m[2]) : 20;
if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20];
const list = [];
for (let h = from; h < to; h++) {
const hh = String(h).padStart(2, '0');
list.push(`${hh}:00`);
list.push(`${hh}:30`);
}
return list;
}
function todayISO() {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
}
export default function LeadModal({ form = 'appointment', doctor, slot, service, specialty, message = '', onClose }) {
const copy = COPY[form] || COPY.appointment;
const { hours } = useSiteData();
const slots = useMemo(() => hourSlots(hours), [hours]);
const uid = useId();
const dialogRef = useRef(null);
const firstFieldRef = useRef(null);
// врач с записью через 1С: сначала выбор окна, потом контакты; если окон нет или 1С не отвечает — обычная заявка
const [nativeOff, setNativeOff] = useState(false);
const native = form === 'appointment' && doctor?.booking?.provider === 'native' && !nativeOff;
const onNativeUnavailable = useCallback(() => setNativeOff(true), []);
const needsDoctor = form === 'appointment' || form === 'review';
// запись на конкретную услугу: в списке только её врачи и врачи её специальности
const scoped = Boolean(service && !doctor && form === 'appointment');
const [doctors, setDoctors] = useState([]);
const [doctorsLoaded, setDoctorsLoaded] = useState(false);
const [unavailableDoctorId, setUnavailableDoctorId] = useState(null);
const [requestedSlot, setRequestedSlot] = useState(slot);
const [values, setValues] = useState({
name: '',
phone: '',
doctorId: doctor?.id ? String(doctor.id) : '',
date: '',
time: '',
message,
rating: 5,
consent: false,
});
const [status, setStatus] = useState({ state: 'idle', message: '' });
const selectedDoctor = doctors.find((d) => String(d.id) === values.doctorId)
|| (doctor && String(doctor.id) === values.doctorId ? doctor : null);
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
&& unavailableDoctorId !== selectedDoctor.id;
const onNativeUnavailable = useCallback(
() => setUnavailableDoctorId(selectedDoctor?.id ?? null),
[selectedDoctor?.id],
);
const set = (key) => (e) => {
const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e;
if (key === 'doctorId') {
setUnavailableDoctorId(null);
setRequestedSlot(undefined);
setStatus({ state: 'idle', message: '' });
}
setValues((prev) => ({
...prev,
[key]: key === 'phone' ? formatPhoneInput(v) : v,
// другая дата — выбранный час мог стать прошедшим
...(key === 'date' ? { time: '' } : {}),
}));
};
// на сегодня — только слоты в будущем
const isPast = (slot) => {
if (values.date !== todayISO()) return false;
const now = new Date();
const [h, m] = slot.split(':').map(Number);
const slotMinutes = h * 60 + (m || 0);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
return slotMinutes <= nowMinutes;
};
useEffect(() => {
if (!needsDoctor) return undefined;
const ctrl = new AbortController();
@ -111,12 +83,15 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
: specialty ? api.doctors({ specialty }, { signal: ctrl.signal }) : Promise.resolve({ data: [] });
load
.then(({ data }) => {
if (ctrl.signal.aborted) return;
const byId = new Map();
for (const d of [...own, ...(data || [])]) if (!byId.has(d.id)) byId.set(d.id, d);
for (const d of [...(data || []), ...own]) {
if (!d.absent && !byId.has(d.id)) byId.set(d.id, d);
}
setDoctors([...byId.values()]);
})
.catch(() => setDoctors(own))
.finally(() => setDoctorsLoaded(true));
.catch(() => { if (!ctrl.signal.aborted) setDoctors(own); })
.finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); });
return () => ctrl.abort();
}, [needsDoctor, scoped, service, specialty]);
@ -161,6 +136,10 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const submit = async (e) => {
e.preventDefault();
if (form === 'appointment' && !selectedDoctor) {
setStatus({ state: 'error', message: 'Сначала выберите врача' });
return;
}
if (nameRequired && !values.name.trim()) {
setStatus({ state: 'error', message: 'Укажите, как к вам обращаться' });
return;
@ -179,8 +158,6 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
}
const extra = {};
if (values.date) extra.date = values.date;
if (values.time) extra.time = values.time;
if (form === 'review') extra.rating = values.rating;
setStatus({ state: 'sending', message: '' });
@ -203,10 +180,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
};
const titleId = `${uid}-title`;
// запись на приём: окно шире, чтобы дни и время помещались без прокрутки; форма заявки — в две колонки
// (контакты слева, дата и время справа)
const wide = form === 'appointment' && status.state !== 'done';
const split = wide && !native;
const wide = native;
return (
<div className="lead-modal__backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
@ -223,7 +197,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
{copy.title}
</h2>
<p className="lead-modal__subtitle">
{doctor ? `Врач: ${doctor.full_name}` : copy.subtitle}
{(form === 'appointment' ? selectedDoctor : doctor)
? `Врач: ${(form === 'appointment' ? selectedDoctor : doctor).full_name}` : copy.subtitle}
</p>
</div>
<button type="button" className="lead-modal__close" onClick={onClose} aria-label="Закрыть">
@ -231,8 +206,34 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</button>
</div>
{native ? (
<NativeBooking doctor={doctor} initialSlot={slot} onClose={onClose} onUnavailable={onNativeUnavailable} />
{form === 'appointment' && status.state !== 'done' && (
<label className="lead-field lead-field--doctor">
<span className="lead-field__label">Специалист</span>
<span className="lead-field__select">
<select ref={firstFieldRef} className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
<option value="">{doctorsLoaded ? 'Выберите врача' : 'Загружаем список врачей…'}</option>
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
<option value={doctor.id}>{doctor.full_name}</option>
)}
{doctorOptions.map((d) => (
<option key={d.id} value={d.id}>
{d.full_name}{d.position ? ` — ${d.position}` : ''}
</option>
))}
</select>
<img src={chevronIcon} alt="" width="16" height="16" />
</span>
</label>
)}
{form === 'appointment' && !selectedDoctor ? (
<p className="lead-modal__availability" role="status">
{doctorsLoaded && doctorOptions.length === 0
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
: 'Сначала выберите врача — покажем только его свободные даты и время из 1С.'}
</p>
) : native ? (
<NativeBooking key={selectedDoctor.id} doctor={selectedDoctor} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
) : status.state === 'done' ? (
<div className="lead-modal__done" role="status">
<p>{status.message}</p>
@ -241,12 +242,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</button>
</div>
) : (
<form className={`lead-modal__form${split ? ' lead-modal__form--split' : ''}`} onSubmit={submit} noValidate>
<form className="lead-modal__form" onSubmit={submit} noValidate>
{form === 'appointment' && (
<p className="lead-modal__availability" role="status">
Свободные окна этого врача сейчас недоступны для онлайн-записи. Оставьте контакты — администратор уточнит расписание.
</p>
)}
<div className="lead-modal__row lead-modal__contacts">
<label className="lead-field">
<span className="lead-field__label">Ваше имя</span>
<input
ref={firstFieldRef}
ref={form === 'appointment' ? undefined : firstFieldRef}
className="lead-field__control"
type="text"
autoComplete="name"
@ -269,13 +275,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</label>
</div>
{needsDoctor && !(scoped && doctorsLoaded && doctorOptions.length === 0) && (
{form === 'review' && (
<label className="lead-field lead-field--doctor">
<span className="lead-field__label">Специалист</span>
<span className="lead-field__select">
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
<option value="">
{form === 'review' ? 'Выберите врача, если отзыв о нём' : scoped ? 'Любой врач по этой услуге' : 'Выберите направление или врача'}
Выберите врача, если отзыв о нём
</option>
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
<option value={doctor.id}>{doctor.full_name}</option>
@ -292,55 +298,6 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</label>
)}
{form === 'appointment' && (
<>
<label className="lead-field lead-field--date">
<span className="lead-field__label">Дата</span>
<span className="lead-field__select lead-field__select--date">
<input
className="lead-field__control"
type="date"
min={todayISO()}
value={values.date}
onChange={set('date')}
/>
<img src={calendarIcon} alt="" width="18" height="18" />
</span>
</label>
<div className="lead-field lead-field--time">
<span className="lead-field__label" id={`${uid}-time`}>
Удобное время
</span>
{values.date ? (
<div className="lead-slots" role="radiogroup" aria-labelledby={`${uid}-time`}>
{slots
.filter((t) => !isPast(t))
.map((t) => {
const on = values.time === t;
return (
<button
key={t}
type="button"
role="radio"
aria-checked={on}
className={`lead-slot${on ? ' lead-slot--on' : ''}`}
onClick={() => setValues((p) => ({ ...p, time: on ? '' : t }))}
>
{t}
</button>
);
})}
{values.date === todayISO() && slots.filter((t) => !isPast(t)).length === 0 && (
<p className="lead-slots__hint">На сегодня свободного времени уже нет. Пожалуйста, выберите другую дату.</p>
)}
</div>
) : (
<p className="lead-slots__hint">Сначала выберите дату — покажем время приёма</p>
)}
</div>
</>
)}
{form === 'review' && (
<div className="lead-field">
<span className="lead-field__label">Оценка</span>

View File

@ -75,16 +75,20 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
api
.doctorSlots(doctor.slug, { signal: ctrl.signal })
.then(({ data }) => {
if (!data?.enabled || !data.slots?.length) {
if (ctrl.signal.aborted) return;
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now());
if (!data?.enabled || !actualSlots.length) {
unavailable.current();
return;
}
setSlots(data.slots);
setSlots(actualSlots);
// Окно из карточки тоже должно оставаться свободным в свежем ответе 1С.
setChosen(initialSlot ? actualSlots.find((s) => s.start === initialSlot.start && s.branch_id === initialSlot.branch_id) || null : null);
setServices(data.services || []);
setLoaded(true);
})
.catch((e) => {
if (e.name !== 'AbortError') unavailable.current();
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
});
return () => ctrl.abort();
}, [doctor.slug]);
@ -100,7 +104,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
return slots.filter((s) => {
if (branch && s.branch_id !== branch) return false;
const t = new Date(s.start).getTime();
return isNaN(t) || t > now;
return s.status === 'free' && t > now;
});
}, [slots, branch]);
const days = useMemo(() => groupByDay(visible), [visible]);
@ -275,7 +279,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
))}
</div>
)}
<p className="native-booking__hint">Выберите удобное время — на следующем шаге укажете контакты.</p>
<p className="native-booking__hint">Выберите свободное окно врача — на следующем шаге укажете контакты.</p>
</div>
);
}