Compare commits
No commits in common. "b26919b73e670063bb5bc610eecd8386d6d37b7c" and "870f929cb283bc5e07d66fbe711a8ab4cb2a3dd3" have entirely different histories.
b26919b73e
...
870f929cb2
@ -1,30 +0,0 @@
|
||||
-- Publish only after the UI overlay has passed its health check.
|
||||
-- Existing prices, specialties and doctor cards are not modified.
|
||||
BEGIN;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM services WHERE path='stomatologiya') THEN
|
||||
RAISE EXCEPTION 'Stomatology service already exists; inspect it before applying';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM doctors WHERE id=20 AND last_name='Шугаев' AND position='Детский стоматолог' AND published AND deleted_at IS NULL) THEN
|
||||
RAISE EXCEPTION 'Expected published pediatric dentist not found';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM price_sections WHERE slug='stomatologiya' AND published) THEN
|
||||
RAISE EXCEPTION 'Existing dentistry price section is missing';
|
||||
END IF;
|
||||
END $$;
|
||||
WITH direction AS (
|
||||
INSERT INTO services (slug,path,name,short_name,lead,blocks,sort,published,show_in_menu,seo_title,seo_description,seo_h1)
|
||||
VALUES (
|
||||
'stomatologiya','stomatologiya','Стоматология','Стоматология',
|
||||
'Стоматологические услуги и цены клиники. Запишитесь к детскому стоматологу или оставьте заявку — администратор уточнит детали обращения.',
|
||||
jsonb_build_array(jsonb_build_object('type','html','html',
|
||||
'<h2>Стоматологические услуги</h2><p>В прайсе клиники представлены консультации стоматолога, лечение зубов, профессиональная гигиена и другие стоматологические услуги для детей и взрослых. Актуальную стоимость смотрите в разделе «Стоматология».</p><h2>Что есть в прайсе</h2><ul><li>Консультации стоматолога</li><li>Лечение кариеса и пульпита</li><li>Профессиональная гигиена полости рта</li><li>Удаление зубов</li><li>Реставрация, протезирование и отбеливание</li><li>Рентген-диагностика в стоматологии</li></ul><h2>Запись к специалисту</h2><p>В каталоге сайта представлен детский стоматолог Шугаев Эльдар Эдманович. Его карточка и запись доступны ниже.</p><p>Для записи взрослого пациента или уточнения конкретной процедуры оставьте заявку администратору в форме внизу страницы. Администратор уточнит возможность приёма, специалиста и стоимость.</p>')),
|
||||
520,false,true,
|
||||
'Стоматология в Махачкале — услуги и цены | DANA Medical',
|
||||
'Стоматологические услуги DANA Medical: актуальный прайс, детский стоматолог и запись на приём в Махачкале.',
|
||||
'Стоматология'
|
||||
) RETURNING id
|
||||
)
|
||||
INSERT INTO doctor_services (doctor_id,service_id) SELECT 20,id FROM direction;
|
||||
COMMIT;
|
||||
@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@ -1,46 +0,0 @@
|
||||
# Услуги в публичной записи: временная защита от неверного справочника
|
||||
|
||||
Проверка 23.10.2026: в `onec_services` у 175 врачей разных направлений сохранён
|
||||
один и тот же набор из 18 идентификаторов/названий (3150 строк). В нём смешаны
|
||||
анализы, рентген, хирургические и стоматологические процедуры. Это не
|
||||
подтверждённая связь «врач → выполняемая услуга». Точное место, где формируется
|
||||
неверный список в действующем отправителе 1С, этой проверкой не установлено.
|
||||
|
||||
До сверки с 1С публичный `GET /api/v1/doctors/{slug}/slots` возвращает `services: []`,
|
||||
форма не предлагает услуги и не отправляет `service_id`. Непустой `service_id`
|
||||
из старой вкладки отклоняется до создания записи с кодом `service_unavailable`.
|
||||
Выбор врача, реального свободного окна, филиала и очередь записи не меняются.
|
||||
Импортированный справочник и прошлые записи не удаляются и не переписываются.
|
||||
|
||||
Для возвращения выбора услуг нужны проверенные привязки к конкретному врачу
|
||||
из 1С, проверка этих привязок перед созданием записи и регрессионные примеры
|
||||
для стоматолога, ЛОРа и хирурга. Нельзя заменять их общим прайсом клиники или
|
||||
угадывать исполнителя по ключевым словам. Это ограничение снимается отдельным
|
||||
проверенным изменением; один новый снимок 1С сам по себе его не отключает.
|
||||
|
||||
## Проверка
|
||||
|
||||
- `node web/scripts/test-booking-service-safety.mjs` запускать из `web/`,
|
||||
используя путь `scripts/test-booking-service-safety.mjs`.
|
||||
- `go test ./internal/api -run TestAppointmentRejectsUnverifiedService` —
|
||||
отказ до обращения к БД для непроверенной услуги.
|
||||
- `TestDoctorServicesQuarantine` — отдельная временная БД через
|
||||
`DANA_AUTONOMY_TEST_DSN`. Проверяет пустой публичный список, сохранность
|
||||
сырого импорта и создание pending-записи без `service_id`. Не запускать с
|
||||
DSN продакшна.
|
||||
- На сайте выбрать Шугаева и свободное время: в форме контактов нет списка
|
||||
услуг; не создавать проверочную запись на продакшне.
|
||||
|
||||
## Откат
|
||||
|
||||
Предыдущий образ: `dana-medical-api:before-booking-service-safety-20261023`.
|
||||
Из каталога `/home/mikail/dana-medical`:
|
||||
|
||||
```sh
|
||||
docker tag dana-medical-api:before-booking-service-safety-20261023 dana-medical-api:latest
|
||||
docker compose -p dana-medical -f docker-compose.prod.yml up -d --no-deps api
|
||||
```
|
||||
|
||||
Проверить `/api/v1/health`. Такой откат возвращает и неверный список услуг;
|
||||
использовать только при регрессии и затем согласовать исправление. БД и
|
||||
медиа не откатывать, контейнер bridge не запускать.
|
||||
@ -1,596 +0,0 @@
|
||||
-- Approved scope: operations and the unoffered cosmetology section only.
|
||||
-- No price amounts, catalog entries, appointments or patient data are deleted.
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout='5s';
|
||||
LOCK TABLE price_items IN SHARE ROW EXCLUSIVE MODE;
|
||||
CREATE TEMP TABLE price_publication_targets(id bigint PRIMARY KEY, fingerprint text, reason text) ON COMMIT DROP;
|
||||
INSERT INTO price_publication_targets VALUES
|
||||
(10433,'c2e7b12628cc858144e407bbd91c3afb','surgical_price_detail'),
|
||||
(10434,'c8b6d263eaf1cc6fa8a6162a42d31552','surgical_price_detail'),
|
||||
(10435,'1371ec7024d8da5e0a47b460d91900e3','operation_price'),
|
||||
(10436,'7b440227f6f89b86db6ede4cda9f0587','operation_price'),
|
||||
(10437,'26dfad19f905ea667b6570acdb949dd1','operation_price'),
|
||||
(10438,'33aa70c469152aa7ef80eecc354d9d30','operation_price'),
|
||||
(10439,'9f1b4f0ad2db943fd8a064d0b277e69b','operation_price'),
|
||||
(10440,'6e5aacfcc2311df141ebc78421d210be','operation_price'),
|
||||
(10441,'0fa740bcbb584b420899cf47bae2ec65','operation_price'),
|
||||
(10442,'2da7ce2f8a3d86079d3b6209b1e90834','operation_price'),
|
||||
(10443,'1bc116d8508bade2940e382a31f01beb','surgical_intervention_price'),
|
||||
(10444,'d34950d14d97506d9e5e530a7ff9d4af','surgical_intervention_price'),
|
||||
(10445,'edbf54ebe7623665ee47bc6517c98d51','surgical_intervention_price'),
|
||||
(10450,'ae407d3d96928d8fc79fdcfa45291412','operation_price'),
|
||||
(10451,'4800d31388dad5af6b15e2a81626a6ca','operation_price'),
|
||||
(10452,'633f47a3a5588895618d48b91f12371a','surgical_intervention_price'),
|
||||
(10453,'33151408284050b64af93e9b66922b9b','surgical_intervention_price'),
|
||||
(10454,'02f80619133da44426abf3722fd18b32','surgical_intervention_price'),
|
||||
(10455,'b1c59680ec57d002767012225e38dba6','operation_price'),
|
||||
(10456,'fd89aa6fd7f7a3492d074faaf6f3922f','surgical_intervention_price'),
|
||||
(10457,'6958abca3e0480977ebac5aa208d2e07','surgical_intervention_price'),
|
||||
(10458,'6599e2dc5d9b295eb9de05e2cf4b3693','surgical_intervention_price'),
|
||||
(10459,'cdfa3f5ff6de52a054e773d715f84e5e','surgical_intervention_price'),
|
||||
(10460,'9ff9f94b82da113156c8ba2e5849dc6f','surgical_intervention_price'),
|
||||
(10461,'d91e28eaf3db98a91d2985b3b5f552c2','surgical_intervention_price'),
|
||||
(10462,'8ee649f343ee5c8ea251d251472e0bdf','operation_price'),
|
||||
(10463,'c8f376454796ae3db9e57312e0e818f2','operation_price'),
|
||||
(10464,'785e4aa911bc1b67d0a09d7919720e36','surgical_intervention_price'),
|
||||
(10465,'bca99056fd0929fa35e598d1042120c5','surgical_intervention_price'),
|
||||
(10466,'e167345749412e10b007db8aee246e11','surgical_intervention_price'),
|
||||
(10467,'2f2fed8d1530e81f73b8f289ef092a3f','surgical_intervention_price'),
|
||||
(10468,'3b5c340ed8b352af0308f4611f794064','operation_price'),
|
||||
(10469,'06f2d9b8a2bbc2066cde59d12672f74f','operation_price'),
|
||||
(10470,'fcec51ae7c93abe6eea7e6411c45b098','operative_correction_price'),
|
||||
(10473,'9bf2269f814d39bfee65387e45a7525d','surgical_intervention_price'),
|
||||
(10474,'849e4f06abb2ddbe87568b1164f754a4','operation_price'),
|
||||
(10475,'b268e903dad66595502bce851e1128dc','surgical_intervention_price'),
|
||||
(10476,'1b32b8a2b40b59c239a639eb7f8bd9bc','surgical_intervention_price'),
|
||||
(10477,'d2ea6e7a1d944aba70611cdda4019420','surgical_intervention_price'),
|
||||
(10478,'bbfdbdb55c8351e7ad1a91f32666b1ec','surgical_intervention_price'),
|
||||
(10479,'bcc82c8eaeefb2d01662583081d77452','surgical_intervention_price'),
|
||||
(10480,'cb17152e02ae54ff49e306ed6f11c0dd','surgical_intervention_price'),
|
||||
(10481,'ca580f9e73702dac6e08427b454df221','surgical_intervention_price'),
|
||||
(10504,'2c2a484aef4468a928cfccd5474377d7','surgical_price_detail'),
|
||||
(10505,'d10a0007bd6c774a4c70e6538a24534b','surgical_price_detail'),
|
||||
(10506,'345d7f32e49113261b419c0932185f81','surgical_price_detail'),
|
||||
(10507,'fb07367c9aad7ffd95e2e6bd4f251d81','surgical_price_detail'),
|
||||
(10518,'b7a7df6aa69ad8d6fc9ac253e723a6d7','operative_correction_price'),
|
||||
(10633,'836ed07a5a593d482570d05969bae94f','operation_price'),
|
||||
(10634,'c01c413db199addad00a2cef05fe3634','operation_price'),
|
||||
(10635,'e997e40d53512fb94c7a9dd8900b6042','operation_price'),
|
||||
(10636,'a81137b1544df44a06449a96799e49ac','operation_price'),
|
||||
(10637,'666dff37728415c1e98c1a73a993ccb1','operation_price'),
|
||||
(10638,'ee37bc7a08a550af52158c7551a7d3a1','surgical_intervention_price'),
|
||||
(10640,'2cab08ae0dcec4d27c9def7809bfc687','surgical_intervention_price'),
|
||||
(10643,'5b98a1fbe08395b89612d601ea16389b','cosmetic_procedure_price'),
|
||||
(10644,'3bde4f12b0c4bb68002da7a0e2de2bf9','gynecological_surgery_price'),
|
||||
(10645,'42f6d7d791bbc7ed670b0b39593b1833','gynecological_surgery_price'),
|
||||
(10646,'4bb71aac6f0f3c449df685d0b1faac26','gynecological_surgery_price'),
|
||||
(10647,'60a79a7cb5eb3d02c693647352ee22d2','gynecological_surgery_price'),
|
||||
(10656,'edbcbae5deca319942029a1bd90256e8','surgical_intervention_price'),
|
||||
(10657,'ce20a163fb2a927d83c263c33388b8f8','surgical_intervention_price'),
|
||||
(10658,'7369f6988cf8e04d11af4657b3831d80','operation_price'),
|
||||
(10659,'40349ccf8f75cf57dae5a80df931457b','operation_price'),
|
||||
(10660,'9f36223ae4643d738979feb063ab1ad5','operation_price'),
|
||||
(10661,'de514786adc4ab687c05022a4cbca253','operation_price'),
|
||||
(10662,'36c891413b6870f471ffadea0289ce22','operation_price'),
|
||||
(10663,'b36cdf9ce778925f9d5568e39acfb644','gynecological_surgery_price'),
|
||||
(10664,'857ab729c53af65b907ddb7ba416f3bf','gynecological_surgery_price'),
|
||||
(10665,'e9d57dacfb82fd2b93637d92f305cd60','gynecological_surgery_price'),
|
||||
(10667,'3afc2d1977a85668836ca4d37549a107','operation_price'),
|
||||
(10668,'00585909e16ce60da5b119f2e75ca1f6','operation_price'),
|
||||
(10669,'1462de0779537d50cb04f566693d194f','operation_price'),
|
||||
(10670,'db8d4a0aed78adf87cf85db933f9ecd4','operation_price'),
|
||||
(10671,'51ad6808708d7af2b1acd9b00e1ff6bd','operation_price'),
|
||||
(10674,'33bd5a88d9a5bb14ce77fa2e76fc841a','cosmetic_procedure_price'),
|
||||
(10675,'f7a3ab5a14d171070ba9716aa69ee739','cosmetic_procedure_price'),
|
||||
(10676,'04f2094d2ad4b2cb191e75193861a694','cosmetic_procedure_price'),
|
||||
(10677,'005a134e9581a367148ef80e17c3770f','cosmetic_procedure_price'),
|
||||
(10678,'59451b367eec8ad89ba23a48a633a343','cosmetic_procedure_price'),
|
||||
(10679,'ff7ad589f623f2c74f78ee9c0a1aa1ad','cosmetic_procedure_price'),
|
||||
(10681,'52467c91c7d3108d785ebabc0a970c39','operation_price'),
|
||||
(10682,'496f4df6f514d08d853b3d35e4e938d3','operation_price'),
|
||||
(10683,'48ed7e9bd70ac076a9e71e2f615cc4d6','operation_price'),
|
||||
(10684,'4b61604e1a5a1277fc94c92d783ddd6a','operation_price'),
|
||||
(10685,'95072e33be368b6fad82eade473f7266','operation_price'),
|
||||
(10686,'3f2fafd73588f2055f0e84609f37424b','operation_price'),
|
||||
(10687,'f1e064fa78b0154d1fc756ae3e6bce67','surgical_intervention_price'),
|
||||
(10688,'bfa7d0815be38f61d02ebb9ea7719d43','operation_price'),
|
||||
(10689,'522d25d10f1e95f525a5fe454e693023','operation_price'),
|
||||
(10690,'88bf63ecae8a4f32a8fe12b5c9e89134','operation_price'),
|
||||
(10691,'66eef6639b04f0aee82352931d0379b5','operation_price'),
|
||||
(10692,'9c08ff73d647f745f4a7ea9e0ec92be5','operation_price'),
|
||||
(10693,'76a3961811a133dbb97e728736ec5e38','gynecological_surgery_price'),
|
||||
(10694,'c5c4e24f9d069c6acbd5b1bc748f8547','gynecological_surgery_price'),
|
||||
(10695,'e51b9f1fe4eca546af58561755cd6fc9','gynecological_surgery_price'),
|
||||
(10696,'9e65e441441b029f1c0918675aede880','gynecological_surgery_price'),
|
||||
(10697,'0a5e0adb2598c38fcea4d5b2195902e7','gynecological_surgery_price'),
|
||||
(10698,'1ed2fca0910dd8e0aa15784c455b38e1','gynecological_surgery_price'),
|
||||
(10699,'be7db087cdf5438a4cd287f67e8a2db8','operation_price'),
|
||||
(10700,'52639e846a272e73c1ee06dec34e9c94','operation_price'),
|
||||
(10701,'94761d538d481825c05d74cb2fb7594e','operation_price'),
|
||||
(10702,'a73168672746f669333d46f62adbc2db','operation_price'),
|
||||
(10703,'8153ed4a4f71216f5bf0efee9d37cd10','operation_price'),
|
||||
(10704,'6de173552024b172f9a29aa24a9f4c9c','operation_price'),
|
||||
(10705,'d0492d4c20d1200feabf2fe18f7f5c68','operation_price'),
|
||||
(10706,'5e4d035b2075fcb330eceda35e9b6bbd','operation_price'),
|
||||
(10707,'4e2a5e096d5139574121b80ea8a23556','operation_price'),
|
||||
(10708,'62275749f0d6cd6f692c833a91d39ed8','operation_price'),
|
||||
(10709,'9930ef4e9cfc78088c6668c2436fffea','operation_price'),
|
||||
(10710,'336d27a2f187d2e1e4500f637e5b4932','cosmetic_procedure_price'),
|
||||
(10711,'06c774891ecb49850d2c2ec085db1b6b','surgical_intervention_price'),
|
||||
(10712,'87106dd495aa6101255293695c947b5a','surgical_intervention_price'),
|
||||
(10713,'e66f44fbb836a40077ba014dbfad2164','operation_price'),
|
||||
(10714,'832222634c14f0b5f63da31b1063ad0e','operation_price'),
|
||||
(10715,'2382ee70fff468ebaf875fb7e8c34efc','operation_price'),
|
||||
(10716,'284521e82b8490cbd1c7c9f310cbf411','operation_price'),
|
||||
(10717,'97cb6f91955ff8033538c29f7290fafd','operation_price'),
|
||||
(10718,'c0f67f04a844da62b481f15d165e3eb1','operation_price'),
|
||||
(10720,'203776c03f88eb7b7a8f638b5618f9a5','operation_price'),
|
||||
(10721,'1f87699d83c9f8d14fa282439dbcde2e','operation_price'),
|
||||
(10722,'93df9cc3e34f0e73e445d9eb5cd10bf2','operation_price'),
|
||||
(10723,'cd35f5b670d7bef80d462771a5b8482d','operation_price'),
|
||||
(10724,'ebd76ef9ad9426ed1dad81659b7f5094','operation_price'),
|
||||
(10725,'dbf535f85b1873f7a7eb328abca0c2d6','operation_price'),
|
||||
(10726,'dfa985c2b7739d048c313eb40ae10334','operative_correction_price'),
|
||||
(10727,'0c799817bd382482561c7737d85672d7','operation_price'),
|
||||
(10728,'3791cd65af9124831a401596c77bca10','operation_price'),
|
||||
(10729,'04becabc4832d8d894af6533450c31fd','operation_price'),
|
||||
(10730,'cfd9f43285ff27ab34dfbd7de33bf8a2','operation_price'),
|
||||
(10731,'ece5a894389f83a43f379df691b4bb80','operation_price'),
|
||||
(10732,'94a603149a73154d50915eb631e76559','operation_price'),
|
||||
(10733,'581d3c51f11698bd9bd572979a7ca07d','operation_price'),
|
||||
(10734,'8f880dcfd91490bae76cd052cf82bb0d','operation_price'),
|
||||
(10735,'98cfe4c58b1b0f3a1c22cd20048e1977','operation_price'),
|
||||
(10736,'96237eea1552df9043c1541e0e9aa35e','operation_price'),
|
||||
(10737,'ba985f86c6d1c47ee32abe5a73e05199','operation_price'),
|
||||
(10738,'d9bdebdc7084d4e8a234a40df06b4200','operation_price'),
|
||||
(10739,'918d8ac0bf14a35408be96036ad2ddc1','operation_price'),
|
||||
(10740,'b15e240442ba50654306b405e6654b7c','surgical_intervention_price'),
|
||||
(10741,'607085bee929dd3f379cbb80c7e79f0d','operation_price'),
|
||||
(10742,'037769aef8981b8e3d0802efd46f1fda','operation_price'),
|
||||
(10743,'d69fae4b64e16dcfd4eb9aa3ae158367','operation_price'),
|
||||
(10744,'db2b3d0ece432dfe60f203142dd771e1','surgical_intervention_price'),
|
||||
(10745,'b84d186c56730bd937b60188a3c3e569','operation_price'),
|
||||
(10746,'46ba2c258b3cb05eae2c4a896a5d14ce','operation_price'),
|
||||
(10747,'90db181137b3b0be06a04fed57b84bbc','surgical_intervention_price'),
|
||||
(10748,'db54322083c3f6c76a588c3185a6eee2','operation_price'),
|
||||
(10749,'22d86f7758da4c3651ff417b2b43df98','operation_price'),
|
||||
(10750,'a98a0c0dda890589cad83b4040d9bfbd','operation_price'),
|
||||
(10751,'c83444090ee1f7d981a04c4892375219','operation_price'),
|
||||
(10752,'87517cd385192c0c438314d4558a27d9','operation_price'),
|
||||
(10753,'06530db74ddd84d56f3d2b97cb8d1c98','operation_price'),
|
||||
(10754,'6606812cfd68fff49cf575567e08c450','operation_price'),
|
||||
(10755,'1d131133affff655387795f0f69b283d','operation_price'),
|
||||
(10756,'b7f1866f768bcd8b61d77ee4b8c0f088','operation_price'),
|
||||
(10757,'2b786890a9d7ad62129504349193cd8a','operation_price'),
|
||||
(10758,'52f9e78bcfa02ac232d34bd053ffb469','operation_price'),
|
||||
(10759,'7279ad719af236d43472144831fdf4ea','operation_price'),
|
||||
(10760,'203012c16f6738542c0aca5534c10491','operation_price'),
|
||||
(10761,'89d3b6ae30b1b70c039eb62d84510fc3','operation_price'),
|
||||
(10762,'723776eafbe3dd8c9c84246e50a39134','surgical_intervention_price'),
|
||||
(10767,'5093cff048e24d5eb30196fd5ca6b7f9','surgical_intervention_price'),
|
||||
(10768,'a624496b31ccd48c03bacc71c6644576','surgical_intervention_price'),
|
||||
(10769,'fd02ee55b3c30c10f4af617738e2708a','surgical_intervention_price'),
|
||||
(10778,'b161b246135f3bdb5e3c09ad63ce535a','gynecological_surgery_price'),
|
||||
(10779,'b029b7374c4aff87d2803086fdb48e89','gynecological_surgery_price'),
|
||||
(10780,'ceb458f655cc000c6825a883f96ffe51','gynecological_surgery_price'),
|
||||
(10783,'0ce7a87bd476357af8877a6702332102','surgical_intervention_price'),
|
||||
(10784,'b77c4ff95a264238b3e538f1af471af3','surgical_intervention_price'),
|
||||
(10785,'5ef7def0d92ff98fcb98afcafa75a0d6','surgical_intervention_price'),
|
||||
(10786,'5b8075ed648e21cdc5d792db7b8ac2fd','surgical_intervention_price'),
|
||||
(10787,'880cdff088c95648952891a21a9d9312','operation_price'),
|
||||
(10788,'d70bb641e0a0e9496cccc7168be2f4a4','operation_price'),
|
||||
(10789,'417f4a63381da5540844473928d354f7','operation_price'),
|
||||
(10790,'9048b286593878df8f7252520063ed8a','operation_price'),
|
||||
(10791,'2d7621bc34330109c201635ca0426539','operation_price'),
|
||||
(10792,'dc5de284abc3db5f3dd09f5c7a0331d1','operation_price'),
|
||||
(10796,'77fc30e67d3b9cf164a20784812b3929','surgical_intervention_price'),
|
||||
(10797,'d3f58ba34a16665f574d0f75cdbe4a23','surgical_intervention_price'),
|
||||
(10798,'3469d392b0ea79fa5f950170e4e999be','surgical_intervention_price'),
|
||||
(10799,'21b01b48a40777c4f79700d8279a9181','operation_price'),
|
||||
(10800,'3b42be6d95cb447808c8f0558cc12a74','operation_price'),
|
||||
(10801,'6d3f4a218500025d2a07b50629756c89','surgical_price_detail'),
|
||||
(10802,'8db358be4fc2f330d933b87251a0da5a','surgical_price_detail'),
|
||||
(10803,'4703eeb23a617b12a20f0ad0e8ab11f2','surgical_price_detail'),
|
||||
(10816,'e9203e9cfa2f47fb46c7593c23aa6a42','cosmetology_not_offered'),
|
||||
(10817,'02a52fbfde44f46906cef0135dd8ef13','cosmetology_not_offered'),
|
||||
(10818,'7ab9588e2b78af929c16256bb876f203','cosmetology_not_offered'),
|
||||
(10819,'b1fd4821b1988a320d59dbbd6cf36db4','cosmetology_not_offered'),
|
||||
(10820,'35b9e3ce780d33b8bbe1dc7154196b18','cosmetology_not_offered'),
|
||||
(10821,'096de7306b2956384edf806c4863e5cd','cosmetology_not_offered'),
|
||||
(10822,'e42a7bb9758b6cd17ed22151835e60c9','cosmetology_not_offered'),
|
||||
(10823,'c6594f982ed6d7fbd47a53330c9b7128','cosmetology_not_offered'),
|
||||
(10824,'880fcf1fddfb3ba770bba2961bf28af6','cosmetology_not_offered'),
|
||||
(10825,'ed5721017d899c5f149c3a3e44ab93fb','cosmetology_not_offered'),
|
||||
(10826,'4ced7fe2fb50316a8d8a981a608276e7','cosmetology_not_offered'),
|
||||
(10827,'1cb611c2dd57cd9ff75f9f08c70fb2e0','cosmetology_not_offered'),
|
||||
(10828,'c0393c594f8677a9a217b4f21364fcb4','cosmetology_not_offered'),
|
||||
(10829,'a38eecdcef9a8965dba2a445e1fa9d64','cosmetology_not_offered'),
|
||||
(10830,'f000b0cd246c69298ccfc591f173e0c4','cosmetology_not_offered'),
|
||||
(10831,'f71d1fa3d6afd3e7c5df06eb6c48f8b6','cosmetology_not_offered'),
|
||||
(10832,'edc9c679448b2a54f996a679745f9f1d','cosmetology_not_offered'),
|
||||
(10833,'20e36210803fd99d7a1200a4218cdd51','cosmetology_not_offered'),
|
||||
(10834,'ea2456cf4055239dd367f1ce39012080','cosmetology_not_offered'),
|
||||
(10835,'dc5d3fc030220c883cd5990893a27e3a','cosmetology_not_offered'),
|
||||
(10836,'d1c756de4d8f630aad48f9660104e4fa','cosmetology_not_offered'),
|
||||
(10837,'153596fa9ec187c47fb315b94ea9b9ad','cosmetology_not_offered'),
|
||||
(10838,'1e748ad03b157d6057fd900388aa158e','cosmetology_not_offered'),
|
||||
(10839,'8f32b8a5fa955a8172aa7084bf25212e','cosmetology_not_offered'),
|
||||
(10840,'e8bdb463139c5922c0a85acce7a1be36','cosmetology_not_offered'),
|
||||
(10841,'c87bceab6bc494a47ae83204fedefafa','cosmetology_not_offered'),
|
||||
(10842,'4749893279b958e8dbbc3d83b4a405d0','cosmetology_not_offered'),
|
||||
(10843,'db63393ddb468461d3acc17594804687','cosmetology_not_offered'),
|
||||
(10844,'efcf5d13acb33d4841bf0799fa46e87a','cosmetology_not_offered'),
|
||||
(10845,'24b3a3910a890624a549345d4a8fde57','cosmetology_not_offered'),
|
||||
(10846,'f2f2226e7c52e64b000dccaeb94665a4','cosmetology_not_offered'),
|
||||
(10847,'500c2639412a89644651dd65b31966dc','cosmetology_not_offered'),
|
||||
(10848,'2c62d26b1bb4fd66b5e0f7098d2ad6b5','cosmetology_not_offered'),
|
||||
(10849,'874538368f4e9cb77f13555c61fc81fd','cosmetology_not_offered'),
|
||||
(10850,'51c3a44c4d8e988be5083c4c64c33c33','cosmetology_not_offered'),
|
||||
(10851,'2d453dda45b8237813c1b7c70d53cab0','cosmetology_not_offered'),
|
||||
(10852,'a7240624b34c493ef1f0de39a730deef','cosmetology_not_offered'),
|
||||
(10853,'0e3f99090495e54f078292a77926a434','cosmetology_not_offered'),
|
||||
(10854,'6ce666f4e2f6d3004a6a906f5facf024','cosmetology_not_offered'),
|
||||
(10855,'6d8075652c310eff50c901af47a95355','cosmetology_not_offered'),
|
||||
(10856,'66e14a6782c1754fa3fceabf599acb24','cosmetology_not_offered'),
|
||||
(10857,'5bffefa722c56dac8b06a91015b99355','cosmetology_not_offered'),
|
||||
(10858,'89be2af6339c0dd3bc714a8477060297','cosmetology_not_offered'),
|
||||
(10859,'242f703122fce689a3d9fa3851717a8e','cosmetology_not_offered'),
|
||||
(10860,'0aa0f4522b86cc8cd280b81dc765d88f','cosmetology_not_offered'),
|
||||
(10861,'5b742d5b9c838d7ad9a1ccbde306f766','cosmetology_not_offered'),
|
||||
(10862,'3e3653283c86908756662ea350eceb03','cosmetology_not_offered'),
|
||||
(10863,'1997c635583b98e763109cd4f0c2441a','cosmetology_not_offered'),
|
||||
(10864,'916b5ac6af0a55be91b34ca8f0c8ff66','cosmetology_not_offered'),
|
||||
(10865,'c405f12c18562d7f4be2a28eb74e3588','cosmetology_not_offered'),
|
||||
(10866,'c2e2380dec0bc4525676abcc93ff1ac8','cosmetology_not_offered'),
|
||||
(10867,'eee8612e777fcbf302f6384c5bdbb5bd','cosmetology_not_offered'),
|
||||
(10868,'1ebb327a0147df8860edcff093cb520a','cosmetology_not_offered'),
|
||||
(10869,'df6b3bbb4c199d81747c0268561953af','cosmetology_not_offered'),
|
||||
(10870,'643c657db49ced08f077233244d27278','cosmetology_not_offered'),
|
||||
(10871,'4a08d88a7c7b3426a1a914b68dde2e7f','cosmetology_not_offered'),
|
||||
(10872,'e1106bdbe70e55523fe0cdd19434688c','cosmetology_not_offered'),
|
||||
(10873,'432c40d5ecbe79d2b8e98e4303eebebf','cosmetology_not_offered'),
|
||||
(10874,'099682d0a8eedb1b0c7694523107c7fe','cosmetology_not_offered'),
|
||||
(10875,'09d308f8a15112af61372b81796d2b66','cosmetology_not_offered'),
|
||||
(10876,'c12146e7ef55994016f89fc2f2f102d6','cosmetology_not_offered'),
|
||||
(10877,'11a9718e8a4e73342a9e86d7558ec6e0','cosmetology_not_offered'),
|
||||
(10878,'7e7d010e4527b0f2313a2808cdf0bdc4','cosmetology_not_offered'),
|
||||
(10879,'8f0ae4d55d209bde7bf3bc3cd7092794','cosmetology_not_offered'),
|
||||
(10880,'f34fe1ea3eaf179eca5965b564ca45d5','cosmetology_not_offered'),
|
||||
(10881,'a3825cbb8d90a38e6a15d6579666a87a','cosmetology_not_offered'),
|
||||
(10882,'359997233b6d83b601f2c1bc2c901ffd','cosmetology_not_offered'),
|
||||
(10883,'fe1469af75ce2f322189d607eb88ba67','cosmetology_not_offered'),
|
||||
(10884,'725ecf93f07032dfd38c4ee7f23c7a62','cosmetology_not_offered'),
|
||||
(10885,'abb9ae3335e099ff36121ba9e7bbbd64','cosmetology_not_offered'),
|
||||
(10886,'5274d17ba030273a7528da0da99d9f84','cosmetology_not_offered'),
|
||||
(10887,'05b82cf65926f358a29e771766f9ddcc','cosmetology_not_offered'),
|
||||
(10888,'c29426cbda49fc29101ab8170c1e887f','cosmetology_not_offered'),
|
||||
(10889,'2e6e7d0f34f49db078b94477aaec8771','cosmetology_not_offered'),
|
||||
(11042,'c54e2f8ad78eb30debd0ef486ad99076','surgical_price_detail'),
|
||||
(11045,'6b66eda75eebdcad21e31e2c52b7cce6','surgical_intervention_price'),
|
||||
(11046,'dc151d8dd7db70d3d910a44d6c427a25','surgical_intervention_price'),
|
||||
(11047,'4b4567a00ae26b7224faf7d07c9c3aed','surgical_intervention_price'),
|
||||
(11056,'70b0a321087f4ecc3a9e5b7ba2dccd92','surgical_intervention_price'),
|
||||
(11057,'3bb00b63e332403050042eaee83b134d','surgical_intervention_price'),
|
||||
(11058,'3bafea4d8811265fc6cef4a2ab608f47','surgical_intervention_price'),
|
||||
(11059,'a6bf1b78ebb51c207d913f7956220c67','surgical_intervention_price'),
|
||||
(11060,'f58820f161967403029836456eec4016','surgical_intervention_price'),
|
||||
(11061,'5aaa3df167d6456853fcc34fffc12a53','operation_price'),
|
||||
(11062,'2b9217c13afee37488a86984f99610da','operation_price'),
|
||||
(11063,'e70f022419b60eb4684d100b9e73b863','surgical_intervention_price'),
|
||||
(11064,'6bf045b5277d1360509cb4ef780faf22','surgical_intervention_price'),
|
||||
(11065,'dfcaad65590a199efb96de785ac788de','surgical_intervention_price'),
|
||||
(11070,'c07000774203934eeefe03ef831f5846','surgical_intervention_price'),
|
||||
(11076,'cdc993dd103c07ddc2356f4f00dfe690','surgical_intervention_price'),
|
||||
(11077,'3050824a65a86b0fadce3e94818d4bbe','surgical_intervention_price'),
|
||||
(11078,'17c4e9eed83b4b0907127f7739442d7a','operation_price'),
|
||||
(11079,'56129caa00d11b2fc25ba850a04c323c','surgical_price_detail'),
|
||||
(11080,'5fb0d501d2b360a68f716453d545af77','surgical_price_detail'),
|
||||
(11081,'a61d98c639819fbfdcf9a804a3c3b9cf','surgical_price_detail'),
|
||||
(11082,'d242ede3851caafa7bd32d5bfaf52fe6','surgical_price_detail'),
|
||||
(11083,'b0235e943177fad8ca6b6040b537af15','surgical_price_detail'),
|
||||
(11084,'fae83f029c88a7ae202afc1adb48226c','surgical_price_detail'),
|
||||
(11085,'e9481633f903e07d32ebf878f4fb3064','surgical_price_detail'),
|
||||
(11086,'57f7992435e0eb96261622ab4e8254eb','surgical_price_detail'),
|
||||
(11087,'f410750fa06d58815d7c63824544d42f','surgical_price_detail'),
|
||||
(11088,'cb25da8c07b4a815a5db4a6787b1f7ce','surgical_price_detail'),
|
||||
(11089,'4bd1576ac4e88b5032e56237e3c02078','surgical_price_detail'),
|
||||
(11090,'29bbb1a80496730c150b1eb52d4b9ce7','surgical_price_detail'),
|
||||
(11091,'117b8b35af1c626bba3ec5928e7ebf90','surgical_price_detail'),
|
||||
(11092,'f6e9dcfbe7b2c67416637f5f513b63e4','surgical_price_detail'),
|
||||
(11093,'00374cdd9bda6dc1ff2188df07a5b36a','surgical_price_detail'),
|
||||
(11094,'a188d980bdd602e8c700f0738b0b7548','surgical_price_detail'),
|
||||
(11095,'912535af80dd8ad2101247ee97f46e65','surgical_price_detail'),
|
||||
(11096,'795bf4f7caf1c45f59cc3b747dfef584','surgical_price_detail'),
|
||||
(11097,'5191252c8ed56f6f7b37e53b9fdc0bc4','surgical_price_detail'),
|
||||
(11098,'d1d9560b8eec2329177525c1c625d563','surgical_price_detail'),
|
||||
(11099,'e50854ab31dd64c0cd6e5ee3f6575fdd','surgical_price_detail'),
|
||||
(11100,'9150fcc84ad1dea6dd578a9d853d7927','surgical_price_detail'),
|
||||
(11101,'6091d8ae558c050db334ce90fd90cf68','surgical_price_detail'),
|
||||
(11102,'bb534e4fb7f1f849bdb4e2a1239556c3','surgical_price_detail'),
|
||||
(11103,'188a9d5041d2b976419df273fd8a8adf','surgical_price_detail'),
|
||||
(11104,'9b940ab4bb1f78605c1b97f0abed4826','surgical_price_detail'),
|
||||
(11105,'6d8fb4e654d6d1122a24b61c2b7f0d2f','surgical_price_detail'),
|
||||
(11106,'2c4c4cb006267d913db8dbc3129ab163','surgical_price_detail'),
|
||||
(11107,'a2f552c14dade7ff204a6ea51df011d8','surgical_price_detail'),
|
||||
(11108,'605ea9336c810ffd05640427d681e4d8','surgical_price_detail'),
|
||||
(11109,'7c7f66c0b19c94346359adee2e57cae9','surgical_price_detail'),
|
||||
(11110,'8a07633f60c7dd857c8168830a28670d','surgical_price_detail'),
|
||||
(11111,'e5ece75a6af5172465766f36b1066eee','surgical_price_detail'),
|
||||
(11112,'12d32962c7d9cf0bc90bf7274ac61a7f','surgical_price_detail'),
|
||||
(11113,'7e1cfc725df2e1d3814780fc3c44df0e','surgical_price_detail'),
|
||||
(11114,'cd20842b80e7dc9a235905cd8db319be','surgical_price_detail'),
|
||||
(11115,'a2f89185214a346ac7a185992b5ff443','surgical_price_detail'),
|
||||
(11116,'dc21994e4eb94323bcd1d806979df638','surgical_price_detail'),
|
||||
(11117,'11a2c1f87bdb5f32240b4a7b5cb759b5','surgical_price_detail'),
|
||||
(11118,'a7725590c1edf3f529fd28524ae21fd8','surgical_price_detail'),
|
||||
(11120,'3f7b6fa4ecab6daa3395ccd70b79ed23','operation_price'),
|
||||
(11124,'22570fc114e73a5c3a26af0f384fc5a5','operation_price'),
|
||||
(11125,'c0869c19ca9329ae6ed7ef8c6bddf3be','operative_correction_price'),
|
||||
(11126,'490d69472d6958b26dd0469ff6a02228','operation_price'),
|
||||
(11127,'b7c51630bdf82ecb229a48a7b3803534','operative_correction_price'),
|
||||
(11128,'cade81079fb4dc3ae1233cfb399d4bfe','surgical_intervention_price'),
|
||||
(11371,'4835325fa9c3db1ab4cf616b4ebebb7b','surgical_price_detail'),
|
||||
(11372,'70e1d321000308214a3d395cd9991087','surgical_price_detail'),
|
||||
(11373,'b9a6e984d2acc8e25db5642440c41004','surgical_price_detail'),
|
||||
(11374,'abed3a1fb693cc3385ae5d08cb10279a','surgical_price_detail'),
|
||||
(11375,'163073482b7c11d1b1a05fa15cc8e5eb','surgical_price_detail'),
|
||||
(11376,'aacb181ddfea8dd4a4678db93732b886','surgical_price_detail'),
|
||||
(11377,'260ad747d302e643cdbb726165ce2b95','surgical_price_detail'),
|
||||
(11378,'0ddbcd28db9c3e68d0c7c506fbff6d56','surgical_price_detail'),
|
||||
(11379,'c210dcf60bb444c7cdd3fe7edc0f7f40','surgical_price_detail'),
|
||||
(11380,'fd26f451f64c945f85da3e0d4fc06684','surgical_price_detail'),
|
||||
(11381,'15ce842e3cb8812f295994af5e431601','surgical_price_detail'),
|
||||
(11382,'0b0723ef38f8d265fe51832a4fa1185f','surgical_price_detail'),
|
||||
(11383,'ed99d882e3ccff9e89a269f3c0873aee','surgical_price_detail'),
|
||||
(11384,'2a6a2af580393a5e9b26999b7014bf96','surgical_price_detail'),
|
||||
(11385,'bcacd8b9ed933e295db5da448c2131c2','surgical_price_detail'),
|
||||
(11386,'8986c17b1d1a4b9b9300c83d17fe0360','surgical_price_detail'),
|
||||
(11387,'71fde66b891318171ff2f79b9758582e','surgical_price_detail'),
|
||||
(11388,'4abb5f2c40654797f28b47cd2de5fb85','surgical_price_detail'),
|
||||
(11389,'aef13333d7c725c1d20cddf9418f89cf','surgical_price_detail'),
|
||||
(11390,'6efda183b30a072ccfc7a9ce9ec70a27','surgical_price_detail'),
|
||||
(11391,'f46f8bcd0906a527b5368d4652239e2d','surgical_price_detail'),
|
||||
(11392,'5ef34592099a55bf6e976a2fcb8bb2b3','surgical_price_detail'),
|
||||
(11393,'43b5692c9344890e51bf0dabde868e97','surgical_price_detail'),
|
||||
(11394,'1be8fadd8fc5b3ed1af7565d3c9a8e15','surgical_price_detail'),
|
||||
(11395,'698e80a8892e997d9528ac854756a5d4','surgical_price_detail'),
|
||||
(11396,'9c6edede1a27b2fb3ff38dabdf9e6121','surgical_price_detail'),
|
||||
(11397,'f63e634a79360660fec73a48dbac88c2','surgical_price_detail'),
|
||||
(11398,'12cb35dc719a1a3d416d7ffd476b2155','surgical_price_detail'),
|
||||
(11399,'72666b27987bb6f1524fe868a5607bc2','surgical_price_detail'),
|
||||
(11400,'0d941276edd4d090eebcaf2f3555f11d','surgical_price_detail'),
|
||||
(11401,'f10f6578213e4de7f9410e89444e57a7','surgical_price_detail'),
|
||||
(11402,'f14b546b9633b11859f7ae3bcadfdcb8','surgical_price_detail'),
|
||||
(11403,'e0d23f3afaeb59d824c80aaa5593036e','surgical_price_detail'),
|
||||
(11404,'2bbd858271ff1f7a58de069c3604f1f0','surgical_price_detail'),
|
||||
(11405,'ddf2b953405867bdedabb3900e57cc4b','surgical_price_detail'),
|
||||
(11406,'b594cd8688887b1823c681e2341941ed','surgical_price_detail'),
|
||||
(11407,'1a02148597fc69c4342ed09be390965b','surgical_price_detail'),
|
||||
(11408,'96772e1cac533f83d498c2d4d285cf16','surgical_price_detail'),
|
||||
(11409,'033b24b79f2423430ec39ee98254f30b','surgical_price_detail'),
|
||||
(11410,'8b846675995a30651044f251d071fb53','surgical_price_detail'),
|
||||
(11411,'68ba94215cc375c8c10b5dd436777366','surgical_price_detail'),
|
||||
(11412,'1fb1334af51aaff14d077b2f772b1e8a','surgical_price_detail'),
|
||||
(11413,'0761ff91fe118dd7fdfa488371b3ac5b','surgical_price_detail'),
|
||||
(11414,'5bb41478aaacf8c8b2c1b5008c34602e','surgical_price_detail'),
|
||||
(11415,'79c83e3c423124512a1bd480294ada70','surgical_price_detail'),
|
||||
(11416,'63d3a34c09acee0fa221a435bde8b926','surgical_price_detail'),
|
||||
(11417,'7b6a1bbcacf5649b1c49e2f62f797309','surgical_price_detail'),
|
||||
(11418,'22d645de1aa4b189f6a8512fb8da0bfa','surgical_price_detail'),
|
||||
(11419,'39d71baf81a5f53af6d46d78b2b480ee','surgical_price_detail'),
|
||||
(11420,'78e131cd660b178a2b7003595fe973f7','surgical_price_detail'),
|
||||
(11421,'456c3e6ea1bfa6a996d2fd81983ff8f3','surgical_price_detail'),
|
||||
(11422,'ac71fea6c7941acd228abbd9a06fcaa0','surgical_price_detail'),
|
||||
(11423,'6469a16b6d197560c592052662b4b137','surgical_price_detail'),
|
||||
(11424,'703dc05b18686061e74e8069bbc92b35','surgical_price_detail'),
|
||||
(11425,'c21816c4d67553def882bcc3b279d94d','surgical_price_detail'),
|
||||
(11540,'0378e2b5973d5d9543f5222f756fc6b0','operation_price'),
|
||||
(11542,'1a6dc8d77dfae93497aa082aeb4d13f3','operation_price'),
|
||||
(11543,'30b4f9699e6eabae15a387e9d9eec6c0','operation_price'),
|
||||
(11544,'39b89b077f69a44e1bd27c691d2418ba','operation_price'),
|
||||
(11546,'d1870950b98896d082290977af9feaf2','operation_price'),
|
||||
(11547,'a00247f43a602cd6ef872ce7cf97f59f','operation_price'),
|
||||
(11548,'b322b2786589d53844964d5658b87c9c','operation_price'),
|
||||
(11549,'88e6873dd43ae94e37507fc1d05026ef','surgical_intervention_price'),
|
||||
(11550,'6b3ae7e02681d06e6cfbaac3fed3d0bf','surgical_intervention_price'),
|
||||
(11734,'575e625b225038437f925c66891f8354','operation_price'),
|
||||
(11742,'294336db397b932643e2c28fe0b290e1','surgical_intervention_price'),
|
||||
(11743,'5a40e6af7d2f39719086c93178973bea','surgical_intervention_price'),
|
||||
(11756,'83c386c22f9712200aea617f620a8722','surgical_intervention_price'),
|
||||
(11783,'8a77cc1efa74b79098cf3806d84159fe','surgical_intervention_price'),
|
||||
(11786,'d87ee3ebe767d49a85d234482961db9b','surgical_intervention_price'),
|
||||
(11958,'ca68d448d87d3350c0caa53e94e849fa','operative_correction_price'),
|
||||
(11973,'bc9f99ce1f49555034f7393b0f1e45bb','operation_price'),
|
||||
(11982,'afb816285e8f91d7570e18531e9f093b','operation_price'),
|
||||
(11998,'5c95a46d4d34b625f8da9bebda7cd2a0','operation_price'),
|
||||
(11999,'4384c815e5fa65e91b12cd79c8e7e476','operation_price'),
|
||||
(12000,'7f38a2461e8d480f397f7fc5c20c6609','operation_price'),
|
||||
(12003,'9505cbb041d36b5059e2f0d2d9033979','surgical_intervention_price'),
|
||||
(12004,'140f64b3cb6b4439bf2fad2fe1deba47','surgical_intervention_price'),
|
||||
(12046,'e3028dc66f7d7d0f86c5e45c47eed593','surgical_intervention_price'),
|
||||
(12048,'5c0c484147ccbc505573680a689279dc','surgical_intervention_price'),
|
||||
(12056,'d98059430332ff237c5e63d611ab98b6','cosmetology_not_offered'),
|
||||
(12057,'38ec5698cd42e42225ae193cac982e49','cosmetology_not_offered'),
|
||||
(12058,'8d3215a1fc6b3c80ea397478a795f75d','cosmetology_not_offered'),
|
||||
(12059,'29f3f86dd88c19eb889696a5b99f3d1a','cosmetology_not_offered'),
|
||||
(12060,'7a30e81b39e37dad29190873ba9ea411','cosmetology_not_offered'),
|
||||
(12061,'a9ace30af64ffdaf7d748708edfc5f7d','cosmetology_not_offered'),
|
||||
(12062,'39153e248f08090c3a3055eb86e18728','cosmetology_not_offered'),
|
||||
(12063,'9a863edfaa397f2c98452899ea4e3c12','cosmetology_not_offered'),
|
||||
(12064,'9a034bd8c0d211d6f1106756d79403e3','cosmetology_not_offered'),
|
||||
(12065,'0a6820c0ef0d4da958d1894177502ada','cosmetology_not_offered'),
|
||||
(12066,'e069a9a399ec0d284b6f448455ccb6de','cosmetology_not_offered'),
|
||||
(12067,'a48df977faa2d02725281746651a8647','cosmetology_not_offered'),
|
||||
(12068,'550ec815d50532a431e3f8cc91a9373e','cosmetology_not_offered'),
|
||||
(12069,'e26e4b130f0f0119d68c693e20e0f12e','cosmetology_not_offered'),
|
||||
(12070,'9f1ae2572a447dec18ca4b7975bb76c6','cosmetology_not_offered'),
|
||||
(12071,'33b803c8a6a937f186f6925016703118','cosmetology_not_offered'),
|
||||
(12072,'c8dbf522b1e762365d98a05cc518369b','cosmetology_not_offered'),
|
||||
(12073,'1005826b078663c1d6a656158d0bd293','cosmetology_not_offered'),
|
||||
(12074,'5833f2592b6e07bcdb9a3cc44617c9d1','cosmetology_not_offered'),
|
||||
(12075,'fbc4b568ccca05478631fd12ee2f478c','cosmetology_not_offered'),
|
||||
(12076,'d7325ad90ad8b82f7b1b52fea1141fb3','cosmetology_not_offered'),
|
||||
(12077,'d1e9e7e92577d32012f31e0dcceaba85','cosmetology_not_offered'),
|
||||
(12078,'f670717590d89b333f8324e6ec37b31b','cosmetology_not_offered'),
|
||||
(12079,'677a1aafe1fa5c0ff773e74f8e68c512','cosmetology_not_offered'),
|
||||
(12080,'5a3c5d05d793e80fb62df3629eae6890','cosmetology_not_offered'),
|
||||
(12081,'8adf05d715ebccc133a4722138b68447','cosmetology_not_offered'),
|
||||
(12082,'3f684b5b205c9dbc2c0b69d2dbae0425','cosmetology_not_offered'),
|
||||
(12083,'b6fcbdec6bda1a0a7a2f6d43dc4661d1','cosmetology_not_offered'),
|
||||
(12084,'88fee28949c00633e963f80ab57e4d80','cosmetology_not_offered'),
|
||||
(12085,'c8ac45aef2b1ea75d7fb2712f7922acd','cosmetology_not_offered'),
|
||||
(12086,'1578165652d4705d73ba5af5f81db35c','cosmetology_not_offered'),
|
||||
(12087,'c42b0daf93cda799290431e5fb300aee','cosmetology_not_offered'),
|
||||
(12088,'ca30d5cb505ad8e802e044c7f7f7a4e5','cosmetology_not_offered'),
|
||||
(12089,'97e4155ef25ad9eda37a6231d4b1ca10','cosmetology_not_offered'),
|
||||
(12090,'b70cb412b4109b1a9522369986152e53','cosmetology_not_offered'),
|
||||
(12091,'6cde0eca5fbae6a3e8639d38e585a2ec','cosmetology_not_offered'),
|
||||
(12092,'50ca0ca3bbf56b000844e117a4666668','cosmetology_not_offered'),
|
||||
(12093,'36fea48f67ee40f57ee09ccde3a59f25','cosmetology_not_offered'),
|
||||
(12094,'f97232126baf222255eb963ca1c5eeff','cosmetology_not_offered'),
|
||||
(12095,'cb1cc59aaf7ede666c7ffd7cb1b600ab','cosmetology_not_offered'),
|
||||
(12096,'ebdb880fd860b2df726fc3b4ffa500af','cosmetology_not_offered'),
|
||||
(12097,'e66bbab385a9a7a7d5ca864e127ac35c','cosmetology_not_offered'),
|
||||
(12098,'a98db63eb94119f889c319cea6c67ea9','cosmetology_not_offered'),
|
||||
(12099,'d79dbd1850ed37244924d7f76fe4cd16','cosmetology_not_offered'),
|
||||
(12100,'49aba0c54f7a5b41484d9fac4b38420c','cosmetology_not_offered'),
|
||||
(12101,'7e89d2e9a79dd100dc5405be95d4bc63','cosmetology_not_offered'),
|
||||
(12102,'5478dd5b6fc441af4ab4c7dd2fd3dbe2','cosmetology_not_offered'),
|
||||
(12103,'a763c317b76c96cde72bda118462c86a','cosmetology_not_offered'),
|
||||
(12104,'4fe0c52d90f929637863393062c36813','cosmetology_not_offered'),
|
||||
(12105,'ea9b407b40836224d3b486602bd6a186','cosmetology_not_offered'),
|
||||
(12106,'b7c605e616df70bcc4c5c23adb89dec3','cosmetology_not_offered'),
|
||||
(12107,'8e915038e5372f17158ee2e9a95f26df','cosmetology_not_offered'),
|
||||
(12108,'b247e363fc233fe1eaec4ec4931598d1','cosmetology_not_offered'),
|
||||
(12109,'92a3a87a28a1205c59a76734ca4246a6','cosmetology_not_offered'),
|
||||
(12110,'0594b56c5946e743ddbc4192a71a51b6','cosmetology_not_offered'),
|
||||
(12111,'699560dacf39428a9b6cdbbbb19ba1c2','cosmetology_not_offered'),
|
||||
(12112,'119fdc728b72b7d7f740292d53c4fce9','cosmetology_not_offered'),
|
||||
(12113,'add125e7021a73fec6964e045246e6e2','cosmetology_not_offered'),
|
||||
(12114,'6b4678a90096ceecdb5a86186428d98c','cosmetology_not_offered'),
|
||||
(12115,'6fe3fecefc244391b6c7b3d9b3eee5a7','cosmetology_not_offered'),
|
||||
(12116,'c2c9832567725ebda0004b894972c1eb','cosmetology_not_offered'),
|
||||
(12117,'58f990a2e230859af42ac1f200fc3b49','cosmetology_not_offered'),
|
||||
(12118,'6c8f1a02171e9a9d25d485cdb12e58e0','cosmetology_not_offered'),
|
||||
(12119,'17456c63d7326ab1d771aad17c7c7ec6','cosmetology_not_offered'),
|
||||
(12120,'78ac831fd6ab7682bbbb9232e69546b0','cosmetology_not_offered'),
|
||||
(12121,'ca8a896bc0488318adbfdc5809a786a2','cosmetology_not_offered'),
|
||||
(12122,'3d30264b2ef620c9bf84e124a0616e1a','cosmetology_not_offered'),
|
||||
(12123,'ff1adb244f917563fed665fd60428b10','cosmetology_not_offered'),
|
||||
(12124,'4a52e207367f602a220934d440ee431e','cosmetology_not_offered'),
|
||||
(12125,'0ecb48d8caee8d216ace4efa9da4ca99','cosmetology_not_offered'),
|
||||
(12162,'a4c3da9144f69caff0b463a51afa7167','operation_price'),
|
||||
(12163,'0ae52e4322bd29835f4dae2ff5238a88','operation_price'),
|
||||
(12193,'542c767c1d26d6912f9e61bbc15d39ed','operative_correction_price'),
|
||||
(12194,'cccc4e8e479fcef138394a91230260af','operation_price'),
|
||||
(12195,'314035d8ffd5aa8c2361d0386eff7088','operative_correction_price'),
|
||||
(12196,'71d4bdfc0fc98c7a0bc2a8df384af920','operation_price'),
|
||||
(12197,'57fe27f42fa99a0d3358f196eb3535a5','operation_price'),
|
||||
(12198,'90490cf14c6ac0504c47b6a6dd749402','operation_price'),
|
||||
(12199,'2ddc3a390b48505fcf8334def26699bf','operation_price'),
|
||||
(12200,'dafa11f3d67bf3c0750f760f4eb11d00','operation_price'),
|
||||
(12201,'ecd7e9c434f8326f45ef1b44c681d5d7','operation_price'),
|
||||
(12202,'2ff12a562aa677a4ff9de597330c421e','operation_price'),
|
||||
(12203,'47941eb20f0a5fe0406346a773664feb','operation_price'),
|
||||
(12204,'1ba44455cb675793a85c908d9bf306f1','operation_price'),
|
||||
(12207,'9d522efbdf389535d76ea5a4179f86df','operation_price'),
|
||||
(12208,'c7de26b2fe741a7023975db500997eb1','operation_price'),
|
||||
(12209,'e3e6cb8bc986bdcf71bb03ba728265f8','operation_price'),
|
||||
(12210,'cb3ca69ac0f09cc9b49f5614e5f67d43','operation_price'),
|
||||
(12211,'fb1f9989ae12feba70cef8762fc3313c','operation_price'),
|
||||
(12214,'e2a5b26da171d66b0ee06b118a13a66b','operation_price'),
|
||||
(12217,'f0ee5f4bfe96b7c45ab5e9614c2e1bfd','operation_price'),
|
||||
(12228,'c5069f96a46edda7f13e21c0869ff8c2','operation_price'),
|
||||
(12238,'87d9f5d15614a38aa09d12c78f247a00','operation_price'),
|
||||
(12239,'45dd27984c36eff5feef811aa5aab72c','operation_price'),
|
||||
(12241,'69c35f029b1dcf79a9afb6e5d736cead','operation_price'),
|
||||
(12307,'8297cd9465f8a36f05260da47252b632','surgical_price_detail'),
|
||||
(12308,'f7ffdf477d105555278de2dd0872b489','surgical_price_detail'),
|
||||
(12316,'72418cde90192156f032920ea285cbb1','surgical_price_detail'),
|
||||
(12317,'18a87beb7fa43564973e7394eb5190c5','surgical_price_detail'),
|
||||
(12318,'8f617b629d1d376afed055c1809b68bc','surgical_price_detail'),
|
||||
(12319,'cfd0a1104f1cd791256f9eb057279848','surgical_price_detail'),
|
||||
(12329,'f79c0a1cb1d89dc88f8c88e58f5f634b','cosmetology_not_offered'),
|
||||
(12451,'fb820f34e7cc179a8ac4db749346b02c','operative_correction_price'),
|
||||
(12452,'a3b9e7059b40c1a9cee9d6a396b0b3de','operative_correction_price'),
|
||||
(12453,'efe7b2ae87d26a889665d1c98637d48c','operative_correction_price'),
|
||||
(12454,'19fdabbc1f10dc06adab322f58165fa5','operative_correction_price'),
|
||||
(12455,'bfe4e4f04059282741fddc504b8b5900','operative_correction_price'),
|
||||
(12515,'15515bcf1133c304424f3aa403a84d66','operation_price'),
|
||||
(12516,'af4f2f96bc0b82b8ac58a7ebfac4a63b','operation_price'),
|
||||
(12518,'9abc5653a6050e7a7f45d86c6eb0bbbf','operation_price'),
|
||||
(12541,'ca64c5d03a1723790c47efddf13303d4','surgical_price_detail'),
|
||||
(12542,'e5870768b77cb960ddbd1aac07337da9','surgical_price_detail'),
|
||||
(12543,'9f2dab559415b5ba845227a85984106e','surgical_price_detail'),
|
||||
(12544,'fadae736e6a7061c4737070af2518e1c','surgical_price_detail'),
|
||||
(12545,'852cd92a5bc6682f4638bb936f5e6b56','surgical_price_detail'),
|
||||
(12546,'cf6773a17090308651623ace4c8ffddc','surgical_price_detail'),
|
||||
(12547,'1b0c6b3b932693dd976553985d4c11e2','surgical_price_detail'),
|
||||
(12548,'34504b6f1bd86f1f292515c0f2a289c3','surgical_price_detail'),
|
||||
(12549,'6951e1005cb7ec72ebf882ebaf3eb287','surgical_price_detail'),
|
||||
(12550,'689ed0c3b18f72b0387a706dbaae480b','surgical_price_detail'),
|
||||
(12551,'bf7d67c4e777492125163ba2aaf35a27','surgical_price_detail'),
|
||||
(12552,'d6acf6ba0b21fff9993f144e8748d794','surgical_price_detail'),
|
||||
(12553,'5c6cc47ccfd1e7d19ddc2e05e9e1b1c5','surgical_price_detail'),
|
||||
(12554,'081c18c9fd4fce37fc708e969aeae7b1','surgical_price_detail'),
|
||||
(12555,'7d41c0198f605c6be65bae417c384ba5','surgical_price_detail'),
|
||||
(12556,'1ac579a1bd3c000739a6518c6a638984','surgical_price_detail'),
|
||||
(12557,'d5d540caeaab5548daf57ee5d22ef822','surgical_price_detail'),
|
||||
(12558,'a25496fe1583455e975dd17e92ebcc71','surgical_price_detail'),
|
||||
(12559,'47c6b2d8e7574cc1ddd6a1b4ebeb451d','surgical_price_detail'),
|
||||
(12560,'69c0abab89b2bd680ebd0390cfe5ee4b','surgical_price_detail'),
|
||||
(12561,'45eff69bdec5e58159d08c6cee3fbfe5','surgical_price_detail'),
|
||||
(12562,'cf90cee68b9460bdbf38906d8d772cb1','surgical_price_detail'),
|
||||
(12563,'9106a74c00b398c8454ac712c32b30b3','surgical_price_detail'),
|
||||
(12564,'4c0907c06c19fb51e7a2f0cfc6baa1ba','surgical_price_detail'),
|
||||
(12565,'246529f025b520391f8be3b972c46fcc','surgical_price_detail'),
|
||||
(12566,'be8442984c9b895fb07bab4c0dd503eb','surgical_price_detail'),
|
||||
(12567,'36879d8f65bd1250eff26f01fb9b8343','surgical_price_detail'),
|
||||
(12568,'9325bc3676af861bb47393f885756c20','surgical_price_detail'),
|
||||
(12569,'9f330591da3a686aeaf90ba996f739d6','surgical_price_detail'),
|
||||
(12570,'57e3c99799b65795a97165f0690daaf8','surgical_price_detail'),
|
||||
(12571,'1db645094661e852838a405e297e479a','surgical_price_detail'),
|
||||
(12572,'11041c5bf6f48751f364411371ce159d','surgical_price_detail'),
|
||||
(12573,'d105445b1ca2ceea9c9dc489f88ae1a0','surgical_price_detail'),
|
||||
(12574,'fd9bf203ac47c50f26fba1cb0280d704','surgical_price_detail'),
|
||||
(12575,'9e42aa66ea210ce22c1c999dd754f077','surgical_price_detail'),
|
||||
(12576,'da7dc22c14767e439544c037f5d4ccb6','surgical_price_detail'),
|
||||
(12577,'a95f40ca9fa48fb7d8fb4d88546c7462','surgical_price_detail'),
|
||||
(12578,'65cc087d3945e15edf48f78148cc49a4','surgical_price_detail'),
|
||||
(12579,'3f09df987fa4767e0ab20e18b916ff10','surgical_price_detail'),
|
||||
(12580,'a3eaf78280199f4808f1da8cf9c07641','surgical_price_detail'),
|
||||
(12581,'827d9ebd9ca804cbdf9b1a7261da5b95','surgical_price_detail'),
|
||||
(12582,'8b95d38f33fd9fba35ec7d31700bb3ae','surgical_price_detail'),
|
||||
(12583,'a604307957461c5d3a9639b88ea229d8','surgical_price_detail'),
|
||||
(12591,'066a9bb51f9b2160632d92b8dd03afb8','operation_price'),
|
||||
(12592,'da03d8ef7cfca6d1b77a4c79cf0f9bbc','operation_price'),
|
||||
(12593,'3b8507abfe96f67267aca1d8c3f4021a','operation_price'),
|
||||
(12594,'582a42ebcb731353b1cf9182fabffe49','operation_price'),
|
||||
(12595,'a7cd2aa40a3f1b71cf02ddbc23375ff2','operation_price'),
|
||||
(12598,'642ef5b673116d573f7116d1fb3e4d2d','surgical_intervention_price'),
|
||||
(12600,'02ec57b25f6e1fa9fec9118f2803b5c6','operative_correction_price'),
|
||||
(12601,'1030eab27b2598e125288f0553378ce2','operative_correction_price'),
|
||||
(12602,'b1c181de0f40ae9e8e990fc60afab728','operation_price'),
|
||||
(12610,'1788a5b70385ecbb25158b4c140bc67d','surgical_intervention_price'),
|
||||
(12626,'e08134b4d408c79cead0e06d1546cbca','surgical_intervention_price'),
|
||||
(12627,'86a12e5745a919cb7d3a8ba51a94f0b8','surgical_intervention_price'),
|
||||
(12727,'fe3f4e9bf0ba07915981f5d4007eeff9','surgical_intervention_price'),
|
||||
(12729,'f426443ffa8c86647eb48ae5f83feca2','surgical_intervention_price'),
|
||||
(12840,'5b293748b6ef04c64c394166805c2695','operative_correction_price'),
|
||||
(12841,'8be46f399179f835662529083f43f3cc','operation_price'),
|
||||
(12844,'40047e838889951a5b84405a3315c88f','operation_price'),
|
||||
(12847,'327a7c71308e620511455bb627da38b3','operation_price');
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT count(*) FROM price_publication_targets t JOIN price_items p USING(id) JOIN price_sections s ON s.id=p.section_id
|
||||
WHERE p.published AND md5(p.name || '|' || p.price::text || '|' || s.slug)=t.fingerprint) <> 575 THEN
|
||||
RAISE EXCEPTION 'Price data changed since review; aborting without changes';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM price_sections WHERE slug='kosmetologiya-price' AND published) THEN
|
||||
RAISE EXCEPTION 'Cosmetology section state changed; inspect before applying';
|
||||
END IF;
|
||||
END $$;
|
||||
UPDATE price_items p SET published=false,updated_at=now() FROM price_publication_targets t WHERE p.id=t.id;
|
||||
UPDATE price_sections SET published=false WHERE slug='kosmetologiya-price';
|
||||
SELECT t.reason,count(*) AS hidden_rows FROM price_publication_targets t GROUP BY t.reason ORDER BY t.reason;
|
||||
COMMIT;
|
||||
@ -1,53 +0,0 @@
|
||||
# Public price corrections, 23 October 2026
|
||||
|
||||
Clinic instruction: hide operations and the unoffered cosmetology direction.
|
||||
Keep the rest of the current price list and consultation prices. Do not publish
|
||||
an invented minimum for plastic surgery; wait for the clinic's approved amount.
|
||||
|
||||
## Provenance
|
||||
|
||||
The original workbooks D:/ЦОМ.xlsx, D:/Остео.xlsx and D:/Дана Дети.xls were
|
||||
read without modification. All 2439 database price names/amounts matched source
|
||||
rows (ignoring only the importer-added source labels). The legacy importers
|
||||
`migrations/import/build_price_map.py` and `rebuild_price_from_1c_only.py`
|
||||
generated INSERT statements with `published=true`. An internal nomenclature
|
||||
item was incorrectly treated as approved public website content. Heading-based
|
||||
grouping also placed surgical entries under unrelated categories such as US.
|
||||
|
||||
## Applied scope
|
||||
|
||||
`hide-nonpublic-prices-20261023.sql` is a one-time visibility-only correction.
|
||||
It identifies 575 reviewed existing rows by ID plus a fingerprint of the exact
|
||||
name, amount and section. It aborts on drift. No prices are changed and no rows
|
||||
are deleted. The cosmetology section is unpublished. The raw catalog remains
|
||||
available to administrators. Other sections, doctor profiles, appointments and
|
||||
clinical records are unchanged.
|
||||
|
||||
The selection rules in `price_policy.py` are an audit helper, not a medical
|
||||
classifier or an ongoing automatic publication rule. New or ambiguous entries
|
||||
require human review; do not blindly apply these expressions to future imports.
|
||||
|
||||
The two legacy generators now produce draft prices and draft new sections.
|
||||
They are not executed by this correction. The full-rebuild generator still
|
||||
contains a historical DELETE and must never be run against production without
|
||||
a separately approved replacement/backup plan.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
Run the standard-library tests from deploy/:
|
||||
|
||||
```sh
|
||||
python3 -m unittest -v test_price_policy test_price_import_defaults
|
||||
```
|
||||
|
||||
After the SQL change, compare every price with the pre-change snapshot. Only
|
||||
the reviewed IDs may change their publication flags/updated timestamps. Public
|
||||
`/api/v1/prices`, service pages and search must exclude hidden IDs; consultation
|
||||
prices and unrelated price amounts must remain intact. SSR builds its cache
|
||||
fingerprint from current public data, so no application restart is required.
|
||||
|
||||
The private task directory `/home/mikail/dana-price-publication-20261023/`
|
||||
contains the before/after price snapshots and `rollback-price-visibility.sql`.
|
||||
Use that rollback only after reviewing any newer manual publication edits. It
|
||||
restores the previous publication flags and does not restore or overwrite
|
||||
patients, appointments or price amounts. Do not restore the entire database.
|
||||
@ -1,47 +0,0 @@
|
||||
"""Publication-only policy; never changes prices, services or medical records."""
|
||||
import re
|
||||
|
||||
CONSULT = re.compile(r"^(?:(?:первичная|повторная|первичный|повторный|послеоперационная)\s+)?(консультац|консултац|при[её]м\s+(врача|хирурга|пластического))", re.I)
|
||||
OPERATION = re.compile(r"операци|оперативн|эктоми|ектоми|резекц|пластик|лапарос|лапаротом|лапараскоп|лапаратом|иссечени|кольпораф|перинеораф|конизаци|циркумци|лигирован|обрезани|гернио|экс[тц]ирпац|шунтир|хирургия|гастрошунт|аденотом|тонзиллотом|полипотом|френулотом|нефростом|остеосинтез|остеотом|варикоцеле|ампутац|гистероскоп|пекси|ректоцеле|вылущивани|дефлорац|эксцизи|перевязка\s+маточных\s+труб", re.I)
|
||||
ANCILLARY = re.compile(r"послеоперац|предоперац|дооперац|после\s+операц|реабилитац|курсы\s+по|анестези|капельниц|восстановлени", re.I)
|
||||
SURGICAL_REMOVAL = re.compile(r"^(удалени|вскрыти|рассечени|эксцизи|ушивани|разрез|энуклеац)", re.I)
|
||||
SURGICAL_SECTIONS = {"hirurgiya-price", "urologiya-price", "proktologiya-price", "gyne-price", "travmatologiya-price", "lor"}
|
||||
EXPLICIT_OPERATION_IDS = {10470, 10518, 10726, 11125, 11127, 11958, 12193, 12195, 12451, 12452, 12453, 12454, 12455, 12600, 12601, 12840}
|
||||
COSMETIC = re.compile(r"биоревитализац|омоложени|моделирование\s+формы\s+половых\s+губ|увеличение\s+точки\s+g|введение\s+гиалуроновой\s+кислоты", re.I)
|
||||
|
||||
def reason(item):
|
||||
name = item["name"].strip()
|
||||
section = item["section"]
|
||||
if section == "kosmetologiya-price":
|
||||
return "cosmetology_not_offered"
|
||||
if CONSULT.search(name):
|
||||
return None
|
||||
# The user explicitly requested consultation-only public surgical pricing.
|
||||
if section in {"plasticheskaya_hirurgiya", "hirurgiya-price"}:
|
||||
return "surgical_price_detail"
|
||||
if item["id"] in EXPLICIT_OPERATION_IDS:
|
||||
return "operative_correction_price"
|
||||
if COSMETIC.search(name):
|
||||
return "cosmetic_procedure_price"
|
||||
if ANCILLARY.search(name) and not re.match(r"^(операци|оперативн|иссечени|удалени)", name, re.I):
|
||||
return None
|
||||
if OPERATION.search(name):
|
||||
return "operation_price"
|
||||
if SURGICAL_REMOVAL.search(name):
|
||||
return "surgical_intervention_price"
|
||||
if section == "gyne-price" and re.search(r"выскабливани|вакуумная\s+аспирац|гистерорезек|рассечени\s+синехи", name, re.I):
|
||||
return "gynecological_surgery_price"
|
||||
return None
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
root = Path(__file__).resolve().parent
|
||||
prices = json.loads((root / "prices-before.json").read_text(encoding="utf-8-sig"))
|
||||
targets = [{**item, "reason": reason(item)} for item in prices if item["published"] and reason(item)]
|
||||
(root / "targets-review.json").write_text(json.dumps(targets, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print("TARGETS", len(targets), dict(Counter(item["section"] for item in targets)))
|
||||
for item in targets:
|
||||
if item["section"] not in {"kosmetologiya-price", "hirurgiya-price", "plasticheskaya_hirurgiya"}:
|
||||
print(item["id"], item["section"], item["name"])
|
||||
@ -1,19 +0,0 @@
|
||||
"""Inspect legacy generators without importing them (they have file-writing side effects)."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
class ImportDefaultsTest(unittest.TestCase):
|
||||
def test_imported_items_are_draft(self):
|
||||
root = Path(__file__).resolve().parents[1] / 'migrations' / 'import'
|
||||
for name in ('build_price_map.py', 'rebuild_price_from_1c_only.py'):
|
||||
with self.subTest(script=name):
|
||||
tree = ast.parse((root / name).read_text(encoding='utf-8'))
|
||||
inserts = [node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value,str) and 'INSERT INTO price_items' in node.value]
|
||||
self.assertTrue(inserts)
|
||||
for sql in inserts:
|
||||
self.assertIn('SELECT id,%s,%s,%s,false',sql)
|
||||
self.assertNotIn('%s,true FROM price_sections',sql)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,26 +0,0 @@
|
||||
import unittest
|
||||
from price_policy import reason
|
||||
|
||||
def item(name, section="gyne-price", item_id=1):
|
||||
return {"id": item_id, "name": name, "section": section}
|
||||
|
||||
class PricePolicyTest(unittest.TestCase):
|
||||
def test_consultations_are_preserved(self):
|
||||
for name in ("Консультация хирурга", "Консультация врача хирурга (Тест)", "Прием хирурга/ортопеда (Тест)"):
|
||||
self.assertIsNone(reason(item(name, "hirurgiya-price")))
|
||||
|
||||
def test_gynaecological_and_misfiled_operations_are_hidden(self):
|
||||
for name in ("Лапароскопическая миомэктомия", "Надвлагалищная ампутация матки (лапараскопия)", "Оперативная коррекция послеоперационного рубца", "Радиоволновая эксцизия ш/м", "Кольпорафия", "Вылущивание кисты шейки матки", "Вагинопластика 3 кат. сложности"):
|
||||
self.assertIsNotNone(reason(item(name)))
|
||||
self.assertIsNotNone(reason(item("Операция на опухоль спинного мозга", "uzi-price")))
|
||||
|
||||
def test_nonoperative_prices_are_unchanged(self):
|
||||
for name, section in (("Кольпоскопия", "gyne-price"), ("Кардиотокография (КТГ) плода", "gyne-price"), ("УЗИ органов брюшной полости", "uzi-price"), ("Кариес (детям)", "stomatologiya"), ("Керамические коронки", "stomatologiya"), ("Капельница Восстановление после операций", "protsedurny-kabinet"), ("Реабилитация после операции", "lech_bol")):
|
||||
self.assertIsNone(reason(item(name, section)))
|
||||
|
||||
def test_whole_unoffered_cosmetology_section_is_hidden(self):
|
||||
self.assertEqual(reason(item("Чистка лица", "kosmetologiya-price")), "cosmetology_not_offered")
|
||||
self.assertEqual(reason(item("Ошибочно включённая консультация", "kosmetologiya-price")), "cosmetology_not_offered")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"dana/internal/storage"
|
||||
)
|
||||
|
||||
var bookingProviders = map[string]bool{"none": true, "native": true}
|
||||
var bookingProviders = map[string]bool{"none": true, "native": true, "medflex": true}
|
||||
var bioKinds = map[string]bool{
|
||||
"education": true, "experience": true, "training": true,
|
||||
"award": true, "conference": true,
|
||||
@ -54,6 +54,12 @@ func (a *API) adminUpdateDoctor(w http.ResponseWriter, r *http.Request, u *stora
|
||||
fail(w, http.StatusUnprocessableEntity, "validation", "Рейтинг должен быть от 1,0 до 5,0")
|
||||
return
|
||||
}
|
||||
// Medflex без ссылки работать не будет — не даём сохранить заведомо мёртвую кнопку.
|
||||
if in.Booking == "medflex" && strings.TrimSpace(in.MedflexUser) == "" {
|
||||
fail(w, http.StatusUnprocessableEntity, "validation",
|
||||
"Для записи через Medflex нужна ссылка")
|
||||
return
|
||||
}
|
||||
oldSlug, err := a.store.PublishedDoctorSlug(r.Context(), id)
|
||||
if err != nil {
|
||||
handleErr(w, err)
|
||||
|
||||
@ -29,7 +29,6 @@ func pageParams(r *http.Request) (limit, offset int) {
|
||||
// ---------- Заявки ----------
|
||||
|
||||
func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
limit, offset := pageParams(r)
|
||||
q := r.URL.Query()
|
||||
var from, to *time.Time
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminLeadsResponseIsNotCacheable(t *testing.T) {
|
||||
// Invalid dates return before storage access; no database or patient data needed.
|
||||
a := &API{}
|
||||
w := httptest.NewRecorder()
|
||||
a.adminLeads(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/leads?from=invalid", nil), nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
}
|
||||
@ -93,7 +93,7 @@ const DoctorDash = {
|
||||
['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)],
|
||||
['Фото', ({ d }) => !!d.photo],
|
||||
['Стаж', ({ d }) => d.practice_start_year != null],
|
||||
['Онлайн-запись', ({ d }) => d.booking_provider === 'native'],
|
||||
['Онлайн-запись', ({ d }) => d.booking_provider === 'medflex'],
|
||||
['Категория', ({ d }) => !!d.category],
|
||||
['«О враче»', ({ d }) => !!d.bio],
|
||||
],
|
||||
@ -172,7 +172,7 @@ const DoctorDash = {
|
||||
['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`],
|
||||
['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`],
|
||||
['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`],
|
||||
['Онлайн-запись 1С', cnt(({ d }) => d.booking_provider === 'native'), (v) => `подключена у ${v}`, 'no-booking'],
|
||||
['Онлайн-запись Medflex', cnt(({ d }) => d.booking_provider === 'medflex'), (v) => `подключена у ${v}`, 'no-medflex'],
|
||||
['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'],
|
||||
['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'],
|
||||
['Отзывы', withReviews, () => (pending + live
|
||||
@ -208,7 +208,7 @@ const DoctorDash = {
|
||||
'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']];
|
||||
for (const { d, q } of DoctorDash.rows()) {
|
||||
rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет',
|
||||
d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'native' ? '1С' : 'заявка',
|
||||
d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'medflex' ? 'Medflex' : 'заявка',
|
||||
d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]);
|
||||
}
|
||||
const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; };
|
||||
@ -224,7 +224,7 @@ $('#dd-spec').onchange = () => DoctorDash.render();
|
||||
window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150));
|
||||
{
|
||||
const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')],
|
||||
['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-booking')],
|
||||
['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-medflex')],
|
||||
['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]];
|
||||
const bar = $('#dd-actions');
|
||||
for (const [icon, label, fn] of acts) {
|
||||
|
||||
@ -44,7 +44,7 @@ const Doctors = {
|
||||
(f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) &&
|
||||
(f !== 'no-photo' || !d.photo) &&
|
||||
(f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) &&
|
||||
(f !== 'no-bio' || !d.bio) && (f !== 'no-booking' || d.booking_provider !== 'native') &&
|
||||
(f !== 'no-bio' || !d.bio) && (f !== 'no-medflex' || d.booking_provider !== 'medflex') &&
|
||||
(f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) &&
|
||||
(!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3)));
|
||||
const { key, dir } = Doctors.sort;
|
||||
@ -150,7 +150,7 @@ const Doctors = {
|
||||
const body = {
|
||||
slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name,
|
||||
position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating,
|
||||
photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider,
|
||||
photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, medflex_user: d.medflex_user,
|
||||
sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home,
|
||||
seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change,
|
||||
};
|
||||
@ -192,7 +192,7 @@ const Doctors = {
|
||||
const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'],
|
||||
['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'],
|
||||
['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'],
|
||||
['no-bio', 'Без «О враче»'], ['no-booking', 'Без онлайн-записи 1С']];
|
||||
['no-bio', 'Без «О враче»'], ['no-medflex', 'Без онлайн-записи']];
|
||||
for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t }));
|
||||
const grp = el('optgroup', { label: 'Специальность' });
|
||||
for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` }));
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<title>DANA — админка</title>
|
||||
<link rel="stylesheet" href="admin.css?v=20261014-share">
|
||||
<link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2">
|
||||
<link rel="stylesheet" href="leads.css?v=20261023-refresh">
|
||||
<link rel="stylesheet" href="leads.css">
|
||||
<link rel="stylesheet" href="onec.css">
|
||||
<link rel="stylesheet" href="reports-history.css">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
@ -93,9 +93,8 @@
|
||||
<label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label>
|
||||
<div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div>
|
||||
<button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button>
|
||||
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки"><span aria-hidden="true">↻</span></button>
|
||||
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки">↻</button>
|
||||
</div>
|
||||
<p id="ld-updated" class="ld-updated" role="status" aria-live="polite"></p>
|
||||
<div class="ld-table-wrap"><table class="ld-table">
|
||||
<thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead>
|
||||
<tbody id="ld-body"></tbody>
|
||||
@ -1718,7 +1717,8 @@ function editDoctor(d) {
|
||||
{ key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 },
|
||||
{ key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 },
|
||||
{ key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select',
|
||||
options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'native', label: 'Онлайн-запись через 1С' }] },
|
||||
options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'medflex', label: 'Онлайн-запись Medflex' }, { value: 'native', label: 'Своя запись' }] },
|
||||
{ key: 'medflex_user', label: 'Ссылка Medflex (для онлайн-записи)', value: d.medflex_user },
|
||||
{ key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' },
|
||||
{ key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' },
|
||||
{ key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' },
|
||||
@ -2409,7 +2409,7 @@ function debounce(fn, ms) {
|
||||
|
||||
</script>
|
||||
<script src="dashboard.js"></script>
|
||||
<script src="leads.js?v=20261023-refresh"></script>
|
||||
<script src="leads.js?v=20261014-wish"></script>
|
||||
<script src="doctors.js?v=20261006-rating"></script>
|
||||
<script src="doctors-dash.js"></script>
|
||||
<script src="banners.js"></script>
|
||||
|
||||
@ -43,14 +43,6 @@ body.leads-view .page-title, body.leads-view .page-sub, body.leads-view .subtabs
|
||||
.ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.ld-export:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; }
|
||||
.ld-refresh span { display: inline-block; }
|
||||
.ld-refresh[aria-busy="true"] span { animation: ld-refresh-spin .8s linear infinite; }
|
||||
.ld-refresh:disabled { opacity: .65; cursor: wait; }
|
||||
@keyframes ld-refresh-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .ld-refresh[aria-busy="true"] span { animation: none; } }
|
||||
.ld-updated { margin: -9px 0 13px; color: #637891; font-size: 12px; }
|
||||
.ld-updated:empty { display: none; }
|
||||
.ld-updated--error { padding: 10px 12px; color: #92291f; background: #ffe3df; border-radius: 9px; }
|
||||
.ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; }
|
||||
.ld-table { min-width: 830px; }
|
||||
.ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; }
|
||||
|
||||
@ -2,52 +2,17 @@
|
||||
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
|
||||
|
||||
const Leads = {
|
||||
offset: 0, limit: 30, total: 0, loaded: false, visible: [], requestId: 0, controller: null,
|
||||
offset: 0, limit: 30, total: 0, loaded: false, visible: [],
|
||||
|
||||
async load({ notify = false } = {}) {
|
||||
const requestId = ++Leads.requestId;
|
||||
Leads.controller?.abort();
|
||||
const controller = new AbortController();
|
||||
Leads.controller = controller;
|
||||
const refresh = $('#ld-refresh'), updated = $('#ld-updated');
|
||||
refresh.disabled = true;
|
||||
refresh.setAttribute('aria-busy', 'true');
|
||||
$('#ld-body').setAttribute('aria-busy', 'true');
|
||||
updated.classList.remove('ld-updated--error');
|
||||
updated.textContent = 'Обновляем заявки…';
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 20000);
|
||||
async load() {
|
||||
const status = $('#ld-status').value;
|
||||
const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset });
|
||||
if ($('#ld-from').value) params.set('from', $('#ld-from').value);
|
||||
if ($('#ld-to').value) params.set('to', $('#ld-to').value);
|
||||
let r;
|
||||
try {
|
||||
const r = await api(`/admin/leads?${params}`, { signal: controller.signal, cache: 'no-store' });
|
||||
if (requestId !== Leads.requestId || controller.signal.aborted) return false;
|
||||
Leads.render(r, status);
|
||||
Leads.loaded = true;
|
||||
updated.textContent = 'Обновлено в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
if (notify) toast('Список заявок обновлён');
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (requestId !== Leads.requestId) return false;
|
||||
const message = timedOut ? 'Истекло время ожидания. Попробуйте обновить заявки ещё раз.' : e.message;
|
||||
updated.classList.add('ld-updated--error');
|
||||
updated.textContent = `Не удалось обновить заявки. ${message}${Leads.loaded ? ' Показаны предыдущие данные.' : ''}`;
|
||||
toast(message, true);
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (requestId === Leads.requestId) {
|
||||
Leads.controller = null;
|
||||
refresh.disabled = false;
|
||||
refresh.setAttribute('aria-busy', 'false');
|
||||
$('#ld-body').setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
render(r, status) {
|
||||
r = await api(`/admin/leads?${params}`);
|
||||
} catch (e) { toast(e.message, true); return; }
|
||||
Leads.total = r.meta?.total || 0;
|
||||
const counts = r.meta?.counts || {};
|
||||
for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0],
|
||||
@ -183,7 +148,7 @@ $('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget
|
||||
$('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); };
|
||||
$('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300));
|
||||
for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); };
|
||||
$('#ld-refresh').onclick = () => { Leads.offset = 0; return Leads.load({ notify: true }); };
|
||||
$('#ld-refresh').onclick = () => Leads.load();
|
||||
$('#ld-export').onclick = () => Leads.exportVisible();
|
||||
$('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); };
|
||||
$('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); };
|
||||
|
||||
@ -165,7 +165,7 @@ const Onec = {
|
||||
if (!r.doctor_id) {
|
||||
way.append(ocEl('span', { className: 'muted', textContent: 'сначала привяжите карточку' }));
|
||||
} else {
|
||||
const label = { native: '1С', none: 'заявка', '': 'заявка' }[r.booking] || 'заявка';
|
||||
const label = { native: '1С', medflex: 'Medflex', none: 'заявка', '': 'заявка' }[r.booking] || r.booking;
|
||||
way.append(ocEl('span', { className: 'pill ' + (bookingNative ? '' : 'grey'), textContent: label }));
|
||||
const b = ocEl('button', { type: 'button', className: 'btn ghost oc-small', textContent: bookingNative ? 'Отключить' : 'Включить запись через 1С' });
|
||||
b.onclick = () => Onec.setBooking(r, !bookingNative);
|
||||
|
||||
@ -90,7 +90,6 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/doctors", a.doctors)
|
||||
mux.HandleFunc("GET /api/v1/doctors/{slug}", a.doctor)
|
||||
mux.HandleFunc("GET /api/v1/doctors/{slug}/slots", a.doctorSlots)
|
||||
mux.HandleFunc("GET /api/v1/booking/availability", a.bookingAvailability)
|
||||
mux.HandleFunc("GET /api/v1/prices", a.prices)
|
||||
mux.HandleFunc("GET /api/v1/price-sections", a.priceSections)
|
||||
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"dana/internal/storage"
|
||||
)
|
||||
|
||||
func (a *API) bookingAvailability(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if !a.onecAutonomousEnabled() || !a.store.OnecFresh(r.Context(), onecStaleAfter) {
|
||||
ok(w, map[string]any{"enabled": false, "days": []storage.BookingDay{}}, nil)
|
||||
return
|
||||
}
|
||||
days, err := a.store.PublicBookingDays(r.Context())
|
||||
if err != nil {
|
||||
handleErr(w, err)
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{"enabled": true, "days": days}, nil)
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dana/internal/storage"
|
||||
)
|
||||
|
||||
func TestDoctorRejectsRemovedExternalBookingProvider(t *testing.T) {
|
||||
app := &API{}
|
||||
r := httptest.NewRequest(http.MethodPut, "/api/v1/admin/doctors/1", strings.NewReader(`{"last_name":"Тест","slug":"test","booking_provider":"medflex","medflex_user":"https://booking.medflex.ru/example"}`))
|
||||
r.SetPathValue("id", "1")
|
||||
w := httptest.NewRecorder()
|
||||
app.adminUpdateDoctor(w, r, &storage.AdminUser{})
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("removed provider accepted: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(bookingProviders) != 2 || !bookingProviders["native"] || !bookingProviders["none"] {
|
||||
t.Fatal("only on-site appointment methods should remain")
|
||||
}
|
||||
}
|
||||
@ -62,18 +62,18 @@ func (a *API) doctorSlots(w http.ResponseWriter, r *http.Request) {
|
||||
off()
|
||||
return
|
||||
}
|
||||
from, _ := onecWindow()
|
||||
// Показываем все реальные будущие окна из принятых снимков 1С.
|
||||
// Нулевая верхняя граница не ограничивает даты в SQL.
|
||||
slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{})
|
||||
from, to := onecWindow()
|
||||
slots, err := a.store.FreeOnecSlots(r.Context(), onecID, from, to)
|
||||
if err != nil {
|
||||
handleErr(w, err)
|
||||
return
|
||||
}
|
||||
// The current import duplicates one mixed catalog across doctors of unrelated
|
||||
// specialties. Keep it in storage for reconciliation/history, but never present
|
||||
// it as a verified list of this doctor's procedures (including to old clients).
|
||||
ok(w, map[string]any{"enabled": true, "slots": slots, "services": []onec.Service{}}, map[string]any{"total": len(slots)})
|
||||
services, err := a.store.OnecServices(r.Context(), onecID)
|
||||
if err != nil {
|
||||
handleErr(w, err)
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{"enabled": true, "slots": slots, "services": services}, map[string]any{"total": len(slots)})
|
||||
}
|
||||
|
||||
type appointmentInput struct {
|
||||
@ -139,16 +139,10 @@ func (a *API) createAppointment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339, in.Start)
|
||||
if err != nil || start.Before(time.Now()) {
|
||||
if err != nil || start.Before(time.Now()) || start.After(time.Now().AddDate(0, 0, onecHorizonDays+2)) {
|
||||
fail(w, http.StatusBadRequest, "start_invalid", "Выберите время приёма ещё раз")
|
||||
return
|
||||
}
|
||||
// A previously loaded form must not attach a procedure from the unverified
|
||||
// catalog. Do not silently discard a choice the patient believes was recorded.
|
||||
if in.ServiceID != "" {
|
||||
fail(w, http.StatusUnprocessableEntity, "service_unavailable", "Список услуг обновлён. Закройте и снова откройте форму, чтобы записаться к врачу без выбора услуги.")
|
||||
return
|
||||
}
|
||||
if !a.onecAutonomousEnabled() {
|
||||
fail(w, http.StatusServiceUnavailable, "onec_disabled", "Онлайн-запись сейчас недоступна, оставьте заявку")
|
||||
return
|
||||
@ -275,8 +269,8 @@ func (a *API) repeatAppointment(w http.ResponseWriter, rec storage.OnecAppointme
|
||||
// slotTaken — окно заняли: отдаём 409 и свежий список окон, чтобы пациент выбрал другое.
|
||||
func (a *API) slotTaken(w http.ResponseWriter, r *http.Request, rec storage.OnecAppointment, onecID, reason string) {
|
||||
_ = a.store.FinishOnecAppointment(context.Background(), rec.ID, "failed", "", "slot_busy: "+reason, nil, nil)
|
||||
from, _ := onecWindow()
|
||||
slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, time.Time{})
|
||||
from, to := onecWindow()
|
||||
slots, _ := a.store.FreeOnecSlots(r.Context(), onecID, from, to)
|
||||
if slots == nil {
|
||||
slots = []storage.OnecSlot{}
|
||||
}
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dana/internal/onec"
|
||||
"dana/internal/storage"
|
||||
)
|
||||
|
||||
// Uses only a disposable database; no real 1C calls or patient records.
|
||||
func TestAutonomousAllActualFutureDates(t *testing.T) {
|
||||
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable autonomy DB")
|
||||
}
|
||||
ctx := context.Background()
|
||||
st, err := storage.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err = st.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &API{store: st, limiter: newRateLimiter(), onecHook: "all-dates-test"}
|
||||
mux := app.Routes()
|
||||
call := func(method, path string, body any, want int) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
r := httptest.NewRequest(method, path, bytes.NewReader(b))
|
||||
r.Header.Set("X-Api-Key", "all-dates-test")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, r)
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s %s: %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
||||
}
|
||||
return w
|
||||
}
|
||||
doctor := fmt.Sprintf("all-dates-%d", time.Now().UnixNano())
|
||||
var doctorID int64
|
||||
if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Тест','Все даты','native') RETURNING id`, doctor).Scan(&doctorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = st.Pool().Exec(ctx, `INSERT INTO onec_doctors(onec_id,full_name,doctor_id) VALUES($1,'Тест Все даты',$2)`, doctor, doctorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
from := time.Now().UTC().Truncate(time.Hour)
|
||||
far := from.AddDate(0, 0, 90)
|
||||
end := far.Add(30 * time.Minute)
|
||||
to := end.Add(time.Hour)
|
||||
slot := onec.Slot{DoctorID: doctor, BranchID: "branch", Start: far, End: end, Status: "free"}
|
||||
payload := onecSyncPayload{From: &from, To: &to, Branches: []onec.Branch{}, Doctors: []onec.Doctor{}, Services: []onecDoctorServices{}, Slots: []onec.Slot{slot}}
|
||||
call("POST", "/api/v1/onec/sync/all", payload, 200)
|
||||
shown := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200)
|
||||
var result struct {
|
||||
Data struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Slots []storage.OnecSlot `json:"slots"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(shown.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Data.Enabled || len(result.Data.Slots) != 1 || !result.Data.Slots[0].Start.Equal(far) {
|
||||
t.Fatalf("actual distant slot hidden: %s", shown.Body.String())
|
||||
}
|
||||
checkAvailability := func(want bool) {
|
||||
t.Helper()
|
||||
w := call("GET", "/api/v1/booking/availability", nil, 200)
|
||||
var availability struct {
|
||||
Data struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Days []storage.BookingDay `json:"days"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &availability); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, day := range availability.Data.Days {
|
||||
if day.Date == far.In(moscow).Format("2006-01-02") && slices.Contains(day.DoctorIDs, doctorID) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !availability.Data.Enabled || found != want {
|
||||
t.Fatalf("date-to-doctor availability mismatch: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
checkAvailability(true)
|
||||
creation := appointmentInput{DoctorSlug: doctor, Start: far.Format(time.RFC3339), Name: "Test", Phone: "79990000001", Consent: true, RequestID: doctor}
|
||||
made := call("POST", "/api/v1/appointments", creation, 201)
|
||||
if !bytes.Contains(made.Body.Bytes(), []byte(`"mode":"pending"`)) {
|
||||
t.Fatal("distant actual slot not queued")
|
||||
}
|
||||
// A push claiming free must not reopen the pending reservation.
|
||||
call("POST", "/api/v1/onec/sync/all", payload, 200)
|
||||
hidden := call("GET", "/api/v1/doctors/"+doctor+"/slots", nil, 200)
|
||||
if err = json.Unmarshal(hidden.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Data.Slots) != 0 {
|
||||
t.Fatal("reserved distant slot reopened")
|
||||
}
|
||||
checkAvailability(false)
|
||||
// Removing the calendar cutoff must not permit invented dates.
|
||||
creation.Start = far.Add(time.Hour).Format(time.RFC3339)
|
||||
creation.RequestID = doctor + "-invented"
|
||||
call("POST", "/api/v1/appointments", creation, 409)
|
||||
}
|
||||
@ -72,9 +72,7 @@ func (a *API) onecSyncAll(w http.ResponseWriter, r *http.Request) {
|
||||
} else if len(payload.Slots) > 0 {
|
||||
from, to = findSlotBounds(payload.Slots)
|
||||
}
|
||||
// Даты определяет фактический снимок 1С, а не произвольный горизонт.
|
||||
// Авторизация, лимит тела и проверка каждого слота остаются обязательными.
|
||||
if !from.Before(to) {
|
||||
if !from.Before(to) || to.Sub(from) > 32*24*time.Hour {
|
||||
fail(w, 400, "validation", "Некорректный диапазон снимка")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dana/internal/onec"
|
||||
"dana/internal/storage"
|
||||
)
|
||||
|
||||
func TestAppointmentRejectsUnverifiedService(t *testing.T) {
|
||||
a := &API{limiter: newRateLimiter()}
|
||||
in := appointmentInput{DoctorSlug: "fictional-dentist", Start: time.Now().Add(48 * time.Hour).Format(time.RFC3339), Name: "Fictional patient", Phone: "79990000000", Consent: true, RequestID: "fictional-request", ServiceID: "unverified-service"}
|
||||
body, _ := json.Marshal(in)
|
||||
w := httptest.NewRecorder()
|
||||
a.createAppointment(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body)))
|
||||
if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), `"code":"service_unavailable"`) {
|
||||
t.Fatalf("unverified service was not rejected before storage access: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
in.ServiceID = ""
|
||||
body, _ = json.Marshal(in)
|
||||
w = httptest.NewRecorder()
|
||||
a.createAppointment(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body)))
|
||||
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), `"code":"onec_disabled"`) {
|
||||
t.Fatalf("booking without service must continue to normal availability checks: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Uses only a disposable database, never production or a real 1C server.
|
||||
func TestDoctorServicesQuarantine(t *testing.T) {
|
||||
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable autonomy DB")
|
||||
}
|
||||
ctx := context.Background()
|
||||
st, err := storage.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err = st.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doctor := fmt.Sprintf("service-guard-%d", time.Now().UnixNano())
|
||||
var doctorID int64
|
||||
if err = st.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider) VALUES($1,'Test','Dentist','native') RETURNING id`, doctor).Scan(&doctorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = st.Pool().Exec(ctx, `INSERT INTO onec_doctors(onec_id,full_name,doctor_id) VALUES($1,'Test Dentist',$2)`, doctor, doctorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = st.SaveOnecServices(ctx, doctor, []onec.Service{{ID: "unverified-lab", Name: "Fictional unrelated lab", DurationMin: 30, Price: 100}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = st.SetBookingSettings(ctx, storage.BookingSettings{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now().UTC().Truncate(time.Hour).Add(72 * time.Hour)
|
||||
end := start.Add(30 * time.Minute)
|
||||
if err = st.AcceptOnecSlots(ctx, start.Add(-time.Hour), end.Add(time.Hour), []onec.Slot{{DoctorID: doctor, BranchID: "branch", Start: start, End: end, Status: "free"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &API{store: st, limiter: newRateLimiter(), onecHook: "fictional-service-guard"}
|
||||
mux := app.Routes()
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/doctors/"+doctor+"/slots", nil))
|
||||
var result struct {
|
||||
Data struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Slots []storage.OnecSlot `json:"slots"`
|
||||
Services []onec.Service `json:"services"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || !result.Data.Enabled || len(result.Data.Slots) != 1 || result.Data.Services == nil || len(result.Data.Services) != 0 {
|
||||
t.Fatalf("unverified catalog leaked or real slot disappeared: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
raw, err := st.OnecServices(ctx, doctor)
|
||||
if err != nil || len(raw) != 1 {
|
||||
t.Fatalf("raw import must remain intact for reconciliation: %+v %v", raw, err)
|
||||
}
|
||||
in := appointmentInput{DoctorSlug: doctor, Start: start.Format(time.RFC3339), Name: "Fictional patient", Phone: "79990000000", Consent: true, RequestID: doctor, BranchID: "branch"}
|
||||
body, _ := json.Marshal(in)
|
||||
w = httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v1/appointments", bytes.NewReader(body)))
|
||||
if w.Code != http.StatusCreated || !bytes.Contains(w.Body.Bytes(), []byte(`"mode":"pending"`)) {
|
||||
t.Fatalf("actual slot cannot be booked without service: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
pending, err := st.PendingOnecAppointments(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, record := range pending {
|
||||
if record.RequestID == doctor {
|
||||
found = true
|
||||
if record.ServiceID != "" {
|
||||
t.Fatal("unverified service was attached to pending appointment")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("booking missing from test pending queue")
|
||||
}
|
||||
}
|
||||
@ -80,7 +80,8 @@ type DoctorService struct {
|
||||
}
|
||||
|
||||
type Booking struct {
|
||||
Provider string `json:"provider"` // none | native
|
||||
Provider string `json:"provider"` // none | native | medflex
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type BioEntry struct {
|
||||
|
||||
@ -22,6 +22,7 @@ type AdminDoctor struct {
|
||||
SpecialtyID *int64 `json:"specialty_id"`
|
||||
SpecialtySlug string `json:"specialty_slug"`
|
||||
Booking string `json:"booking_provider"`
|
||||
MedflexUser string `json:"medflex_user"`
|
||||
Sort int `json:"sort"`
|
||||
Published bool `json:"published"`
|
||||
Absent bool `json:"absent"`
|
||||
@ -47,7 +48,7 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er
|
||||
WHERE ds.doctor_id = d.id
|
||||
), ''),
|
||||
ps.id, COALESCE(ps.slug, ''),
|
||||
d.booking_provider, d.sort, d.published,
|
||||
d.booking_provider, d.medflex_user, d.sort, d.published,
|
||||
d.absent, d.show_on_home,
|
||||
(SELECT count(*) FROM doctor_bio_entries e WHERE e.doctor_id = d.id),
|
||||
d.seo_title, d.seo_description, d.seo_h1,
|
||||
@ -78,14 +79,11 @@ func (s *Storage) AdminDoctors(ctx context.Context, q string) ([]AdminDoctor, er
|
||||
&a.Position, &a.Category, &a.Degree, &a.PracticeYear, &a.Rating,
|
||||
&a.PhotoID, &a.Photo, &a.Quote, &a.Bio, &a.Specialties,
|
||||
&a.SpecialtyID, &a.SpecialtySlug,
|
||||
&a.Booking, &a.Sort, &a.Published,
|
||||
&a.Booking, &a.MedflexUser, &a.Sort, &a.Published,
|
||||
&a.Absent, &a.ShowOnHome, &a.BioCount,
|
||||
&a.SEOTitle, &a.SEODesc, &a.SEOH1, &a.ServiceIDs, &a.BranchIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Booking != "native" {
|
||||
a.Booking = "none"
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@ -105,6 +103,7 @@ type DoctorUpdate struct {
|
||||
Quote string `json:"quote"`
|
||||
Bio string `json:"bio"`
|
||||
Booking string `json:"booking_provider"`
|
||||
MedflexUser string `json:"medflex_user"`
|
||||
Sort int `json:"sort"`
|
||||
Published bool `json:"published"`
|
||||
Absent bool `json:"absent"`
|
||||
@ -124,7 +123,7 @@ func (s *Storage) AdminUpdateDoctor(ctx context.Context, id int64, in DoctorUpda
|
||||
WHERE id=$1 AND deleted_at IS NULL`,
|
||||
id, in.Slug, in.LastName, in.FirstName, in.MiddleName,
|
||||
in.Position, in.Category, in.Degree, in.PracticeYear, in.Rating,
|
||||
in.Quote, in.Bio, in.Booking, "",
|
||||
in.Quote, in.Bio, in.Booking, in.MedflexUser,
|
||||
in.Sort, in.Published, in.Absent, in.ShowOnHome,
|
||||
in.SEOTitle, in.SEODesc, in.SEOH1, in.PhotoID)
|
||||
if err != nil {
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
package storage
|
||||
|
||||
import "context"
|
||||
|
||||
type BookingDay struct {
|
||||
Date string `json:"date"`
|
||||
DoctorIDs []int64 `json:"doctor_ids"`
|
||||
}
|
||||
|
||||
// PublicBookingDays returns only dates and public doctor IDs, never patient data.
|
||||
// Keep the occupancy rules identical to FreeOnecSlots, including cross-branch reservations.
|
||||
func (s *Storage) PublicBookingDays(ctx context.Context) ([]BookingDay, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT to_char(sl.start_at AT TIME ZONE 'Europe/Moscow','YYYY-MM-DD'),
|
||||
array_agg(DISTINCT d.id ORDER BY d.id)
|
||||
FROM onec_slots sl JOIN onec_doctors od ON od.onec_id=sl.doctor_onec_id
|
||||
JOIN doctors d ON d.id=od.doctor_id
|
||||
WHERE sl.status='free' AND sl.start_at>now() AND od.active
|
||||
AND d.published AND d.deleted_at IS NULL AND NOT d.absent AND d.booking_provider='native'
|
||||
AND NOT EXISTS(SELECT 1 FROM onec_appointments a
|
||||
WHERE a.doctor_onec_id=sl.doctor_onec_id AND a.status IN('pending','booked','arrived')
|
||||
AND a.start_at<sl.end_at AND COALESCE(a.end_at,a.start_at+interval '30 minutes')>sl.start_at)
|
||||
GROUP BY 1 ORDER BY 1`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []BookingDay{}
|
||||
for rows.Next() {
|
||||
var day BookingDay
|
||||
if err := rows.Scan(&day.Date, &day.DoctorIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, day)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDoctorExternalBookingCannotLeakOrReturn(t *testing.T) {
|
||||
dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable autonomy database")
|
||||
}
|
||||
ctx := context.Background()
|
||||
s, err := New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err = s.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slug := fmt.Sprintf("legacy-external-%d", time.Now().UnixNano())
|
||||
var id int64
|
||||
if err = s.Pool().QueryRow(ctx, `INSERT INTO doctors(slug,last_name,first_name,booking_provider,medflex_user) VALUES($1,'Тест','Врач','medflex','https://booking.medflex.ru/legacy') RETURNING id`, slug).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := s.DoctorBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
public, _ := json.Marshal(d)
|
||||
if d.Booking.Provider != "none" || strings.Contains(string(public), "medflex") {
|
||||
t.Fatalf("legacy link leaked: %s", public)
|
||||
}
|
||||
rows, err := s.AdminDoctors(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatal("slug must not match the name filter")
|
||||
}
|
||||
rows, err = s.AdminDoctors(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, row := range rows {
|
||||
if row.ID == id {
|
||||
found = true
|
||||
admin, _ := json.Marshal(row)
|
||||
if row.Booking != "none" || strings.Contains(string(admin), "medflex") {
|
||||
t.Fatalf("legacy option leaked in admin: %s", admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("doctor missing from admin")
|
||||
}
|
||||
if err = s.AdminUpdateDoctor(ctx, id, DoctorUpdate{Slug: slug, LastName: "Тест", FirstName: "Врач", Booking: "none", Published: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var link string
|
||||
if err = s.Pool().QueryRow(ctx, `SELECT medflex_user FROM doctors WHERE id=$1`, id).Scan(&link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if link != "" {
|
||||
t.Fatal("editing doctor restored an external link")
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ import (
|
||||
// приоритет, а при пустом значении используется средняя оценка отзывов.
|
||||
const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d.position,
|
||||
d.category, d.degree, d.practice_start_year, COALESCE(m.path, ''), d.quote,
|
||||
d.languages, d.accepts_kids_from, d.booking_provider, d.absent,
|
||||
d.languages, d.accepts_kids_from, d.booking_provider, d.medflex_user, d.absent,
|
||||
d.seo_title, d.seo_description, d.seo_h1,
|
||||
COALESCE(d.rating_override::float8,
|
||||
(SELECT round(avg(r.rating)::numeric, 1)::float8 FROM reviews r
|
||||
@ -24,12 +24,15 @@ const doctorColumns = `d.id, d.slug, d.last_name, d.first_name, d.middle_name, d
|
||||
(SELECT count(*) FROM reviews r
|
||||
WHERE r.doctor_id = d.id AND r.status = 'published')`
|
||||
|
||||
const medflexBase = "https://booking.medflex.ru/?user="
|
||||
|
||||
func scanDoctor(row pgx.Row) (domain.Doctor, error) {
|
||||
var d domain.Doctor
|
||||
var startYear *int
|
||||
var medflexUser string
|
||||
err := row.Scan(&d.ID, &d.Slug, &d.LastName, &d.FirstName, &d.MiddleName, &d.Position,
|
||||
&d.Category, &d.Degree, &startYear, &d.Photo, &d.Quote,
|
||||
&d.Languages, &d.KidsFrom, &d.Booking.Provider, &d.Absent,
|
||||
&d.Languages, &d.KidsFrom, &d.Booking.Provider, &medflexUser, &d.Absent,
|
||||
&d.SEO.Title, &d.SEO.Description, &d.SEO.H1,
|
||||
&d.Rating, &d.ReviewCount)
|
||||
if err != nil {
|
||||
@ -43,8 +46,14 @@ func scanDoctor(row pgx.Row) (domain.Doctor, error) {
|
||||
d.Experience = &years
|
||||
}
|
||||
}
|
||||
if d.Booking.Provider != "native" {
|
||||
d.Booking.Provider = "none"
|
||||
if d.Booking.Provider == "medflex" && medflexUser != "" {
|
||||
// При переносе со старого сайта в поле попала ссылка целиком;
|
||||
// через админку можно ввести и только идентификатор.
|
||||
if strings.HasPrefix(medflexUser, "http://") || strings.HasPrefix(medflexUser, "https://") {
|
||||
d.Booking.URL = medflexUser
|
||||
} else {
|
||||
d.Booking.URL = medflexBase + medflexUser
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@ -253,19 +253,14 @@ func (s *Storage) OnecDoctorBySlug(ctx context.Context, slug string) (onecID str
|
||||
|
||||
// FreeOnecSlots — свободные окна врача в будущем; названия филиалов берутся из справочника 1С.
|
||||
func (s *Storage) FreeOnecSlots(ctx context.Context, doctorOnecID string, from, to time.Time) ([]OnecSlot, error) {
|
||||
var upper any
|
||||
if !to.IsZero() {
|
||||
upper = to
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT sl.start_at, sl.end_at, sl.branch_onec_id, COALESCE(NULLIF(b.address, ''), b.name, ''), sl.cabinet, sl.slot_id
|
||||
FROM onec_slots sl LEFT JOIN onec_branches b ON b.onec_id = sl.branch_onec_id
|
||||
WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now())
|
||||
AND ($3::timestamptz IS NULL OR sl.start_at < $3) AND NOT EXISTS (
|
||||
WHERE sl.doctor_onec_id=$1 AND sl.status='free' AND sl.start_at >= greatest($2::timestamptz, now()) AND sl.start_at < $3 AND NOT EXISTS (
|
||||
SELECT 1 FROM onec_appointments a WHERE a.doctor_onec_id=sl.doctor_onec_id
|
||||
AND a.status IN ('pending','booked','arrived') AND a.start_at < sl.end_at
|
||||
AND COALESCE(a.end_at,a.start_at+interval '30 minutes') > sl.start_at)
|
||||
ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, upper)
|
||||
ORDER BY sl.start_at, sl.branch_onec_id`, doctorOnecID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -176,14 +176,12 @@ o = io.open('seed_price_1c.sql', 'w', encoding='utf-8')
|
||||
o.write('BEGIN;\n')
|
||||
o.write("DELETE FROM price_items WHERE section_id=(SELECT id FROM price_sections WHERE slug='edinyy-svereno');\n")
|
||||
o.write("DELETE FROM price_sections WHERE slug='edinyy-svereno';\n")
|
||||
# Internal 1C nomenclature is not an approved public price list.
|
||||
# New sections and prices stay draft until explicitly approved by the clinic.
|
||||
for slug, name in NEW_SECTIONS:
|
||||
o.write("INSERT INTO price_sections (slug,name,sort,published) VALUES (%s,%s,100,false) "
|
||||
o.write("INSERT INTO price_sections (slug,name,sort,published) VALUES (%s,%s,100,true) "
|
||||
"ON CONFLICT (slug) DO NOTHING;\n" % (esc(slug), esc(name)))
|
||||
for i, (slug, name, price) in enumerate(mapped_rows):
|
||||
o.write("INSERT INTO price_items (section_id,name,price,sort,published) "
|
||||
"SELECT id,%s,%s,%s,false FROM price_sections WHERE slug=%s;\n" % (
|
||||
"SELECT id,%s,%s,%s,true FROM price_sections WHERE slug=%s;\n" % (
|
||||
esc(name), price, i + 1, esc(slug)))
|
||||
o.write('COMMIT;\n')
|
||||
o.close()
|
||||
|
||||
@ -56,12 +56,10 @@ for (slug, name), pricemap in groups.items():
|
||||
|
||||
o = io.open('seed_price_final.sql', 'w', encoding='utf-8')
|
||||
o.write('BEGIN;\n')
|
||||
o.write('DELETE FROM price_items;\n') # Legacy full replacement; never run without a separate approved plan.
|
||||
# Importing an internal price is not permission to publish it on the website.
|
||||
# Approval/publication must be separate from this offline SQL generator.
|
||||
o.write('DELETE FROM price_items;\n') # прайс теперь целиком из 3 файлов 1С
|
||||
for i, (slug, name, price) in enumerate(rows_out):
|
||||
o.write("INSERT INTO price_items (section_id,name,price,sort,published) "
|
||||
"SELECT id,%s,%s,%s,false FROM price_sections WHERE slug=%s;\n" % (
|
||||
"SELECT id,%s,%s,%s,true FROM price_sections WHERE slug=%s;\n" % (
|
||||
esc(name), price, i + 1, esc(slug)))
|
||||
o.write('COMMIT;\n')
|
||||
o.close()
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
// Embedded admin UI with a fake API and fictional leads; never connects to a server.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import { parseHTML } from 'linkedom';
|
||||
const html = await readFile(new URL('../../internal/api/adminui/index.html', import.meta.url), 'utf8');
|
||||
const source = await readFile(new URL('../../internal/api/adminui/leads.js', import.meta.url), 'utf8');
|
||||
const { document } = parseHTML(html);
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const select = $('#ld-status');
|
||||
Object.defineProperty(select, 'options', { get: () => [...select.querySelectorAll('option')] });
|
||||
function selectStatus(status) { for (const option of select.options) { if (option.value === status) option.setAttribute('selected', ''); else option.removeAttribute('selected'); } }
|
||||
selectStatus('new');
|
||||
const pending = [], notifications = [], timers = new Map();
|
||||
let timerId = 0;
|
||||
const context = vm.createContext({ document, $, URLSearchParams, AbortController, Date, Intl, console,
|
||||
el: (tag, props = {}) => Object.assign(document.createElement(tag), props),
|
||||
api: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
|
||||
toast: (message, bad) => notifications.push({ message, bad }),
|
||||
debounce: (fn) => fn, plural: () => 'заявок',
|
||||
LEAD_STATUS: { new: ['Новая', 'blue'], done: ['Обработана', 'green'] },
|
||||
setTimeout: (fn) => { const id = ++timerId; timers.set(id, fn); return id; },
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
vm.runInContext(source + '\nglobalThis.testLeads = Leads;', context);
|
||||
const leads = context.testLeads;
|
||||
const response = (name = 'Тестовая заявка') => ({ data: [{ id: 1, created_at: '2026-10-23T10:00:00+03:00', name, phone: '+79990000000', status: 'new', form_title: 'Тестовая форма', message: 'Вымышленные данные' }], meta: { total: 1, counts: { new: 1 }, today: 1 } });
|
||||
leads.offset = 60;
|
||||
const first = $('#ld-refresh').onclick();
|
||||
assert.equal($('#ld-refresh').disabled, true, 'Refresh must visibly enter loading state');
|
||||
assert.equal($('#ld-refresh').getAttribute('aria-busy'), 'true');
|
||||
assert.match($('#ld-updated').textContent, /Обновляем/);
|
||||
assert.equal(new URLSearchParams(pending[0].path.split('?')[1]).get('offset'), '0', 'Manual refresh returns to newest results');
|
||||
assert.equal(pending[0].options.cache, 'no-store');
|
||||
pending[0].resolve(response());
|
||||
await first;
|
||||
assert.match($('#ld-body').textContent, /Тестовая заявка/);
|
||||
assert.equal($('#ld-refresh').disabled, false);
|
||||
assert.match($('#ld-updated').textContent, /Обновлено/);
|
||||
assert.ok(notifications.some((n) => n.message === 'Список заявок обновлён'));
|
||||
assert.equal(timers.size, 0);
|
||||
|
||||
// Filters win over an older request even if a transport ignores cancellation.
|
||||
const older = leads.load();
|
||||
selectStatus('done');
|
||||
const newer = leads.load();
|
||||
assert.equal(pending[1].options.signal.aborted, true);
|
||||
pending[2].resolve(response('Свежий ответ'));
|
||||
await newer;
|
||||
pending[1].resolve(response('УСТАРЕВШИЙ ответ'));
|
||||
await older;
|
||||
assert.match($('#ld-body').textContent, /Свежий ответ/);
|
||||
assert.doesNotMatch($('#ld-body').textContent, /УСТАРЕВШИЙ/);
|
||||
|
||||
// Preserve filters, but reset pagination for an explicit refresh.
|
||||
$('#ld-q').value = 'проверка'; $('#ld-from').value = '2026-10-01'; $('#ld-to').value = '2026-10-23';
|
||||
leads.offset = 30;
|
||||
const refresh = $('#ld-refresh').onclick();
|
||||
const params = new URLSearchParams(pending[3].path.split('?')[1]);
|
||||
assert.equal(params.get('status'), 'done'); assert.equal(params.get('q'), 'проверка');
|
||||
assert.equal(params.get('from'), '2026-10-01'); assert.equal(params.get('to'), '2026-10-23');
|
||||
pending[3].reject(new Error('Тестовая ошибка сервера'));
|
||||
await refresh;
|
||||
assert.equal($('#ld-refresh').disabled, false, 'Errors must release the button');
|
||||
assert.match($('#ld-updated').textContent, /Не удалось/);
|
||||
assert.match($('#ld-body').textContent, /Свежий ответ/, 'Failure must not erase the previous results');
|
||||
assert.ok(notifications.some((n) => n.bad && n.message.includes('Тестовая ошибка')));
|
||||
|
||||
const retry = $('#ld-refresh').onclick();
|
||||
pending.at(-1).resolve({ data: [], meta: { total: 0, counts: {} } });
|
||||
await retry;
|
||||
assert.match($('#ld-body').textContent, /Заявки не найдены/);
|
||||
assert.equal($('#ld-refresh').disabled, false);
|
||||
|
||||
const timeout = $('#ld-refresh').onclick();
|
||||
for (const fn of [...timers.values()]) fn();
|
||||
pending.at(-1).reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||
await timeout;
|
||||
assert.equal($('#ld-refresh').disabled, false, 'Timeout cannot leave refresh stuck');
|
||||
assert.match($('#ld-updated').textContent, /время ожидания/i);
|
||||
assert.equal(timers.size, 0);
|
||||
console.log('PASS: actual refresh handler, loading/success/error/timeout feedback, no-store requests, first page, retained filters and stale-response protection; no live lead changes');
|
||||
@ -1,222 +0,0 @@
|
||||
// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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');
|
||||
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' }, 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 = [];
|
||||
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] },
|
||||
] } });
|
||||
api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject }));
|
||||
api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); };
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
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) {
|
||||
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) {
|
||||
await act(async () => request.resolve({ data: { enabled, slots, services: [] } }));
|
||||
}
|
||||
|
||||
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.doesNotMatch(text(), /Сначала выберите врача|Выберите дату или врача/);
|
||||
assert.equal(container.querySelector('.lead-modal__availability'), null, 'The removed initial hint must not leave an empty panel');
|
||||
assert.equal(container.querySelector('.lead-modal__availability--warning'), null, 'Selection instructions must not look like an unavailable-schedule warning');
|
||||
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');
|
||||
assert.match(text(), /Загружаем свободное время/);
|
||||
await reply(pending[0], [free('09:15'), { ...free('10:00'), status: 'busy' }, { ...free('11:00'), start: '2000-01-01T11:00:00+03:00' }, { ...free('12:00'), start: 'invalid' }]);
|
||||
assert.deepEqual(times(), ['09:15'], 'Only actual free future slots appear');
|
||||
await act(async () => container.querySelector('.slot-chip--time').dispatchEvent(new window.Event('click', { bubbles: true })));
|
||||
assert.ok(container.querySelector('input[type="tel"]'), 'Contacts follow an actual slot');
|
||||
|
||||
await chooseDoctor(2);
|
||||
assert.deepEqual(times(), []);
|
||||
assert.equal(container.querySelector('input[type="tel"]'), null, 'Switching doctor clears the chosen slot');
|
||||
assert.equal(pending[1].slug, 'second');
|
||||
await chooseDoctor(1);
|
||||
assert.equal(pending[2].slug, 'first');
|
||||
await reply(pending[1], [], false);
|
||||
assert.match(text(), /Загружаем свободное время/, 'Late response for another doctor must be ignored');
|
||||
await reply(pending[2], [free('15:45')]);
|
||||
assert.deepEqual(times(), ['15:45']);
|
||||
|
||||
await chooseDoctor('');
|
||||
assert.deepEqual(times(), []);
|
||||
assert.equal(pending.length, 3, 'Clearing selection must not request an all-doctors schedule');
|
||||
await chooseDoctor(3);
|
||||
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
|
||||
assert.match(container.querySelector('.lead-modal__availability--warning').textContent, /Свободные окна этого врача сейчас недоступны/, 'The unavailable-schedule message has its own prominent warning style');
|
||||
assert.deepEqual(times(), []);
|
||||
assert.equal(container.querySelectorAll('.slot-chip--time').length, 0, 'Fallback cannot offer invented times');
|
||||
assert.ok(container.querySelector('input[type="tel"]'));
|
||||
await chooseDoctor(2);
|
||||
await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable')));
|
||||
assert.match(text(), /Свободные окна этого врача сейчас недоступны/);
|
||||
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');
|
||||
|
||||
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(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 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-25' } }));
|
||||
assert.deepEqual(times(), []);
|
||||
assert.equal(container.querySelector('input[type="tel"]'), null, 'Changing date clears the chosen time and contacts step');
|
||||
assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Второй врач/, 'Changing date preserves the doctor even when no windows are available');
|
||||
assert.deepEqual(await availableDoctorIDs(), ['2']);
|
||||
assert.match(text(), /У выбранного врача на эту дату свободных окон нет/);
|
||||
assert.ok(container.querySelector('.lead-modal__availability--warning'));
|
||||
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '2070-10-23' } }));
|
||||
assert.match(container.querySelector('.doctor-picker__trigger .doctor-picker__name').textContent, /Второй врач/);
|
||||
assert.equal(pending.at(-1).slug, 'second');
|
||||
await reply(pending.at(-1), [free('11:45')]);
|
||||
assert.deepEqual(times(), ['11:45'], 'Available date opens the preserved doctor schedule without a second selection');
|
||||
await act(async () => Simulate.change(container.querySelector('input[type="date"]'), { target: { value: '' } }));
|
||||
await reply(pending.at(-1), [free('12:45')]);
|
||||
assert.deepEqual(times(), ['12:45'], 'Clearing date keeps the doctor and restores all their dates');
|
||||
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__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]) {
|
||||
await act(async () => root.render(null));
|
||||
api.doctors = async () => {
|
||||
if (fail) throw new Error('Fictional catalog outage');
|
||||
return { data: [] };
|
||||
};
|
||||
await render();
|
||||
assert.match(container.querySelector('.lead-modal__availability--warning').textContent, /Список врачей сейчас недоступен/, 'An empty or failed doctor catalog has the prominent red warning style');
|
||||
}
|
||||
api.doctors = originalDoctorLoader;
|
||||
await act(async () => root.render(null));
|
||||
await render({ form: 'callback' });
|
||||
assert.ok(container.querySelector('input[type="tel"]'));
|
||||
assert.equal(container.querySelector('select'), null);
|
||||
await act(async () => root.render(null));
|
||||
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, fixed doctor from card without catalog requests, date/fallback lock, callback and review');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
// Only fictional data and a mocked API. No live requests or appointments.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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/');
|
||||
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');
|
||||
const { MemoryRouter } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { default: NativeBooking } = await vite.ssrLoadModule('/src/components/NativeBooking.jsx');
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
const slot = { start: '2070-10-23T12:00:00+03:00', end: '2070-10-23T12:30:00+03:00', status: 'free', branch_id: 'test', branch: 'Тестовый филиал' };
|
||||
api.doctorSlots = async () => ({ data: { enabled: true, slots: [slot], services: [
|
||||
{ id: 'unverified-lab', name: 'Непроверенный анализ из общего справочника' },
|
||||
{ id: 'unverified-procedure', name: 'Непроверенная процедура другого направления' },
|
||||
] } });
|
||||
let submitted;
|
||||
api.createAppointment = async (payload) => { submitted = payload; return { data: { message: 'Тестовая заявка принята' } }; };
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(NativeBooking, {
|
||||
doctor: { slug: 'fictional-dentist' }, initialSlot: slot,
|
||||
onClose() {}, onUnavailable() { throw new Error('Actual free slot must stay bookable'); },
|
||||
}))));
|
||||
assert.ok(!container.querySelector('select'), 'Unverified imported services must not appear in the booking form');
|
||||
assert.doesNotMatch(container.textContent, /Непроверенный анализ|Непроверенная процедура/);
|
||||
assert.match(container.textContent, /23 октября/);
|
||||
await act(async () => {
|
||||
Simulate.change(container.querySelector('input[type="text"]'), { target: { type: 'text', value: 'Вымышленный пациент' } });
|
||||
Simulate.change(container.querySelector('input[type="tel"]'), { target: { type: 'tel', value: '+79990000000' } });
|
||||
Simulate.change(container.querySelector('input[type="checkbox"]'), { target: { type: 'checkbox', checked: true } });
|
||||
});
|
||||
await act(async () => Simulate.submit(container.querySelector('form')));
|
||||
assert.equal(submitted.doctor_slug, 'fictional-dentist');
|
||||
assert.equal(submitted.start, slot.start);
|
||||
assert.equal(submitted.branch_id, 'test');
|
||||
assert.equal(Object.hasOwn(submitted, 'service_id'), false, 'No unverified service identifier may be sent');
|
||||
assert.match(container.textContent, /Тестовая заявка принята/);
|
||||
console.log('PASS: unrelated imported services hidden, real chosen slot retained, booking submitted without service_id; fictional data only');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
// In-memory browser history and fictional programs only. No network calls.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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://test.invalid/checkup/');
|
||||
window.history = { scrollRestoration: 'auto' };
|
||||
const scrolls = [];
|
||||
window.scrollTo = (...args) => scrolls.push(['top', ...args]);
|
||||
window.HTMLElement.prototype.scrollIntoView = function(options) { scrolls.push(['list', this.textContent, options]); };
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { Simulate } = await import('react-dom/test-utils');
|
||||
const { MemoryRouter, Routes, Route, useNavigate, useLocation } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { default: Layout } = await vite.ssrLoadModule('/src/components/Layout.jsx');
|
||||
const { default: CheckupsPage } = await vite.ssrLoadModule('/src/pages/CheckupsPage.jsx');
|
||||
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
const programs = [{ id: 1, slug: 'child', name: 'Детский тест', audience: 'kids' }, { id: 2, slug: 'adult', name: 'Взрослый тест', audience: 'adults' }];
|
||||
api.checkups = async () => ({ data: programs });
|
||||
api.branches = api.settings = async () => ({ data: [] });
|
||||
let navigate;
|
||||
let currentLocation;
|
||||
function HistoryProbe() { navigate = useNavigate(); currentLocation = useLocation(); return null; }
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
const click = async (node) => { assert.ok(node); await act(async () => Simulate.click(node, { button: 0 })); };
|
||||
async function mount(url) {
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, { key: url, initialEntries: ['/previous/', url] },
|
||||
React.createElement(ApiDataProvider, { value: {} }, React.createElement(HistoryProbe), React.createElement(Routes, {},
|
||||
React.createElement(Route, { element: React.createElement(Layout) },
|
||||
React.createElement(Route, { path: '/checkup/', element: React.createElement(CheckupsPage) }),
|
||||
React.createElement(Route, { path: '/checkup/:slug/', element: React.createElement('p', {}, 'Detail page') }),
|
||||
React.createElement(Route, { path: '/previous/', element: React.createElement('p', {}, 'Previous page') })))))));
|
||||
}
|
||||
const selected = () => container.querySelector('[role="tab"][aria-selected="true"]').textContent;
|
||||
try {
|
||||
await mount('/checkup/');
|
||||
assert.equal(window.history.scrollRestoration, 'manual');
|
||||
assert.equal(selected(), 'Взрослые');
|
||||
scrolls.length = 0;
|
||||
await click([...container.querySelectorAll('[role="tab"]')].find((node) => node.textContent === 'Детские'));
|
||||
assert.equal(selected(), 'Детские');
|
||||
assert.equal(currentLocation.search, '?audience=kids');
|
||||
assert.equal(scrolls.length, 0, 'Tab selection must not jump');
|
||||
await click(container.querySelector('.checkup-card__more'));
|
||||
assert.match(container.textContent, /Detail page/);
|
||||
assert.ok(scrolls.some((s) => s[0] === 'top'), 'New detail page starts at top');
|
||||
scrolls.length = 0;
|
||||
await act(async () => navigate(-1));
|
||||
assert.equal(selected(), 'Детские');
|
||||
assert.match(container.querySelector('.checkup-grid').textContent, /Детский тест/);
|
||||
assert.equal(scrolls.length, 1);
|
||||
assert.equal(scrolls[0][0], 'list');
|
||||
assert.equal(scrolls[0][1], 'Программы чек-апов');
|
||||
await act(async () => navigate(1));
|
||||
scrolls.length = 0;
|
||||
await act(async () => navigate(-1));
|
||||
assert.equal(selected(), 'Детские');
|
||||
assert.equal(scrolls[0][0], 'list');
|
||||
await act(async () => navigate(-1));
|
||||
assert.equal(currentLocation.pathname, '/previous/', 'Tab changes do not pollute history');
|
||||
await mount('/checkup/?audience=kids');
|
||||
assert.equal(selected(), 'Детские', 'Direct links and reloads preserve audience');
|
||||
await click([...container.querySelectorAll('[role="tab"]')].find((node) => node.textContent === 'Взрослые'));
|
||||
assert.equal(currentLocation.search, '');
|
||||
await click(container.querySelector('.checkup-card__more'));
|
||||
await act(async () => navigate(-1));
|
||||
assert.equal(selected(), 'Взрослые');
|
||||
console.log('PASS: kids/adult tab persists, Back returns to list heading, Forward/Back works, no extra tab history, new details start at top');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
assert.equal(window.history.scrollRestoration, 'auto', 'Unmount restores browser policy');
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
// Fictional contact data and mocked submissions only. No external requests.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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://test.invalid/sales/');
|
||||
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');
|
||||
const { MemoryRouter, Routes, Route } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
const { LeadModalProvider } = await vite.ssrLoadModule('/src/context/LeadModalContext.jsx');
|
||||
const { default: PromosPage } = await vite.ssrLoadModule('/src/pages/PromosPage.jsx');
|
||||
const { default: CheckupPage } = await vite.ssrLoadModule('/src/pages/CheckupPage.jsx');
|
||||
const program = { id: 1, slug: 'chek-ap-ya-v-sad', name: 'Я в сад', audience: 'kids', blocks: [], seo: {} };
|
||||
api.promos = async () => ({ data: [
|
||||
{ id: 1, slug: 'ya-v-sad-sale', title: 'Я в сад' },
|
||||
{ id: 2, slug: 'unrelated-promo', title: 'Другая акция', checkup_slug: program.slug },
|
||||
] });
|
||||
api.checkups = async () => ({ data: [program] });
|
||||
api.checkup = async () => ({ data: program });
|
||||
let bookingCalls = 0;
|
||||
api.doctors = api.bookingAvailability = api.doctorSlots = async () => {
|
||||
bookingCalls++;
|
||||
throw new Error('Checkup request must not load doctors or schedules');
|
||||
};
|
||||
const submissions = [];
|
||||
api.createLead = async (payload) => { submissions.push(payload); return { data: { message: 'Тестовая заявка принята' } }; };
|
||||
api.createAppointment = () => { throw new Error('Checkup must not create an appointment in 1C'); };
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
const click = async (node) => {
|
||||
assert.ok(node, 'Expected actionable element');
|
||||
await act(async () => node.dispatchEvent(new window.Event('click', { bubbles: true })));
|
||||
};
|
||||
const mount = async (url, Page, route) => act(async () => root.render(
|
||||
React.createElement(MemoryRouter, { key: url, initialEntries: [url] },
|
||||
React.createElement(LeadModalProvider, {}, React.createElement(Routes, {},
|
||||
React.createElement(Route, { path: route, element: React.createElement(Page) }))))));
|
||||
function checkSimpleForm() {
|
||||
const modal = container.querySelector('[role="dialog"]');
|
||||
assert.ok(modal);
|
||||
assert.match(modal.textContent, /Записаться на чек-ап/);
|
||||
assert.match(modal.textContent, /Программа: Я в сад/);
|
||||
assert.equal(modal.querySelectorAll('input').length, 3, 'Only name, phone and consent');
|
||||
assert.equal(modal.querySelector('select, textarea, input[type="date"], .doctor-picker, .native-booking'), null);
|
||||
assert.equal(bookingCalls, 0);
|
||||
return modal;
|
||||
}
|
||||
try {
|
||||
await mount('/sales/', PromosPage, '/sales/');
|
||||
for (const card of container.querySelectorAll('.promo-card')) {
|
||||
await click(card.querySelector('button'));
|
||||
checkSimpleForm();
|
||||
await click(container.querySelector('[aria-label="Закрыть"]'));
|
||||
}
|
||||
await click(container.querySelector('.promo-card button'));
|
||||
let modal = checkSimpleForm();
|
||||
await act(async () => Simulate.submit(modal.querySelector('form')));
|
||||
assert.equal(submissions.length, 0, 'Empty form rejected');
|
||||
await act(async () => Simulate.change(modal.querySelector('input[type="text"]'), { target: { value: 'Тестовая заявка' } }));
|
||||
await act(async () => Simulate.change(modal.querySelector('input[type="tel"]'), { target: { value: '79990000000' } }));
|
||||
await act(async () => Simulate.submit(modal.querySelector('form')));
|
||||
assert.equal(submissions.length, 0, 'Consent required');
|
||||
await act(async () => Simulate.change(modal.querySelector('input[type="checkbox"]'), { target: { type: 'checkbox', checked: true } }));
|
||||
await act(async () => Simulate.submit(modal.querySelector('form')));
|
||||
assert.equal(submissions.length, 1);
|
||||
assert.equal(submissions[0].form, 'appointment', 'Use existing lead endpoint form');
|
||||
assert.deepEqual(submissions[0].extra, { checkup_slug: program.slug, checkup_name: program.name });
|
||||
assert.match(submissions[0].message, /Чек-ап: Я в сад/);
|
||||
assert.match(submissions[0].message, /Акция: Я в сад/);
|
||||
assert.equal(submissions[0].doctor_id, undefined);
|
||||
assert.equal(submissions[0].slot, undefined);
|
||||
assert.equal(submissions[0].consent, true);
|
||||
assert.match(container.querySelector('[role="dialog"]').textContent, /Тестовая заявка принята/);
|
||||
await mount(`/checkup/${program.slug}/`, CheckupPage, '/checkup/:slug/');
|
||||
await click(container.querySelector('.checkup-hero__btn'));
|
||||
checkSimpleForm();
|
||||
await click(container.querySelector('[aria-label="Закрыть"]'));
|
||||
const bottomCta = [...container.querySelectorAll('button')].find((node) => node.textContent === 'Оставить заявку');
|
||||
await click(bottomCta);
|
||||
checkSimpleForm();
|
||||
console.log('PASS: promotion links (slug fallback and explicit), checkup hero/bottom CTAs, contact validation and program metadata; no doctors, schedules or 1C appointment calls');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
// Fictional service and doctors only; no production submissions.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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://test.invalid/services/stomatologiya/');
|
||||
document.oninput = null;
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { MemoryRouter, Routes, Route } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
const { default: ServicePage } = await vite.ssrLoadModule('/src/pages/ServicePage.jsx');
|
||||
const { default: ServicesPage } = await vite.ssrLoadModule('/src/pages/ServicesPage.jsx');
|
||||
const { LeadModalProvider } = await vite.ssrLoadModule('/src/context/LeadModalContext.jsx');
|
||||
const { renderService } = await vite.ssrLoadModule('/src/entry-server.jsx');
|
||||
const { servicePriceSection, priceDirection } = await vite.ssrLoadModule('/src/lib/serviceNavigation.js');
|
||||
const dentist = { id: 20, slug: 'test-dentist', full_name: 'Тестовый стоматолог', position: 'Детский стоматолог', specialties: [{ slug: 'detskie-vrachi', name: 'Детские врачи' }], booking: { provider: 'none' } };
|
||||
const service = { id: 99, slug: 'stomatologiya', path: 'stomatologiya', name: 'Стоматология', doctors: [dentist], blocks: [], seo: {}, children: [] };
|
||||
const prices = [{ id: 21, slug: 'stomatologiya', name: 'Стоматология', items: [{ id: 1, name: 'Тестовая консультация', price: 1000 }] }];
|
||||
api.services = async () => ({ data: [service] });
|
||||
api.service = async () => ({ data: service });
|
||||
api.prices = async () => ({ data: prices });
|
||||
api.branches = api.settings = async () => ({ data: [] });
|
||||
api.bookingAvailability = async () => ({ data: { enabled: true, days: [] } });
|
||||
api.doctors = () => { throw new Error('Dentistry must not load the whole pediatric catalog'); };
|
||||
api.createLead = api.createAppointment = () => { throw new Error('Submissions forbidden'); };
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
const click = async (node) => { assert.ok(node); await act(async () => node.dispatchEvent(new window.Event('click', { bubbles: true }))); };
|
||||
try {
|
||||
assert.equal(servicePriceSection(service.path, prices).id, 21);
|
||||
assert.equal(priceDirection('stomatologiya', [service]).id, service.id);
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, { key: 'list', initialEntries: ['/services/'] }, React.createElement(ServicesPage))));
|
||||
assert.ok(container.querySelector('a[href="/services/stomatologiya/"]'));
|
||||
assert.ok(container.querySelector('.svc-tile__image img'));
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, { key: 'detail', initialEntries: ['/services/stomatologiya/'] }, React.createElement(LeadModalProvider, {},
|
||||
React.createElement(Routes, {}, React.createElement(Route, { path: '/services/*', element: React.createElement(ServicePage) }))))));
|
||||
assert.match(container.querySelector('h1').textContent, /Стоматология/);
|
||||
assert.ok(container.querySelector('.service-hero__image img'));
|
||||
assert.ok(container.querySelector('a[href="/price/stomatologiya/"]'));
|
||||
assert.match(container.querySelector('.service-doctors').textContent, /Тестовый стоматолог/);
|
||||
await click(container.querySelector('.service-hero__btn'));
|
||||
await click(container.querySelector('.doctor-picker__trigger'));
|
||||
assert.deepEqual([...container.querySelectorAll('[data-doctor-id]')].map((node) => node.dataset.doctorId), ['20']);
|
||||
const rendered = renderService(service, null, { doctors: [dentist], specialties: [], prices, site: { addresses: [], branches: [] } }, 'https://test.invalid');
|
||||
assert.match(rendered.html, /Стоматология/);
|
||||
assert.match(rendered.html, /Тестовый стоматолог/);
|
||||
assert.match(rendered.html, /\/price\/stomatologiya\//);
|
||||
console.log('PASS: dentistry directory tile, styled service page, existing price links both ways, SSR and only the explicitly linked dentist in booking');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
// Regression for cross-menu data leaks. All catalog responses are fictional;
|
||||
// no network calls or patient data are used.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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://menu-test.invalid/');
|
||||
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');
|
||||
const { MemoryRouter } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { default: Header } = await vite.ssrLoadModule('/src/components/Header.jsx');
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
|
||||
const pending = [];
|
||||
for (const [method, kind] of [['services', 'services'], ['specialties', 'doctors'], ['priceSections', 'prices']]) {
|
||||
api[method] = ({ signal }) => new Promise((resolve, reject) => pending.push({ kind, signal, resolve, reject }));
|
||||
}
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
const prices = [{ id: 1, name: 'Тестовая консультация', slug: 'test-price', count: 411 }];
|
||||
const specialties = [{ id: 2, name: 'Тестовые педиатры', slug: 'test-kids', count: 3 }];
|
||||
const services = [{ id: 3, name: 'Тестовая услуга', path: 'test-service', children: [] }];
|
||||
const panel = () => container.querySelector('.mega');
|
||||
const panelText = () => panel()?.textContent || '';
|
||||
const links = () => [...(panel()?.querySelectorAll('.mega__item') || [])].map((link) => link.getAttribute('href'));
|
||||
const trigger = (title) => [...container.querySelectorAll('.nav__item')].find((node) => node.textContent === title);
|
||||
async function open(title, focus = false) {
|
||||
await act(async () => Simulate[focus ? 'focus' : 'mouseEnter'](trigger(title)));
|
||||
return pending.at(-1);
|
||||
}
|
||||
async function reply(request, data) { await act(async () => request.resolve({ data })); }
|
||||
async function render(initial = {}) {
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, {},
|
||||
React.createElement(ApiDataProvider, { value: initial }, React.createElement(Header)))));
|
||||
}
|
||||
|
||||
try {
|
||||
await render();
|
||||
const priceRequest = await open('Цены');
|
||||
await reply(priceRequest, prices);
|
||||
assert.match(panelText(), /411 услуг/);
|
||||
const doctorRequest = await open('Врачи');
|
||||
assert.equal(panel().getAttribute('aria-label'), 'Меню «Врачи»');
|
||||
assert.deepEqual(links(), [], 'Price sections must disappear immediately when switching to doctors');
|
||||
assert.match(panelText(), /Загружаем/);
|
||||
assert.equal(priceRequest.signal.aborted, true, 'Previous menu request is cancelled on switch');
|
||||
await reply(doctorRequest, specialties);
|
||||
assert.match(panelText(), /3 врача/);
|
||||
assert.deepEqual(links(), ['/company/staff/test-kids/']);
|
||||
assert.doesNotMatch(panelText(), /411|Тестовая консультация/);
|
||||
|
||||
const delayedPrice = await open('Цены');
|
||||
assert.deepEqual(links(), [], 'Doctor specialties must not leak into the price menu');
|
||||
const delayedServices = await open('Услуги');
|
||||
const latestDoctor = await open('Врачи', true);
|
||||
assert.equal(delayedPrice.signal.aborted, true);
|
||||
assert.equal(delayedServices.signal.aborted, true);
|
||||
await reply(latestDoctor, specialties);
|
||||
// Deliberately resolve cancelled requests: even a transport ignoring AbortSignal
|
||||
// must not overwrite a newly opened menu.
|
||||
await reply(delayedPrice, prices);
|
||||
await reply(delayedServices, services);
|
||||
assert.deepEqual(links(), ['/company/staff/test-kids/']);
|
||||
assert.match(panelText(), /3 врача/);
|
||||
assert.doesNotMatch(panelText(), /411|Тестовая консультация|Тестовая услуга/);
|
||||
|
||||
const serviceRequest = await open('Услуги');
|
||||
await reply(serviceRequest, services);
|
||||
assert.deepEqual(links(), ['/services/test-service/']);
|
||||
const failedPrice = await open('Цены');
|
||||
await act(async () => failedPrice.reject(new Error('Fictional failure')));
|
||||
assert.match(panelText(), /Не удалось загрузить список/);
|
||||
assert.deepEqual(links(), [], 'An error must not leave links from another menu');
|
||||
|
||||
await act(async () => root.render(null));
|
||||
await render({ specialties: { data: specialties, meta: {} } });
|
||||
const initialPrices = await open('Цены');
|
||||
await reply(initialPrices, prices);
|
||||
const initialDoctors = await open('Врачи', true);
|
||||
assert.deepEqual(links(), ['/company/staff/test-kids/'], 'SSR data belongs only to its own catalog');
|
||||
assert.match(panelText(), /3 врача/);
|
||||
await reply(initialDoctors, specialties);
|
||||
console.log('PASS: hover/focus menu switching, delayed replies, cancellation, errors, and SSR data isolation');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
// Public fixtures only. No network or production mutations.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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://test.invalid/');
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { renderToString } = await import('react-dom/server');
|
||||
const { MemoryRouter } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { useApi } = await vite.ssrLoadModule('/src/lib/useApi.js');
|
||||
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
|
||||
const { SiteDataProvider } = await vite.ssrLoadModule('/src/context/SiteData.jsx');
|
||||
const { default: HeroSlider } = await vite.ssrLoadModule('/src/components/HeroSlider.jsx');
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
let calls = 0;
|
||||
let siteCalls = 0;
|
||||
api.branches = api.settings = async () => { siteCalls++; return { data: [] }; };
|
||||
const pending = [];
|
||||
const load = (signal) => { calls++; return new Promise((resolve) => pending.push({ signal, resolve })); };
|
||||
function Probe({ query }) {
|
||||
const result = useApi(load, [query], query);
|
||||
return React.createElement('output', {}, result.data || 'loading');
|
||||
}
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
const snapshot = { first: { data: 'SSR first' }, second: { data: 'SSR second' }, site: { phone: 'test' }, __receivedAt: Date.now() };
|
||||
async function mount(query, initial = snapshot, key = 'same') {
|
||||
await act(async () => root.render(React.createElement(React.StrictMode, {},
|
||||
React.createElement(ApiDataProvider, { value: initial }, React.createElement(SiteDataProvider, {},
|
||||
React.createElement(Probe, { key, query }))))));
|
||||
}
|
||||
const banners = Array.from({ length: 6 }, (_, id) => ({ id, image: `/test-${id}.webp`, title: `Banner ${id}` }));
|
||||
const images = () => [...container.querySelectorAll('.hero-slide img[src]')].map((node) => node.getAttribute('src'));
|
||||
try {
|
||||
await mount('first');
|
||||
assert.equal(calls, 0, 'Hydration including StrictMode must not repeat catalog request');
|
||||
assert.equal(siteCalls, 0, 'No redundant branch/settings requests');
|
||||
assert.equal(container.querySelector('output').textContent, 'SSR first');
|
||||
await mount('second');
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(container.querySelector('output').textContent, 'SSR second');
|
||||
await mount('uncached');
|
||||
assert.equal(calls, 1, 'Client-only route still loads');
|
||||
await mount('new-query');
|
||||
await act(async () => pending.at(-2).resolve({ data: 'STALE' }));
|
||||
assert.doesNotMatch(container.textContent, /STALE/);
|
||||
await act(async () => pending.at(-1).resolve({ data: 'Fresh query' }));
|
||||
assert.match(container.textContent, /Fresh query/);
|
||||
await mount('first', { ...snapshot, __receivedAt: Date.now() - 31_000 }, 'expired');
|
||||
assert.ok(calls >= 3, 'Old SSR snapshot must revalidate');
|
||||
for (const request of pending) await act(async () => request.resolve({ data: 'Refreshed' }));
|
||||
const ssr = renderToString(React.createElement(MemoryRouter, {}, React.createElement(HeroSlider, { banners })));
|
||||
assert.equal(new Set([...ssr.matchAll(/src="(\/test-[^"]+)"/g)].map((m) => m[1])).size, 3, 'SSR exposes only current/neighbor images');
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(HeroSlider, { banners }))));
|
||||
assert.deepEqual([...new Set(images())].sort(), ['/test-0.webp', '/test-1.webp', '/test-5.webp']);
|
||||
assert.equal(container.querySelector('.hero__item--active picture img').getAttribute('fetchpriority'), 'high');
|
||||
const next = container.querySelector('[aria-label="Следующий слайд"]');
|
||||
for (let i = 0; i < 6; i++) await act(async () => next.dispatchEvent(new window.Event('click', { bubbles: true })));
|
||||
assert.equal(new Set(images()).size, 6, 'All banners load on demand as the visitor browses');
|
||||
console.log('PASS: SSR catalog/site requests 0, uncached/expired queries load, stale replies ignored; initial banner images 6 → 3, all slides reachable');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
// Fictional doctors only; no external network or appointment submissions.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
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://test.invalid/services/detskaya-klinika/priem-detskogo-oftalmologa/');
|
||||
document.oninput = null;
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { MemoryRouter, Routes, Route } = await import('react-router-dom');
|
||||
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
|
||||
const { api } = await vite.ssrLoadModule('/src/api.js');
|
||||
const { default: ServicePage } = await vite.ssrLoadModule('/src/pages/ServicePage.jsx');
|
||||
const { LeadModalProvider } = await vite.ssrLoadModule('/src/context/LeadModalContext.jsx');
|
||||
const { serviceSpecialty } = await vite.ssrLoadModule('/src/lib/media.js');
|
||||
const { renderService, renderPage } = await vite.ssrLoadModule('/src/entry-server.jsx');
|
||||
const path = 'detskaya-klinika/priem-detskogo-oftalmologa';
|
||||
const service = { id: 17, path, slug: 'priem-detskogo-oftalmologa', name: 'Тестовый детский офтальмолог', doctors: [], blocks: [], seo: {} };
|
||||
const parent = { id: 1, path: 'detskaya-klinika', name: 'Детская клиника', children: [service], blocks: [], seo: {} };
|
||||
const doctors = [
|
||||
{ id: 1, slug: 'eye-test', full_name: 'Тестовый офтальмолог', last_name: 'Тестовый', first_name: 'Офтальмолог', position: 'Офтальмолог', specialties: [{ slug: 'oftalmologi', name: 'Офтальмологи', name_plural: 'Офтальмологи' }], booking: { provider: 'none' } },
|
||||
{ id: 2, slug: 'uzi-test', full_name: 'Посторонний врач УЗИ', last_name: 'Посторонний', first_name: 'Врач', position: 'Врач УЗИ', specialties: [{ slug: 'detskie-vrachi', name: 'Детские врачи' }], booking: { provider: 'none' } },
|
||||
];
|
||||
const requested = [];
|
||||
api.service = async (requestedPath) => ({ data: requestedPath === path ? service : parent });
|
||||
api.prices = async () => ({ data: [] });
|
||||
api.specialties = async () => ({ data: [{ slug: 'oftalmologi', name: 'Офтальмологи' }] });
|
||||
api.doctors = async (params = {}) => {
|
||||
requested.push(params.specialty);
|
||||
return { data: doctors.filter((doctor) => !params.specialty || doctor.specialties.some((s) => s.slug === params.specialty)) };
|
||||
};
|
||||
api.bookingAvailability = async () => ({ data: { enabled: true, days: [] } });
|
||||
api.createLead = api.createAppointment = () => { throw new Error('Submissions forbidden'); };
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
const click = async (node) => act(async () => node.dispatchEvent(new window.Event('click', { bubbles: true })));
|
||||
try {
|
||||
assert.equal(serviceSpecialty(path), 'oftalmologi');
|
||||
assert.equal(serviceSpecialty('detskaya-klinika'), 'detskie-vrachi');
|
||||
assert.equal(serviceSpecialty('oko'), 'oftalmologi');
|
||||
assert.equal(serviceSpecialty('ginekologiya/test'), 'ginekologi');
|
||||
assert.equal(serviceSpecialty('unknown/test'), undefined);
|
||||
await act(async () => root.render(React.createElement(MemoryRouter, { initialEntries: [`/services/${path}/`] },
|
||||
React.createElement(LeadModalProvider, {}, React.createElement(Routes, {}, React.createElement(Route, { path: '/services/*', element: React.createElement(ServicePage) }))))));
|
||||
assert.match(container.querySelector('.service-doctors').textContent, /Тестовый офтальмолог/);
|
||||
assert.doesNotMatch(container.querySelector('.service-doctors').textContent, /Посторонний/);
|
||||
await click(container.querySelector('button.service-hero__btn'));
|
||||
await click(container.querySelector('.doctor-picker__trigger'));
|
||||
assert.deepEqual([...container.querySelectorAll('[data-doctor-id]')].map((node) => node.dataset.doctorId), ['1']);
|
||||
assert.ok(requested.length >= 2);
|
||||
assert.ok(requested.every((specialty) => specialty === 'oftalmologi'), 'Both service cards and booking must request ophthalmologists');
|
||||
const common = { doctors, specialties: [], prices: [], site: { branches: [], addresses: [], phone: '', hours: '' } };
|
||||
const server = renderService(service, parent, common, 'https://test.invalid');
|
||||
assert.deepEqual(JSON.parse(server.initial)['doctors:oftalmologi'].data.map((d) => d.id), [1]);
|
||||
const runtime = renderPage(`/services/${path}/`, { [`service:${path}`]: { data: service }, doctors: { data: doctors } }, 'https://test.invalid');
|
||||
assert.deepEqual(JSON.parse(runtime.initial)['doctors:oftalmologi'].data.map((d) => d.id), [1]);
|
||||
console.log('PASS: service doctor cards, booking picker, runtime SSR and prerender use the specific ophthalmology specialty');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await vite.close();
|
||||
}
|
||||
@ -61,7 +61,6 @@ export const api = {
|
||||
waitCallStatus: (sessionId, o) => request('GET', `/auth/wait-call/status?session_id=${encodeURIComponent(sessionId)}`, o),
|
||||
// свободные окна врача из 1С и запись на конкретное окно
|
||||
doctorSlots: (slug, o) => request('GET', `/doctors/${encodeURIComponent(slug)}/slots`, o),
|
||||
bookingAvailability: (o) => request('GET', '/booking/availability', o),
|
||||
createAppointment: (body) =>
|
||||
request('POST', '/appointments', { body: { utm: attribution(), ...body } }).then((res) => {
|
||||
reachGoal('lead', { form: 'appointment' });
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="70" y1="40" x2="580" y2="460" gradientUnits="userSpaceOnUse"><stop stop-color="#eef4fa"/><stop offset="1" stop-color="#dae3f1"/></linearGradient>
|
||||
<linearGradient id="tooth" x1="220" y1="115" x2="415" y2="385" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset=".55" stop-color="#f5fafc"/><stop offset="1" stop-color="#b9cddc"/></linearGradient>
|
||||
<linearGradient id="coral" x1="420" y1="80" x2="550" y2="230" gradientUnits="userSpaceOnUse"><stop stop-color="#ffb5a7"/><stop offset="1" stop-color="#f56355"/></linearGradient>
|
||||
<filter id="shadow" x="100" y="70" width="440" height="400" filterUnits="userSpaceOnUse"><feDropShadow dx="0" dy="20" stdDeviation="17" flood-color="#48697d" flood-opacity=".18"/></filter>
|
||||
</defs>
|
||||
<rect width="640" height="480" rx="36" fill="url(#bg)"/>
|
||||
<circle cx="481" cy="147" r="76" fill="url(#coral)" opacity=".85"/>
|
||||
<circle cx="145" cy="345" r="59" fill="#b9d9ed"/>
|
||||
<ellipse cx="322" cy="403" rx="129" ry="18" fill="#adc1d3" opacity=".25"/>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M318 133c-32-17-65-34-98-21-33 13-47 44-40 82 7 37 29 59 33 96 4 36 12 85 36 99 25 14 29-22 39-54 8-26 13-42 31-42 17 0 23 16 31 42 10 32 14 68 39 54 24-14 33-63 37-99 4-37 25-59 32-96 7-38-7-69-40-82-33-13-67 4-100 21Z" fill="url(#tooth)" stroke="#dae5eb" stroke-width="3"/>
|
||||
<path d="M236 145c24-6 48 2 71 12 18 8 36 13 53 9" stroke="#fff" stroke-width="14" stroke-linecap="round" opacity=".85"/>
|
||||
<path d="M220 186c0 25 9 44 18 64" stroke="#fff" stroke-width="9" stroke-linecap="round" opacity=".7"/>
|
||||
</g>
|
||||
<path d="m461 257 9 24 24 9-24 9-9 24-9-24-24-9 24-9 9-24Z" fill="#fff"/>
|
||||
<path d="m162 154 6 15 15 6-15 6-6 15-6-15-15-6 15-6 6-15Z" fill="#7896ab" opacity=".65"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@ -9,9 +9,16 @@ export function roleLabel(doctor) {
|
||||
return doctor.position || doctor.specialties?.[0]?.name || '';
|
||||
}
|
||||
|
||||
// Запись всегда остаётся на сайте: окна 1С либо обычная форма заявки.
|
||||
// Запись к врачу: у кого настроен Medflex — ведём туда, у остальных открываем форму заявки.
|
||||
export function BookButton({ doctor, className = '', children = 'Записаться' }) {
|
||||
const { open } = useLeadModal();
|
||||
if (doctor.booking?.provider === 'medflex' && doctor.booking.url) {
|
||||
return (
|
||||
<a className={className} href={doctor.booking.url} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={className} onClick={() => open({ form: 'appointment', doctor })}>
|
||||
{children}
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
.doctor-picker__trigger, .doctor-picker__fixed {
|
||||
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;
|
||||
}
|
||||
.doctor-picker__trigger {
|
||||
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__search:focus-within { border-color: var(--coral); box-shadow: 0 0 0 3px rgba(249,94,82,.08); }
|
||||
.doctor-picker__search input:focus-visible { outline: none; }
|
||||
.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 { position: relative; 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, .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; }
|
||||
.doctor-picker__direction { padding: 11px 9px; font-size: 12px; }
|
||||
.doctor-picker__doctor { padding: 9px 5px; gap: 9px; }
|
||||
}
|
||||
@ -1,154 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Не кнопка и не disabled-селект: выбранный в каталоге врач не меняется в этой записи.
|
||||
export function DoctorSummary({ doctor }) {
|
||||
return (
|
||||
<div className="lead-field lead-field--doctor doctor-picker">
|
||||
<span className="lead-field__label">Специалист</span>
|
||||
<div className="doctor-picker__fixed" data-fixed-doctor-id={doctor.id}>
|
||||
<Avatar doctor={doctor} />
|
||||
<span className="doctor-picker__selection">
|
||||
<span className="doctor-picker__name">{doctor.full_name}</span>
|
||||
{doctor.position && <span className="doctor-picker__role">{doctor.position}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 results = 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 || query.trim() || !expanded || !results.current) return;
|
||||
const group = [...results.current.children].find((element) => element.dataset.group === expanded);
|
||||
if (typeof group?.offsetTop === 'number') results.current.scrollTop = group.offsetTop;
|
||||
}, [expanded, open, query]);
|
||||
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" ref={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} data-group={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>
|
||||
);
|
||||
}
|
||||
@ -115,9 +115,7 @@ export default function Header() {
|
||||
|
||||
{openMenu && (
|
||||
<div className="header__mega">
|
||||
{/* Каталоги имеют разную структуру: при смене меню сбрасываем
|
||||
данные и отменяем запрос предыдущего, не меняя лишь заголовок. */}
|
||||
<MegaMenu key={openMenu} menu={openMenu} onNavigate={() => setOpenMenu(null)} />
|
||||
<MegaMenu menu={openMenu} onNavigate={() => setOpenMenu(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -45,7 +45,7 @@ function Chips({ chips }) {
|
||||
}
|
||||
|
||||
// Один слайд. Три вида: готовая картинка, текст на цветном фоне и слайд с фото врача.
|
||||
function Slide({ banner, decorative = false, loadImage = true }) {
|
||||
function Slide({ banner, decorative = false }) {
|
||||
const layout = banner.layout || 'image';
|
||||
const theme = THEMES.has(banner.theme) ? banner.theme : 'navy';
|
||||
const alt = decorative ? '' : banner.title || 'Предложение клиники DANA';
|
||||
@ -54,10 +54,10 @@ function Slide({ banner, decorative = false, loadImage = true }) {
|
||||
return (
|
||||
<div className="hero-slide hero-slide--image">
|
||||
{/* размытая копия заполняет края, когда на телефоне баннер показан целиком */}
|
||||
{loadImage && !banner.mobile_image && <img className="hero-slide__blur" src={banner.image} alt="" aria-hidden="true" decoding="async" />}
|
||||
{!banner.mobile_image && <img className="hero-slide__blur" src={banner.image} alt="" aria-hidden="true" />}
|
||||
<picture>
|
||||
{loadImage && banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />}
|
||||
{loadImage && <img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
|
||||
{banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />}
|
||||
<img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} />
|
||||
</picture>
|
||||
</div>
|
||||
);
|
||||
@ -69,7 +69,7 @@ function Slide({ banner, decorative = false, loadImage = true }) {
|
||||
<span className="hero-slide__glow hero-slide__glow--warm" aria-hidden="true" />
|
||||
<span className="hero-slide__glow hero-slide__glow--deep" aria-hidden="true" />
|
||||
<img className="hero-slide__letters" src={lettersImage} alt="" aria-hidden="true" />
|
||||
{loadImage && banner.image && <img className="hero-slide__photo" src={banner.image} alt={decorative ? '' : banner.subtitle || ''} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
|
||||
{banner.image && <img className="hero-slide__photo" src={banner.image} alt={decorative ? '' : banner.subtitle || ''} />}
|
||||
<div className="hero-slide__copy">
|
||||
{banner.badge && <p className="hero-slide__badge">{banner.badge}</p>}
|
||||
<p className="hero-slide__title">
|
||||
@ -113,12 +113,6 @@ export default function HeroSlider({ banners }) {
|
||||
const touch = useRef(null);
|
||||
|
||||
const current = looped ? (((pos - CLONES) % count) + count) % count : 0;
|
||||
const [visited, setVisited] = useState(() => new Set());
|
||||
useEffect(() => {
|
||||
// Загружаем текущий баннер и двух видимых соседей. Уже загруженные сохраняем.
|
||||
const ids = [current - 1, current, current + 1].map((index) => banners[(index + count) % count]?.id);
|
||||
setVisited((previous) => ids.every((id) => previous.has(id)) ? previous : new Set([...previous, ...ids]));
|
||||
}, [current, banners, count]);
|
||||
const items = looped
|
||||
? [...banners.slice(-CLONES), ...banners, ...banners.slice(0, CLONES)].map((b, i) => ({ b, i }))
|
||||
: banners.map((b, i) => ({ b, i }));
|
||||
@ -206,7 +200,7 @@ export default function HeroSlider({ banners }) {
|
||||
onClickCapture={active ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); go(i < pos ? -1 : 1); }}
|
||||
>
|
||||
<SlideLink href={b.link_url} tabIndex={active ? undefined : -1}>
|
||||
<Slide banner={b} decorative={!active} loadImage={Math.abs(i - pos) <= 1 || visited.has(b.id)} />
|
||||
<Slide banner={b} decorative={!active} />
|
||||
</SlideLink>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,15 +11,6 @@ import { getConsent, hit, startMetrika } from '../lib/metrika.js';
|
||||
export default function Layout() {
|
||||
const { pathname, search, state } = useLocation();
|
||||
|
||||
// Иначе браузер после нашего scrollTo/scrollIntoView восстанавливает старую
|
||||
// позицию документа и при «Назад» снова уводит посетителя к низу страницы.
|
||||
useEffect(() => {
|
||||
if (!window.history || !('scrollRestoration' in window.history)) return undefined;
|
||||
const previous = window.history.scrollRestoration;
|
||||
window.history.scrollRestoration = 'manual';
|
||||
return () => { window.history.scrollRestoration = previous; };
|
||||
}, []);
|
||||
|
||||
// Метрика — только если посетитель уже согласился раньше; иначе ждём выбора в баннере.
|
||||
useEffect(() => {
|
||||
if (getConsent() === 'all') startMetrika();
|
||||
|
||||
@ -144,6 +144,24 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lead-field__select--date img {
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
/* нативный календарь оставляем, но его значок прячем под иконкой из макета */
|
||||
.lead-field__select--date input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lead-field__select--date input {
|
||||
padding: 0 44px 0 14px;
|
||||
}
|
||||
|
||||
.lead-rating {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@ -283,32 +301,124 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lead-modal__availability {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
background: var(--chip);
|
||||
border-radius: var(--r-12);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--navy);
|
||||
/* слоты по часам: выбор удобного часа приёма */
|
||||
.lead-slots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lead-modal__availability--warning {
|
||||
border: 1px solid #f3a398;
|
||||
border-left: 4px solid var(--coral);
|
||||
background: #ffe3df;
|
||||
color: #92291f;
|
||||
.lead-slot {
|
||||
height: 40px;
|
||||
padding: 0 4px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--chip-border);
|
||||
border-radius: var(--r-12);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--navy);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Расширяем окно только для реального расписания выбранного врача. */
|
||||
.lead-slot:hover:not(:disabled) {
|
||||
border-color: var(--coral);
|
||||
}
|
||||
|
||||
.lead-slot--on {
|
||||
background: var(--coral);
|
||||
border-color: var(--coral);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.lead-slot:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.lead-slots__hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted, #7a8699);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.lead-slots {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- запись на приём: широкое окно в две колонки ----------
|
||||
24 слота по 30 минут в одну колонку не помещались на экран ноутбука — окно приходилось прокручивать. */
|
||||
.lead-modal--wide {
|
||||
width: 920px;
|
||||
}
|
||||
|
||||
.lead-modal__form--split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'contacts date'
|
||||
'doctor time'
|
||||
'comment time';
|
||||
align-items: start;
|
||||
column-gap: 32px;
|
||||
row-gap: 18px;
|
||||
}
|
||||
|
||||
/* всё остальное (согласие, кнопка, подсказки) — на всю ширину под колонками */
|
||||
.lead-modal__form--split > * {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.lead-modal__form--split > .lead-modal__contacts {
|
||||
grid-area: contacts;
|
||||
/* в половине окна имя и телефон рядом не помещаются — друг под другом */
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 18px;
|
||||
}
|
||||
|
||||
.lead-modal__form--split > .lead-field--doctor {
|
||||
grid-area: doctor;
|
||||
}
|
||||
|
||||
.lead-modal__form--split > .lead-field--comment {
|
||||
grid-area: comment;
|
||||
}
|
||||
|
||||
.lead-modal__form--split > .lead-field--date {
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.lead-modal__form--split > .lead-field--time {
|
||||
grid-area: time;
|
||||
}
|
||||
|
||||
.lead-modal__form--split .lead-slot {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* узкий экран: снова одна колонка */
|
||||
@media (max-width: 960px) {
|
||||
.lead-modal--wide {
|
||||
width: 650px;
|
||||
}
|
||||
.lead-modal__form--split {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* телефон: слоты по 6 в ряд — 4 ряда вместо 6 */
|
||||
@media (max-width: 600px) {
|
||||
.lead-modal__form--split .lead-slots {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.lead-modal__form--split .lead-slot {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom';
|
||||
import closeIcon from '../assets/figma/icon-close.svg';
|
||||
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
|
||||
import calendarIcon from '../assets/figma/icon-calendar.svg';
|
||||
import checkIcon from '../assets/figma/icon-check.svg';
|
||||
import dotGreen from '../assets/figma/icon-dot-green.svg';
|
||||
import starIcon from '../assets/figma/icon-star.svg';
|
||||
@ -9,18 +10,12 @@ 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, { DoctorSummary } from './DoctorPicker.jsx';
|
||||
import { todayKey } from '../lib/slots.js';
|
||||
import './LeadModal.css';
|
||||
|
||||
const COPY = {
|
||||
checkup: {
|
||||
title: 'Записаться на чек-ап',
|
||||
subtitle: 'Оставьте контакты — администратор согласует дату и расскажет о подготовке',
|
||||
},
|
||||
appointment: {
|
||||
title: 'Записаться на приём',
|
||||
subtitle: 'Выберите дату — покажем врачей со свободными окнами на этот день',
|
||||
subtitle: 'Оставьте контакты — администратор поможет выбрать врача и время',
|
||||
},
|
||||
callback: {
|
||||
title: 'Обратный звонок',
|
||||
@ -36,102 +31,94 @@ const COPY = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function LeadModal({ form = 'appointment', doctor, slot, service, specialty, program, message = '', onClose }) {
|
||||
// Часы работы из настроек сайта («Пн–Вс: 08:00–20:00») → слоты по часам: 08:00 … 19:00.
|
||||
// Пока нет расписания 1С, это пожелание пациента; администратор подтверждает время звонком.
|
||||
function hourSlots(hours) {
|
||||
const m = /(\d{1,2}):\d{2}\s*[–—-]\s*(\d{1,2}):\d{2}/.exec(hours || '');
|
||||
let from = m ? Number(m[1]) : 8;
|
||||
let to = m ? Number(m[2]) : 20;
|
||||
if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20];
|
||||
const list = [];
|
||||
for (let h = from; h < to; h++) {
|
||||
const hh = String(h).padStart(2, '0');
|
||||
list.push(`${hh}:00`);
|
||||
list.push(`${hh}:30`);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function todayISO() {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function LeadModal({ form = 'appointment', doctor, slot, service, specialty, message = '', onClose }) {
|
||||
const copy = COPY[form] || COPY.appointment;
|
||||
const { hours } = useSiteData();
|
||||
const slots = useMemo(() => hourSlots(hours), [hours]);
|
||||
const uid = useId();
|
||||
const dialogRef = useRef(null);
|
||||
const firstFieldRef = useRef(null);
|
||||
|
||||
// врач с записью через 1С: сначала выбор окна, потом контакты; если окон нет или 1С не отвечает — обычная заявка
|
||||
const [nativeOff, setNativeOff] = useState(false);
|
||||
const native = form === 'appointment' && doctor?.booking?.provider === 'native' && !nativeOff;
|
||||
const onNativeUnavailable = useCallback(() => setNativeOff(true), []);
|
||||
|
||||
const needsDoctor = form === 'appointment' || form === 'review';
|
||||
// Карточка врача открывает запись только к нему; общая форма сохраняет выбор.
|
||||
const fixedDoctor = form === 'appointment' && Boolean(doctor?.id);
|
||||
// запись на конкретную услугу: в списке только её врачи и врачи её специальности
|
||||
const scoped = Boolean(service && !doctor && form === 'appointment');
|
||||
const [doctors, setDoctors] = useState([]);
|
||||
const [doctorsLoaded, setDoctorsLoaded] = useState(false);
|
||||
const [unavailableDoctorId, setUnavailableDoctorId] = useState(null);
|
||||
const [requestedSlot, setRequestedSlot] = useState(slot);
|
||||
const [date, setDate] = useState(slot?.start?.slice(0, 10) || '');
|
||||
const [availability, setAvailability] = useState({ loading: true, days: [], error: false });
|
||||
const [values, setValues] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
doctorId: doctor?.id ? String(doctor.id) : '',
|
||||
date: '',
|
||||
time: '',
|
||||
message,
|
||||
rating: 5,
|
||||
consent: false,
|
||||
});
|
||||
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 = fixedDoctor ? doctor : chosenDoctor;
|
||||
const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native'
|
||||
&& unavailableDoctorId !== selectedDoctor.id;
|
||||
const onNativeUnavailable = useCallback(
|
||||
() => setUnavailableDoctorId(selectedDoctor?.id ?? null),
|
||||
[selectedDoctor?.id],
|
||||
);
|
||||
|
||||
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: '' });
|
||||
}
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[key]: key === 'phone' ? formatPhoneInput(v) : v,
|
||||
// другая дата — выбранный час мог стать прошедшим
|
||||
...(key === 'date' ? { time: '' } : {}),
|
||||
}));
|
||||
};
|
||||
|
||||
const changeDate = (e) => {
|
||||
setDate(e.target.value);
|
||||
setRequestedSlot(undefined);
|
||||
setUnavailableDoctorId(null);
|
||||
setStatus({ state: 'idle', message: '' });
|
||||
// на сегодня — только слоты в будущем
|
||||
const isPast = (slot) => {
|
||||
if (values.date !== todayISO()) return false;
|
||||
const now = new Date();
|
||||
const [h, m] = slot.split(':').map(Number);
|
||||
const slotMinutes = h * 60 + (m || 0);
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
return slotMinutes <= nowMinutes;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (form !== 'appointment') return undefined;
|
||||
const ctrl = new AbortController();
|
||||
setAvailability((previous) => ({ ...previous, loading: true, error: false }));
|
||||
api.bookingAvailability({ signal: ctrl.signal })
|
||||
.then(({ data }) => {
|
||||
if (!ctrl.signal.aborted) setAvailability({ loading: false, days: data?.enabled ? data.days || [] : [], error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!ctrl.signal.aborted) setAvailability({ loading: false, days: [], error: true });
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [form, date]);
|
||||
|
||||
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 })
|
||||
: specialty ? api.doctors({ specialty }, { signal: ctrl.signal }) : Promise.resolve({ data: [] });
|
||||
load
|
||||
.then(({ data }) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const byId = new Map();
|
||||
for (const d of [...(data || []), ...own]) {
|
||||
if (!d.absent && !byId.has(d.id)) byId.set(d.id, d);
|
||||
}
|
||||
for (const d of [...own, ...(data || [])]) if (!byId.has(d.id)) byId.set(d.id, d);
|
||||
setDoctors([...byId.values()]);
|
||||
})
|
||||
.catch(() => { if (!ctrl.signal.aborted) setDoctors(own); })
|
||||
.finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); });
|
||||
.catch(() => setDoctors(own))
|
||||
.finally(() => setDoctorsLoaded(true));
|
||||
return () => ctrl.abort();
|
||||
}, [needsDoctor, fixedDoctor, doctor, scoped, service, specialty]);
|
||||
}, [needsDoctor, scoped, service, specialty]);
|
||||
|
||||
// Escape, фокус внутри окна, блокировка прокрутки страницы под ним
|
||||
useEffect(() => {
|
||||
@ -143,9 +130,7 @@ 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')]
|
||||
.filter((element) => !element.disabled && !element.closest('[hidden]')
|
||||
&& (typeof element.getClientRects !== 'function' || element.getClientRects().length > 0));
|
||||
const focusable = dialogRef.current.querySelectorAll('button, [href], input, select, textarea');
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
@ -167,41 +152,15 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
}, [onClose]);
|
||||
|
||||
const doctorOptions = useMemo(
|
||||
() => {
|
||||
const allowed = date && form === 'appointment'
|
||||
? new Set(availability.days.find((day) => day.date === date)?.doctor_ids || []) : null;
|
||||
return [...doctors].filter((d) => !allowed || allowed.has(d.id))
|
||||
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru'));
|
||||
},
|
||||
[doctors, date, form, availability.days],
|
||||
() => [...doctors].sort((a, b) => a.full_name.localeCompare(b.full_name, 'ru')),
|
||||
[doctors],
|
||||
);
|
||||
|
||||
const nameRequired = form !== 'callback';
|
||||
const textRequired = form === 'review';
|
||||
const availabilityMessage = date && availability.error
|
||||
? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
|
||||
: date && !availability.loading && doctorsLoaded && doctorOptions.length === 0
|
||||
? 'На эту дату свободных окон нет. Выберите другую дату.'
|
||||
: doctorsLoaded && doctorOptions.length === 0
|
||||
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
|
||||
: date ? 'Выберите врача из списка — покажем его свободные часы на выбранную дату.' : '';
|
||||
// Дата фильтрует окна, но не отменяет сделанный посетителем выбор врача.
|
||||
const selectedDateMessage = selectedDoctor && date
|
||||
? availability.loading ? 'Проверяем свободные окна выбранного врача…'
|
||||
: availability.error ? 'Не удалось проверить расписание. Попробуйте выбрать дату ещё раз.'
|
||||
: !availability.days.find((day) => day.date === date)?.doctor_ids.includes(selectedDoctor.id)
|
||||
? fixedDoctor
|
||||
? 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату.'
|
||||
: 'У выбранного врача на эту дату свободных окон нет. Выберите другую дату или другого специалиста.' : ''
|
||||
: '';
|
||||
const appointmentMessage = selectedDoctor ? selectedDateMessage : availabilityMessage;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (form === 'appointment' && !selectedDoctor) {
|
||||
setStatus({ state: 'error', message: 'Сначала выберите врача' });
|
||||
return;
|
||||
}
|
||||
if (nameRequired && !values.name.trim()) {
|
||||
setStatus({ state: 'error', message: 'Укажите, как к вам обращаться' });
|
||||
return;
|
||||
@ -220,21 +179,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (values.date) extra.date = values.date;
|
||||
if (values.time) extra.time = values.time;
|
||||
if (form === 'review') extra.rating = values.rating;
|
||||
if (form === 'checkup' && program) {
|
||||
extra.checkup_slug = program.slug;
|
||||
extra.checkup_name = program.name;
|
||||
}
|
||||
|
||||
setStatus({ state: 'sending', message: '' });
|
||||
try {
|
||||
const { data } = await api.createLead({
|
||||
// Заявка на программу использует существующую форму записи,
|
||||
// а не создание приёма конкретного врача в 1С.
|
||||
form: form === 'checkup' ? 'appointment' : form,
|
||||
form,
|
||||
name: values.name.trim(),
|
||||
phone: values.phone,
|
||||
doctor_id: fixedDoctor ? doctor.id : values.doctorId ? Number(values.doctorId) : undefined,
|
||||
doctor_id: values.doctorId ? Number(values.doctorId) : undefined,
|
||||
service_id: service?.id,
|
||||
message: values.message.trim(),
|
||||
extra,
|
||||
@ -248,7 +203,10 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
};
|
||||
|
||||
const titleId = `${uid}-title`;
|
||||
const wide = native;
|
||||
// запись на приём: окно шире, чтобы дни и время помещались без прокрутки; форма заявки — в две колонки
|
||||
// (контакты слева, дата и время справа)
|
||||
const wide = form === 'appointment' && status.state !== 'done';
|
||||
const split = wide && !native;
|
||||
|
||||
return (
|
||||
<div className="lead-modal__backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
@ -265,8 +223,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="lead-modal__subtitle">
|
||||
{(form === 'appointment' ? selectedDoctor : doctor)
|
||||
? `Врач: ${(form === 'appointment' ? selectedDoctor : doctor).full_name}` : copy.subtitle}
|
||||
{doctor ? `Врач: ${doctor.full_name}` : copy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="lead-modal__close" onClick={onClose} aria-label="Закрыть">
|
||||
@ -274,43 +231,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form === 'appointment' && status.state !== 'done' && (
|
||||
<label className="lead-field">
|
||||
<span className="lead-field__label">Дата приёма</span>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
className="lead-field__control"
|
||||
type="date"
|
||||
min={todayKey()}
|
||||
value={date}
|
||||
onInput={changeDate}
|
||||
onChange={changeDate}
|
||||
/>
|
||||
<span className="lead-modal__subtitle">{selectedDoctor ? 'Выберите дату — покажем свободные окна выбранного врача.' : 'Можно выбрать дату и увидеть только свободных в этот день врачей.'}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form === 'appointment' && status.state !== 'done' && (fixedDoctor ? (
|
||||
<DoctorSummary doctor={selectedDoctor} />
|
||||
) : (
|
||||
<DoctorPicker
|
||||
doctors={selectedDoctor && !doctorOptions.some((d) => d.id === selectedDoctor.id) ? [selectedDoctor, ...doctorOptions] : doctorOptions}
|
||||
value={values.doctorId}
|
||||
onChange={set('doctorId')}
|
||||
date={date}
|
||||
loading={!doctorsLoaded}
|
||||
disabled={Boolean(date && availability.loading)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{form === 'appointment' && (!selectedDoctor || selectedDateMessage) ? (
|
||||
appointmentMessage ? (
|
||||
<p className={`lead-modal__availability${(selectedDoctor && date && !availability.loading) || (doctorsLoaded && !date && doctorOptions.length === 0) ? ' lead-modal__availability--warning' : ''}`} role="status">
|
||||
{appointmentMessage}
|
||||
</p>
|
||||
) : null
|
||||
) : native ? (
|
||||
<NativeBooking key={`${selectedDoctor.id}:${date}`} doctor={selectedDoctor} filterDate={date} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
|
||||
{native ? (
|
||||
<NativeBooking doctor={doctor} initialSlot={slot} onClose={onClose} onUnavailable={onNativeUnavailable} />
|
||||
) : status.state === 'done' ? (
|
||||
<div className="lead-modal__done" role="status">
|
||||
<p>{status.message}</p>
|
||||
@ -319,20 +241,12 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="lead-modal__form" onSubmit={submit} noValidate>
|
||||
{form === 'appointment' && (
|
||||
<p className="lead-modal__availability lead-modal__availability--warning" role="status">
|
||||
Свободные окна этого врача сейчас недоступны для онлайн-записи. Оставьте контакты — администратор уточнит расписание.
|
||||
</p>
|
||||
)}
|
||||
{form === 'checkup' && program?.name && (
|
||||
<p className="lead-modal__availability">Программа: <strong>{program.name}</strong></p>
|
||||
)}
|
||||
<form className={`lead-modal__form${split ? ' lead-modal__form--split' : ''}`} onSubmit={submit} noValidate>
|
||||
<div className="lead-modal__row lead-modal__contacts">
|
||||
<label className="lead-field">
|
||||
<span className="lead-field__label">Ваше имя</span>
|
||||
<input
|
||||
ref={form === 'appointment' ? undefined : firstFieldRef}
|
||||
ref={firstFieldRef}
|
||||
className="lead-field__control"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
@ -355,13 +269,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form === 'review' && (
|
||||
{needsDoctor && !(scoped && doctorsLoaded && doctorOptions.length === 0) && (
|
||||
<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')}>
|
||||
<option value="">
|
||||
Выберите врача, если отзыв о нём
|
||||
{form === 'review' ? 'Выберите врача, если отзыв о нём' : scoped ? 'Любой врач по этой услуге' : 'Выберите направление или врача'}
|
||||
</option>
|
||||
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||
<option value={doctor.id}>{doctor.full_name}</option>
|
||||
@ -378,6 +292,55 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form === 'appointment' && (
|
||||
<>
|
||||
<label className="lead-field lead-field--date">
|
||||
<span className="lead-field__label">Дата</span>
|
||||
<span className="lead-field__select lead-field__select--date">
|
||||
<input
|
||||
className="lead-field__control"
|
||||
type="date"
|
||||
min={todayISO()}
|
||||
value={values.date}
|
||||
onChange={set('date')}
|
||||
/>
|
||||
<img src={calendarIcon} alt="" width="18" height="18" />
|
||||
</span>
|
||||
</label>
|
||||
<div className="lead-field lead-field--time">
|
||||
<span className="lead-field__label" id={`${uid}-time`}>
|
||||
Удобное время
|
||||
</span>
|
||||
{values.date ? (
|
||||
<div className="lead-slots" role="radiogroup" aria-labelledby={`${uid}-time`}>
|
||||
{slots
|
||||
.filter((t) => !isPast(t))
|
||||
.map((t) => {
|
||||
const on = values.time === t;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={`lead-slot${on ? ' lead-slot--on' : ''}`}
|
||||
onClick={() => setValues((p) => ({ ...p, time: on ? '' : t }))}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{values.date === todayISO() && slots.filter((t) => !isPast(t)).length === 0 && (
|
||||
<p className="lead-slots__hint">На сегодня свободного времени уже нет. Пожалуйста, выберите другую дату.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lead-slots__hint">Сначала выберите дату — покажем время приёма</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form === 'review' && (
|
||||
<div className="lead-field">
|
||||
<span className="lead-field__label">Оценка</span>
|
||||
@ -399,7 +362,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form !== 'callback' && form !== 'checkup' && (
|
||||
{form !== 'callback' && (
|
||||
<label className="lead-field lead-field--comment">
|
||||
<span className="lead-field__label">{form === 'review' ? 'Текст отзыва' : 'Комментарий'}</span>
|
||||
<textarea
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import checkIcon from '../assets/figma/icon-check.svg';
|
||||
import chevronIcon from '../assets/figma/icon-accordion-chevron.svg';
|
||||
import { api } from '../api.js';
|
||||
import { dayLabel, groupByDay, longDate, newRequestId, slotDate, slotTime, todayKey } from '../lib/slots.js';
|
||||
import { formatPhoneInput, phoneComplete } from '../lib/phone.js';
|
||||
@ -52,12 +53,14 @@ export function NextSlots({ doctor, onPick, limit = 6 }) {
|
||||
|
||||
// Запись в два шага: 1) окно, 2) контакты, подтверждение звонком и согласие.
|
||||
// onUnavailable — 1С не отвечает или окон нет: окно заявки возвращается к обычной форме.
|
||||
export default function NativeBooking({ doctor, initialSlot, filterDate = '', onClose, onUnavailable }) {
|
||||
export default function NativeBooking({ doctor, initialSlot, onClose, onUnavailable }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [slots, setSlots] = useState([]);
|
||||
const [services, setServices] = useState([]);
|
||||
const [day, setDay] = useState(initialSlot ? slotDate(initialSlot) : '');
|
||||
const [branch, setBranch] = useState('');
|
||||
const [chosen, setChosen] = useState(initialSlot || null);
|
||||
const [serviceId, setServiceId] = useState('');
|
||||
const [values, setValues] = useState({ name: '', phone: '', comment: '', consent: false });
|
||||
const [status, setStatus] = useState({ state: 'idle', message: '' });
|
||||
const [notice, setNotice] = useState('');
|
||||
@ -72,25 +75,19 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
|
||||
api
|
||||
.doctorSlots(doctor.slug, { signal: ctrl.signal })
|
||||
.then(({ data }) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now()
|
||||
&& (!filterDate || slotDate(s) === filterDate));
|
||||
if (!data?.enabled || !actualSlots.length) {
|
||||
if (!data?.enabled || !data.slots?.length) {
|
||||
unavailable.current();
|
||||
return;
|
||||
}
|
||||
setSlots(actualSlots);
|
||||
// Окно из карточки тоже должно оставаться свободным в свежем ответе 1С.
|
||||
setChosen(initialSlot ? actualSlots.find((s) => s.start === initialSlot.start && s.branch_id === initialSlot.branch_id) || null : null);
|
||||
// Imported catalog has no verified doctor-to-service bindings.
|
||||
// Book the real doctor/slot without assigning an arbitrary procedure.
|
||||
setSlots(data.slots);
|
||||
setServices(data.services || []);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
|
||||
if (e.name !== 'AbortError') unavailable.current();
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [doctor.slug, filterDate]);
|
||||
}, [doctor.slug]);
|
||||
|
||||
const branches = useMemo(() => {
|
||||
const m = new Map();
|
||||
@ -103,7 +100,7 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
|
||||
return slots.filter((s) => {
|
||||
if (branch && s.branch_id !== branch) return false;
|
||||
const t = new Date(s.start).getTime();
|
||||
return s.status === 'free' && t > now;
|
||||
return isNaN(t) || t > now;
|
||||
});
|
||||
}, [slots, branch]);
|
||||
const days = useMemo(() => groupByDay(visible), [visible]);
|
||||
@ -126,6 +123,7 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
|
||||
doctor_slug: doctor.slug,
|
||||
start: chosen.start,
|
||||
branch_id: chosen.branch_id,
|
||||
service_id: serviceId || undefined,
|
||||
name: values.name.trim(),
|
||||
phone: values.phone,
|
||||
verification_id: verificationId,
|
||||
@ -277,7 +275,7 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="native-booking__hint">Выберите свободное окно врача — на следующем шаге укажете контакты.</p>
|
||||
<p className="native-booking__hint">Выберите удобное время — на следующем шаге укажете контакты.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -366,6 +364,23 @@ export default function NativeBooking({ doctor, initialSlot, filterDate = '', on
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{services.length > 1 && (
|
||||
<label className="lead-field">
|
||||
<span className="lead-field__label">Услуга</span>
|
||||
<span className="lead-field__select">
|
||||
<select className="lead-field__control" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||
<option value="">Выберите, если знаете</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<img src={chevronIcon} alt="" width="16" height="16" />
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="lead-consent">
|
||||
<input type="checkbox" checked={values.consent} onChange={set('consent')} />
|
||||
<span className="lead-consent__box" aria-hidden="true">
|
||||
|
||||
@ -26,11 +26,9 @@ function normalizeAddress(address) {
|
||||
|
||||
export function SiteDataProvider({ children }) {
|
||||
const initial = useInitialApiData('site');
|
||||
const receivedAt = useInitialApiData('__receivedAt');
|
||||
const [value, setValue] = useState(initial || FALLBACK);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial && receivedAt && Date.now() - receivedAt < 30_000) return undefined;
|
||||
const ctrl = new AbortController();
|
||||
const opts = { signal: ctrl.signal };
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import App from './App.jsx';
|
||||
import { ApiDataProvider } from './context/ApiData.jsx';
|
||||
import { parseBlocks } from './lib/serviceContent.js';
|
||||
import { serviceMetadata, doctorMetadata, priceMetadata, pageMetadata, jsonForHTML } from './lib/seo.js';
|
||||
import { serviceSpecialty } from './lib/media.js';
|
||||
import { DIRECTION_MEDIA, directionOf } from './lib/media.js';
|
||||
export { parseBlocks };
|
||||
|
||||
export function siteFromCommon(common) {
|
||||
@ -27,7 +27,7 @@ export function renderPage(url, data, siteURL) {
|
||||
const service = initial[`service:${servicePath}`]?.data;
|
||||
if (service) {
|
||||
service.content_sections = parseBlocks(service.blocks);
|
||||
const specialty = serviceSpecialty(service.path);
|
||||
const specialty = DIRECTION_MEDIA[directionOf(service.path)]?.specialty;
|
||||
initial[`doctors:${specialty}`] = { data: (initial.doctors?.data || []).filter((d) => d.specialties?.some((s) => s.slug === specialty)), meta: {} };
|
||||
}
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
@ -82,7 +82,7 @@ export function renderPage(url, data, siteURL) {
|
||||
|
||||
export function renderService(service, parent, common, siteURL) {
|
||||
service.content_sections = parseBlocks(service.blocks);
|
||||
const specialty = serviceSpecialty(service.path);
|
||||
const specialty = DIRECTION_MEDIA[directionOf(service.path)]?.specialty;
|
||||
const initial = {
|
||||
prerendered: true, siteURL, site: common.site,
|
||||
[`service:${service.path}`]: { data: service, meta: {} },
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
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')) }));
|
||||
}
|
||||
@ -12,7 +12,6 @@ import svcProkto from '../assets/figma/svc-proktologiya.webp';
|
||||
import svcUro from '../assets/figma/svc-urologiya.webp';
|
||||
import svcSurgery from '../assets/figma/svc-khirurgiya.webp';
|
||||
import svcEndo from '../assets/figma/svc-endokrinologiya.webp';
|
||||
import svcDental from '../assets/figma/svc-stomatologiya.svg';
|
||||
|
||||
import ckGirl from '../assets/figma/ck-zdorovaya-devochka.webp';
|
||||
import ckWoman from '../assets/figma/ck-zhenskoe-zdorove.webp';
|
||||
@ -81,8 +80,6 @@ export const DIRECTION_MEDIA = {
|
||||
urologiya: { image: svcUro, specialty: 'urologi' },
|
||||
khirurgiya: { image: svcSurgery, specialty: 'khirurgi' },
|
||||
endokrinologiya: { image: svcEndo, specialty: 'endokrinologi' },
|
||||
// У направления — только явно привязанные стоматологи, не вся детская клиника.
|
||||
stomatologiya: { image: svcDental },
|
||||
};
|
||||
|
||||
// Отдельные услуги, для которых в макете нарисован свой герой.
|
||||
@ -94,15 +91,6 @@ export function directionOf(path) {
|
||||
return String(path || '').split('/')[0];
|
||||
}
|
||||
|
||||
// Узкий профиль услуги важнее широкого раздела «Детская клиника».
|
||||
// Только явные соответствия каталогу: не угадываем специальность по тексту.
|
||||
const SERVICE_SPECIALTIES = {
|
||||
'detskaya-klinika/priem-detskogo-oftalmologa': 'oftalmologi',
|
||||
};
|
||||
export function serviceSpecialty(path) {
|
||||
return SERVICE_SPECIALTIES[path] || DIRECTION_MEDIA[directionOf(path)]?.specialty;
|
||||
}
|
||||
|
||||
// Фото, загруженное через админку (/media/uploads/…), главнее картинки макета — как у чек-апов.
|
||||
// Старые картинки, перенесённые из Битрикса (/media/iblock/…), макет не перебивают.
|
||||
const isUploaded = (src) => typeof src === 'string' && src.includes('/media/uploads/');
|
||||
|
||||
@ -9,7 +9,6 @@ export const DIRECTION_PRICE_SECTIONS = {
|
||||
proktologiya: 'proktologiya-price',
|
||||
urologiya: 'urologiya-price',
|
||||
khirurgiya: 'hirurgiya-price',
|
||||
stomatologiya: 'stomatologiya',
|
||||
};
|
||||
|
||||
function flatten(tree) { return (tree || []).flatMap((s) => [s, ...flatten(s.children)]); }
|
||||
|
||||
@ -5,31 +5,22 @@ import { useInitialApiData } from '../context/ApiData.jsx';
|
||||
// и не перетирает результат нового.
|
||||
export function useApi(load, deps, key) {
|
||||
const initial = useInitialApiData(key);
|
||||
const receivedAt = useInitialApiData('__receivedAt');
|
||||
const [state, setState] = useState(() => initial
|
||||
? { ...initial, loading: false, error: null }
|
||||
: { data: null, meta: {}, loading: true, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
// Сервер уже прислал эти публичные данные: при гидратации второй запрос не нужен.
|
||||
// Через 30 секунд при следующем открытии компонента снова идём в API.
|
||||
if (initial && receivedAt && Date.now() - receivedAt < 30_000) {
|
||||
setState({ ...initial, loading: false, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
load(ctrl.signal)
|
||||
.then(({ data, meta }) => {
|
||||
if (!ctrl.signal.aborted) setState({ data, meta, loading: false, error: null });
|
||||
})
|
||||
.then(({ data, meta }) => setState({ data, meta, loading: false, error: null }))
|
||||
.catch((error) => {
|
||||
if (ctrl.signal.aborted || error.name === 'AbortError') return;
|
||||
if (error.name === 'AbortError') return;
|
||||
setState({ data: null, meta: {}, loading: false, error });
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, ...deps]);
|
||||
}, deps);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@ -17,8 +17,6 @@ import { ApiDataProvider } from './context/ApiData.jsx';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
const initialData = JSON.parse(document.getElementById('page-data')?.textContent || '{}');
|
||||
// Время получения публичного SSR-снимка. Не кэшируем данные админки и расписание.
|
||||
initialData.__receivedAt = Date.now();
|
||||
const app = (
|
||||
<React.StrictMode>
|
||||
<ApiDataProvider value={initialData}>
|
||||
|
||||
@ -32,7 +32,7 @@ export default function CheckupPage() {
|
||||
const intro = introText(c.blocks);
|
||||
const lead = cleanText(c.lead);
|
||||
|
||||
const book = () => open({ form: 'checkup', program: { name, slug: c.slug }, message: `Чек-ап: ${name}` });
|
||||
const book = () => open({ form: 'appointment', message: `Чек-ап: ${name}` });
|
||||
const askPrice = () => open({ form: 'service_order', message: `Стоимость чек-апа: ${name}` });
|
||||
|
||||
// первый столбец списка заполняется сверху вниз, затем второй — как в макете
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, useNavigationType, useSearchParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import heroImage from '../assets/figma/checkups-hero-3d.webp';
|
||||
import { api } from '../api.js';
|
||||
import Breadcrumbs from '../components/Breadcrumbs.jsx';
|
||||
@ -30,29 +30,12 @@ export default function CheckupsPage() {
|
||||
usePageTitle('Чек-апы');
|
||||
const { open } = useLeadModal();
|
||||
const checkups = useApi((signal) => api.checkups({ signal }), [], 'checkups');
|
||||
const [params, setParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const listStart = useRef(null);
|
||||
const active = params.get('audience') === 'kids' ? 'kids' : 'adults';
|
||||
|
||||
useEffect(() => {
|
||||
if (navigationType === 'POP' && location.state?.checkupList) {
|
||||
listStart.current?.scrollIntoView({ block: 'start', behavior: 'instant' });
|
||||
}
|
||||
}, [location.key, navigationType]);
|
||||
const [active, setActive] = useState('adults');
|
||||
|
||||
const pick = () => open({ form: 'callback', message: 'Помогите подобрать чек-ап' });
|
||||
|
||||
// вкладки переключают группу: показываются программы только для взрослых или только детские
|
||||
const selectTab = (id) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (id === 'kids') next.set('audience', 'kids');
|
||||
else next.delete('audience');
|
||||
// Меняем текущую запись истории, чтобы «Назад» возвращал к списку,
|
||||
// а не перебирал ранее выбранные вкладки.
|
||||
setParams(next, { replace: true, state: { ...location.state, keepScroll: true, checkupList: true } });
|
||||
};
|
||||
const selectTab = (id) => setActive(id);
|
||||
|
||||
return (
|
||||
<div className="checkups-page">
|
||||
@ -73,7 +56,7 @@ export default function CheckupsPage() {
|
||||
|
||||
<section className="checkups-content">
|
||||
<div className="container checkups-content__inner">
|
||||
<h2 ref={listStart} className="checkups-content__title" style={{ scrollMarginTop: 24 }}>Программы чек-апов</h2>
|
||||
<h2 className="checkups-content__title">Программы чек-апов</h2>
|
||||
<p className="checkups-content__text">
|
||||
Выберите программу по возрасту и задаче. Полный состав обследования и актуальную стоимость уточнит
|
||||
администратор.
|
||||
|
||||
@ -47,7 +47,6 @@ export default function PromosPage() {
|
||||
{(promos.data || []).map((p) => {
|
||||
// связь с чек-апом задаётся в админке; сопоставление по адресу — запасной вариант
|
||||
const checkup = p.checkup_slug ? { slug: p.checkup_slug } : checkupBySlug.get(baseSlug(p.slug));
|
||||
const programName = checkup ? cleanText(checkupBySlug.get(baseSlug(checkup.slug))?.name || p.title) : '';
|
||||
const image = promoImage(p);
|
||||
const lead = cleanText(p.lead);
|
||||
return (
|
||||
@ -72,11 +71,7 @@ export default function PromosPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--coral promo-card__btn"
|
||||
onClick={() => open(checkup ? {
|
||||
form: 'checkup',
|
||||
program: { slug: checkup.slug, name: programName },
|
||||
message: `Чек-ап: ${programName}. Акция: ${cleanText(p.title)}`,
|
||||
} : { form: 'appointment', message: `Акция: ${p.title}` })}
|
||||
onClick={() => open({ form: 'appointment', message: `Акция: ${p.title}` })}
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
|
||||
@ -11,7 +11,7 @@ import { useSiteData } from '../context/SiteData.jsx';
|
||||
import { priceLabel, telHref } from '../lib/format.js';
|
||||
import { servicePath, specialtyPath, priceSectionPath } from '../lib/links.js';
|
||||
import { servicePriceSection } from '../lib/serviceNavigation.js';
|
||||
import { DIRECTION_MEDIA, SERVICE_IMAGES, directionOf, serviceSpecialty } from '../lib/media.js';
|
||||
import { DIRECTION_MEDIA, SERVICE_IMAGES, directionOf } from '../lib/media.js';
|
||||
import { URGENT_TITLE, parseBlocks } from '../lib/serviceContent.js';
|
||||
import { cleanText } from '../lib/text.js';
|
||||
import { useApi } from '../lib/useApi.js';
|
||||
@ -76,7 +76,7 @@ function DirectionView({ direction }) {
|
||||
className="btn btn--coral direction-card__btn"
|
||||
onClick={() => open({
|
||||
form: 'appointment', service: c, message: `Услуга: ${c.name}`,
|
||||
specialty: serviceSpecialty(c.path),
|
||||
specialty: DIRECTION_MEDIA[directionOf(direction.path)]?.specialty,
|
||||
})}
|
||||
>
|
||||
Записаться
|
||||
@ -106,7 +106,7 @@ function ServiceView({ service, parent }) {
|
||||
const media = DIRECTION_MEDIA[directionOf(service.path)];
|
||||
const image = SERVICE_IMAGES[service.path] || service.image || (!parent ? media?.image : '');
|
||||
const lead = cleanText(service.lead);
|
||||
const book = () => open({ form: 'appointment', service, specialty: serviceSpecialty(service.path), message: `Услуга: ${service.name}` });
|
||||
const book = () => open({ form: 'appointment', service, specialty: media?.specialty, message: `Услуга: ${service.name}` });
|
||||
|
||||
const crumbs = [
|
||||
{ to: '/', label: 'Главная' },
|
||||
@ -165,7 +165,7 @@ function ServiceView({ service, parent }) {
|
||||
|
||||
{(service.prices?.length > 0 || service.path.startsWith('estetic/') || priceSection) && <PricesSection prices={service.prices || []} service={service} priceSection={priceSection} />}
|
||||
|
||||
<ServiceDoctors service={service} specialtySlug={serviceSpecialty(service.path)} />
|
||||
<ServiceDoctors service={service} specialtySlug={media?.specialty} />
|
||||
<BookingSection service={service} />
|
||||
<script id="service-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonForHTML(serviceSchema(service, parent, site, siteURL,
|
||||
(service.doctors || []).filter((d) => !d.absent).sort((a, b) => Number(Boolean(b.photo)) - Number(Boolean(a.photo))).slice(0, 4))) }} />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user