fix(booking): keep doctor-card appointments bound to chosen doctor
This commit is contained in:
parent
c7be67f96c
commit
da0fb50ad0
11
deploy/booking-doctor-lock.md
Normal file
11
deploy/booking-doctor-lock.md
Normal 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.
|
||||
@ -22,7 +22,8 @@ const doctors = [
|
||||
{ id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] },
|
||||
];
|
||||
const pending = [];
|
||||
api.doctors = async () => ({ data: doctors });
|
||||
let doctorCatalogRequests = 0;
|
||||
api.doctors = async () => { doctorCatalogRequests++; return { data: doctors }; };
|
||||
api.bookingAvailability = async () => ({ data: { enabled: true, days: [
|
||||
{ date: '2070-10-23', doctor_ids: [1, 2] },
|
||||
{ date: '2070-10-24', doctor_ids: [2] },
|
||||
@ -116,7 +117,11 @@ try {
|
||||
assert.deepEqual(times(), []);
|
||||
|
||||
await act(async () => root.render(null));
|
||||
const beforeCardCatalogRequests = doctorCatalogRequests;
|
||||
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')]);
|
||||
assert.deepEqual(times(), ['16:15']);
|
||||
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');
|
||||
|
||||
await act(async () => root.render(null));
|
||||
const beforeLockedCatalogRequests = doctorCatalogRequests;
|
||||
await render({ doctor: doctors[0] });
|
||||
await reply(pending.at(-1), [free('10:30')]);
|
||||
await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } }));
|
||||
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.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;
|
||||
for (const fail of [false, true]) {
|
||||
@ -174,7 +215,7 @@ try {
|
||||
await render({ form: 'review' });
|
||||
assert.equal(container.querySelectorAll('.lead-rating__star').length, 5);
|
||||
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 {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.doctor-picker__trigger {
|
||||
.doctor-picker__trigger, .doctor-picker__fixed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@ -10,6 +10,8 @@
|
||||
background: #fff;
|
||||
color: var(--navy);
|
||||
text-align: left;
|
||||
}
|
||||
.doctor-picker__trigger {
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
@ -88,7 +90,7 @@
|
||||
@media (max-width: 600px) {
|
||||
.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); }
|
||||
.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__name { font-size: 12px; }
|
||||
.doctor-picker__role { font-size: 10px; }
|
||||
|
||||
@ -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 = '' }) {
|
||||
const uid = useId();
|
||||
const wrapper = useRef(null);
|
||||
|
||||
@ -9,7 +9,7 @@ import { api } from '../api.js';
|
||||
import { useSiteData } from '../context/SiteData.jsx';
|
||||
import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
|
||||
import NativeBooking from './NativeBooking.jsx';
|
||||
import DoctorPicker from './DoctorPicker.jsx';
|
||||
import DoctorPicker, { DoctorSummary } from './DoctorPicker.jsx';
|
||||
import { todayKey } from '../lib/slots.js';
|
||||
import './LeadModal.css';
|
||||
|
||||
@ -44,6 +44,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
const firstFieldRef = useRef(null);
|
||||
|
||||
const needsDoctor = form === 'appointment' || form === 'review';
|
||||
// Карточка врача открывает запись только к нему; общая форма сохраняет выбор.
|
||||
const fixedDoctor = form === 'appointment' && Boolean(doctor?.id);
|
||||
// запись на конкретную услугу: в списке только её врачи и врачи её специальности
|
||||
const scoped = Boolean(service && !doctor && form === 'appointment');
|
||||
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 chosenDoctor = doctors.find((d) => String(d.id) === values.doctorId)
|
||||
|| (doctor && String(doctor.id) === values.doctorId ? doctor : null);
|
||||
const selectedDoctor = chosenDoctor;
|
||||
const selectedDoctor = fixedDoctor ? doctor : chosenDoctor;
|
||||
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
|
||||
&& unavailableDoctorId !== selectedDoctor.id;
|
||||
const onNativeUnavailable = useCallback(
|
||||
@ -74,6 +76,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
const set = (key) => (e) => {
|
||||
const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e;
|
||||
if (key === 'doctorId') {
|
||||
if (fixedDoctor) return;
|
||||
setUnavailableDoctorId(null);
|
||||
setRequestedSlot(undefined);
|
||||
setStatus({ state: 'idle', message: '' });
|
||||
@ -107,6 +110,11 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsDoctor) return undefined;
|
||||
if (fixedDoctor) {
|
||||
setDoctors([doctor]);
|
||||
setDoctorsLoaded(true);
|
||||
return undefined;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const own = scoped ? (service.doctors || []).filter((d) => !d.absent) : [];
|
||||
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); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); });
|
||||
return () => ctrl.abort();
|
||||
}, [needsDoctor, scoped, service, specialty]);
|
||||
}, [needsDoctor, fixedDoctor, doctor, scoped, service, specialty]);
|
||||
|
||||
// Escape, фокус внутри окна, блокировка прокрутки страницы под ним
|
||||
useEffect(() => {
|
||||
@ -182,7 +190,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
? availability.loading ? 'Проверяем свободные окна выбранного врача…'
|
||||
: availability.error ? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
|
||||
: !availability.days.find((day) => day.date === date)?.doctor_ids.includes(selectedDoctor.id)
|
||||
? 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату или другого специалиста.' : ''
|
||||
? fixedDoctor
|
||||
? 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату.'
|
||||
: 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату или другого специалиста.' : ''
|
||||
: '';
|
||||
const appointmentMessage = selectedDoctor ? selectedDateMessage : availabilityMessage;
|
||||
|
||||
@ -224,7 +234,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
form: form === 'checkup' ? 'appointment' : form,
|
||||
name: values.name.trim(),
|
||||
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,
|
||||
message: values.message.trim(),
|
||||
extra,
|
||||
@ -280,7 +290,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form === 'appointment' && status.state !== 'done' && (
|
||||
{form === 'appointment' && status.state !== 'done' && (fixedDoctor ? (
|
||||
<DoctorSummary doctor={selectedDoctor} />
|
||||
) : (
|
||||
<DoctorPicker
|
||||
doctors={selectedDoctor && !doctorOptions.some((d) => d.id === selectedDoctor.id) ? [selectedDoctor, ...doctorOptions] : doctorOptions}
|
||||
value={values.doctorId}
|
||||
@ -289,7 +301,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
loading={!doctorsLoaded}
|
||||
disabled={Boolean(date && availability.loading)}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
|
||||
{form === 'appointment' && (!selectedDoctor || selectedDateMessage) ? (
|
||||
appointmentMessage ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user