feat(booking): searchable specialty-grouped doctor picker

This commit is contained in:
Dana Medical Developer 2026-10-22 10:59:52 +00:00
parent 64ee909b3e
commit ec46d9af67
5 changed files with 304 additions and 34 deletions

View File

@ -6,6 +6,8 @@ import { createServer } from 'vite';
const { window } = parseHTML('<html><body><div id="root"></div></body></html>'); const { window } = parseHTML('<html><body><div id="root"></div></body></html>');
Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
window.location = new URL('https://booking-test.invalid/'); 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 { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client'); const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils'); 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 vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx'); const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js'); const { api } = await vite.ssrLoadModule('/src/api.js');
const { groupDoctors } = await vite.ssrLoadModule('/src/lib/doctorGroups.js');
const doctors = [ const doctors = [
{ id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' } }, { id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] },
{ id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' } }, { id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' }, specialties: [{slug:'gyn',name_plural:'Гинекологи'}] },
{ id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' } }, { id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' }, specialties: [{slug:'kids',name_plural:'Детские врачи'}] },
]; ];
const pending = []; const pending = [];
api.doctors = async () => ({ data: doctors }); 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'); }; api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
const container = document.getElementById('root'); const container = document.getElementById('root');
const root = createRoot(container); 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 text = () => container.textContent;
const times = () => [...container.querySelectorAll('.slot-chip--time')].map((e) => e.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) { async function chooseDoctor(id) {
const select = container.querySelector('select'); await openPicker();
await act(async () => { if (id === '') { await click(container.querySelector('.doctor-picker__reset')); return; }
// linkedom exposes select.value as read-only; option.selected sets the value. const doctor = doctors.find((entry) => entry.id === id);
for (const option of select.options) option.selected = false; await act(async () => Simulate.input(container.querySelector('.doctor-picker__search input'), {target:{value:doctor.full_name}}));
[...select.options].find((option) => option.value === String(id)).selected = true; await click(container.querySelector(`[data-doctor-id="${id}"]`));
select.dispatchEvent(new window.Event('change', { bubbles: true }));
});
} }
const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' }); const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' });
async function reply(request, slots, enabled = true) { async function reply(request, slots, enabled = true) {
@ -46,12 +58,27 @@ async function reply(request, slots, enabled = true) {
} }
try { 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(); await render();
assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor'); assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor');
assert.ok(container.querySelector('input[type="date"]')); assert.ok(container.querySelector('input[type="date"]'));
assert.equal(container.querySelector('input[type="tel"]'), null); assert.equal(container.querySelector('input[type="tel"]'), null);
assert.deepEqual(times(), []); assert.deepEqual(times(), []);
assert.match(text(), /Выберите дату или врача/); 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); await chooseDoctor(1);
assert.equal(pending[0].slug, 'first'); assert.equal(pending[0].slug, 'first');
@ -94,17 +121,17 @@ try {
await act(async () => root.render(null)); await act(async () => root.render(null));
await render(); await render();
await act(async () => Simulate.input(container.querySelector('input[type="date"]'), { target: { value: '2070-10-24' } })); 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 chooseDoctor(2);
await reply(pending.at(-1), [free('09:00'), { ...free('14:15'), start: '2070-10-24T14:15:00+03:00' }]); 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'); 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' } })); await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-25' } }));
assert.deepEqual(times(), []); assert.deepEqual(times(), []);
assert.equal(container.querySelector('select').value, '', 'Changing date clears the selected doctor and window'); assert.equal(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, 'Выберите врача', 'Changing date clears the selected doctor and window');
assert.equal(container.querySelector('select').options.length, 1); assert.deepEqual(await availableDoctorIDs(), []);
assert.match(text(), /На эту дату свободных окон нет/); assert.match(text(), /На эту дату свободных окон нет/);
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } })); 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 act(async () => root.render(null));
await render({ form: 'callback' }); await render({ form: 'callback' });
@ -114,7 +141,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: 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 { } finally {
await act(async () => root.unmount()); await act(async () => root.unmount());
await vite.close(); await vite.close();

View File

@ -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; }
}

View File

