From ec46d9af6703a92e63a7457308ed3f9f3261b977 Mon Sep 17 00:00:00 2001 From: Dana Medical Developer Date: Thu, 22 Oct 2026 10:59:52 +0000 Subject: [PATCH] feat(booking): searchable specialty-grouped doctor picker --- web/scripts/test-booking-flow.mjs | 59 +++++++++---- web/src/components/DoctorPicker.css | 95 ++++++++++++++++++++ web/src/components/DoctorPicker.jsx | 132 ++++++++++++++++++++++++++++ web/src/components/LeadModal.jsx | 30 +++---- web/src/lib/doctorGroups.js | 22 +++++ 5 files changed, 304 insertions(+), 34 deletions(-) create mode 100644 web/src/components/DoctorPicker.css create mode 100644 web/src/components/DoctorPicker.jsx create mode 100644 web/src/lib/doctorGroups.js diff --git a/web/scripts/test-booking-flow.mjs b/web/scripts/test-booking-flow.mjs index 4e103c5..556c514 100644 --- a/web/scripts/test-booking-flow.mjs +++ b/web/scripts/test-booking-flow.mjs @@ -6,6 +6,8 @@ import { createServer } from 'vite'; const { window } = parseHTML('
'); Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); window.location = new URL('https://booking-test.invalid/'); +// linkedom is a modern DOM but does not advertise input-event support to React's detector. +document.oninput = null; const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client'); const { Simulate } = await import('react-dom/test-utils'); @@ -13,10 +15,11 @@ 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 { groupDoctors } = await vite.ssrLoadModule('/src/lib/doctorGroups.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' } }, + { id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] }, + { id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' }, specialties: [{slug:'gyn',name_plural:'Гинекологи'}] }, + { id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] }, ]; const pending = []; api.doctors = async () => ({ data: doctors }); @@ -28,17 +31,26 @@ api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug 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() {} })))); +let closeCalls = 0; +const render = async (props = {}) => act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(LeadModal, { ...props, onClose() { closeCalls++; } })))); const text = () => container.textContent; const times = () => [...container.querySelectorAll('.slot-chip--time')].map((e) => e.textContent); +const click = async (element) => act(async () => element.dispatchEvent(new window.Event('click', { bubbles: true }))); +async function openPicker() { + if (container.querySelector('.doctor-picker__trigger').getAttribute('aria-expanded') !== 'true') await click(container.querySelector('.doctor-picker__trigger')); +} +async function availableDoctorIDs() { + await openPicker(); + const ids = [...new Set([...container.querySelectorAll('[data-doctor-id]')].map((element) => element.dataset.doctorId))].sort(); + await click(container.querySelector('.doctor-picker__trigger')); + return ids; +} 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 })); - }); + await openPicker(); + if (id === '') { await click(container.querySelector('.doctor-picker__reset')); return; } + const doctor = doctors.find((entry) => entry.id === id); + await act(async () => Simulate.input(container.querySelector('.doctor-picker__search input'), {target:{value:doctor.full_name}})); + await click(container.querySelector(`[data-doctor-id="${id}"]`)); } const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' }); async function reply(request, slots, enabled = true) { @@ -46,12 +58,27 @@ async function reply(request, slots, enabled = true) { } try { + assert.deepEqual(groupDoctors(doctors).map((group) => group.name), ['Гинекологи','Детские врачи']); + assert.equal(groupDoctors(doctors, 'детские')[0].doctors.length, 2, 'Search can find a whole specialty'); + assert.equal(groupDoctors(doctors, 'врач первый')[0].doctors[0].id, 1, 'Name tokens can be entered in any order'); + assert.equal(groupDoctors(doctors, 'несуществующий').length, 0); + assert.equal(groupDoctors([{...doctors[0],specialties:[...doctors[0].specialties,...doctors[0].specialties]}])[0].doctors.length, 1, 'Duplicate specialty entries cannot duplicate a doctor'); await render(); assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor'); assert.ok(container.querySelector('input[type="date"]')); assert.equal(container.querySelector('input[type="tel"]'), null); assert.deepEqual(times(), []); assert.match(text(), /Выберите дату или врача/); + assert.deepEqual(await availableDoctorIDs(), ['1','2','3']); + await openPicker(); + assert.equal(container.querySelectorAll('.doctor-picker__direction').length, 2); + await click(container.querySelector('.doctor-picker__direction')); + assert.equal(container.querySelector('.doctor-picker__direction').getAttribute('aria-expanded'), 'true'); + const escape = new window.Event('keydown', {bubbles:true}); + escape.key = 'Escape'; + await act(async () => container.querySelector('.doctor-picker__search input').dispatchEvent(escape)); + assert.equal(container.querySelector('.doctor-picker__panel'), null, 'Escape closes the picker'); + assert.equal(closeCalls, 0, 'Escape in the picker must not close the booking dialog'); await chooseDoctor(1); assert.equal(pending[0].slug, 'first'); @@ -94,17 +121,17 @@ try { await act(async () => root.render(null)); await render(); await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } })); - assert.deepEqual([...container.querySelector('select').options].map((option) => option.value), ['', '2'], 'Only doctors free on the selected date appear'); + assert.deepEqual(await availableDoctorIDs(), ['2'], 'Only doctors free on the selected date appear'); await chooseDoctor(2); await reply(pending.at(-1), [free('09:00'), { ...free('14:15'), start: '2070-10-24T14:15:00+03:00' }]); assert.deepEqual(times(), ['14:15'], 'Selected date restricts the actual time list too'); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } })); assert.deepEqual(times(), []); - assert.equal(container.querySelector('select').value, '', 'Changing date clears the selected doctor and window'); - assert.equal(container.querySelector('select').options.length, 1); + assert.equal(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, 'Выберите врача', 'Changing date clears the selected doctor and window'); + assert.deepEqual(await availableDoctorIDs(), []); assert.match(text(), /На эту дату свободных окон нет/); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } })); - assert.equal(container.querySelector('select').options.length, 4, '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 render({ form: 'callback' }); @@ -114,7 +141,7 @@ try { await render({ form: 'review' }); assert.equal(container.querySelectorAll('.lead-rating__star').length, 5); assert.ok(container.querySelector('select')); - console.log('PASS: date-to-doctor filtering, actual times on selected date, reset/empty dates, doctor-first flow, 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, fallback, 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 new file mode 100644 index 0000000..f37d652 --- /dev/null +++ b/web/src/components/DoctorPicker.css @@ -0,0 +1,95 @@ +.doctor-picker__trigger { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 68px; + padding: 11px 15px; + border: 1px solid var(--chip-border); + border-radius: 14px; + background: #fff; + color: var(--navy); + text-align: left; + cursor: pointer; + transition: border-color .15s, box-shadow .15s; +} +.doctor-picker__trigger:hover:not(:disabled), .doctor-picker__trigger--open { + border-color: var(--coral); + box-shadow: 0 0 0 3px rgba(249, 94, 82, .08); +} +.doctor-picker__trigger:disabled { opacity: .65; cursor: wait; } +.doctor-picker button:focus-visible, .doctor-picker input:focus-visible { + outline: 2px solid var(--coral); + outline-offset: 3px; +} +.doctor-picker__avatar, .doctor-picker__symbol { + position: relative; + display: grid; + place-items: center; + flex: 0 0 44px; + width: 44px; + height: 44px; + overflow: hidden; + border-radius: 12px; + background: #edf2f7; + color: var(--navy); + font-size: 14px; + font-weight: 600; +} +.doctor-picker__avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 24%; } +.doctor-picker__symbol { background: #fff0ed; color: var(--coral); font-size: 25px; } +.doctor-picker__selection { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; } +.doctor-picker__name { color: var(--navy); font-size: 14px; font-weight: 600; line-height: 1.45; } +.doctor-picker__role { color: #788695; font-size: 11px; font-weight: 400; line-height: 1.5; } +.doctor-picker__chevron { flex-shrink: 0; transition: transform .15s; } +.doctor-picker__trigger--open .doctor-picker__chevron { transform: rotate(180deg); } +.doctor-picker__panel { + padding: 14px; + border: 1px solid #dce5ed; + border-radius: 18px; + background: #fff; + box-shadow: 0 10px 28px rgba(44, 73, 92, .08); +} +.doctor-picker__search { display: flex; align-items: center; gap: 10px; padding: 0 13px; border: 1px solid #e1e8ee; border-radius: 12px; background: #f6f8fb; } +.doctor-picker__search input { width: 100%; min-width: 0; height: 43px; border: 0; background: transparent; color: var(--navy); font: inherit; font-size: 13px; outline: none; } +.doctor-picker__search input::placeholder { color: #81909d; } +.doctor-picker__summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 2px 10px; color: #7a8792; font-size: 11px; } +.doctor-picker__total { display: inline-block; margin-left: 4px; color: var(--navy); font-weight: 600; } +.doctor-picker__reset { padding: 2px 0; color: var(--coral); font: inherit; cursor: pointer; } +.doctor-picker__results { max-height: min(360px, 42vh); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #cdd9e3 transparent; } +.doctor-picker__group + .doctor-picker__group { margin-top: 6px; } +.doctor-picker__group h3 { margin: 0; } +.doctor-picker__direction { display: flex; align-items: center; gap: 9px; width: 100%; padding: 12px; border-radius: 10px; background: #f2f6f9; text-align: left; color: var(--navy); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } +.doctor-picker__direction:hover:not(:disabled) { background: #eaf0f6; } +.doctor-picker__direction:disabled { cursor: default; opacity: 1; } +.doctor-picker__direction-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 50%; background: var(--coral); } +.doctor-picker__count { display: grid; place-items: center; min-width: 24px; height: 24px; margin-left: auto; padding: 0 6px; border-radius: 8px; background: #fff; font-size: 11px; } +.doctor-picker__direction img { flex-shrink: 0; } +.doctor-picker__direction[aria-expanded='true'] img { transform: rotate(180deg); } +.doctor-picker__doctors { padding: 5px 0 3px; } +.doctor-picker__doctors[hidden] { display: none; } +.doctor-picker__doctor { display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px; border: 1px solid transparent; border-radius: 12px; background: #fff; text-align: left; cursor: pointer; transition: background .15s, border-color .15s; } +.doctor-picker__doctor + .doctor-picker__doctor { margin-top: 3px; } +.doctor-picker__doctor:hover, .doctor-picker__doctor--selected { border-color: #f8d8d2; background: #fff6f3; } +.doctor-picker__pick { flex-shrink: 0; color: var(--coral); font-size: 19px; } +.doctor-picker__empty { padding: 20px 10px; color: #788695; font-size: 13px; line-height: 1.6; text-align: center; } +/* Keep the picker within the viewport; only its results scroll, not the entire form. */ +.lead-modal__backdrop:has(.doctor-picker__panel) { padding-top: 24px; padding-bottom: 24px; } +.lead-modal:has(.doctor-picker__panel) { height: min(820px, calc(100dvh - 48px)); max-height: calc(100dvh - 48px); overflow: hidden; } +.lead-modal:has(.doctor-picker__panel) > :not(.doctor-picker) { flex-shrink: 0; } +.doctor-picker:has(.doctor-picker__panel) { flex: 1; min-height: 0; } +.doctor-picker__trigger { flex-shrink: 0; } +.doctor-picker__panel { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } +.doctor-picker__search, .doctor-picker__summary { flex-shrink: 0; } +.doctor-picker__results { flex: 1; min-height: 0; max-height: none; } +.doctor-picker:has(.doctor-picker__panel) ~ * { display: none; } +@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__panel { padding: 10px; border-radius: 14px; } + .doctor-picker__name { font-size: 12px; } + .doctor-picker__role { font-size: 10px; } + .doctor-picker__direction { padding: 11px 9px; font-size: 12px; } + .doctor-picker__doctor { padding: 9px 5px; gap: 9px; } +} diff --git a/web/src/components/DoctorPicker.jsx b/web/src/components/DoctorPicker.jsx new file mode 100644 index 0000000..ff71685 --- /dev/null +++ b/web/src/components/DoctorPicker.jsx @@ -0,0 +1,132 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import chevronIcon from '../assets/figma/icon-accordion-chevron.svg'; +import searchIcon from '../assets/figma/icon-search-18.svg'; +import { groupDoctors } from '../lib/doctorGroups.js'; +import './DoctorPicker.css'; + +function Avatar({ doctor }) { + const initials = doctor.full_name.split(/\s+/).slice(0, 2).map((part) => part[0]).join(''); + return ( + + ); +} + +export default function DoctorPicker({ doctors, value, onChange, disabled = false, loading = false, date = '' }) { + const uid = useId(); + const wrapper = useRef(null); + const trigger = useRef(null); + const search = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [expanded, setExpanded] = useState(''); + const selected = doctors.find((doctor) => String(doctor.id) === String(value)); + const groups = useMemo(() => groupDoctors(doctors, query), [doctors, query]); + const count = new Set(groups.flatMap((group) => group.doctors.map((doctor) => doctor.id))).size; + + useEffect(() => { + if (open) search.current?.focus(); + }, [open]); + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + useEffect(() => { + if (!open) return undefined; + const outside = (event) => { if (!wrapper.current?.contains(event.target)) setOpen(false); }; + document.addEventListener('mousedown', outside); + return () => document.removeEventListener('mousedown', outside); + }, [open]); + + const choose = (id) => { + onChange(String(id)); + setOpen(false); + setQuery(''); + trigger.current?.focus(); + }; + + return ( +
+ Специалист + + + {open && ( +
{ + if (event.key === 'Escape') { + event.stopPropagation(); + event.preventDefault(); + setOpen(false); + trigger.current?.focus(); + } + }} + > +
+ + setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)} /> +
+
+ {date ? 'Свободны на выбранную дату' : 'Специалисты по направлениям'} {count} + {value && } +
+
+ {!groups.length &&

Врачи не найдены. Измените поиск или выберите другую дату.

} + {groups.map((group, index) => { + const shown = Boolean(query.trim()) || expanded === group.key; + const listId = `${uid}-group-${index}`; + return ( +
+

+ +

+ +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/web/src/components/LeadModal.jsx b/web/src/components/LeadModal.jsx index 51612e6..70bde12 100644 --- a/web/src/components/LeadModal.jsx +++ b/web/src/components/LeadModal.jsx @@ -9,6 +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 { todayKey } from '../lib/slots.js'; import './LeadModal.css'; @@ -133,7 +134,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, const onKey = (e) => { if (e.key === 'Escape') onClose(); if (e.key === 'Tab' && dialogRef.current) { - const focusable = dialogRef.current.querySelectorAll('button, [href], input, select, textarea'); + const focusable = [...dialogRef.current.querySelectorAll('button, [href], input, select, textarea')] + .filter((element) => !element.disabled && !element.closest('[hidden]') + && (typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; @@ -256,23 +259,14 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, )} {form === 'appointment' && status.state !== 'done' && ( - + d.id === doctor.id) ? [doctor, ...doctorOptions] : doctorOptions} + value={values.doctorId} + onChange={set('doctorId')} + date={date} + loading={!doctorsLoaded} + disabled={Boolean(date && availability.loading)} + /> )} {form === 'appointment' && !selectedDoctor ? ( diff --git a/web/src/lib/doctorGroups.js b/web/src/lib/doctorGroups.js new file mode 100644 index 0000000..41f6767 --- /dev/null +++ b/web/src/lib/doctorGroups.js @@ -0,0 +1,22 @@ +const normalize = (value) => String(value || '').toLocaleLowerCase('ru').replaceAll('ё', 'е').trim(); + +// Group by the catalog's real specialties, not by guesses from a doctor's name or role. +export function groupDoctors(doctors, query = '') { + const terms = normalize(query).split(/\s+/).filter(Boolean); + const groups = new Map(); + for (const doctor of doctors) { + const specialties = doctor.specialties?.length ? doctor.specialties : [{ slug: 'other', name: 'Другие специалисты' }]; + for (const specialty of specialties) { + const name = specialty.name_plural || specialty.name || 'Другие специалисты'; + const key = String(specialty.slug || specialty.id || name); + const searchable = normalize(`${doctor.full_name} ${doctor.position || ''} ${name}`); + if (!terms.every((term) => searchable.includes(term))) continue; + if (!groups.has(key)) groups.set(key, { key, name, doctors: [] }); + const group = groups.get(key); + if (!group.doctors.some((entry) => entry.id === doctor.id)) group.doctors.push(doctor); + } + } + return [...groups.values()] + .sort((a, b) => (a.key === 'other') - (b.key === 'other') || a.name.localeCompare(b.name, 'ru')) + .map((group) => ({ ...group, doctors: group.doctors.sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')) })); +}