feat(booking): searchable specialty-grouped doctor picker
This commit is contained in:
parent
64ee909b3e
commit
ec46d9af67
@ -6,6 +6,8 @@ import { createServer } from 'vite';
|
||||
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 });
|
||||
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();
|
||||
|
||||
95
web/src/components/DoctorPicker.css
Normal file
95
web/src/components/DoctorPicker.css
Normal 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; }
|
||||
}
|
||||
132
web/src/components/DoctorPicker.jsx
Normal file
132
web/src/components/DoctorPicker.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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' && (
|
||||
<label className="lead-field lead-field--doctor">
|
||||
<span className="lead-field__label">Специалист</span>
|
||||
<span className="lead-field__select">
|
||||
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')} disabled={Boolean(date && availability.loading)}>
|
||||
<option value="">{!doctorsLoaded || (date && availability.loading) ? 'Загружаем список врачей…' : 'Выберите врача'}</option>
|
||||
{doctor && !date && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||
<option value={doctor.id}>{doctor.full_name}</option>
|
||||
)}
|
||||
{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>
|
||||
<DoctorPicker
|
||||
doctors={doctor && !date && !doctorOptions.some((d) => 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 ? (
|
||||
|
||||
22
web/src/lib/doctorGroups.js
Normal file
22
web/src/lib/doctorGroups.js
Normal 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')) }));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user