diff --git a/deploy/booking-doctor-lock.md b/deploy/booking-doctor-lock.md new file mode 100644 index 0000000..6d4c3b9 --- /dev/null +++ b/deploy/booking-doctor-lock.md @@ -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. diff --git a/web/scripts/test-booking-flow.mjs b/web/scripts/test-booking-flow.mjs index a09a645..76f0006 100644 --- a/web/scripts/test-booking-flow.mjs +++ b/web/scripts/test-booking-flow.mjs @@ -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(); diff --git a/web/src/components/DoctorPicker.css b/web/src/components/DoctorPicker.css index f059bcd..13e0135 100644 --- a/web/src/components/DoctorPicker.css +++ b/web/src/components/DoctorPicker.css @@ -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; } diff --git a/web/src/components/DoctorPicker.jsx b/web/src/components/DoctorPicker.jsx index f7357d6..8a4e231 100644 --- a/web/src/components/DoctorPicker.jsx +++ b/web/src/components/DoctorPicker.jsx @@ -14,6 +14,22 @@ function Avatar({ doctor }) { ); } +// Не кнопка и не disabled-селект: выбранный в каталоге врач не меняется в этой записи. +export function DoctorSummary({ doctor }) { + return ( +