fix(booking): keep doctor-card appointments bound to chosen doctor

This commit is contained in:
Dana Medical Developer 2026-10-23 13:47:38 +00:00
parent c7be67f96c
commit da0fb50ad0
5 changed files with 94 additions and 12 deletions

View File

@ -0,0 +1,11 @@
# Doctor-card booking keeps the chosen doctor
When an appointment modal receives a doctor from a catalog card, profile or explicit slot button, show a static doctor summary instead of DoctorPicker. The doctor prop is authoritative for native booking and fallback lead doctor_id. Do not request the all-doctors catalog. Calendar changes and unavailable schedules cannot reset or replace the doctor. The no-slots hint suggests another date, not another specialist.
General appointment entry points without a doctor retain the grouped/searchable DoctorPicker, including date filtering and switching doctors. Review forms are unchanged.
Files: web/src/components/LeadModal.jsx, DoctorPicker.jsx, DoctorPicker.css; web/scripts/test-booking-flow.mjs.
Verification: from web/, run node scripts/test-booking-flow.mjs and the other regression scripts; build client + SSR. Tests use fictional doctors, mocked API responses and no network submissions. Live verification opens booking only; do not create patient appointments or leads for a smoke test.
Deployment is a frontend-only image overlay on the current runtime, preserving the server binary, admin refresh fix, service-catalog quarantine, database and media. Back up the current image before switching; restore its tag and recreate only api if health checks fail. No 1C data, appointments, prices or schema are changed.

View File