@ -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 (
<span className="doctor-picker__avatar" aria-hidden="true">
<span>{initials}</span>
{doctor.photo && <img src={doctor.photo} alt="" width="44" height="44" loading="lazy" onError={(event) => { event.currentTarget.style.display = 'none'; }} />}
</span>
);
}
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 (
<div className="lead-field lead-field--doctor doctor-picker" ref={wrapper}>
<span className="lead-field__label" id={`${uid}-label`}>Специалист</span>
<button
ref={trigger}
type="button"
className={`doctor-picker__trigger${open ? ' doctor-picker__trigger--open' : ''}`}
aria-labelledby={`${uid}-label ${uid}-value`}
aria-expanded={open}
aria-controls={`${uid}-panel`}
disabled={disabled || loading}
onClick={() => {
setQuery('');
setExpanded('');
setOpen((previous) => !previous);
}}
>
{selected ? <Avatar doctor={selected} /> : <span className="doctor-picker__symbol" aria-hidden="true">✚</span>}
<span className="doctor-picker__selection">
<span id={`${uid}-value`} className="doctor-picker__name">{loading || disabled ? 'Загружаем список врачей…' : selected?.full_name || 'Выберите врача'}</span>
<span className="doctor-picker__role">{selected?.position || 'Поиск по имени и выбор по направлению'}</span>
</span>
<img className="doctor-picker__chevron" src={chevronIcon} alt="" width="16" height="16" />
</button>
{open && (
<div
id={`${uid}-panel`}
className="doctor-picker__panel"
role="region"
aria-label="Специалисты по направлениям"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
event.preventDefault();
setOpen(false);
trigger.current?.focus();
}
}}
>
<div className="doctor-picker__search">
<img src={searchIcon} alt="" width="18" height="18" />
<input ref={search} type="search" aria-label="Поиск врача или направления" placeholder="Фамилия, имя или направление" value={query} onInput={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="doctor-picker__summary">
<span>{date ? 'Свободны на выбранную дату' : 'Специалисты по направлениям'} <span className="doctor-picker__total">{count}</span></span>
{value && <button type="button" className="doctor-picker__reset" onClick={() => choose('')}>Сбросить выбор</button>}
</div>
<div className="doctor-picker__results">
{!groups.length && <p className="doctor-picker__empty">Врачи не найдены. Измените поиск или выберите другую дату.</p>}
{groups.map((group, index) => {
const shown = Boolean(query.trim()) || expanded === group.key;
const listId = `${uid}-group-${index}`;
return (
<section className="doctor-picker__group" key={group.key}>
<h3>
<button type="button" className="doctor-picker__direction" aria-expanded={shown} aria-controls={listId} disabled={Boolean(query.trim())} onClick={() => setExpanded(shown ? '' : group.key)}>
<span className="doctor-picker__direction-dot" aria-hidden="true" />
<span>{group.name}</span>
<span className="doctor-picker__count">{group.doctors.length}</span>
<img src={chevronIcon} alt="" width="14" height="14" />
</button>
</h3>
<div className="doctor-picker__doctors" id={listId} hidden={!shown}>
{group.doctors.map((doctor) => (
<button key={doctor.id} type="button" data-doctor-id={doctor.id} className={`doctor-picker__doctor${String(doctor.id) === String(value) ? ' doctor-picker__doctor--selected' : ''}`} aria-label={`Выбрать: ${doctor.full_name}`} aria-pressed={String(doctor.id) === String(value)} onClick={() => choose(doctor.id)}>
<Avatar doctor={doctor} />
<span className="doctor-picker__selection">
<span className="doctor-picker__name">{doctor.full_name}</span>
<span className="doctor-picker__role">{doctor.position || group.name}</span>
</span>
<span className="doctor-picker__pick" aria-hidden="true">{String(doctor.id) === String(value) ? '✓' : '→'}</span>
</button>
))}
</div>
</section>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -9,6 +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 { todayKey } from '../lib/slots.js'; import { todayKey } from '../lib/slots.js';
import './LeadModal.css'; import './LeadModal.css';
@ -133,7 +134,9 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
const onKey = (e) => { const onKey = (e) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
if (e.key === 'Tab' && dialogRef.current) { 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; if (!focusable.length) return;
const first = focusable[0]; const first = focusable[0];
const last = focusable[focusable.length - 1]; const last = focusable[focusable.length - 1];
@ -256,23 +259,14 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
)} )}
{form === 'appointment' && status.state !== 'done' && ( {form === 'appointment' && status.state !== 'done' && (
<label className="lead-field lead-field--doctor"> <DoctorPicker
<span className="lead-field__label">Специалист</span> doctors={doctor && !date && !doctorOptions.some((d) => d.id === doctor.id) ? [doctor, ...doctorOptions] : doctorOptions}
<span className="lead-field__select"> value={values.doctorId}
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')} disabled={Boolean(date && availability.loading)}> onChange={set('doctorId')}
<option value="">{!doctorsLoaded || (date && availability.loading) ? 'Загружаем список врачей…' : 'Выберите врача'}</option> date={date}
{doctor && !date && !doctorOptions.some((d) => d.id === doctor.id) && ( loading={!doctorsLoaded}
<option value={doctor.id}>{doctor.full_name}</option> disabled={Boolean(date && availability.loading)}
)} />
{doctorOptions.map((d) => (
<option key={d.id} value={d.id}>
{d.full_name}{d.position ? ` — ${d.position}` : ''}
</option>
))}
</select>
<img src={chevronIcon} alt="" width="16" height="16" />
</span>
</label>
)} )}
{form === 'appointment' && !selectedDoctor ? ( {form === 'appointment' && !selectedDoctor ? (

View File

@ -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')) }));
}