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 ( +
+ Специалист +
+ + + {doctor.full_name} + {doctor.position && {doctor.position}} + +
+
+ ); +} + export default function DoctorPicker({ doctors, value, onChange, disabled = false, loading = false, date = '' }) { const uid = useId(); const wrapper = useRef(null); diff --git a/web/src/components/LeadModal.jsx b/web/src/components/LeadModal.jsx index a0f012e..dd670b2 100644 --- a/web/src/components/LeadModal.jsx +++ b/web/src/components/LeadModal.jsx @@ -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, )} - {form === 'appointment' && status.state !== 'done' && ( + {form === 'appointment' && status.state !== 'done' && (fixedDoctor ? ( + + ) : ( 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 ? (