@ -22,7 +22,8 @@ const doctors = [
{ id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] }, { id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] },
]; ];
const pending = []; const pending = [];
api.doctors = async () => ({ data: doctors }); let doctorCatalogRequests = 0;
api.doctors = async () => { doctorCatalogRequests++; return { data: doctors }; };
api.bookingAvailability = async () => ({ data: { enabled: true, days: [ api.bookingAvailability = async () => ({ data: { enabled: true, days: [
{ date: '2070-10-23', doctor_ids: [1, 2] }, { date: '2070-10-23', doctor_ids: [1, 2] },
{ date: '2070-10-24', doctor_ids: [2] }, { date: '2070-10-24', doctor_ids: [2] },
@ -116,7 +117,11 @@ try {
assert.deepEqual(times(), []); assert.deepEqual(times(), []);
await act(async () => root.render(null)); await act(async () => root.render(null));
const beforeCardCatalogRequests = doctorCatalogRequests;
await render({ doctor: doctors[0], slot: free('10:00') }); await render({ doctor: doctors[0], slot: free('10:00') });
assert.equal(doctorCatalogRequests, beforeCardCatalogRequests, 'A doctor card must not load the all-doctors catalog');
assert.equal(container.querySelector('.doctor-picker__trigger'), null);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
await reply(pending[4], [free('16:15')]); await reply(pending[4], [free('16:15')]);
assert.deepEqual(times(), ['16:15']); assert.deepEqual(times(), ['16:15']);
assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected'); assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected');
@ -148,12 +153,48 @@ try {
assert.deepEqual(await availableDoctorIDs(), ['1','2','3'], 'Clearing date restores the doctor-first flow'); assert.deepEqual(await availableDoctorIDs(), ['1','2','3'], 'Clearing date restores the doctor-first flow');
await act(async () => root.render(null)); await act(async () => root.render(null));
const beforeLockedCatalogRequests = doctorCatalogRequests;
await render({ doctor: doctors[0] }); await render({ doctor: doctors[0] });
await reply(pending.at(-1), [free('10:30')]); await reply(pending.at(-1), [free('10:30')]);
await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } })); await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } }));
await reply(pending.at(-1), [free('13:30')]); await reply(pending.at(-1), [free('13:30')]);
assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Первый врач/); assert.match(container.querySelector('.doctor-picker__fixed .doctor-picker__name').textContent, /Первый врач/);
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'Doctor-card booking has no doctor selector');
assert.deepEqual(times(), ['13:30'], 'Doctor preselected from a card also survives calendar selection'); assert.deepEqual(times(), ['13:30'], 'Doctor preselected from a card also survives calendar selection');
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
await click(container.querySelector('.doctor-picker__fixed'));
assert.equal(container.querySelector('.doctor-picker__panel'), null, 'Static doctor summary cannot open a dropdown');
assert.equal(container.querySelector('.doctor-picker__chevron'), null, 'No misleading dropdown arrow for a fixed doctor');
await click(container.querySelector('.slot-chip--time'));
assert.ok(container.querySelector('input[type="tel"]'));
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } }));
assert.deepEqual(times(), []);
assert.equal(container.querySelector('input[type="tel"]'), null, 'Date changes clear the time, not the fixed doctor');
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
assert.match(text(), /У выбранного врача на эту дату свободных окон нет/);
assert.doesNotMatch(text(), /другого специалиста/);
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'An unavailable date cannot unlock the doctor');
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } }));
await reply(pending.at(-1), [free('14:30')]);
assert.deepEqual(times(), ['14:30']);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
await act(async () => root.render(null));
await render({ doctor: doctors[2] });
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '3');
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'Fallback requests also keep the card doctor fixed');
assert.ok(container.querySelector('input[type="tel"]'));
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
await act(async () => root.render(null));
await render({ doctor: doctors[0] });
await reply(pending.at(-1), [], false);
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
assert.equal(container.querySelector('.doctor-picker__fixed').dataset.fixedDoctorId, '1');
assert.equal(container.querySelector('.doctor-picker__trigger'), null, 'A failed native schedule cannot unlock the doctor');
assert.equal(doctorCatalogRequests, beforeLockedCatalogRequests);
const originalDoctorLoader = api.doctors; const originalDoctorLoader = api.doctors;
for (const fail of [false, true]) { for (const fail of [false, true]) {
@ -174,7 +215,7 @@ try {
await render({ form: 'review' }); await render({ form: 'review' });
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5); assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
assert.ok(container.querySelector('select')); assert.ok(container.querySelector('select'));
console.log('PASS: specialty grouping/search/selection, picker keyboard handling, date-to-doctor filtering, actual times, resets, stale responses, fallback, callback and review'); console.log('PASS: specialty grouping/search/selection, picker keyboard handling, date-to-doctor filtering, actual times, resets, stale responses, fixed doctor from card without catalog requests, date/fallback lock, callback and review');
} finally { } finally {
await act(async () => root.unmount()); await act(async () => root.unmount());
await vite.close(); await vite.close();

View File

@ -1,4 +1,4 @@
.doctor-picker__trigger { .doctor-picker__trigger, .doctor-picker__fixed {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@ -10,6 +10,8 @@
background: #fff; background: #fff;
color: var(--navy); color: var(--navy);
text-align: left; text-align: left;
}
.doctor-picker__trigger {
cursor: pointer; cursor: pointer;
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
} }
@ -88,7 +90,7 @@
@media (max-width: 600px) { @media (max-width: 600px) {
.lead-modal__backdrop:has(.doctor-picker__panel) { padding-top: 12px; padding-bottom: 12px; } .lead-modal__backdrop:has(.doctor-picker__panel) { padding-top: 12px; padding-bottom: 12px; }
.lead-modal:has(.doctor-picker__panel) { height: min(760px, calc(100dvh - 24px)); max-height: calc(100dvh - 24px); } .lead-modal:has(.doctor-picker__panel) { height: min(760px, calc(100dvh - 24px)); max-height: calc(100dvh - 24px); }
.doctor-picker__trigger { gap: 9px; padding: 10px; } .doctor-picker__trigger, .doctor-picker__fixed { gap: 9px; padding: 10px; }
.doctor-picker__panel { padding: 10px; border-radius: 14px; } .doctor-picker__panel { padding: 10px; border-radius: 14px; }
.doctor-picker__name { font-size: 12px; } .doctor-picker__name { font-size: 12px; }
.doctor-picker__role { font-size: 10px; } .doctor-picker__role { font-size: 10px; }

View File

@ -14,6 +14,22 @@ function Avatar({ doctor }) {
); );
} }
// Не кнопка и не disabled-селект: выбранный в каталоге врач не меняется в этой записи.
export function DoctorSummary({ doctor }) {
return (
<div className="lead-field lead-field--doctor doctor-picker">
<span className="lead-field__label">Специалист</span>
<div className="doctor-picker__fixed" data-fixed-doctor-id={doctor.id}>
<Avatar doctor={doctor} />
<span className="doctor-picker__selection">
<span className="doctor-picker__name">{doctor.full_name}</span>
{doctor.position && <span className="doctor-picker__role">{doctor.position}</span>}
</span>
</div>
</div>
);
}
export default function DoctorPicker({ doctors, value, onChange, disabled = false, loading = false, date = '' }) { export default function DoctorPicker({ doctors, value, onChange, disabled = false, loading = false, date = '' }) {
const uid = useId(); const uid = useId();
const wrapper = useRef(null); const wrapper = useRef(null);

View File

@ -9,7 +9,7 @@ import { api } from '../api.js';
import { useSiteData } from '../context/SiteData.jsx'; import { useSiteData } from '../context/SiteData.jsx';
import { formatPhoneInput, phoneComplete } from '../lib/phone.js'; import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
import NativeBooking from './NativeBooking.jsx'; import NativeBooking from './NativeBooking.jsx';
import DoctorPicker from './DoctorPicker.jsx'; import DoctorPicker, { DoctorSummary } from './DoctorPicker.jsx';
import { todayKey } from '../lib/slots.js'; import { todayKey } from '../lib/slots.js';
import './LeadModal.css'; import './LeadModal.css';
@ -44,6 +44,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const firstFieldRef = useRef(null); const firstFieldRef = useRef(null);
const needsDoctor = form === 'appointment' || form === 'review'; const needsDoctor = form === 'appointment' || form === 'review';
// Карточка врача открывает запись только к нему; общая форма сохраняет выбор.
const fixedDoctor = form === 'appointment' && Boolean(doctor?.id);
// запись на конкретную услугу: в списке только её врачи и врачи её специальности // запись на конкретную услугу: в списке только её врачи и врачи её специальности
const scoped = Boolean(service && !doctor && form === 'appointment'); const scoped = Boolean(service && !doctor && form === 'appointment');
const [doctors, setDoctors] = useState([]); const [doctors, setDoctors] = useState([]);
@ -63,7 +65,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const [status, setStatus] = useState({ state: 'idle', message: '' }); const [status, setStatus] = useState({ state: 'idle', message: '' });
const chosenDoctor = doctors.find((d) => String(d.id) === values.doctorId) const chosenDoctor = doctors.find((d) => String(d.id) === values.doctorId)
|| (doctor && String(doctor.id) === values.doctorId ? doctor : null); || (doctor && String(doctor.id) === values.doctorId ? doctor : null);
const selectedDoctor = chosenDoctor; const selectedDoctor = fixedDoctor ? doctor : chosenDoctor;
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native' const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
&& unavailableDoctorId !== selectedDoctor.id; && unavailableDoctorId !== selectedDoctor.id;
const onNativeUnavailable = useCallback( const onNativeUnavailable = useCallback(
@ -74,6 +76,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const set = (key) => (e) => { const set = (key) => (e) => {
const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e; const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e;
if (key === 'doctorId') { if (key === 'doctorId') {
if (fixedDoctor) return;
setUnavailableDoctorId(null); setUnavailableDoctorId(null);
setRequestedSlot(undefined); setRequestedSlot(undefined);
setStatus({ state: 'idle', message: '' }); setStatus({ state: 'idle', message: '' });
@ -107,6 +110,11 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
useEffect(() => { useEffect(() => {
if (!needsDoctor) return undefined; if (!needsDoctor) return undefined;
if (fixedDoctor) {
setDoctors([doctor]);
setDoctorsLoaded(true);
return undefined;
}
const ctrl = new AbortController(); const ctrl = new AbortController();
const own = scoped ? (service.doctors || []).filter((d) => !d.absent) : []; const own = scoped ? (service.doctors || []).filter((d) => !d.absent) : [];
const load = !scoped ? api.doctors({}, { signal: ctrl.signal }) const load = !scoped ? api.doctors({}, { signal: ctrl.signal })
@ -123,7 +131,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
.catch(() => { if (!ctrl.signal.aborted) setDoctors(own); }) .catch(() => { if (!ctrl.signal.aborted) setDoctors(own); })
.finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); }); .finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); });
return () => ctrl.abort(); return () => ctrl.abort();
}, [needsDoctor, scoped, service, specialty]); }, [needsDoctor, fixedDoctor, doctor, scoped, service, specialty]);
// Escape, фокус внутри окна, блокировка прокрутки страницы под ним // Escape, фокус внутри окна, блокировка прокрутки страницы под ним
useEffect(() => { useEffect(() => {
@ -182,7 +190,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
? availability.loading ? 'Проверяем свободные окна выбранного врача…' ? availability.loading ? 'Проверяем свободные окна выбранного врача…'
: availability.error ? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.' : availability.error ? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
: !availability.days.find((day) => day.date === date)?.doctor_ids.includes(selectedDoctor.id) : !availability.days.find((day) => day.date === date)?.doctor_ids.includes(selectedDoctor.id)
? 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату или другого специалиста.' : '' ? fixedDoctor
? 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату.'
: 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату или другого специалиста.' : ''
: ''; : '';
const appointmentMessage = selectedDoctor ? selectedDateMessage : availabilityMessage; const appointmentMessage = selectedDoctor ? selectedDateMessage : availabilityMessage;
@ -224,7 +234,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
form: form === 'checkup' ? 'appointment' : form, form: form === 'checkup' ? 'appointment' : form,
name: values.name.trim(), name: values.name.trim(),
phone: values.phone, phone: values.phone,
doctor_id: values.doctorId ? Number(values.doctorId) : undefined, doctor_id: fixedDoctor ? doctor.id : values.doctorId ? Number(values.doctorId) : undefined,
service_id: service?.id, service_id: service?.id,
message: values.message.trim(), message: values.message.trim(),
extra, extra,
@ -280,7 +290,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
</label> </label>
)} )}
{form === 'appointment' && status.state !== 'done' && ( {form === 'appointment' && status.state !== 'done' && (fixedDoctor ? (
<DoctorSummary doctor={selectedDoctor} />
) : (
<DoctorPicker <DoctorPicker
doctors={selectedDoctor && !doctorOptions.some((d) => d.id === selectedDoctor.id) ? [selectedDoctor, ...doctorOptions] : doctorOptions} doctors={selectedDoctor && !doctorOptions.some((d) => d.id === selectedDoctor.id) ? [selectedDoctor, ...doctorOptions] : doctorOptions}
value={values.doctorId} value={values.doctorId}
@ -289,7 +301,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
loading={!doctorsLoaded} loading={!doctorsLoaded}
disabled={Boolean(date && availability.loading)} disabled={Boolean(date && availability.loading)}
/> />
)} ))}
{form === 'appointment' && (!selectedDoctor || selectedDateMessage) ? ( {form === 'appointment' && (!selectedDoctor || selectedDateMessage) ? (
appointmentMessage ? ( appointmentMessage ? (