From 4058a1636d1b247b4fa03ee83551f1db0f0d4945 Mon Sep 17 00:00:00 2001
From: Dana Medical Developer
Date: Wed, 14 Oct 2026 13:33:13 +0000
Subject: [PATCH] chore: initial commit of dana-medical production state
---
.deploy/dana-medical.caddy | 74 +
.deploy/remote-build-start-dana.sh | 33 +
.deploy/remote-init-dana.sh | 38 +
.deploy/remote-install-dana-caddy.sh | 40 +
.deploy/remote-restore-dana.sh | 15 +
.deploy/remote-secure-dana.sh | 12 +
.deploy/remote-smoke-dana.sh | 49 +
.deploy/remote-start-dana-db.sh | 21 +
.deploy/remote-update-dana-caddy.sh | 35 +
.deploy/remote-verify-dana-preview.sh | 34 +
.dockerignore | 13 +
.env.example | 19 +
.gitignore | 24 +
Dockerfile | 28 +
cmd/server/main.go | 140 +
deploy.env.example | 24 +
docker-compose.prod.yml | 53 +
docker-compose.yml | 44 +
go.mod | 17 +
go.sum | 30 +
internal/api/admin.go | 318 +++
internal/api/admin_banners.go | 161 ++
internal/api/admin_banners_test.go | 41 +
internal/api/admin_checkups.go | 147 +
internal/api/admin_doctors.go | 206 ++
internal/api/admin_documents.go | 230 ++
internal/api/admin_documents_test.go | 65 +
internal/api/admin_inbox.go | 198 ++
internal/api/admin_media.go | 120 +
internal/api/admin_partners.go | 119 +
internal/api/admin_partners_test.go | 55 +
internal/api/admin_posts.go | 174 ++
internal/api/admin_posts_test.go | 87 +
internal/api/admin_promos.go | 205 ++
internal/api/admin_promos_test.go | 92 +
internal/api/admin_redirects.go | 143 +
internal/api/admin_redirects_test.go | 23 +
internal/api/admin_reports.go | 436 +++
internal/api/admin_services.go | 49 +
internal/api/admin_site.go | 317 +++
internal/api/admin_site_test.go | 70 +
internal/api/admin_specialties.go | 153 ++
internal/api/admin_specialties_test.go | 52 +
internal/api/admin_stats.go | 67 +
internal/api/admin_stats_test.go | 27 +
internal/api/admin_vacancies.go | 124 +
internal/api/admin_weekly.go | 281 ++
internal/api/adminui.go | 18 +
internal/api/adminui/admin.css | 1275 +++++++++
internal/api/adminui/assets/README.txt | 4 +
internal/api/adminui/assets/channels/2gis.png | Bin 0 -> 4236 bytes
.../api/adminui/assets/channels/SOURCES.md | 19 +
.../api/adminui/assets/channels/doctu.png | Bin 0 -> 6622 bytes
.../api/adminui/assets/channels/doctu.svg | 28 +
.../api/adminui/assets/channels/google.png | Bin 0 -> 1319 bytes
.../api/adminui/assets/channels/instagram.png | Bin 0 -> 17025 bytes
internal/api/adminui/assets/channels/max.png | Bin 0 -> 18115 bytes
.../adminui/assets/channels/napopravku.png | Bin 0 -> 1251 bytes
.../assets/channels/ochevidno-site.png | Bin 0 -> 10503 bytes
.../adminui/assets/channels/prodoctorov.png | Bin 0 -> 1031 bytes
internal/api/adminui/assets/channels/sber.png | Bin 0 -> 1506 bytes
.../api/adminui/assets/channels/telegram.png | Bin 0 -> 4642 bytes
internal/api/adminui/assets/channels/vk.png | Bin 0 -> 6400 bytes
.../api/adminui/assets/channels/website.svg | 11 +
.../api/adminui/assets/channels/yandex.png | Bin 0 -> 4300 bytes
internal/api/adminui/assets/channels/zoon.png | Bin 0 -> 4787 bytes
internal/api/adminui/assets/hall.webp | Bin 0 -> 221606 bytes
internal/api/adminui/assets/heart.webp | Bin 0 -> 477438 bytes
internal/api/adminui/assets/lobby.webp | Bin 0 -> 256800 bytes
internal/api/adminui/banners.js | 189 ++
internal/api/adminui/clinic.js | 32 +
internal/api/adminui/dashboard.js | 289 ++
internal/api/adminui/doctors-dash.js | 236 ++
internal/api/adminui/doctors.js | 366 +++
...errat-alternates-cyrillic-300-normal.woff2 | Bin 0 -> 12948 bytes
...errat-alternates-cyrillic-400-normal.woff2 | Bin 0 -> 13200 bytes
...errat-alternates-cyrillic-500-normal.woff2 | Bin 0 -> 13368 bytes
...errat-alternates-cyrillic-600-normal.woff2 | Bin 0 -> 13356 bytes
...ntserrat-alternates-latin-300-normal.woff2 | Bin 0 -> 21604 bytes
...ntserrat-alternates-latin-400-normal.woff2 | Bin 0 -> 21916 bytes
...ntserrat-alternates-latin-500-normal.woff2 | Bin 0 -> 21824 bytes
...ntserrat-alternates-latin-600-normal.woff2 | Bin 0 -> 21972 bytes
internal/api/adminui/index.html | 2427 +++++++++++++++++
internal/api/adminui/leads.css | 151 +
internal/api/adminui/leads.js | 154 ++
internal/api/adminui/onec.css | 27 +
internal/api/adminui/onec.js | 281 ++
internal/api/adminui/pbx.js | 453 +++
internal/api/adminui/reports-history.css | 134 +
internal/api/adminui/reports-history.js | 158 ++
internal/api/adminui/reports.js | 515 ++++
internal/api/adminui/services.js | 278 ++
internal/api/adminui/share.html | 56 +
internal/api/adminui/share.js | 60 +
internal/api/adminui/shell.js | 316 +++
internal/api/adminui/stats.js | 225 ++
internal/api/adminui/weekly-experiment.css | 71 +
internal/api/adminui/weekly-experiment.js | 102 +
internal/api/adminui/weekly.js | 521 ++++
internal/api/api.go | 327 +++
internal/api/booking_confirm.go | 405 +++
internal/api/booking_confirm_test.go | 106 +
internal/api/clientip_test.go | 37 +
internal/api/clinics.go | 118 +
internal/api/clinics_test.go | 116 +
internal/api/handlers.go | 88 +
internal/api/handlers_content.go | 143 +
internal/api/handlers_leads.go | 207 ++
internal/api/handlers_leads_test.go | 32 +
internal/api/handlers_site.go | 57 +
internal/api/legacy.go | 160 ++
internal/api/media_webp.go | 62 +
internal/api/media_webp_test.go | 49 +
internal/api/notify.go | 38 +
internal/api/onec.go | 644 +++++
internal/api/onec_webhook.go | 135 +
internal/api/onec_webhook_test.go | 60 +
internal/api/pbx.go | 669 +++++
internal/api/pbx_test.go | 112 +
internal/api/phones_parse.go | 207 ++
internal/api/ratelimit.go | 71 +
internal/api/redact.go | 17 +
internal/api/report_bitrix.go | 114 +
internal/api/report_partner_uploads.go | 235 ++
internal/api/report_partner_uploads_test.go | 181 ++
internal/api/report_phones.go | 758 +++++
internal/api/report_phones_test.go | 318 +++
internal/api/report_prodoctorov.go | 145 +
.../api/report_prodoctorov_sample_test.go | 42 +
internal/api/report_prodoctorov_test.go | 139 +
internal/api/report_sberhealth.go | 141 +
internal/api/report_sberhealth_large_test.go | 49 +
internal/api/report_sberhealth_test.go | 148 +
internal/api/security_pack_test.go | 127 +
internal/api/share.go | 103 +
internal/api/ssr.go | 400 +++
internal/api/ssr_test.go | 83 +
internal/api/tables.go | 372 +++
internal/api/testdata/render-fixture.mjs | 7 +
internal/api/video.go | 61 +
internal/api/video_test.go | 34 +
internal/api/waitcall.go | 343 +++
internal/api/waitcall.go.root-backup | 327 +++
internal/api/waitcall_test.go | 87 +
internal/api/website.go | 435 +++
internal/api/website_test.go | 101 +
internal/bitrix/client.go | 134 +
internal/bitrix/leads.go | 153 ++
internal/bitrix/leads_test.go | 96 +
internal/bitrix/redact_test.go | 25 +
internal/config/config.go | 187 ++
internal/domain/content.go | 157 ++
internal/domain/domain.go | 120 +
internal/domain/notice.go | 22 +
internal/domain/site.go | 42 +
internal/megafon/client.go | 226 ++
internal/megafon/client_test.go | 65 +
internal/notify/email.go | 207 ++
internal/notify/email_test.go | 166 ++
internal/onec/client.go | 297 ++
internal/onec/client_test.go | 93 +
internal/prodoctorov/client.go | 111 +
internal/prodoctorov/client_test.go | 145 +
internal/sberhealth/biff.go | 470 ++++
internal/sberhealth/biff_test.go | 290 ++
internal/sberhealth/client.go | 135 +
internal/sberhealth/client_test.go | 157 ++
internal/sberhealth/xls.go | 34 +
internal/storage/admin.go | 120 +
internal/storage/admin_checkups.go | 159 ++
internal/storage/admin_content.go | 221 ++
internal/storage/admin_doctors.go | 248 ++
internal/storage/admin_documents.go | 94 +
internal/storage/admin_faq.go | 79 +
internal/storage/admin_inbox.go | 240 ++
internal/storage/admin_partners.go | 78 +
internal/storage/admin_posts.go | 107 +
internal/storage/admin_promos.go | 116 +
internal/storage/admin_services.go | 45 +
internal/storage/admin_site.go | 181 ++
internal/storage/admin_specialties.go | 124 +
internal/storage/admin_vacancies.go | 90 +
internal/storage/banners.go | 152 ++
internal/storage/booking_confirm.go | 179 ++
internal/storage/booking_confirm_test.go | 22 +
internal/storage/conflict.go | 27 +
internal/storage/content.go | 367 +++
internal/storage/dashboard.go | 142 +
internal/storage/doctor_quality.go | 79 +
internal/storage/doctors.go | 322 +++
internal/storage/lead_notice.go | 43 +
internal/storage/leads.go | 106 +
internal/storage/media.go | 33 +
internal/storage/migrations/0001_core.sql | 203 ++
internal/storage/migrations/0002_content.sql | 150 +
.../storage/migrations/0003_leads_admin.sql | 108 +
.../storage/migrations/0004_seed_forms.sql | 51 +
.../storage/migrations/0005_review_video.sql | 12 +
internal/storage/migrations/0006_mockup.sql | 32 +
internal/storage/migrations/0007_legal.sql | 23 +
.../migrations/0008_legal_new_site.sql | 10 +
.../migrations/0009_promo_checkup_admin.sql | 15 +
.../migrations/0010_banner_layouts.sql | 10 +
internal/storage/migrations/0011_company.sql | 166 ++
.../storage/migrations/0012_documents.sql | 33 +
.../storage/migrations/0013_review_dates.sql | 63 +
.../migrations/0014_doctor_bio_text.sql | 75 +
.../storage/migrations/0015_patients_page.sql | 9 +
.../storage/migrations/0016_checkup_kra.sql | 12 +
internal/storage/migrations/0017_reports.sql | 24 +
internal/storage/migrations/0018_pbx.sql | 41 +
.../storage/migrations/0019_doctor_photos.sql | 69 +
internal/storage/migrations/0020_onec.sql | 71 +
.../migrations/0021_weekly_reports.sql | 18 +
.../0022_doctor_rating_override.sql | 5 +
.../migrations/0023_booking_confirm.sql | 16 +
internal/storage/migrations/0024_clinics.sql | 20 +
.../storage/migrations/0025_share_links.sql | 10 +
internal/storage/onec.go | 589 ++++
internal/storage/pbx.go | 180 ++
internal/storage/price_sections.go | 32 +
internal/storage/prices.go | 81 +
internal/storage/public_paths.go | 32 +
internal/storage/redirects.go | 122 +
internal/storage/reports.go | 125 +
internal/storage/search.go | 146 +
internal/storage/services.go | 156 ++
internal/storage/share.go | 55 +
internal/storage/stats.go | 211 ++
internal/storage/storage.go | 105 +
internal/storage/weekly.go | 166 ++
migrations/import/README.md | 21 +
migrations/import/build_price_map.py | 193 ++
migrations/import/dedup_and_label.py | 92 +
migrations/import/export_content.py | 224 ++
migrations/import/export_dir_icons.py | 72 +
migrations/import/export_doctor_bio.py | 87 +
migrations/import/export_doctor_experience.py | 47 +
migrations/import/export_media.py | 117 +
migrations/import/export_seed.py | 229 ++
migrations/import/find_bio_glitches.py | 34 +
migrations/import/fix_blocks_rn.py | 51 +
migrations/import/fix_review_texts.py | 39 +
migrations/import/make_webp.py | 67 +
.../import/rebuild_price_from_1c_only.py | 73 +
migrations/import/sqlread.py | 93 +
seo-structure.md | 707 +++++
seo/DEPLOYMENT.md | 57 +
seo/analyze.mjs | 118 +
seo/cluster-analysis.json | 1746 ++++++++++++
seo/clusters_exact.csv | 161 ++
seo/content-audit.json | 365 +++
seo/content-gaps.md | 133 +
seo/crawl-audit.json | 38 +
seo/service-profiles.json | 8 +
seo/site-profiles.json | 10 +
web/index.html | 16 +
web/package-lock.json | 2074 ++++++++++++++
web/package.json | 27 +
web/public/apple-touch-icon.png | Bin 0 -> 4538 bytes
.../doctors/abakarova-patimat-alievna.png | Bin 0 -> 184182 bytes
.../abdullaev-magomed-abdullaevich.png | Bin 0 -> 201508 bytes
.../abdurakhmanova-zaliyat-abdulaevna.png | Bin 0 -> 147776 bytes
.../doctors/afanaseva-elena-fyedorovna.png | Bin 0 -> 212376 bytes
.../doctors/akhmedova-maira-gazalievna.png | Bin 0 -> 189534 bytes
.../akhmedova-milana-nimatullakhovna.png | Bin 0 -> 169662 bytes
.../akhmetkhanova-saida-abdulaevna.png | Bin 0 -> 162150 bytes
.../assets/doctors/alieva-elvira-alievna.png | Bin 0 -> 170616 bytes
.../doctors/alilova-patimat-mirzaevna.png | Bin 0 -> 195194 bytes
.../azmedkhanovu-patimat-bagomedovnu.png | Bin 0 -> 194920 bytes
.../doctors/bakhishov-rinat-tagirovich.png | Bin 0 -> 167552 bytes
.../biarslanova-liana-biarslanovna.png | Bin 0 -> 166703 bytes
.../doctors/chergesova-zaira-muratovna.png | Bin 0 -> 154914 bytes
.../doctors/eldarkhanova-pakhu-mutaevna.png | Bin 0 -> 190938 bytes
.../doctors/esetov-murad-azedinovich.png | Bin 0 -> 213870 bytes
.../gadzhimuradova-rasiyat-gadzhievna.png | Bin 0 -> 142324 bytes
.../doctors/gasanova-galimat-shikhovna.png | Bin 0 -> 172148 bytes
.../gasanova-shakhrizat-magomedovna.png | Bin 0 -> 170561 bytes
.../doctors/gaydarova-arapat-eldarovna.png | Bin 0 -> 160134 bytes
.../doctors/gimbatova-zalina-muradovna.png | Bin 0 -> 194289 bytes
.../doctors/idrisova-aida-shamilevna-.png | Bin 0 -> 234197 bytes
.../doctors/idrisova-zarema-zurkanayevna.png | Bin 0 -> 164969 bytes
.../doctors/inkova-ayshat-inkavovna.png | Bin 0 -> 194305 bytes
.../doctors/irzaeva-sevil-vagifovna.png | Bin 0 -> 161818 bytes
.../doctors/isaeva-zarema-kaybullakhovna.png | Bin 0 -> 198310 bytes
.../doctors/ismailova-naizat-tashbekovna.png | Bin 0 -> 185121 bytes
.../doctors/khizrieva-zaira-sayputdinovna.png | Bin 0 -> 187374 bytes
.../kurbanismailova-rakhimat-ramazanovna.png | Bin 0 -> 172832 bytes
.../doctors/magdieva-zarema-khulatdaevna.png | Bin 0 -> 202370 bytes
.../doctors/magomedova-sabina-yaverovna.png | Bin 0 -> 208410 bytes
.../magomedova-svetlana-seferbekovna-.png | Bin 0 -> 179520 bytes
.../doctors/maksudov-albert-akhmedovich.png | Bin 0 -> 197307 bytes
.../doctors/medzhidova-asiyat-muminovna.png | Bin 0 -> 213359 bytes
.../doctors/mikailov-magomed-shamilovich.png | Bin 0 -> 168581 bytes
web/public/assets/doctors/mingbolatov.png | Bin 0 -> 177034 bytes
.../muftalieva-dzhuleta-byunyaminovna.png | Bin 0 -> 187898 bytes
.../doctors/mutaeva-irina-vitalevna.png | Bin 0 -> 180552 bytes
.../nurmagomedova-rekuyat-mamedovna-.png | Bin 0 -> 235605 bytes
.../doctors/omarova-aminat-magomedovna.png | Bin 0 -> 180234 bytes
.../doctors/omarova-maysarat-askhabovna.png | Bin 0 -> 173411 bytes
.../rasulova-dzhamilyat-akhmedovna.png | Bin 0 -> 222738 bytes
.../doctors/saaduev-saadu-khabibovich.png | Bin 0 -> 172787 bytes
.../assets/doctors/savina-iya-yurevna.png | Bin 0 -> 177747 bytes
.../doctors/saygidnorova-marin-muradovna.png | Bin 0 -> 195804 bytes
.../doctors/shugaev-eldar-edmanovich.png | Bin 0 -> 188790 bytes
.../doctors/taibov-murad-tayibovich.png | Bin 0 -> 199940 bytes
.../temirova-gyulbakhar-tadzhirovna.png | Bin 0 -> 180887 bytes
.../doctors/tumalaeva-naida-ganipaevna.png | Bin 0 -> 245481 bytes
.../yusufova-dzhamilya-sirazhitdinovna.png | Bin 0 -> 234014 bytes
web/public/favicon.ico | Bin 0 -> 3653 bytes
web/scripts/audit-content.mjs | 55 +
web/scripts/audit-site.mjs | 63 +
web/scripts/prerender.mjs | 62 +
web/scripts/render-page.mjs | 21 +
web/scripts/seo-document.mjs | 20 +
web/scripts/verify-runtime-seo.mjs | 58 +
web/scripts/verify-seo.mjs | 109 +
web/src/App.jsx | 82 +
web/src/api.js | 93 +
web/src/assets/figma/callback-phone-3d.png | Bin 0 -> 317823 bytes
web/src/assets/figma/callback-phone-3d.webp | Bin 0 -> 15850 bytes
web/src/assets/figma/checkup-cta-3d.webp | Bin 0 -> 21098 bytes
web/src/assets/figma/checkup-hero-girl.webp | Bin 0 -> 78836 bytes
web/src/assets/figma/checkups-hero-3d.webp | Bin 0 -> 47868 bytes
web/src/assets/figma/ck-beremennost.webp | Bin 0 -> 10490 bytes
web/src/assets/figma/ck-davlenie.webp | Bin 0 -> 11428 bytes
web/src/assets/figma/ck-mini.webp | Bin 0 -> 12936 bytes
web/src/assets/figma/ck-mne-god.webp | Bin 0 -> 12386 bytes
web/src/assets/figma/ck-pervoklashka.webp | Bin 0 -> 13348 bytes
web/src/assets/figma/ck-podrostok.webp | Bin 0 -> 12934 bytes
web/src/assets/figma/ck-rak-sheyki.webp | Bin 0 -> 11856 bytes
web/src/assets/figma/ck-roditeli.webp | Bin 0 -> 14380 bytes
web/src/assets/figma/ck-v-sad.webp | Bin 0 -> 13092 bytes
.../assets/figma/ck-zdorovaya-devochka.webp | Bin 0 -> 16820 bytes
web/src/assets/figma/ck-zhenskoe-zdorove.webp | Bin 0 -> 12038 bytes
web/src/assets/figma/ck-zhkt.webp | Bin 0 -> 14442 bytes
web/src/assets/figma/dir-detskaya-klinika.png | Bin 0 -> 2526027 bytes
.../assets/figma/dir-detskaya-klinika.webp | Bin 0 -> 142346 bytes
web/src/assets/figma/dir-estetic.png | Bin 0 -> 1325975 bytes
web/src/assets/figma/dir-estetic.webp | Bin 0 -> 97010 bytes
web/src/assets/figma/dir-ginekologiya.png | Bin 0 -> 1305311 bytes
web/src/assets/figma/dir-ginekologiya.webp | Bin 0 -> 43938 bytes
web/src/assets/figma/dir-osteo.png | Bin 0 -> 248826 bytes
web/src/assets/figma/dir-osteo.webp | Bin 0 -> 19944 bytes
web/src/assets/figma/hero-dana-letters.svg | 16 +
web/src/assets/figma/ico-baby.png | Bin 0 -> 2336 bytes
web/src/assets/figma/ico-cancer-ribbon.png | Bin 0 -> 2684 bytes
web/src/assets/figma/ico-family.png | Bin 0 -> 1644 bytes
web/src/assets/figma/ico-female.png | Bin 0 -> 1040 bytes
web/src/assets/figma/ico-health-checkup.png | Bin 0 -> 2136 bytes
web/src/assets/figma/ico-playground.png | Bin 0 -> 1751 bytes
web/src/assets/figma/ico-pregnant.png | Bin 0 -> 1668 bytes
web/src/assets/figma/ico-school.png | Bin 0 -> 938 bytes
web/src/assets/figma/ico-stethoscope.png | Bin 0 -> 1892 bytes
web/src/assets/figma/ico-stomach.png | Bin 0 -> 1991 bytes
web/src/assets/figma/ico-teenager.png | Bin 0 -> 1641 bytes
web/src/assets/figma/ico-tonometer.png | Bin 0 -> 1368 bytes
.../assets/figma/icon-accordion-chevron.svg | 5 +
web/src/assets/figma/icon-arrow-right.svg | 5 +
web/src/assets/figma/icon-calendar.svg | 6 +
web/src/assets/figma/icon-check.svg | 5 +
web/src/assets/figma/icon-chevron-dark.svg | 5 +
web/src/assets/figma/icon-chevron-down.svg | 5 +
web/src/assets/figma/icon-chevron-white.svg | 5 +
web/src/assets/figma/icon-close.svg | 5 +
web/src/assets/figma/icon-dot-coral.svg | 3 +
web/src/assets/figma/icon-dot-green.svg | 3 +
web/src/assets/figma/icon-dot-urgent.svg | 3 +
web/src/assets/figma/icon-max.png | Bin 0 -> 352458 bytes
web/src/assets/figma/icon-max.svg | 1 +
web/src/assets/figma/icon-max.webp | Bin 0 -> 2680 bytes
web/src/assets/figma/icon-search-16.svg | 6 +
web/src/assets/figma/icon-search-18.svg | 6 +
web/src/assets/figma/icon-search-mobile.svg | 6 +
web/src/assets/figma/icon-search-price.svg | 6 +
web/src/assets/figma/icon-search.svg | 6 +
web/src/assets/figma/icon-star-small.svg | 10 +
web/src/assets/figma/icon-star.svg | 3 +
web/src/assets/figma/icon-whatsapp-bg.svg | 11 +
web/src/assets/figma/icon-whatsapp-circle.svg | 14 +
web/src/assets/figma/icon-whatsapp-glyph.svg | 5 +
web/src/assets/figma/icon-whatsapp-mark.svg | 10 +
web/src/assets/figma/icon-whatsapp.svg | 1 +
web/src/assets/figma/logo-dana-white.svg | 5 +
web/src/assets/figma/logo-dana.svg | 5 +
web/src/assets/figma/logo-mobile-white.svg | 10 +
web/src/assets/figma/logo-mobile.svg | 10 +
web/src/assets/figma/logo-subtitle-white.svg | 28 +
web/src/assets/figma/logo-subtitle.svg | 28 +
web/src/assets/figma/map-contacts.png | Bin 0 -> 2074956 bytes
web/src/assets/figma/map-contacts.webp | Bin 0 -> 130210 bytes
web/src/assets/figma/promo-podrostok.webp | Bin 0 -> 55610 bytes
web/src/assets/figma/promo-rodilsya.webp | Bin 0 -> 69166 bytes
web/src/assets/figma/promo-sad.webp | Bin 0 -> 161952 bytes
web/src/assets/figma/promo-sportsmen.webp | Bin 0 -> 112752 bytes
web/src/assets/figma/promo-zhkt.webp | Bin 0 -> 92094 bytes
web/src/assets/figma/promos-hero-3d.webp | Bin 0 -> 60944 bytes
web/src/assets/figma/service-urgent-3d.webp | Bin 0 -> 39266 bytes
web/src/assets/figma/slider-arrow-doctors.svg | 6 +
.../assets/figma/slider-arrow-hero-next.svg | 6 +
web/src/assets/figma/slider-arrow-hero.svg | 6 +
web/src/assets/figma/svc-bol.webp | Bin 0 -> 45656 bytes
.../assets/figma/svc-detskaya-klinika.webp | Bin 0 -> 85438 bytes
web/src/assets/figma/svc-endokrinologiya.webp | Bin 0 -> 61910 bytes
web/src/assets/figma/svc-ginekologiya.webp | Bin 0 -> 35862 bytes
web/src/assets/figma/svc-khirurgiya.webp | Bin 0 -> 123764 bytes
web/src/assets/figma/svc-mammologiya.webp | Bin 0 -> 62236 bytes
web/src/assets/figma/svc-oftalmologiya.webp | Bin 0 -> 40874 bytes
web/src/assets/figma/svc-onkologiya.webp | Bin 0 -> 39060 bytes
.../figma/svc-plasticheskaya-khirurgiya.webp | Bin 0 -> 56744 bytes
web/src/assets/figma/svc-proktologiya.webp | Bin 0 -> 99268 bytes
web/src/assets/figma/svc-urologiya.webp | Bin 0 -> 124976 bytes
web/src/components/BookingSection.css | 170 ++
web/src/components/BookingSection.jsx | 143 +
web/src/components/Breadcrumbs.jsx | 23 +
web/src/components/CallbackForm.jsx | 81 +
web/src/components/ClinicMap.css | 51 +
web/src/components/ClinicMap.jsx | 37 +
web/src/components/CompanySection.css | 228 ++
web/src/components/CompanySection.jsx | 94 +
web/src/components/ContactsSection.css | 336 +++
web/src/components/ContactsSection.jsx | 57 +
web/src/components/CookieBanner.css | 63 +
web/src/components/CookieBanner.jsx | 35 +
web/src/components/CtaBand.css | 149 +
web/src/components/CtaBand.jsx | 19 +
web/src/components/DoctorCard.css | 320 +++
web/src/components/DoctorCard.jsx | 108 +
web/src/components/Footer.css | 251 ++
web/src/components/Footer.jsx | 115 +
web/src/components/Header.css | 187 ++
web/src/components/Header.jsx | 148 +
web/src/components/HeroSlider.css | 496 ++++
web/src/components/HeroSlider.jsx | 243 ++
web/src/components/Layout.jsx | 41 +
web/src/components/LeadModal.css | 351 +++
web/src/components/LeadModal.jsx | 394 +++
web/src/components/MegaMenu.css | 108 +
web/src/components/MegaMenu.jsx | 111 +
web/src/components/MobileMenu.css | 48 +
web/src/components/MobileMenu.jsx | 59 +
web/src/components/NativeBooking.css | 258 ++
web/src/components/NativeBooking.jsx | 399 +++
web/src/components/SearchBox.css | 123 +
web/src/components/SearchBox.jsx | 128 +
web/src/components/SocialLinks.css | 26 +
web/src/components/SocialLinks.jsx | 34 +
web/src/context/ApiData.jsx | 8 +
web/src/context/LeadModalContext.jsx | 27 +
web/src/context/SiteData.jsx | 67 +
web/src/entry-server.jsx | 101 +
web/src/lib/attribution.js | 45 +
web/src/lib/format.js | 35 +
web/src/lib/links.js | 47 +
web/src/lib/media.js | 133 +
web/src/lib/metrika.js | 81 +
web/src/lib/phone.js | 21 +
web/src/lib/sanitize.js | 36 +
web/src/lib/seo.js | 85 +
web/src/lib/serviceContent.js | 67 +
web/src/lib/serviceNavigation.js | 22 +
web/src/lib/slots.js | 42 +
web/src/lib/text.js | 41 +
web/src/lib/useApi.js | 26 +
web/src/lib/usePageTitle.js | 41 +
web/src/lib/video.js | 7 +
web/src/main.jsx | 31 +
web/src/pages/AboutPage.jsx | 65 +
web/src/pages/CheckupPage.css | 431 +++
web/src/pages/CheckupPage.jsx | 153 ++
web/src/pages/CheckupsPage.css | 405 +++
web/src/pages/CheckupsPage.jsx | 145 +
web/src/pages/CompanyListPages.jsx | 330 +++
web/src/pages/CompanyPages.css | 758 +++++
web/src/pages/ContactsPage.css | 308 +++
web/src/pages/ContactsPage.jsx | 139 +
web/src/pages/DoctorPage.css | 566 ++++
web/src/pages/DoctorPage.jsx | 243 ++
web/src/pages/DoctorsPage.css | 211 ++
web/src/pages/DoctorsPage.jsx | 129 +
web/src/pages/HomePage.css | 246 ++
web/src/pages/HomePage.jsx | 122 +
web/src/pages/LegalPage.css | 114 +
web/src/pages/LegalPage.jsx | 70 +
web/src/pages/PostPage.jsx | 74 +
web/src/pages/PostsPage.jsx | 54 +
web/src/pages/PricesPage.css | 493 ++++
web/src/pages/PricesPage.jsx | 174 ++
web/src/pages/PromosPage.css | 240 ++
web/src/pages/PromosPage.jsx | 94 +
web/src/pages/ServicePage.css | 721 +++++
web/src/pages/ServicePage.jsx | 312 +++
web/src/pages/ServicesPage.css | 101 +
web/src/pages/ServicesPage.jsx | 48 +
web/src/pages/StubPage.css | 57 +
web/src/pages/StubPage.jsx | 32 +
web/src/styles/base.css | 194 ++
web/src/styles/tokens.css | 83 +
web/vite.config.js | 17 +
499 files changed, 52904 insertions(+)
create mode 100644 .deploy/dana-medical.caddy
create mode 100644 .deploy/remote-build-start-dana.sh
create mode 100644 .deploy/remote-init-dana.sh
create mode 100644 .deploy/remote-install-dana-caddy.sh
create mode 100644 .deploy/remote-restore-dana.sh
create mode 100644 .deploy/remote-secure-dana.sh
create mode 100644 .deploy/remote-smoke-dana.sh
create mode 100644 .deploy/remote-start-dana-db.sh
create mode 100755 .deploy/remote-update-dana-caddy.sh
create mode 100644 .deploy/remote-verify-dana-preview.sh
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 cmd/server/main.go
create mode 100644 deploy.env.example
create mode 100644 docker-compose.prod.yml
create mode 100644 docker-compose.yml
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/api/admin.go
create mode 100644 internal/api/admin_banners.go
create mode 100644 internal/api/admin_banners_test.go
create mode 100644 internal/api/admin_checkups.go
create mode 100644 internal/api/admin_doctors.go
create mode 100644 internal/api/admin_documents.go
create mode 100644 internal/api/admin_documents_test.go
create mode 100644 internal/api/admin_inbox.go
create mode 100644 internal/api/admin_media.go
create mode 100644 internal/api/admin_partners.go
create mode 100644 internal/api/admin_partners_test.go
create mode 100644 internal/api/admin_posts.go
create mode 100644 internal/api/admin_posts_test.go
create mode 100644 internal/api/admin_promos.go
create mode 100644 internal/api/admin_promos_test.go
create mode 100644 internal/api/admin_redirects.go
create mode 100644 internal/api/admin_redirects_test.go
create mode 100644 internal/api/admin_reports.go
create mode 100644 internal/api/admin_services.go
create mode 100644 internal/api/admin_site.go
create mode 100644 internal/api/admin_site_test.go
create mode 100644 internal/api/admin_specialties.go
create mode 100644 internal/api/admin_specialties_test.go
create mode 100644 internal/api/admin_stats.go
create mode 100644 internal/api/admin_stats_test.go
create mode 100644 internal/api/admin_vacancies.go
create mode 100644 internal/api/admin_weekly.go
create mode 100644 internal/api/adminui.go
create mode 100644 internal/api/adminui/admin.css
create mode 100644 internal/api/adminui/assets/README.txt
create mode 100644 internal/api/adminui/assets/channels/2gis.png
create mode 100644 internal/api/adminui/assets/channels/SOURCES.md
create mode 100644 internal/api/adminui/assets/channels/doctu.png
create mode 100644 internal/api/adminui/assets/channels/doctu.svg
create mode 100644 internal/api/adminui/assets/channels/google.png
create mode 100644 internal/api/adminui/assets/channels/instagram.png
create mode 100644 internal/api/adminui/assets/channels/max.png
create mode 100644 internal/api/adminui/assets/channels/napopravku.png
create mode 100644 internal/api/adminui/assets/channels/ochevidno-site.png
create mode 100644 internal/api/adminui/assets/channels/prodoctorov.png
create mode 100644 internal/api/adminui/assets/channels/sber.png
create mode 100644 internal/api/adminui/assets/channels/telegram.png
create mode 100644 internal/api/adminui/assets/channels/vk.png
create mode 100644 internal/api/adminui/assets/channels/website.svg
create mode 100644 internal/api/adminui/assets/channels/yandex.png
create mode 100644 internal/api/adminui/assets/channels/zoon.png
create mode 100644 internal/api/adminui/assets/hall.webp
create mode 100644 internal/api/adminui/assets/heart.webp
create mode 100644 internal/api/adminui/assets/lobby.webp
create mode 100644 internal/api/adminui/banners.js
create mode 100644 internal/api/adminui/clinic.js
create mode 100644 internal/api/adminui/dashboard.js
create mode 100644 internal/api/adminui/doctors-dash.js
create mode 100644 internal/api/adminui/doctors.js
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-cyrillic-300-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-cyrillic-400-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-cyrillic-500-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-cyrillic-600-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-latin-300-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-latin-400-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-latin-500-normal.woff2
create mode 100644 internal/api/adminui/fonts/montserrat-alternates-latin-600-normal.woff2
create mode 100644 internal/api/adminui/index.html
create mode 100644 internal/api/adminui/leads.css
create mode 100644 internal/api/adminui/leads.js
create mode 100644 internal/api/adminui/onec.css
create mode 100644 internal/api/adminui/onec.js
create mode 100644 internal/api/adminui/pbx.js
create mode 100644 internal/api/adminui/reports-history.css
create mode 100644 internal/api/adminui/reports-history.js
create mode 100644 internal/api/adminui/reports.js
create mode 100644 internal/api/adminui/services.js
create mode 100644 internal/api/adminui/share.html
create mode 100644 internal/api/adminui/share.js
create mode 100644 internal/api/adminui/shell.js
create mode 100644 internal/api/adminui/stats.js
create mode 100644 internal/api/adminui/weekly-experiment.css
create mode 100644 internal/api/adminui/weekly-experiment.js
create mode 100644 internal/api/adminui/weekly.js
create mode 100644 internal/api/api.go
create mode 100644 internal/api/booking_confirm.go
create mode 100644 internal/api/booking_confirm_test.go
create mode 100644 internal/api/clientip_test.go
create mode 100644 internal/api/clinics.go
create mode 100644 internal/api/clinics_test.go
create mode 100644 internal/api/handlers.go
create mode 100644 internal/api/handlers_content.go
create mode 100644 internal/api/handlers_leads.go
create mode 100644 internal/api/handlers_leads_test.go
create mode 100644 internal/api/handlers_site.go
create mode 100644 internal/api/legacy.go
create mode 100644 internal/api/media_webp.go
create mode 100644 internal/api/media_webp_test.go
create mode 100644 internal/api/notify.go
create mode 100644 internal/api/onec.go
create mode 100644 internal/api/onec_webhook.go
create mode 100644 internal/api/onec_webhook_test.go
create mode 100644 internal/api/pbx.go
create mode 100644 internal/api/pbx_test.go
create mode 100644 internal/api/phones_parse.go
create mode 100644 internal/api/ratelimit.go
create mode 100644 internal/api/redact.go
create mode 100644 internal/api/report_bitrix.go
create mode 100644 internal/api/report_partner_uploads.go
create mode 100644 internal/api/report_partner_uploads_test.go
create mode 100644 internal/api/report_phones.go
create mode 100644 internal/api/report_phones_test.go
create mode 100644 internal/api/report_prodoctorov.go
create mode 100644 internal/api/report_prodoctorov_sample_test.go
create mode 100644 internal/api/report_prodoctorov_test.go
create mode 100644 internal/api/report_sberhealth.go
create mode 100644 internal/api/report_sberhealth_large_test.go
create mode 100644 internal/api/report_sberhealth_test.go
create mode 100644 internal/api/security_pack_test.go
create mode 100644 internal/api/share.go
create mode 100644 internal/api/ssr.go
create mode 100644 internal/api/ssr_test.go
create mode 100644 internal/api/tables.go
create mode 100644 internal/api/testdata/render-fixture.mjs
create mode 100644 internal/api/video.go
create mode 100644 internal/api/video_test.go
create mode 100644 internal/api/waitcall.go
create mode 100644 internal/api/waitcall.go.root-backup
create mode 100644 internal/api/waitcall_test.go
create mode 100644 internal/api/website.go
create mode 100644 internal/api/website_test.go
create mode 100644 internal/bitrix/client.go
create mode 100644 internal/bitrix/leads.go
create mode 100644 internal/bitrix/leads_test.go
create mode 100644 internal/bitrix/redact_test.go
create mode 100644 internal/config/config.go
create mode 100644 internal/domain/content.go
create mode 100644 internal/domain/domain.go
create mode 100644 internal/domain/notice.go
create mode 100644 internal/domain/site.go
create mode 100644 internal/megafon/client.go
create mode 100644 internal/megafon/client_test.go
create mode 100644 internal/notify/email.go
create mode 100644 internal/notify/email_test.go
create mode 100644 internal/onec/client.go
create mode 100644 internal/onec/client_test.go
create mode 100644 internal/prodoctorov/client.go
create mode 100644 internal/prodoctorov/client_test.go
create mode 100644 internal/sberhealth/biff.go
create mode 100644 internal/sberhealth/biff_test.go
create mode 100644 internal/sberhealth/client.go
create mode 100644 internal/sberhealth/client_test.go
create mode 100644 internal/sberhealth/xls.go
create mode 100644 internal/storage/admin.go
create mode 100644 internal/storage/admin_checkups.go
create mode 100644 internal/storage/admin_content.go
create mode 100644 internal/storage/admin_doctors.go
create mode 100644 internal/storage/admin_documents.go
create mode 100644 internal/storage/admin_faq.go
create mode 100644 internal/storage/admin_inbox.go
create mode 100644 internal/storage/admin_partners.go
create mode 100644 internal/storage/admin_posts.go
create mode 100644 internal/storage/admin_promos.go
create mode 100644 internal/storage/admin_services.go
create mode 100644 internal/storage/admin_site.go
create mode 100644 internal/storage/admin_specialties.go
create mode 100644 internal/storage/admin_vacancies.go
create mode 100644 internal/storage/banners.go
create mode 100644 internal/storage/booking_confirm.go
create mode 100644 internal/storage/booking_confirm_test.go
create mode 100644 internal/storage/conflict.go
create mode 100644 internal/storage/content.go
create mode 100644 internal/storage/dashboard.go
create mode 100644 internal/storage/doctor_quality.go
create mode 100644 internal/storage/doctors.go
create mode 100644 internal/storage/lead_notice.go
create mode 100644 internal/storage/leads.go
create mode 100644 internal/storage/media.go
create mode 100644 internal/storage/migrations/0001_core.sql
create mode 100644 internal/storage/migrations/0002_content.sql
create mode 100644 internal/storage/migrations/0003_leads_admin.sql
create mode 100644 internal/storage/migrations/0004_seed_forms.sql
create mode 100644 internal/storage/migrations/0005_review_video.sql
create mode 100644 internal/storage/migrations/0006_mockup.sql
create mode 100644 internal/storage/migrations/0007_legal.sql
create mode 100644 internal/storage/migrations/0008_legal_new_site.sql
create mode 100644 internal/storage/migrations/0009_promo_checkup_admin.sql
create mode 100644 internal/storage/migrations/0010_banner_layouts.sql
create mode 100644 internal/storage/migrations/0011_company.sql
create mode 100644 internal/storage/migrations/0012_documents.sql
create mode 100644 internal/storage/migrations/0013_review_dates.sql
create mode 100644 internal/storage/migrations/0014_doctor_bio_text.sql
create mode 100644 internal/storage/migrations/0015_patients_page.sql
create mode 100644 internal/storage/migrations/0016_checkup_kra.sql
create mode 100644 internal/storage/migrations/0017_reports.sql
create mode 100644 internal/storage/migrations/0018_pbx.sql
create mode 100644 internal/storage/migrations/0019_doctor_photos.sql
create mode 100644 internal/storage/migrations/0020_onec.sql
create mode 100644 internal/storage/migrations/0021_weekly_reports.sql
create mode 100644 internal/storage/migrations/0022_doctor_rating_override.sql
create mode 100644 internal/storage/migrations/0023_booking_confirm.sql
create mode 100644 internal/storage/migrations/0024_clinics.sql
create mode 100644 internal/storage/migrations/0025_share_links.sql
create mode 100644 internal/storage/onec.go
create mode 100644 internal/storage/pbx.go
create mode 100644 internal/storage/price_sections.go
create mode 100644 internal/storage/prices.go
create mode 100644 internal/storage/public_paths.go
create mode 100644 internal/storage/redirects.go
create mode 100644 internal/storage/reports.go
create mode 100644 internal/storage/search.go
create mode 100644 internal/storage/services.go
create mode 100644 internal/storage/share.go
create mode 100644 internal/storage/stats.go
create mode 100644 internal/storage/storage.go
create mode 100644 internal/storage/weekly.go
create mode 100644 migrations/import/README.md
create mode 100644 migrations/import/build_price_map.py
create mode 100644 migrations/import/dedup_and_label.py
create mode 100644 migrations/import/export_content.py
create mode 100644 migrations/import/export_dir_icons.py
create mode 100644 migrations/import/export_doctor_bio.py
create mode 100644 migrations/import/export_doctor_experience.py
create mode 100644 migrations/import/export_media.py
create mode 100644 migrations/import/export_seed.py
create mode 100644 migrations/import/find_bio_glitches.py
create mode 100644 migrations/import/fix_blocks_rn.py
create mode 100644 migrations/import/fix_review_texts.py
create mode 100644 migrations/import/make_webp.py
create mode 100644 migrations/import/rebuild_price_from_1c_only.py
create mode 100644 migrations/import/sqlread.py
create mode 100644 seo-structure.md
create mode 100644 seo/DEPLOYMENT.md
create mode 100644 seo/analyze.mjs
create mode 100644 seo/cluster-analysis.json
create mode 100644 seo/clusters_exact.csv
create mode 100644 seo/content-audit.json
create mode 100644 seo/content-gaps.md
create mode 100644 seo/crawl-audit.json
create mode 100644 seo/service-profiles.json
create mode 100644 seo/site-profiles.json
create mode 100644 web/index.html
create mode 100644 web/package-lock.json
create mode 100644 web/package.json
create mode 100644 web/public/apple-touch-icon.png
create mode 100644 web/public/assets/doctors/abakarova-patimat-alievna.png
create mode 100644 web/public/assets/doctors/abdullaev-magomed-abdullaevich.png
create mode 100644 web/public/assets/doctors/abdurakhmanova-zaliyat-abdulaevna.png
create mode 100644 web/public/assets/doctors/afanaseva-elena-fyedorovna.png
create mode 100644 web/public/assets/doctors/akhmedova-maira-gazalievna.png
create mode 100644 web/public/assets/doctors/akhmedova-milana-nimatullakhovna.png
create mode 100644 web/public/assets/doctors/akhmetkhanova-saida-abdulaevna.png
create mode 100644 web/public/assets/doctors/alieva-elvira-alievna.png
create mode 100644 web/public/assets/doctors/alilova-patimat-mirzaevna.png
create mode 100644 web/public/assets/doctors/azmedkhanovu-patimat-bagomedovnu.png
create mode 100644 web/public/assets/doctors/bakhishov-rinat-tagirovich.png
create mode 100644 web/public/assets/doctors/biarslanova-liana-biarslanovna.png
create mode 100644 web/public/assets/doctors/chergesova-zaira-muratovna.png
create mode 100644 web/public/assets/doctors/eldarkhanova-pakhu-mutaevna.png
create mode 100644 web/public/assets/doctors/esetov-murad-azedinovich.png
create mode 100644 web/public/assets/doctors/gadzhimuradova-rasiyat-gadzhievna.png
create mode 100644 web/public/assets/doctors/gasanova-galimat-shikhovna.png
create mode 100644 web/public/assets/doctors/gasanova-shakhrizat-magomedovna.png
create mode 100644 web/public/assets/doctors/gaydarova-arapat-eldarovna.png
create mode 100644 web/public/assets/doctors/gimbatova-zalina-muradovna.png
create mode 100644 web/public/assets/doctors/idrisova-aida-shamilevna-.png
create mode 100644 web/public/assets/doctors/idrisova-zarema-zurkanayevna.png
create mode 100644 web/public/assets/doctors/inkova-ayshat-inkavovna.png
create mode 100644 web/public/assets/doctors/irzaeva-sevil-vagifovna.png
create mode 100644 web/public/assets/doctors/isaeva-zarema-kaybullakhovna.png
create mode 100644 web/public/assets/doctors/ismailova-naizat-tashbekovna.png
create mode 100644 web/public/assets/doctors/khizrieva-zaira-sayputdinovna.png
create mode 100644 web/public/assets/doctors/kurbanismailova-rakhimat-ramazanovna.png
create mode 100644 web/public/assets/doctors/magdieva-zarema-khulatdaevna.png
create mode 100644 web/public/assets/doctors/magomedova-sabina-yaverovna.png
create mode 100644 web/public/assets/doctors/magomedova-svetlana-seferbekovna-.png
create mode 100644 web/public/assets/doctors/maksudov-albert-akhmedovich.png
create mode 100644 web/public/assets/doctors/medzhidova-asiyat-muminovna.png
create mode 100644 web/public/assets/doctors/mikailov-magomed-shamilovich.png
create mode 100644 web/public/assets/doctors/mingbolatov.png
create mode 100644 web/public/assets/doctors/muftalieva-dzhuleta-byunyaminovna.png
create mode 100644 web/public/assets/doctors/mutaeva-irina-vitalevna.png
create mode 100644 web/public/assets/doctors/nurmagomedova-rekuyat-mamedovna-.png
create mode 100644 web/public/assets/doctors/omarova-aminat-magomedovna.png
create mode 100644 web/public/assets/doctors/omarova-maysarat-askhabovna.png
create mode 100644 web/public/assets/doctors/rasulova-dzhamilyat-akhmedovna.png
create mode 100644 web/public/assets/doctors/saaduev-saadu-khabibovich.png
create mode 100644 web/public/assets/doctors/savina-iya-yurevna.png
create mode 100644 web/public/assets/doctors/saygidnorova-marin-muradovna.png
create mode 100644 web/public/assets/doctors/shugaev-eldar-edmanovich.png
create mode 100644 web/public/assets/doctors/taibov-murad-tayibovich.png
create mode 100644 web/public/assets/doctors/temirova-gyulbakhar-tadzhirovna.png
create mode 100644 web/public/assets/doctors/tumalaeva-naida-ganipaevna.png
create mode 100644 web/public/assets/doctors/yusufova-dzhamilya-sirazhitdinovna.png
create mode 100644 web/public/favicon.ico
create mode 100644 web/scripts/audit-content.mjs
create mode 100644 web/scripts/audit-site.mjs
create mode 100644 web/scripts/prerender.mjs
create mode 100644 web/scripts/render-page.mjs
create mode 100644 web/scripts/seo-document.mjs
create mode 100644 web/scripts/verify-runtime-seo.mjs
create mode 100644 web/scripts/verify-seo.mjs
create mode 100644 web/src/App.jsx
create mode 100644 web/src/api.js
create mode 100644 web/src/assets/figma/callback-phone-3d.png
create mode 100644 web/src/assets/figma/callback-phone-3d.webp
create mode 100644 web/src/assets/figma/checkup-cta-3d.webp
create mode 100644 web/src/assets/figma/checkup-hero-girl.webp
create mode 100644 web/src/assets/figma/checkups-hero-3d.webp
create mode 100644 web/src/assets/figma/ck-beremennost.webp
create mode 100644 web/src/assets/figma/ck-davlenie.webp
create mode 100644 web/src/assets/figma/ck-mini.webp
create mode 100644 web/src/assets/figma/ck-mne-god.webp
create mode 100644 web/src/assets/figma/ck-pervoklashka.webp
create mode 100644 web/src/assets/figma/ck-podrostok.webp
create mode 100644 web/src/assets/figma/ck-rak-sheyki.webp
create mode 100644 web/src/assets/figma/ck-roditeli.webp
create mode 100644 web/src/assets/figma/ck-v-sad.webp
create mode 100644 web/src/assets/figma/ck-zdorovaya-devochka.webp
create mode 100644 web/src/assets/figma/ck-zhenskoe-zdorove.webp
create mode 100644 web/src/assets/figma/ck-zhkt.webp
create mode 100644 web/src/assets/figma/dir-detskaya-klinika.png
create mode 100644 web/src/assets/figma/dir-detskaya-klinika.webp
create mode 100644 web/src/assets/figma/dir-estetic.png
create mode 100644 web/src/assets/figma/dir-estetic.webp
create mode 100644 web/src/assets/figma/dir-ginekologiya.png
create mode 100644 web/src/assets/figma/dir-ginekologiya.webp
create mode 100644 web/src/assets/figma/dir-osteo.png
create mode 100644 web/src/assets/figma/dir-osteo.webp
create mode 100644 web/src/assets/figma/hero-dana-letters.svg
create mode 100644 web/src/assets/figma/ico-baby.png
create mode 100644 web/src/assets/figma/ico-cancer-ribbon.png
create mode 100644 web/src/assets/figma/ico-family.png
create mode 100644 web/src/assets/figma/ico-female.png
create mode 100644 web/src/assets/figma/ico-health-checkup.png
create mode 100644 web/src/assets/figma/ico-playground.png
create mode 100644 web/src/assets/figma/ico-pregnant.png
create mode 100644 web/src/assets/figma/ico-school.png
create mode 100644 web/src/assets/figma/ico-stethoscope.png
create mode 100644 web/src/assets/figma/ico-stomach.png
create mode 100644 web/src/assets/figma/ico-teenager.png
create mode 100644 web/src/assets/figma/ico-tonometer.png
create mode 100644 web/src/assets/figma/icon-accordion-chevron.svg
create mode 100644 web/src/assets/figma/icon-arrow-right.svg
create mode 100644 web/src/assets/figma/icon-calendar.svg
create mode 100644 web/src/assets/figma/icon-check.svg
create mode 100644 web/src/assets/figma/icon-chevron-dark.svg
create mode 100644 web/src/assets/figma/icon-chevron-down.svg
create mode 100644 web/src/assets/figma/icon-chevron-white.svg
create mode 100644 web/src/assets/figma/icon-close.svg
create mode 100644 web/src/assets/figma/icon-dot-coral.svg
create mode 100644 web/src/assets/figma/icon-dot-green.svg
create mode 100644 web/src/assets/figma/icon-dot-urgent.svg
create mode 100644 web/src/assets/figma/icon-max.png
create mode 100644 web/src/assets/figma/icon-max.svg
create mode 100644 web/src/assets/figma/icon-max.webp
create mode 100644 web/src/assets/figma/icon-search-16.svg
create mode 100644 web/src/assets/figma/icon-search-18.svg
create mode 100644 web/src/assets/figma/icon-search-mobile.svg
create mode 100644 web/src/assets/figma/icon-search-price.svg
create mode 100644 web/src/assets/figma/icon-search.svg
create mode 100644 web/src/assets/figma/icon-star-small.svg
create mode 100644 web/src/assets/figma/icon-star.svg
create mode 100644 web/src/assets/figma/icon-whatsapp-bg.svg
create mode 100644 web/src/assets/figma/icon-whatsapp-circle.svg
create mode 100644 web/src/assets/figma/icon-whatsapp-glyph.svg
create mode 100644 web/src/assets/figma/icon-whatsapp-mark.svg
create mode 100644 web/src/assets/figma/icon-whatsapp.svg
create mode 100644 web/src/assets/figma/logo-dana-white.svg
create mode 100644 web/src/assets/figma/logo-dana.svg
create mode 100644 web/src/assets/figma/logo-mobile-white.svg
create mode 100644 web/src/assets/figma/logo-mobile.svg
create mode 100644 web/src/assets/figma/logo-subtitle-white.svg
create mode 100644 web/src/assets/figma/logo-subtitle.svg
create mode 100644 web/src/assets/figma/map-contacts.png
create mode 100644 web/src/assets/figma/map-contacts.webp
create mode 100644 web/src/assets/figma/promo-podrostok.webp
create mode 100644 web/src/assets/figma/promo-rodilsya.webp
create mode 100644 web/src/assets/figma/promo-sad.webp
create mode 100644 web/src/assets/figma/promo-sportsmen.webp
create mode 100644 web/src/assets/figma/promo-zhkt.webp
create mode 100644 web/src/assets/figma/promos-hero-3d.webp
create mode 100644 web/src/assets/figma/service-urgent-3d.webp
create mode 100644 web/src/assets/figma/slider-arrow-doctors.svg
create mode 100644 web/src/assets/figma/slider-arrow-hero-next.svg
create mode 100644 web/src/assets/figma/slider-arrow-hero.svg
create mode 100644 web/src/assets/figma/svc-bol.webp
create mode 100644 web/src/assets/figma/svc-detskaya-klinika.webp
create mode 100644 web/src/assets/figma/svc-endokrinologiya.webp
create mode 100644 web/src/assets/figma/svc-ginekologiya.webp
create mode 100644 web/src/assets/figma/svc-khirurgiya.webp
create mode 100644 web/src/assets/figma/svc-mammologiya.webp
create mode 100644 web/src/assets/figma/svc-oftalmologiya.webp
create mode 100644 web/src/assets/figma/svc-onkologiya.webp
create mode 100644 web/src/assets/figma/svc-plasticheskaya-khirurgiya.webp
create mode 100644 web/src/assets/figma/svc-proktologiya.webp
create mode 100644 web/src/assets/figma/svc-urologiya.webp
create mode 100644 web/src/components/BookingSection.css
create mode 100644 web/src/components/BookingSection.jsx
create mode 100644 web/src/components/Breadcrumbs.jsx
create mode 100644 web/src/components/CallbackForm.jsx
create mode 100644 web/src/components/ClinicMap.css
create mode 100644 web/src/components/ClinicMap.jsx
create mode 100644 web/src/components/CompanySection.css
create mode 100644 web/src/components/CompanySection.jsx
create mode 100644 web/src/components/ContactsSection.css
create mode 100644 web/src/components/ContactsSection.jsx
create mode 100644 web/src/components/CookieBanner.css
create mode 100644 web/src/components/CookieBanner.jsx
create mode 100644 web/src/components/CtaBand.css
create mode 100644 web/src/components/CtaBand.jsx
create mode 100644 web/src/components/DoctorCard.css
create mode 100644 web/src/components/DoctorCard.jsx
create mode 100644 web/src/components/Footer.css
create mode 100644 web/src/components/Footer.jsx
create mode 100644 web/src/components/Header.css
create mode 100644 web/src/components/Header.jsx
create mode 100644 web/src/components/HeroSlider.css
create mode 100644 web/src/components/HeroSlider.jsx
create mode 100644 web/src/components/Layout.jsx
create mode 100644 web/src/components/LeadModal.css
create mode 100644 web/src/components/LeadModal.jsx
create mode 100644 web/src/components/MegaMenu.css
create mode 100644 web/src/components/MegaMenu.jsx
create mode 100644 web/src/components/MobileMenu.css
create mode 100644 web/src/components/MobileMenu.jsx
create mode 100644 web/src/components/NativeBooking.css
create mode 100644 web/src/components/NativeBooking.jsx
create mode 100644 web/src/components/SearchBox.css
create mode 100644 web/src/components/SearchBox.jsx
create mode 100644 web/src/components/SocialLinks.css
create mode 100644 web/src/components/SocialLinks.jsx
create mode 100644 web/src/context/ApiData.jsx
create mode 100644 web/src/context/LeadModalContext.jsx
create mode 100644 web/src/context/SiteData.jsx
create mode 100644 web/src/entry-server.jsx
create mode 100644 web/src/lib/attribution.js
create mode 100644 web/src/lib/format.js
create mode 100644 web/src/lib/links.js
create mode 100644 web/src/lib/media.js
create mode 100644 web/src/lib/metrika.js
create mode 100644 web/src/lib/phone.js
create mode 100644 web/src/lib/sanitize.js
create mode 100644 web/src/lib/seo.js
create mode 100644 web/src/lib/serviceContent.js
create mode 100644 web/src/lib/serviceNavigation.js
create mode 100644 web/src/lib/slots.js
create mode 100644 web/src/lib/text.js
create mode 100644 web/src/lib/useApi.js
create mode 100644 web/src/lib/usePageTitle.js
create mode 100644 web/src/lib/video.js
create mode 100644 web/src/main.jsx
create mode 100644 web/src/pages/AboutPage.jsx
create mode 100644 web/src/pages/CheckupPage.css
create mode 100644 web/src/pages/CheckupPage.jsx
create mode 100644 web/src/pages/CheckupsPage.css
create mode 100644 web/src/pages/CheckupsPage.jsx
create mode 100644 web/src/pages/CompanyListPages.jsx
create mode 100644 web/src/pages/CompanyPages.css
create mode 100644 web/src/pages/ContactsPage.css
create mode 100644 web/src/pages/ContactsPage.jsx
create mode 100644 web/src/pages/DoctorPage.css
create mode 100644 web/src/pages/DoctorPage.jsx
create mode 100644 web/src/pages/DoctorsPage.css
create mode 100644 web/src/pages/DoctorsPage.jsx
create mode 100644 web/src/pages/HomePage.css
create mode 100644 web/src/pages/HomePage.jsx
create mode 100644 web/src/pages/LegalPage.css
create mode 100644 web/src/pages/LegalPage.jsx
create mode 100644 web/src/pages/PostPage.jsx
create mode 100644 web/src/pages/PostsPage.jsx
create mode 100644 web/src/pages/PricesPage.css
create mode 100644 web/src/pages/PricesPage.jsx
create mode 100644 web/src/pages/PromosPage.css
create mode 100644 web/src/pages/PromosPage.jsx
create mode 100644 web/src/pages/ServicePage.css
create mode 100644 web/src/pages/ServicePage.jsx
create mode 100644 web/src/pages/ServicesPage.css
create mode 100644 web/src/pages/ServicesPage.jsx
create mode 100644 web/src/pages/StubPage.css
create mode 100644 web/src/pages/StubPage.jsx
create mode 100644 web/src/styles/base.css
create mode 100644 web/src/styles/tokens.css
create mode 100644 web/vite.config.js
diff --git a/.deploy/dana-medical.caddy b/.deploy/dana-medical.caddy
new file mode 100644
index 0000000..ff454b2
--- /dev/null
+++ b/.deploy/dana-medical.caddy
@@ -0,0 +1,74 @@
+danamedical.ru {
+ encode zstd gzip
+
+ header {
+ -Server
+ }
+
+ reverse_proxy 172.17.0.1:8082 {
+ header_up X-Real-IP {remote_host}
+ }
+
+ request_body {
+ max_size 20MB
+ }
+
+ log {
+ output file /var/log/caddy/dana-medical.log {
+ roll_size 10MB
+ roll_keep 10
+ }
+ }
+}
+
+www.danamedical.ru {
+ redir https://danamedical.ru{uri} permanent
+}
+
+new.danamedical.ru {
+ encode zstd gzip
+
+ header {
+ X-Robots-Tag "noindex, nofollow"
+ -Server
+ }
+
+ reverse_proxy 172.17.0.1:8082 {
+ header_up X-Real-IP {remote_host}
+ }
+
+ request_body {
+ max_size 20MB
+ }
+
+ log {
+ output file /var/log/caddy/dana-medical-preview.log {
+ roll_size 10MB
+ roll_keep 5
+ }
+ }
+}
+
+dana.195-95-133-47.sslip.io {
+ encode zstd gzip
+
+ header {
+ X-Robots-Tag "noindex, nofollow"
+ -Server
+ }
+
+ reverse_proxy 172.17.0.1:8082 {
+ header_up X-Real-IP {remote_host}
+ }
+
+ request_body {
+ max_size 20MB
+ }
+
+ log {
+ output file /var/log/caddy/dana-medical-preview.log {
+ roll_size 10MB
+ roll_keep 5
+ }
+ }
+}
diff --git a/.deploy/remote-build-start-dana.sh b/.deploy/remote-build-start-dana.sh
new file mode 100644
index 0000000..8ebe258
--- /dev/null
+++ b/.deploy/remote-build-start-dana.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+set -eu
+
+cd /home/mikail/dana-medical
+docker compose -p dana-medical -f docker-compose.prod.yml config --quiet
+docker compose -p dana-medical -f docker-compose.prod.yml build api
+docker compose -p dana-medical -f docker-compose.prod.yml create api
+
+media_volume="$(docker volume ls -q \
+ --filter label=com.docker.compose.project=dana-medical \
+ --filter label=com.docker.compose.volume=media)"
+test -n "$media_volume"
+docker run --rm \
+ -v "$media_volume:/dest" \
+ -v /home/mikail/dana-medical/media:/src:ro \
+ postgres:16-alpine sh -c 'cp -a /src/. /dest/ && chown -R 10001:10001 /dest'
+
+docker compose -p dana-medical -f docker-compose.prod.yml up -d api
+api_id="$(docker compose -p dana-medical -f docker-compose.prod.yml ps -q api)"
+test -n "$api_id"
+i=0
+status=starting
+while [ "$i" -lt 45 ]; do
+ status="$(docker inspect --format '{{.State.Health.Status}}' "$api_id")"
+ [ "$status" = healthy ] && break
+ [ "$status" = unhealthy ] && docker logs --tail 80 "$api_id" && exit 1
+ sleep 2
+ i=$((i + 1))
+done
+[ "$status" = healthy ]
+printf 'api_health=%s\n' "$status"
+printf 'media_volume=%s\n' "$media_volume"
+printf 'media_files=%s\n' "$(docker run --rm -v "$media_volume:/data:ro" postgres:16-alpine sh -c 'find /data -type f | wc -l')"
diff --git a/.deploy/remote-init-dana.sh b/.deploy/remote-init-dana.sh
new file mode 100644
index 0000000..6fadc96
--- /dev/null
+++ b/.deploy/remote-init-dana.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+set -eu
+
+root=/home/mikail/dana-medical
+deploy="$root/.deploy"
+test -f "$deploy/dana-source.tar.gz"
+test -f "$deploy/dana-media.tar.gz"
+test -f "$deploy/dana-predeploy.dump"
+test -f "$deploy/.env"
+
+tar -xzf "$deploy/dana-source.tar.gz" -C "$root"
+tar -xzf "$deploy/dana-media.tar.gz" -C "$root"
+
+umask 077
+db_password="$(openssl rand -hex 32)"
+{
+ printf 'POSTGRES_PASSWORD=%s\n' "$db_password"
+ printf 'SITE_URL=https://danamedical.ru\n'
+ printf 'DANA_PORT=8082\n'
+ printf 'ALLOWED_ORIGINS=https://danamedical.ru,https://www.danamedical.ru\n'
+ grep '^MEGAFON_API_URL=' "$deploy/.env" || true
+ grep '^MEGAFON_API_KEY=' "$deploy/.env" || true
+ printf 'SMTP_HOST=\n'
+ printf 'SMTP_PORT=465\n'
+ printf 'SMTP_USER=\n'
+ printf 'SMTP_PASSWORD=\n'
+ printf 'NOTIFY_FROM=\n'
+ printf 'NOTIFY_TO=\n'
+} > "$root/.env"
+chmod 600 "$root/.env" "$deploy/dana-predeploy.dump"
+rm -f "$deploy/.env"
+
+test -f "$root/docker-compose.prod.yml"
+test -f "$root/Dockerfile"
+test -d "$root/media"
+printf 'source_files=%s\n' "$(find "$root" -path "$deploy" -prune -o -type f -print | wc -l)"
+printf 'media_files=%s\n' "$(find "$root/media" -type f | wc -l)"
+printf 'env_mode=%s\n' "$(stat -c %a "$root/.env")"
diff --git a/.deploy/remote-install-dana-caddy.sh b/.deploy/remote-install-dana-caddy.sh
new file mode 100644
index 0000000..0969e35
--- /dev/null
+++ b/.deploy/remote-install-dana-caddy.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+set -eu
+
+source_file=/home/mikail/dana-medical/.deploy/dana-medical.caddy
+custom_dir=/home/kadir/timechecker/caddy-custom
+target_file="$custom_dir/dana-medical.caddy"
+container=timechecker-caddy-1
+
+test -s "$source_file"
+if docker run --rm -v "$custom_dir:/target:ro" alpine:3.22 test -e /target/dana-medical.caddy; then
+ echo 'target_already_exists'
+ exit 1
+fi
+
+docker run --rm \
+ -v /home/mikail/dana-medical/.deploy:/source:ro \
+ -v "$custom_dir:/target" \
+ alpine:3.22 cp /source/dana-medical.caddy /target/dana-medical.caddy
+
+rollback() {
+ docker run --rm -v "$custom_dir:/target" alpine:3.22 rm -f /target/dana-medical.caddy >/dev/null 2>&1 || true
+}
+trap rollback INT TERM HUP
+
+if ! docker exec "$container" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile; then
+ rollback
+ echo 'caddy_validation_failed'
+ exit 1
+fi
+
+if ! docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile; then
+ rollback
+ docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || true
+ echo 'caddy_reload_failed'
+ exit 1
+fi
+
+trap - INT TERM HUP
+docker exec "$container" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile 2>/dev/null | grep -q 'new.danamedical.ru'
+echo 'caddy_route=installed'
diff --git a/.deploy/remote-restore-dana.sh b/.deploy/remote-restore-dana.sh
new file mode 100644
index 0000000..5d21a28
--- /dev/null
+++ b/.deploy/remote-restore-dana.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -eu
+
+cd /home/mikail/dana-medical
+dump=.deploy/dana-predeploy.dump
+test -s "$dump"
+
+existing="$(docker compose -p dana-medical -f docker-compose.prod.yml exec -T db psql -U dana -d dana -Atqc "select count(*) from information_schema.tables where table_schema='public'")"
+[ "$existing" = 0 ]
+
+docker compose -p dana-medical -f docker-compose.prod.yml exec -T db \
+ pg_restore -U dana -d dana --exit-on-error --no-owner --no-privileges < "$dump"
+
+docker compose -p dana-medical -f docker-compose.prod.yml exec -T db psql -U dana -d dana -At \
+ -c "select 'admins='||count(*) from admin_users; select 'services='||count(*) from services; select 'leads='||count(*) from leads; select 'pbx_calls='||count(*) from pbx_calls;"
diff --git a/.deploy/remote-secure-dana.sh b/.deploy/remote-secure-dana.sh
new file mode 100644
index 0000000..1bc0b2e
--- /dev/null
+++ b/.deploy/remote-secure-dana.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+
+root=/home/mikail/dana-medical
+chmod 700 "$root/.deploy"
+find "$root/.deploy" -maxdepth 1 -type f -exec chmod 600 {} \;
+if [ -f "$root/remote-build-start-dana.sh" ]; then
+ mv "$root/remote-build-start-dana.sh" "$root/.deploy/"
+ chmod 600 "$root/.deploy/remote-build-start-dana.sh"
+fi
+printf 'deploy_mode=%s\n' "$(stat -c %a "$root/.deploy")"
+printf 'env_mode=%s\n' "$(stat -c %a "$root/.env")"
diff --git a/.deploy/remote-smoke-dana.sh b/.deploy/remote-smoke-dana.sh
new file mode 100644
index 0000000..a8e50af
--- /dev/null
+++ b/.deploy/remote-smoke-dana.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+set -eu
+
+cd /home/mikail/dana-medical
+origin=http://172.17.0.1:8082
+marker="DANA-DEPLOY-CHECK-$(date +%Y%m%d%H%M%S)"
+body="/tmp/dana-lead-check-$$.json"
+cleanup() {
+ docker compose -p dana-medical -f docker-compose.prod.yml exec -T db \
+ psql -U dana -d dana -v ON_ERROR_STOP=1 -c "delete from leads where name='$marker'" >/dev/null 2>&1 || true
+ rm -f "$body"
+}
+trap cleanup EXIT INT TERM
+
+for path in / /services/ /company/staff/ /price/ /checkup/ /contacts/ /robots.txt /sitemap.xml /admin/ /api/v1/health /api/v1/forms; do
+ code="$(curl -sS -o /dev/null -w '%{http_code}' "$origin$path")"
+ [ "$code" = 200 ]
+ printf '%s=%s\n' "$path" "$code"
+done
+
+curl -fsS "$origin/" | grep -q '/dev/null 2>&1 || true
+}
+trap rollback INT TERM HUP
+
+if ! docker exec "$container" caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile; then
+ rollback
+ echo 'caddy_validation_failed'
+ exit 1
+fi
+
+if ! docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile; then
+ rollback
+ docker exec "$container" caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 || true
+ echo 'caddy_reload_failed'
+ exit 1
+fi
+
+trap - INT TERM HUP
+docker run --rm -v "$custom_dir:/target" alpine:3.22 rm -f /target/dana-medical.caddy.previous
+docker exec "$container" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile 2>/dev/null | grep -q 'danamedical.ru'
+echo 'caddy_dana_routes=installed'
diff --git a/.deploy/remote-verify-dana-preview.sh b/.deploy/remote-verify-dana-preview.sh
new file mode 100644
index 0000000..4d931c2
--- /dev/null
+++ b/.deploy/remote-verify-dana-preview.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+set -eu
+
+origin=https://dana.195-95-133-47.sslip.io
+headers="/tmp/dana-preview-headers-$$"
+body="/tmp/dana-preview-body-$$"
+cleanup() { rm -f "$headers" "$body"; }
+trap cleanup EXIT INT TERM
+
+i=0
+status=000
+while [ "$i" -lt 30 ]; do
+ status="$(curl -sS --max-time 10 -D "$headers" -o "$body" -w '%{http_code}' "$origin/" 2>/dev/null || true)"
+ [ "$status" = 200 ] && break
+ sleep 2
+ i=$((i + 1))
+done
+[ "$status" = 200 ]
+grep -qi '^x-robots-tag: noindex, nofollow' "$headers"
+grep -q '/dev/null | openssl x509 -noout -subject -issuer -dates
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3a0da2c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+.git
+.env
+.env.*
+!.env.example
+.work
+.gocache
+.gotmp
+media
+web/node_modules
+web/dist
+web/.ssr
+*.exe
+*.log
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2a83b68
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Подключение к базе. Для локальной установки PostgreSQL порт 5432,
+# для варианта с docker compose — 5433.
+DATABASE_URL=postgres://dana:CHANGE_ME@localhost:5432/dana?sslmode=disable
+
+APP_ADDR=:8080
+MEDIA_DIR=./media
+ALLOWED_ORIGINS=http://localhost:5173
+DEBUG=true
+
+# Готовый HTML сайта после npm run build в web.
+WEB_DIR=./web/dist
+# Один и тот же origin при сборке web и запуске Go. Для боевого сайта укажите HTTPS-домен.
+SITE_URL=http://127.0.0.1:8080
+
+# Актуальный HTML из БД на запросе; кеш обновляется при изменении данных.
+# На сервере нужны Node.js, web/.ssr и установленные зависимости web.
+SSR_RENDERER=./web/scripts/render-page.mjs
+NODE_BINARY=node
+# SSR_RENDERER=off — только снимки сборки, требующие ручной пересборки после правок.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5c658f4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,24 @@
+/server
+/server.exe
+/server.exe~
+/media/
+/tmp/
+.env
+*.log
+.gocache/
+.gotmp/
+.work/
+.idea/
+__pycache__/
+migrations/import/seed_*.sql
+migrations/import/fix*.sql
+migrations/import/*.csv
+migrations/import/*_report.txt
+migrations/import/*_plan.txt
+web/node_modules/
+web/dist/
+web/.ssr/
+tmp_seed/
+backups/
+.deploy/*.tar.gz
+.deploy/*.dump
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f1093e7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+FROM node:24-alpine AS web-build
+WORKDIR /src
+COPY web/package.json web/package-lock.json ./web/
+RUN cd web && npm ci
+COPY seo ./seo
+COPY web ./web
+RUN cd web && npm run build:client && npx vite build --ssr src/entry-server.jsx --outDir .ssr && npm prune --omit=dev
+
+FROM golang:1.26-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
+
+FROM node:24-alpine
+RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app
+COPY --from=build /out/server /usr/local/bin/server
+WORKDIR /app
+COPY --from=web-build /src/web/dist ./web/dist
+COPY --from=web-build /src/web/.ssr ./web/.ssr
+COPY --from=web-build /src/web/node_modules ./web/node_modules
+COPY web/package.json ./web/package.json
+COPY web/scripts/render-page.mjs web/scripts/seo-document.mjs ./web/scripts/
+ENV WEB_DIR=/app/web/dist SSR_RENDERER=/app/web/scripts/render-page.mjs
+USER app
+EXPOSE 8080
+ENTRYPOINT ["server"]
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..16ca929
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,140 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "dana/internal/api"
+ "dana/internal/bitrix"
+ "dana/internal/config"
+ "dana/internal/megafon"
+ "dana/internal/notify"
+ "dana/internal/onec"
+ "dana/internal/prodoctorov"
+ "dana/internal/sberhealth"
+ "dana/internal/storage"
+)
+
+func main() {
+ if err := run(); err != nil {
+ slog.Error("fatal", "err", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ cfg, err := config.Load()
+ if err != nil {
+ return err
+ }
+
+ level := slog.LevelInfo
+ if cfg.Debug {
+ level = slog.LevelDebug
+ }
+ slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})))
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ pd, err := prodoctorov.New(cfg.ProdoctorovCookie, cfg.ProdoctorovLPU)
+ if err != nil {
+ return err
+ }
+ sh, err := sberhealth.New(cfg.SberHealthCookie, cfg.SberHealthClinic)
+ if err != nil {
+ return err
+ }
+
+ store, err := storage.New(ctx, cfg.DatabaseURL)
+ if err != nil {
+ return err
+ }
+ defer store.Close()
+
+ if err := store.Migrate(ctx); err != nil {
+ return err
+ }
+
+ // Первый администратор заводится один раз, если таблица пуста.
+ if err := store.EnsureAdmin(ctx, cfg.AdminEmail, cfg.AdminPassword); err != nil {
+ return err
+ }
+
+ if err := api.SetTrustedProxies(cfg.TrustedProxies); err != nil {
+ return err
+ }
+ handler := api.New(store, cfg.MediaDir)
+ if err := handler.FailInterruptedReports(ctx); err != nil {
+ slog.Warn("mark interrupted reports", "err", err)
+ }
+ handler.WithPBX(megafon.New(cfg.MegafonURL, cfg.MegafonKey))
+ if cfg.OchevidnoMegafonURL != "" && cfg.OchevidnoMegafonKey != "" {
+ handler.WithClinicPBX("ochevidno", megafon.New(cfg.OchevidnoMegafonURL, cfg.OchevidnoMegafonKey))
+ }
+ go handler.RunPBXSync(ctx)
+ handler.WithOneC(onec.New(cfg.OnecURL, cfg.OnecKey, cfg.OnecAuthHeader))
+ handler.WithOneCWebhook(cfg.OnecWebhookKey)
+ handler.WithOneCTuning(time.Duration(cfg.OnecSyncSecs)*time.Second, cfg.OnecLongPoll)
+ go handler.RunOneCSync(ctx)
+ handler.WithBitrix(bitrix.New(cfg.BitrixWebhookURL))
+ go handler.RunBookingConfirm(ctx)
+ handler.WithSMSC(cfg.SMSCLogin, cfg.SMSCPassword)
+ handler.WithSMSCCallbackToken(cfg.SMSCCallbackToken)
+ handler.WithProdoctorov(pd)
+ handler.WithSberHealth(sh)
+ if err := handler.WithWebsite(cfg.WebDir, cfg.SiteURL); err != nil {
+ return err
+ }
+ if err := handler.WithSSR(cfg.SSRRenderer, cfg.NodeBinary); err != nil {
+ return err
+ }
+ if cfg.SMTPHost != "" {
+ handler.WithNotifier(¬ify.Email{
+ Host: cfg.SMTPHost,
+ Port: cfg.SMTPPort,
+ User: cfg.SMTPUser,
+ Password: cfg.SMTPPassword,
+ From: cfg.NotifyFrom,
+ To: cfg.NotifyTo,
+ SiteURL: cfg.SiteURL,
+ })
+ slog.Info("lead notifications by email", "smtp", cfg.SMTPHost, "to", cfg.NotifyTo)
+ } else {
+ slog.Warn("SMTP_HOST is not set: lead notifications are disabled")
+ }
+
+ srv := &http.Server{
+ Addr: cfg.Addr,
+ Handler: handler.Routes(),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ slog.Info("listening", "addr", cfg.Addr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ errCh <- err
+ }
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ slog.Info("shutting down")
+ }
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
diff --git a/deploy.env.example b/deploy.env.example
new file mode 100644
index 0000000..5574914
--- /dev/null
+++ b/deploy.env.example
@@ -0,0 +1,24 @@
+# Скопировать в .env только на VPS и заменить значения.
+POSTGRES_PASSWORD=CHANGE_ME_LONG_RANDOM_VALUE
+ADMIN_EMAIL=admin@example.ru
+ADMIN_PASSWORD=CHANGE_ME_LONG_RANDOM_VALUE
+
+# Канонический адрес нового сайта. До переключения DNS приложение можно
+# проверять через SSH-туннель, не публикуя его в Caddy.
+SITE_URL=https://danamedical.ru
+DANA_PORT=8082
+ALLOWED_ORIGINS=https://danamedical.ru,https://www.danamedical.ru
+
+# Уведомления о новых заявках. Если SMTP_HOST пуст, заявки сохраняются в
+# админке, но письма не отправляются.
+SMTP_HOST=
+SMTP_PORT=465
+SMTP_USER=
+SMTP_PASSWORD=
+NOTIFY_FROM=
+NOTIFY_TO=
+
+# Виртуальная АТС МегаФон. Без ключа раздел работает с уже загруженными
+# данными, но автоматическая синхронизация отключена.
+MEGAFON_API_URL=vats970345.megapbx.ru
+MEGAFON_API_KEY=
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..7e46242
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,53 @@
+services:
+ db:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: dana
+ POSTGRES_USER: dana
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U dana -d dana"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ api:
+ build:
+ context: .
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: postgres://dana:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/dana?sslmode=disable
+ APP_ADDR: ":8080"
+ MEDIA_DIR: /data/media
+ WEB_DIR: /app/web/dist
+ SSR_RENDERER: /app/web/scripts/render-page.mjs
+ NODE_BINARY: node
+ SITE_URL: ${SITE_URL:?SITE_URL is required}
+ DEBUG: "false"
+ # Caddy уже работает в контейнере Ability Center. Публикуем DANA только
+ # на docker0 хоста: из интернета порт недоступен, Caddy обращается к нему
+ # как к 172.17.0.1:8082.
+ ports:
+ - "172.17.0.1:${DANA_PORT:-8082}:8080"
+ volumes:
+ - media:/data/media
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 20s
+ security_opt:
+ - no-new-privileges:true
+
+volumes:
+ db-data:
+ media:
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..31e93c7
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: dana
+ POSTGRES_USER: dana
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dana}
+ ports:
+ - "5433:5432"
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U dana -d dana"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+
+ api:
+ build: .
+ environment:
+ DATABASE_URL: postgres://dana:${POSTGRES_PASSWORD:-dana}@db:5432/dana?sslmode=disable
+ APP_ADDR: ":8080"
+ MEDIA_DIR: /data/media
+ DEBUG: "true"
+ SITE_URL: ${SITE_URL:-}
+ # Уведомления о заявках на почту; пустой SMTP_HOST — отключены.
+ # Для Яндекс Почты: smtp.yandex.ru, порт 465, пароль приложения, а не пароль ящика.
+ SMTP_HOST: ${SMTP_HOST:-}
+ SMTP_PORT: ${SMTP_PORT:-465}
+ SMTP_USER: ${SMTP_USER:-}
+ SMTP_PASSWORD: ${SMTP_PASSWORD:-}
+ NOTIFY_FROM: ${NOTIFY_FROM:-}
+ NOTIFY_TO: ${NOTIFY_TO:-}
+ ports:
+ - "8080:8080"
+ volumes:
+ - media:/data/media
+ depends_on:
+ db:
+ condition: service_healthy
+
+volumes:
+ db-data:
+ media:
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..68511db
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,17 @@
+module dana
+
+go 1.26.0
+
+require (
+ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7
+ github.com/jackc/pgx/v5 v5.9.2
+ golang.org/x/crypto v0.56.0
+ golang.org/x/text v0.41.0
+)
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..62d0ff1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,30 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
+github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
+golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/api/admin.go b/internal/api/admin.go
new file mode 100644
index 0000000..269ff3d
--- /dev/null
+++ b/internal/api/admin.go
@@ -0,0 +1,318 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "dana/internal/storage"
+)
+
+const sessionCookie = "dana_admin"
+
+type ctxKey string
+
+const userKey ctxKey = "admin_user"
+
+// trustedProxies — откуда принимаем X-Forwarded-For / X-Real-IP. По умолчанию сам сервер и Docker-сети:
+// порт сайта открыт только внутри сервера (172.17.0.1), снаружи к нему ходит лишь Caddy.
+// Заголовки от любого другого адреса — подделка клиента для обхода лимитов по IP.
+var trustedProxies = mustParseProxies("127.0.0.0/8,::1/128,172.16.0.0/12")
+
+// SetTrustedProxies задаёт доверенные прокси (TRUSTED_PROXIES): адреса или подсети через запятую,
+// например адрес Caddy в Docker-сети. Пустая строка оставляет значение по умолчанию.
+func SetTrustedProxies(spec string) error {
+ if strings.TrimSpace(spec) == "" {
+ return nil
+ }
+ nets, err := parseProxies(spec)
+ if err != nil {
+ return err
+ }
+ trustedProxies = nets
+ return nil
+}
+
+func parseProxies(spec string) ([]*net.IPNet, error) {
+ var out []*net.IPNet
+ for _, part := range strings.Split(spec, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if !strings.Contains(part, "/") {
+ if ip := net.ParseIP(part); ip != nil && ip.To4() != nil {
+ part += "/32"
+ } else {
+ part += "/128"
+ }
+ }
+ _, n, err := net.ParseCIDR(part)
+ if err != nil {
+ return nil, errors.New("TRUSTED_PROXIES: не адрес и не подсеть: " + part)
+ }
+ out = append(out, n)
+ }
+ return out, nil
+}
+
+func mustParseProxies(spec string) []*net.IPNet {
+ n, err := parseProxies(spec)
+ if err != nil {
+ panic(err)
+ }
+ return n
+}
+
+func isTrustedProxy(ip net.IP) bool {
+ for _, n := range trustedProxies {
+ if n.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// clientIP определяет адрес посетителя. Заголовки прокси учитываем, только если соединение
+// пришло от доверенного прокси; иначе берём адрес соединения.
+// Первым смотрим X-Forwarded-For: его Caddy формирует сам (входящий от посетителя отбрасывает),
+// а X-Real-IP Caddy без отдельной настройки пропускает от посетителя как есть.
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ host = r.RemoteAddr
+ }
+ remote := net.ParseIP(host)
+ if remote == nil || !isTrustedProxy(remote) {
+ return host
+ }
+ // справа налево: правые элементы дописали наши прокси, первый чужой — клиент
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ parts := strings.Split(xff, ",")
+ for i := len(parts) - 1; i >= 0; i-- {
+ ip := net.ParseIP(strings.TrimSpace(parts[i]))
+ if ip == nil {
+ break
+ }
+ if !isTrustedProxy(ip) {
+ return ip.String()
+ }
+ }
+ }
+ if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(real) != nil {
+ return real
+ }
+ return host
+}
+
+// drainBody дочитывает небольшое тело запроса перед отказом. Если ответить, не прочитав его,
+// а клиент дошлёт тело уже после закрытия соединения, Windows рвёт соединение (RST),
+// и клиент вместо ответа 401 получает ошибку сети.
+func drainBody(r *http.Request) {
+ _, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1<<20))
+}
+
+// requireAuth пропускает только запросы с действующей сессией.
+func (a *API) requireAuth(next func(http.ResponseWriter, *http.Request, *storage.AdminUser)) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil || c.Value == "" {
+ drainBody(r)
+ fail(w, http.StatusUnauthorized, "unauthorized", "Требуется вход")
+ return
+ }
+ u, err := a.store.UserByToken(r.Context(), c.Value)
+ if err != nil {
+ drainBody(r)
+ fail(w, http.StatusUnauthorized, "unauthorized", "Сессия истекла")
+ return
+ }
+ next(w, r, u)
+ }
+}
+
+func (a *API) adminLogin(w http.ResponseWriter, r *http.Request) {
+ // защита от перебора пароля: не больше 10 попыток входа с одного IP за 15 минут
+ if a.tooMany(w, "login:"+clientIP(r), 10, 15*time.Minute) {
+ return
+ }
+ var in struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ token, u, err := a.store.Login(r.Context(),
+ strings.TrimSpace(strings.ToLower(in.Email)), in.Password,
+ clientIP(r), r.UserAgent())
+ if errors.Is(err, storage.ErrBadCredentials) {
+ fail(w, http.StatusUnauthorized, "bad_credentials", "Неверный логин или пароль")
+ return
+ }
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: sessionCookie,
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: 12 * 3600,
+ })
+ ok(w, u, nil)
+}
+
+func (a *API) adminLogout(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie(sessionCookie); err == nil {
+ _ = a.store.Logout(r.Context(), c.Value)
+ }
+ http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1})
+ ok(w, map[string]string{"status": "ok"}, nil)
+}
+
+func (a *API) adminMe(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ ok(w, u, nil)
+}
+
+// ---------- Услуги ----------
+
+func (a *API) adminServices(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminServices(r.Context(), r.URL.Query().Get("q"))
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminUpdateService(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var in storage.ServiceUpdate
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ if strings.TrimSpace(in.Name) == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Название не может быть пустым")
+ return
+ }
+ if err := a.store.AdminUpdateService(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "service", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// ---------- Прайс ----------
+
+func (a *API) adminPriceSections(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminPriceSections(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, nil)
+}
+
+func (a *API) adminPriceItems(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ q := r.URL.Query()
+ sectionID, _ := strconv.ParseInt(q.Get("section_id"), 10, 64)
+ limit, _ := strconv.Atoi(q.Get("limit"))
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ offset, _ := strconv.Atoi(q.Get("offset"))
+ if offset < 0 {
+ offset = 0
+ }
+ list, total, err := a.store.AdminPriceItems(r.Context(), sectionID, q.Get("q"), limit, offset)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset})
+}
+
+func validPriceInput(in storage.PriceItemInput) string {
+ if strings.TrimSpace(in.Name) == "" {
+ return "Название не может быть пустым"
+ }
+ if in.Price < 0 {
+ return "Цена не может быть отрицательной"
+ }
+ if in.SectionID == 0 {
+ return "Не выбран раздел"
+ }
+ return ""
+}
+
+func (a *API) adminUpdatePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var in storage.PriceItemInput
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ if msg := validPriceInput(in); msg != "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", msg)
+ return
+ }
+ if err := a.store.AdminUpdatePriceItem(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "price_item", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminCreatePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ var in storage.PriceItemInput
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ if msg := validPriceInput(in); msg != "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", msg)
+ return
+ }
+ id, err := a.store.AdminCreatePriceItem(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "price_item", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeletePriceItem(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeletePriceItem(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "price_item", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_banners.go b/internal/api/admin_banners.go
new file mode 100644
index 0000000..8df9b86
--- /dev/null
+++ b/internal/api/admin_banners.go
@@ -0,0 +1,161 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "dana/internal/storage"
+)
+
+// Совпадает со значением по умолчанию в миграции 0006.
+const defaultBannerDisclaimer = "Имеются противопоказания, необходима консультация специалиста"
+
+const (
+ maxBannerChips = 6
+ maxBannerChipLen = 40
+)
+
+func (a *API) adminBanners(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminBanners(r.Context(), r.URL.Query().Get("position"))
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func decodeBanner(w http.ResponseWriter, r *http.Request) (storage.BannerInput, bool) {
+ var in storage.BannerInput
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return in, false
+ }
+ in.Position = strings.TrimSpace(in.Position)
+ in.Layout = strings.TrimSpace(in.Layout)
+ in.Theme = strings.TrimSpace(in.Theme)
+ in.Title = strings.TrimSpace(in.Title)
+ in.Subtitle = strings.TrimSpace(in.Subtitle)
+ in.Badge = strings.TrimSpace(in.Badge)
+ in.LinkURL = strings.TrimSpace(in.LinkURL)
+ in.Disclaimer = strings.TrimSpace(in.Disclaimer)
+ if in.Position == "" {
+ in.Position = "home_hero"
+ }
+ if in.Layout == "" {
+ in.Layout = "image"
+ }
+ if in.Theme == "" {
+ in.Theme = "navy"
+ }
+ // Пустое поле из формы перетёрло бы значение по умолчанию из схемы, и реклама
+ // медуслуг ушла бы на сайт без обязательного предупреждения (ФЗ «О рекламе», ст. 24).
+ if in.Disclaimer == "" {
+ in.Disclaimer = defaultBannerDisclaimer
+ }
+
+ switch in.Layout {
+ case "image":
+ // Текст на таких слайдах нарисован на картинке, поэтому достаточно
+ // либо картинки, либо заголовка — но хоть что-то показывать нужно.
+ if in.ImageID == nil && in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Нужна картинка или заголовок")
+ return in, false
+ }
+ case "text":
+ if in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "У текстового слайда нужен заголовок")
+ return in, false
+ }
+ case "doctor":
+ if in.Title == "" || in.ImageID == nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "У слайда с врачом нужны заголовок и фото")
+ return in, false
+ }
+ default:
+ fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный вид слайда")
+ return in, false
+ }
+ if in.Theme != "navy" && in.Theme != "lilac" && in.Theme != "orange" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный цвет слайда")
+ return in, false
+ }
+
+ chips := make([]string, 0, len(in.Chips))
+ for _, c := range in.Chips {
+ if c = strings.TrimSpace(c); c == "" {
+ continue
+ }
+ if utf8.RuneCountInString(c) > maxBannerChipLen {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Плашка длиннее 40 символов: «"+c+"»")
+ return in, false
+ }
+ chips = append(chips, c)
+ }
+ if len(chips) > maxBannerChips {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Не больше 6 плашек на слайде")
+ return in, false
+ }
+ in.Chips = chips
+
+ // Ссылка только на страницу сайта или на http(s): javascript: и прочее отсекаем.
+ if in.LinkURL != "" && !strings.HasPrefix(in.LinkURL, "/") &&
+ !strings.HasPrefix(in.LinkURL, "https://") && !strings.HasPrefix(in.LinkURL, "http://") {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Ссылка должна вести на страницу сайта (/...) или начинаться с https://")
+ return in, false
+ }
+ if in.StartsAt != nil && in.EndsAt != nil && !in.EndsAt.After(*in.StartsAt) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Окончание показа должно быть позже начала")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminCreateBanner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeBanner(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateBanner(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "banner", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateBanner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeBanner(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdateBanner(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "banner", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteBanner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteBanner(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "banner", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_banners_test.go b/internal/api/admin_banners_test.go
new file mode 100644
index 0000000..412c097
--- /dev/null
+++ b/internal/api/admin_banners_test.go
@@ -0,0 +1,41 @@
+package api
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestDecodeBannerRules(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantOK bool
+ wantText string
+ }{
+ {"text slide", `{"layout":"text","theme":"lilac","title":"Программы чек-апов"}`, true, ""},
+ {"doctor slide", `{"layout":"doctor","title":"Уролог из Москвы","image_id":5,"chips":["26 лет стажа"," "]}`, true, ""},
+ {"image slide by picture", `{"layout":"image","image_id":5}`, true, ""},
+ {"text slide without title", `{"layout":"text","theme":"lilac"}`, false, "заголовок"},
+ {"doctor slide without photo", `{"layout":"doctor","title":"Уролог"}`, false, "фото"},
+ {"unknown layout", `{"layout":"video","title":"x"}`, false, "вид"},
+ {"unknown theme", `{"layout":"text","theme":"red","title":"x"}`, false, "цвет"},
+ {"too many chips", `{"layout":"text","title":"x","chips":["1","2","3","4","5","6","7"]}`, false, "6 плашек"},
+ {"javascript link", `{"layout":"text","title":"x","link_url":"javascript:alert(1)"}`, false, "Ссылка"},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/api/v1/admin/banners", strings.NewReader(tc.body))
+ in, valid := decodeBanner(w, r)
+ if valid != tc.wantOK || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: valid=%v body=%s, want valid=%v containing %q", tc.name, valid, w.Body.String(), tc.wantOK, tc.wantText)
+ continue
+ }
+ if valid && in.Disclaimer == "" {
+ t.Errorf("%s: disclaimer must default to the ФЗ «О рекламе» warning", tc.name)
+ }
+ if tc.name == "doctor slide" && len(in.Chips) != 1 {
+ t.Errorf("empty chips must be dropped, got %q", in.Chips)
+ }
+ }
+}
diff --git a/internal/api/admin_checkups.go b/internal/api/admin_checkups.go
new file mode 100644
index 0000000..3984b7f
--- /dev/null
+++ b/internal/api/admin_checkups.go
@@ -0,0 +1,147 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "dana/internal/storage"
+)
+
+const (
+ maxCheckupItems = 100
+ maxCheckupItemLen = 300
+)
+
+type checkupRequest struct {
+ Slug string `json:"slug"`
+ Audience string `json:"audience"`
+ Name string `json:"name"`
+ Lead string `json:"lead"`
+ Price *float64 `json:"price"`
+ OldPrice *float64 `json:"old_price"`
+ ImageID *int64 `json:"image_id"`
+ Items []string `json:"items"`
+ Sort int `json:"sort"`
+ Published bool `json:"published"`
+ SEOTitle string `json:"seo_title"`
+ SEODesc string `json:"seo_description"`
+ SEOH1 string `json:"seo_h1"`
+}
+
+func decodeCheckup(w http.ResponseWriter, r *http.Request) (storage.CheckupInput, bool) {
+ var req checkupRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.CheckupInput{}, false
+ }
+ in := storage.CheckupInput{
+ Audience: strings.TrimSpace(req.Audience),
+ Name: strings.TrimSpace(req.Name),
+ Lead: strings.TrimSpace(req.Lead),
+ Price: req.Price,
+ OldPrice: req.OldPrice,
+ ImageID: req.ImageID,
+ Sort: req.Sort,
+ Published: req.Published,
+ SEOTitle: strings.TrimSpace(req.SEOTitle),
+ SEODesc: strings.TrimSpace(req.SEODesc),
+ SEOH1: strings.TrimSpace(req.SEOH1),
+ }
+ if in.Name == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название программы")
+ return in, false
+ }
+ slug, valid := resolveSlug(w, req.Slug, in.Name)
+ if !valid {
+ return in, false
+ }
+ in.Slug = slug
+
+ if in.Audience != "adults" && in.Audience != "kids" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Выберите, для кого программа: взрослые или дети")
+ return in, false
+ }
+ if in.Price != nil && *in.Price < 0 || in.OldPrice != nil && *in.OldPrice < 0 {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Цена не может быть отрицательной")
+ return in, false
+ }
+ // Цена без скидки на сайте зачёркивается — она обязана быть больше действующей.
+ if in.OldPrice != nil && (in.Price == nil || *in.OldPrice <= *in.Price) {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Цена без скидки должна быть больше цены программы")
+ return in, false
+ }
+ if in.Sort == 0 {
+ in.Sort = 100
+ }
+
+ for _, item := range req.Items {
+ item = strings.TrimSpace(item)
+ if item == "" {
+ continue
+ }
+ if utf8.RuneCountInString(item) > maxCheckupItemLen {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Пункт состава длиннее 300 символов: "+string([]rune(item)[:40])+"…")
+ return in, false
+ }
+ in.Items = append(in.Items, item)
+ }
+ if len(in.Items) > maxCheckupItems {
+ fail(w, http.StatusUnprocessableEntity, "validation", "В составе программы не больше 100 пунктов")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminCheckups(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminCheckups(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreateCheckup(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeCheckup(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateCheckup(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "checkup", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}})
+}
+
+func (a *API) adminUpdateCheckup(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeCheckup(w, r)
+ if !valid {
+ return
+ }
+ oldSlug, err := a.store.PublishedCheckupSlug(r.Context(), id)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ if err := a.store.AdminUpdateCheckup(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ if oldSlug != "" && in.Published {
+ a.keepOldPath(r.Context(), checkupWebPath(oldSlug), checkupWebPath(in.Slug))
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "checkup", id, clientIP(r))
+ ok(w, map[string]any{"id": id, "slug": in.Slug}, nil)
+}
diff --git a/internal/api/admin_doctors.go b/internal/api/admin_doctors.go
new file mode 100644
index 0000000..dc98af1
--- /dev/null
+++ b/internal/api/admin_doctors.go
@@ -0,0 +1,206 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+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,
+}
+
+func (a *API) adminDoctors(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminDoctors(r.Context(), r.URL.Query().Get("q"))
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminUpdateDoctor(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var in storage.DoctorUpdate
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ if strings.TrimSpace(in.LastName) == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Фамилия не может быть пустой")
+ return
+ }
+ if strings.TrimSpace(in.Slug) == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Адрес (slug) не может быть пустым")
+ return
+ }
+ if in.Booking == "" {
+ in.Booking = "none"
+ }
+ if !bookingProviders[in.Booking] {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый способ записи")
+ return
+ }
+ if in.Rating != nil && (*in.Rating < 1 || *in.Rating > 5) {
+ 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)
+ return
+ }
+ oldPath := a.doctorPathBySlug(r.Context(), oldSlug)
+ if err := a.store.AdminUpdateDoctor(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ if oldPath != "" && in.Published {
+ a.keepOldPath(r.Context(), oldPath, a.doctorPathBySlug(r.Context(), in.Slug))
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "doctor", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+type doctorLinksRequest struct {
+ ServiceIDs []int64 `json:"service_ids"`
+ BranchIDs []int64 `json:"branch_ids"`
+}
+
+// adminSetDoctorLinks сохраняет услуги, которые ведёт врач, и филиалы, где он принимает.
+func (a *API) adminSetDoctorLinks(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var in doctorLinksRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ if in.ServiceIDs == nil {
+ in.ServiceIDs = []int64{}
+ }
+ if in.BranchIDs == nil {
+ in.BranchIDs = []int64{}
+ }
+ if len(in.ServiceIDs) > 500 || len(in.BranchIDs) > 50 {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Слишком много связей")
+ return
+ }
+ if err := a.store.SetDoctorLinks(r.Context(), id, in.ServiceIDs, in.BranchIDs); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update_links", "doctor", id, clientIP(r))
+ ok(w, map[string]any{"id": id, "services": len(in.ServiceIDs), "branches": len(in.BranchIDs)}, nil)
+}
+
+// ---------- Биография врача ----------
+
+func (a *API) adminBioEntries(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ doctorID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id врача")
+ return
+ }
+ list, err := a.store.AdminBioEntries(r.Context(), doctorID)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) decodeBioEntry(w http.ResponseWriter, r *http.Request) (storage.BioEntryInput, bool) {
+ var in storage.BioEntryInput
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return in, false
+ }
+ if !bioKinds[in.Kind] {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый тип записи")
+ return in, false
+ }
+ if strings.TrimSpace(in.Title) == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Заполните текст записи")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminCreateBioEntry(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, okReq := a.decodeBioEntry(w, r)
+ if !okReq {
+ return
+ }
+ if in.DoctorID == 0 {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Не указан врач")
+ return
+ }
+ id, err := a.store.AdminCreateBioEntry(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "doctor_bio", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateBioEntry(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, okReq := a.decodeBioEntry(w, r)
+ if !okReq {
+ return
+ }
+ if err := a.store.AdminUpdateBioEntry(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "doctor_bio", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteBioEntry(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteBioEntry(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "doctor_bio", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// adminDoctorQuality — данные для «Дашборда врачей»: биография по видам, отзывы и заявки по врачам.
+func (a *API) adminDoctorQuality(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ rep, err := a.store.DoctorQuality(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, rep, nil)
+}
diff --git a/internal/api/admin_documents.go b/internal/api/admin_documents.go
new file mode 100644
index 0000000..c15948d
--- /dev/null
+++ b/internal/api/admin_documents.go
@@ -0,0 +1,230 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+// documentKinds — допустимые виды документов (как в CHECK таблицы documents).
+var documentKinds = map[string]bool{
+ "license": true, "certificate": true, "award": true, "supervisor": true, "other": true,
+}
+
+type documentRequest struct {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ FileID *int64 `json:"file_id"`
+ PreviewID *int64 `json:"preview_id"`
+ IssuedAt *string `json:"issued_at"`
+ Sort int `json:"sort"`
+ Published bool `json:"published"`
+}
+
+func decodeDocument(w http.ResponseWriter, r *http.Request) (storage.DocumentInput, bool) {
+ var req documentRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.DocumentInput{}, false
+ }
+ in := storage.DocumentInput{
+ Kind: req.Kind,
+ Title: strings.TrimSpace(req.Title),
+ Body: strings.TrimSpace(strings.ReplaceAll(req.Body, "\r", "")),
+ FileID: req.FileID,
+ PreviewID: req.PreviewID,
+ Sort: req.Sort,
+ Published: req.Published,
+ }
+ if in.Sort == 0 {
+ in.Sort = 100
+ }
+ if !documentKinds[in.Kind] {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Выберите вид документа")
+ return in, false
+ }
+ if in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название")
+ return in, false
+ }
+ var err error
+ if in.IssuedAt, err = parseDate(req.IssuedAt); err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Дата выдачи в формате ГГГГ-ММ-ДД")
+ return in, false
+ }
+ // лицензию или награду без скана и файла на сайте показать нечем
+ if in.Published && in.Kind != "supervisor" && in.FileID == nil && in.PreviewID == nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы показать документ на сайте, загрузите скан или PDF")
+ return in, false
+ }
+ return in, true
+}
+
+// checkDocumentMedia не даёт поставить PDF на место картинки-скана.
+func (a *API) checkDocumentMedia(w http.ResponseWriter, r *http.Request, in storage.DocumentInput) bool {
+ if in.PreviewID == nil {
+ return true
+ }
+ mime, err := a.store.MediaMime(r.Context(), *in.PreviewID)
+ if errors.Is(err, storage.ErrNotFound) || (err == nil && !strings.HasPrefix(mime, "image/")) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Скан для просмотра должен быть картинкой, а PDF загрузите в поле «Файл документа»")
+ return false
+ }
+ if err != nil {
+ handleErr(w, err)
+ return false
+ }
+ return true
+}
+
+func (a *API) adminDocuments(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminDocuments(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreateDocument(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeDocument(w, r)
+ if !valid || !a.checkDocumentMedia(w, r, in) {
+ return
+ }
+ id, err := a.store.AdminCreateDocument(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "document", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateDocument(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeDocument(w, r)
+ if !valid || !a.checkDocumentMedia(w, r, in) {
+ return
+ }
+ if err := a.store.AdminUpdateDocument(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "document", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteDocument(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteDocument(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "document", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// ---------- вопросы и ответы ----------
+
+type faqRequest struct {
+ Question string `json:"question"`
+ Answer string `json:"answer"`
+ ServiceID *int64 `json:"service_id"`
+ Sort int `json:"sort"`
+ Published bool `json:"published"`
+}
+
+func decodeFAQ(w http.ResponseWriter, r *http.Request) (storage.FAQInput, bool) {
+ var req faqRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.FAQInput{}, false
+ }
+ in := storage.FAQInput{
+ Question: strings.TrimSpace(req.Question),
+ Answer: strings.TrimSpace(strings.ReplaceAll(req.Answer, "\r", "")),
+ ServiceID: req.ServiceID,
+ Sort: req.Sort,
+ Published: req.Published,
+ }
+ if in.Sort == 0 {
+ in.Sort = 100
+ }
+ if in.Question == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите вопрос")
+ return in, false
+ }
+ if in.Published && in.Answer == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы показать вопрос на сайте, напишите ответ")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminFAQ(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreateFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeFAQ(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateFAQ(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "faq", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeFAQ(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdateFAQ(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "faq", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteFAQ(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteFAQ(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "faq", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_documents_test.go b/internal/api/admin_documents_test.go
new file mode 100644
index 0000000..d9708cc
--- /dev/null
+++ b/internal/api/admin_documents_test.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestDecodeDocumentRules(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantCode int
+ wantText string
+ }{
+ {"license with scan", `{"kind":"license","title":"Лицензия","preview_id":5,"issued_at":"2025-12-17","published":true}`, 200, ""},
+ {"supervisor without files", `{"kind":"supervisor","title":"Минздрав","body":"Телефон: +7","published":true}`, 200, ""},
+ {"hidden draft without files", `{"kind":"award","title":"Премия","published":false}`, 200, ""},
+ {"unknown kind", `{"kind":"passport","title":"x"}`, 422, "вид документа"},
+ {"no title", `{"kind":"license","title":" "}`, 422, "название"},
+ {"published license without files", `{"kind":"license","title":"Лицензия","published":true}`, 422, "загрузите скан"},
+ {"bad date", `{"kind":"license","title":"Лицензия","issued_at":"17.12.2025"}`, 422, "ГГГГ-ММ-ДД"},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
+ _, valid := decodeDocument(w, r)
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ }
+}
+
+func TestDecodeFAQRules(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantCode int
+ wantText string
+ }{
+ {"published answer", `{"question":"Где парковка?","answer":"Во дворе\r\nбесплатно","published":true}`, 200, ""},
+ {"draft without answer", `{"question":"Черновик","published":false}`, 200, ""},
+ {"no question", `{"question":"","answer":"x"}`, 422, "вопрос"},
+ {"published without answer", `{"question":"Где парковка?","published":true}`, 422, "напишите ответ"},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
+ in, valid := decodeFAQ(w, r)
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ if valid && strings.Contains(in.Answer, "\r") {
+ t.Errorf("%s: answer keeps CR", tc.name)
+ }
+ }
+}
diff --git a/internal/api/admin_inbox.go b/internal/api/admin_inbox.go
new file mode 100644
index 0000000..df27c8f
--- /dev/null
+++ b/internal/api/admin_inbox.go
@@ -0,0 +1,198 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "dana/internal/storage"
+)
+
+// допустимые значения статусов — совпадают с CHECK в схеме
+var leadStatuses = map[string]bool{"new": true, "in_progress": true, "done": true, "spam": true}
+var reviewStatuses = map[string]bool{"pending": true, "published": true, "rejected": true}
+
+func pageParams(r *http.Request) (limit, offset int) {
+ limit = queryInt(r, "limit", 50)
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ offset = queryInt(r, "offset", 0)
+ if offset < 0 {
+ offset = 0
+ }
+ return limit, offset
+}
+
+// ---------- Заявки ----------
+
+func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ limit, offset := pageParams(r)
+ q := r.URL.Query()
+ var from, to *time.Time
+ for _, filter := range []struct {
+ key string
+ out **time.Time
+ }{{"from", &from}, {"to", &to}} {
+ if value := q.Get(filter.key); value != "" {
+ day, err := time.ParseInLocation("2006-01-02", value, time.Local)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректная дата в фильтре")
+ return
+ }
+ if filter.key == "to" {
+ day = day.AddDate(0, 0, 1)
+ }
+ *filter.out = &day
+ }
+ }
+ if from != nil && to != nil && !from.Before(*to) {
+ fail(w, http.StatusBadRequest, "bad_request", "Дата начала позже даты окончания")
+ return
+ }
+ list, total, err := a.store.AdminLeads(r.Context(), q.Get("status"), q.Get("q"), from, to, limit, offset)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ counts, err := a.store.LeadCounts(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ today, err := a.store.LeadTodayCount(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ // Просмотр заявок — это доступ к персональным данным, фиксируем в журнале.
+ a.store.Audit(r.Context(), u.ID, "view_leads", "lead", 0, clientIP(r))
+ ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset, "counts": counts, "today": today})
+}
+
+func (a *API) adminUpdateLead(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var in storage.LeadUpdate
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ if !leadStatuses[in.Status] {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый статус заявки")
+ return
+ }
+ if err := a.store.AdminUpdateLead(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "lead", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// ---------- Отзывы ----------
+
+func (a *API) adminReviews(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ limit, offset := pageParams(r)
+ q := r.URL.Query()
+ list, total, err := a.store.AdminReviews(r.Context(), q.Get("status"), q.Get("q"), limit, offset)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ counts, err := a.store.ReviewCounts(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": total, "limit": limit, "offset": offset, "counts": counts})
+}
+
+func decodeReview(w http.ResponseWriter, r *http.Request) (storage.ReviewUpdate, bool) {
+ var in storage.ReviewUpdate
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return in, false
+ }
+ in.AuthorName = strings.TrimSpace(in.AuthorName)
+ in.Text = strings.TrimSpace(strings.ReplaceAll(in.Text, "\r", ""))
+ in.Reply = strings.TrimSpace(strings.ReplaceAll(in.Reply, "\r", ""))
+ if !reviewStatuses[in.Status] {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Недопустимый статус отзыва")
+ return in, false
+ }
+ video, err := embedVideoURL(in.VideoURL)
+ if err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Видео — ссылка на YouTube или Rutube, например https://youtu.be/…")
+ return in, false
+ }
+ in.VideoURL = video
+ // У видео-отзывов текста нет — содержимое там в ролике.
+ if in.Text == "" && in.VideoURL == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Отзыв должен содержать текст или ссылку на видео")
+ return in, false
+ }
+ if in.AuthorName == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите имя автора отзыва")
+ return in, false
+ }
+ if in.Rating != nil && (*in.Rating < 1 || *in.Rating > 5) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Оценка должна быть от 1 до 5")
+ return in, false
+ }
+ return in, true
+}
+
+// adminCreateReview — отзыв, записанный со слов пациента или видеоотзыв с YouTube.
+func (a *API) adminCreateReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeReview(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateReview(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "review", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeReview(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdateReview(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "review", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteReview(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteReview(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "review", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_media.go b/internal/api/admin_media.go
new file mode 100644
index 0000000..5b6b1dd
--- /dev/null
+++ b/internal/api/admin_media.go
@@ -0,0 +1,120 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "dana/internal/storage"
+)
+
+const maxUploadSize = 10 << 20 // 10 МБ
+
+// Разрешены только растровые картинки. SVG не пускаем: внутри него может быть
+// скрипт, и загруженный файл выполнился бы на домене сайта.
+var uploadTypes = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp",
+ "image/gif": ".gif",
+}
+
+// adminUploadMedia принимает картинку или PDF документа (multipart, поле file) и регистрирует её
+// в медиатеке. Тип определяется по содержимому, а не по расширению из имени файла.
+func (a *API) adminUploadMedia(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ if a.mediaDir == "" {
+ fail(w, http.StatusInternalServerError, "no_media_dir", "Не настроена папка для файлов")
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize+(1<<20))
+ if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+ fail(w, http.StatusBadRequest, "bad_upload", "Файл не больше 10 МБ")
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ fail(w, http.StatusBadRequest, "no_file", "Не передан файл")
+ return
+ }
+ defer file.Close()
+
+ head := make([]byte, 512)
+ n, _ := io.ReadFull(file, head)
+ mime := http.DetectContentType(head[:n])
+ ext, allowed := uploadTypes[mime]
+ // PDF принимаем только в поля документов (?accept=document), чтобы он не попал на место картинки.
+ documentUpload := r.URL.Query().Get("accept") == "document"
+ if mime == "application/pdf" && documentUpload {
+ ext, allowed = ".pdf", true
+ }
+ if !allowed {
+ msg := "Можно загрузить JPEG, PNG, WebP или GIF"
+ if documentUpload {
+ msg = "Можно загрузить PDF, JPEG, PNG, WebP или GIF"
+ }
+ fail(w, http.StatusUnprocessableEntity, "bad_type", msg)
+ return
+ }
+
+ var width, height *int
+ if _, err := file.Seek(0, io.SeekStart); err != nil {
+ handleErr(w, err)
+ return
+ }
+ if cfg, _, err := image.DecodeConfig(file); err == nil {
+ width, height = &cfg.Width, &cfg.Height
+ }
+ if _, err := file.Seek(0, io.SeekStart); err != nil {
+ handleErr(w, err)
+ return
+ }
+
+ rnd := make([]byte, 16)
+ if _, err := rand.Read(rnd); err != nil {
+ handleErr(w, err)
+ return
+ }
+ rel := path.Join("uploads", time.Now().Format("2006/01"), hex.EncodeToString(rnd)+ext)
+ dst := filepath.Join(a.mediaDir, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ handleErr(w, err)
+ return
+ }
+ out, err := os.Create(dst)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ size, copyErr := io.Copy(out, file)
+ closeErr := out.Close()
+ if copyErr != nil || closeErr != nil {
+ _ = os.Remove(dst)
+ slog.Error("save upload", "copy", copyErr, "close", closeErr)
+ fail(w, http.StatusInternalServerError, "save_failed", "Не удалось сохранить файл")
+ return
+ }
+
+ webPath := "/media/" + rel
+ alt := strings.TrimSpace(r.FormValue("alt"))
+ id, err := a.store.CreateMedia(r.Context(), webPath, mime, size, width, height, alt)
+ if err != nil {
+ _ = os.Remove(dst) // файл без записи в медиатеке никому не нужен
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "media", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{
+ "id": id, "path": webPath, "width": width, "height": height,
+ }})
+}
diff --git a/internal/api/admin_partners.go b/internal/api/admin_partners.go
new file mode 100644
index 0000000..fbddffa
--- /dev/null
+++ b/internal/api/admin_partners.go
@@ -0,0 +1,119 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+type partnerRequest struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ LogoID *int64 `json:"logo_id"`
+ Sort int `json:"sort"`
+ Published bool `json:"published"`
+}
+
+// partnerURL проверяет ссылку на сайт партнёра: только http(s) с доменом.
+// Адрес «glaz-almaz05.ru» без схемы дополняем до https://.
+func partnerURL(raw string) (string, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", true
+ }
+ if !strings.Contains(raw, "://") {
+ raw = "https://" + raw
+ }
+ u, err := url.Parse(raw)
+ if err != nil || (u.Scheme != "https" && u.Scheme != "http") || !strings.Contains(u.Hostname(), ".") || u.User != nil {
+ return "", false
+ }
+ return u.String(), true
+}
+
+func decodePartner(w http.ResponseWriter, r *http.Request) (storage.PartnerInput, bool) {
+ var req partnerRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.PartnerInput{}, false
+ }
+ in := storage.PartnerInput{
+ Name: strings.TrimSpace(req.Name),
+ LogoID: req.LogoID,
+ Sort: req.Sort,
+ Published: req.Published,
+ }
+ if in.Sort == 0 {
+ in.Sort = 100
+ }
+ if in.Name == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название партнёра")
+ return in, false
+ }
+ link, valid := partnerURL(req.URL)
+ if !valid {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Сайт — адрес вида https://example.ru")
+ return in, false
+ }
+ in.URL = link
+ return in, true
+}
+
+func (a *API) adminPartners(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminPartners(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreatePartner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodePartner(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreatePartner(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "partner", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdatePartner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodePartner(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdatePartner(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "partner", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeletePartner(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeletePartner(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "partner", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_partners_test.go b/internal/api/admin_partners_test.go
new file mode 100644
index 0000000..613fd34
--- /dev/null
+++ b/internal/api/admin_partners_test.go
@@ -0,0 +1,55 @@
+package api
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestPartnerURL(t *testing.T) {
+ for in, want := range map[string]string{
+ "": "",
+ "glaz-almaz05.ru": "https://glaz-almaz05.ru",
+ " https://ochevidno.ru/ ": "https://ochevidno.ru/",
+ "http://abilitycenter.ru": "http://abilitycenter.ru",
+ } {
+ if got, ok := partnerURL(in); !ok || got != want {
+ t.Errorf("partnerURL(%q) = %q, %v; want %q", in, got, ok, want)
+ }
+ }
+ for _, in := range []string{"javascript:alert(1)", "https://localhost", "ftp://files.example.ru", "https://user:pass@example.ru"} {
+ if got, ok := partnerURL(in); ok {
+ t.Errorf("partnerURL(%q) accepted as %q", in, got)
+ }
+ }
+}
+
+func TestDecodeReviewRules(t *testing.T) {
+ cases := []struct {
+ name, body, wantText string
+ wantCode int
+ }{
+ {"text review", `{"author_name":"Мадина","text":"Спасибо","status":"published","rating":5}`, "", 200},
+ {"video review link is normalised", `{"author_name":"Пациент","video_url":"https://youtu.be/V1w8DhkWARc","status":"published"}`, "", 200},
+ {"foreign video host", `{"author_name":"Пациент","video_url":"javascript:alert(1)","status":"published"}`, "YouTube или Rutube", 422},
+ {"empty review", `{"author_name":"Пациент","status":"pending"}`, "текст или ссылку", 422},
+ {"no author", `{"author_name":" ","text":"Спасибо","status":"pending"}`, "имя автора", 422},
+ {"bad status", `{"author_name":"А","text":"Б","status":"spam"}`, "статус", 422},
+ {"bad rating", `{"author_name":"А","text":"Б","status":"pending","rating":7}`, "от 1 до 5", 422},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
+ in, valid := decodeReview(w, r)
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ if tc.name == "video review link is normalised" && in.VideoURL != "https://www.youtube.com/embed/V1w8DhkWARc" {
+ t.Errorf("video url = %q", in.VideoURL)
+ }
+ }
+}
diff --git a/internal/api/admin_posts.go b/internal/api/admin_posts.go
new file mode 100644
index 0000000..d051a88
--- /dev/null
+++ b/internal/api/admin_posts.go
@@ -0,0 +1,174 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "dana/internal/storage"
+)
+
+// Новости и статьи редактируются в админке одинаково; различаются только видом записи.
+var postKindLabels = map[string]string{"news": "новость", "article": "статью"}
+
+// authHandler — обработчик админки, которому requireAuth передаёт вошедшего пользователя.
+type authHandler = func(http.ResponseWriter, *http.Request, *storage.AdminUser)
+
+const maxPostBody = 2 << 20 // текст статьи с разметкой; картинки грузятся отдельно
+
+var tagRE = regexp.MustCompile(`<[^>]*>`)
+
+type postRequest struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Lead string `json:"lead"`
+ HTML string `json:"html"`
+ ImageID *int64 `json:"image_id"`
+ PublishedAt string `json:"published_at"`
+ Published bool `json:"published"`
+ SEOTitle string `json:"seo_title"`
+ SEODesc string `json:"seo_description"`
+ SEOH1 string `json:"seo_h1"`
+}
+
+// parsePublishedAt принимает дату «2025-05-28» (полдень по Москве) или полную метку времени,
+// которую админка возвращает без изменений, если дату не трогали.
+func parsePublishedAt(raw string) (time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Now(), nil
+ }
+ if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
+ return t, nil
+ }
+ moscow := time.FixedZone("MSK", 3*60*60)
+ return time.ParseInLocation("2006-01-02 15:04", raw+" 12:00", moscow)
+}
+
+func decodePost(w http.ResponseWriter, r *http.Request, kind string) (storage.PostInput, bool) {
+ var req postRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст")
+ return storage.PostInput{}, false
+ }
+ in := storage.PostInput{
+ Kind: kind,
+ Title: strings.TrimSpace(req.Title),
+ Lead: strings.TrimSpace(req.Lead),
+ ImageID: req.ImageID,
+ Published: req.Published,
+ SEOTitle: strings.TrimSpace(req.SEOTitle),
+ SEODesc: strings.TrimSpace(req.SEODesc),
+ SEOH1: strings.TrimSpace(req.SEOH1),
+ }
+ if in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок")
+ return in, false
+ }
+ if utf8.RuneCountInString(in.Title) > 300 {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Заголовок длиннее 300 символов")
+ return in, false
+ }
+ slug, valid := resolveSlug(w, req.Slug, in.Title)
+ if !valid {
+ return in, false
+ }
+ in.Slug = slug
+
+ html := strings.TrimSpace(req.HTML)
+ text := strings.TrimSpace(strings.ReplaceAll(tagRE.ReplaceAllString(html, ""), " ", ""))
+ if in.Published && text == "" && !strings.Contains(html, "
Текст
","published_at":"2026-09-17","published":true}`, 200, ""},
+ {"draft without text", `{"title":"Черновик","published":false}`, 200, ""},
+ {"picture-only post", `{"title":"Афиша","html":"
","published":true}`, 200, ""},
+ {"no title", `{"title":" ","published":false}`, 422, "заголовок"},
+ {"published without text", `{"title":"Пусто","html":"
","published":true}`, 422, "добавьте текст"},
+ {"bad date", `{"title":"Дата","published_at":"17.09.2026"}`, 422, "ГГГГ-ММ-ДД"},
+ {"bad slug", `{"title":"Адрес","slug":"новость"}`, 422, "латинские"},
+ // адреса со старого сайта: хвостовой дефис, подчёркивание — запись должна сохраняться
+ {"legacy slug", `{"title":"Старая","slug":"v-klinike-dana-","published":false}`, 200, ""},
+ {"legacy underscore", `{"title":"Старая","slug":"lech_bol","published":false}`, 200, ""},
+ {"leading dash", `{"title":"Адрес","slug":"-dana"}`, 422, "латинские"},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/api/v1/admin/news", strings.NewReader(tc.body))
+ _, valid := decodePost(w, r, "news")
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ }
+}
+
+func TestDecodePostStoresHTMLBlockAndDate(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Статья","html":"Раздел
","published_at":"2025-05-28"}`))
+ in, valid := decodePost(w, r, "article")
+ if !valid {
+ t.Fatal(w.Body.String())
+ }
+ var blocks []map[string]string
+ if err := json.Unmarshal(in.Blocks, &blocks); err != nil || len(blocks) != 1 || blocks[0]["html"] != "Раздел
" {
+ t.Errorf("blocks = %s", in.Blocks)
+ }
+ if in.Slug != "statya" {
+ t.Errorf("slug = %s", in.Slug)
+ }
+ if got := in.PublishedAt.UTC().Format(time.RFC3339); got != "2025-05-28T09:00:00Z" {
+ t.Errorf("published_at = %s, want midday Moscow", got)
+ }
+
+ // неизменённая дата возвращается полной меткой времени и сохраняется как есть
+ w = httptest.NewRecorder()
+ r = httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Статья","published_at":"2024-01-13T14:03:41+03:00"}`))
+ in, _ = decodePost(w, r, "article")
+ if got := in.PublishedAt.UTC().Format(time.RFC3339); got != "2024-01-13T11:03:41Z" {
+ t.Errorf("kept published_at = %s", got)
+ }
+}
+
+func TestDecodeVacancy(t *testing.T) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Администратор","requirements":"- грамотная речь\r\n\r\n• ответственность ","published":true}`))
+ in, valid := decodeVacancy(w, r)
+ if !valid {
+ t.Fatal(w.Body.String())
+ }
+ if in.Requirements != "грамотная речь\nответственность" || in.Slug != "administrator" || in.Sort != 100 {
+ t.Errorf("got %+v", in)
+ }
+
+ w = httptest.NewRecorder()
+ r = httptest.NewRequest("POST", "/", strings.NewReader(`{"title":"Пустая","published":true}`))
+ if _, valid := decodeVacancy(w, r); valid || !strings.Contains(w.Body.String(), "опишите") {
+ t.Errorf("empty published vacancy accepted: %d %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/api/admin_promos.go b/internal/api/admin_promos.go
new file mode 100644
index 0000000..d1e2c25
--- /dev/null
+++ b/internal/api/admin_promos.go
@@ -0,0 +1,205 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "dana/internal/storage"
+)
+
+// Адрес: латиница, цифры, дефис и подчёркивание. Хвостовой дефис и «_» встречаются в адресах
+// со старого сайта («…v-klinike-dana-», «lech_bol») — их нужно сохранять, иначе запись не сохранить.
+var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
+
+var translit = map[rune]string{
+ 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z",
+ 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r",
+ 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch",
+ 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya",
+}
+
+// slugFromTitle делает адрес из названия: «Я в сад» → «ya-v-sad».
+func slugFromTitle(title string) string {
+ var b strings.Builder
+ dash := false
+ for _, r := range strings.ToLower(title) {
+ switch {
+ case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
+ b.WriteRune(r)
+ dash = false
+ case translit[r] != "" || r == 'ъ' || r == 'ь':
+ b.WriteString(translit[r])
+ dash = false
+ default:
+ if !dash && b.Len() > 0 {
+ b.WriteByte('-')
+ dash = true
+ }
+ }
+ }
+ s := strings.Trim(b.String(), "-")
+ if len(s) > 80 {
+ s = strings.Trim(s[:80], "-")
+ }
+ return s
+}
+
+// resolveSlug берёт адрес из формы или делает его из названия и проверяет формат.
+func resolveSlug(w http.ResponseWriter, raw, title string) (string, bool) {
+ slug := strings.ToLower(strings.TrimSpace(raw))
+ if slug == "" {
+ slug = slugFromTitle(title)
+ }
+ if !slugRE.MatchString(slug) || len(slug) > 100 {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Адрес — латинские буквы, цифры и дефисы, например ya-v-sad")
+ return "", false
+ }
+ return slug, true
+}
+
+func parseDate(raw *string) (*time.Time, error) {
+ if raw == nil || strings.TrimSpace(*raw) == "" {
+ return nil, nil
+ }
+ t, err := time.Parse("2006-01-02", strings.TrimSpace(*raw))
+ if err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+type promoRequest struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Lead string `json:"lead"`
+ ImageID *int64 `json:"image_id"`
+ DiscountPercent *int `json:"discount_percent"`
+ Terms string `json:"terms"`
+ CheckupID *int64 `json:"checkup_id"`
+ StartsAt *string `json:"starts_at"`
+ EndsAt *string `json:"ends_at"`
+ Published bool `json:"published"`
+ SEOTitle string `json:"seo_title"`
+ SEODesc string `json:"seo_description"`
+ SEOH1 string `json:"seo_h1"`
+}
+
+func decodePromo(w http.ResponseWriter, r *http.Request) (storage.PromoInput, bool) {
+ var req promoRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.PromoInput{}, false
+ }
+ in := storage.PromoInput{
+ Title: strings.TrimSpace(req.Title),
+ Lead: strings.TrimSpace(req.Lead),
+ ImageID: req.ImageID,
+ DiscountPercent: req.DiscountPercent,
+ Terms: strings.TrimSpace(req.Terms),
+ CheckupID: req.CheckupID,
+ Published: req.Published,
+ SEOTitle: strings.TrimSpace(req.SEOTitle),
+ SEODesc: strings.TrimSpace(req.SEODesc),
+ SEOH1: strings.TrimSpace(req.SEOH1),
+ }
+ if in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название акции")
+ return in, false
+ }
+ slug, valid := resolveSlug(w, req.Slug, in.Title)
+ if !valid {
+ return in, false
+ }
+ in.Slug = slug
+
+ if in.DiscountPercent != nil && (*in.DiscountPercent < 1 || *in.DiscountPercent > 99) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Скидка — от 1 до 99 %")
+ return in, false
+ }
+ var err error
+ if in.StartsAt, err = parseDate(req.StartsAt); err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Дата начала в формате ГГГГ-ММ-ДД")
+ return in, false
+ }
+ if in.EndsAt, err = parseDate(req.EndsAt); err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Дата окончания в формате ГГГГ-ММ-ДД")
+ return in, false
+ }
+ if in.StartsAt != nil && in.EndsAt != nil && in.EndsAt.Before(*in.StartsAt) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Окончание акции не может быть раньше начала")
+ return in, false
+ }
+
+ // Закон «О рекламе» (ст. 9): рекламируя скидку, нужно сообщить её условия и срок.
+ if in.Published && in.Terms == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Чтобы показать акцию на сайте, заполните её условия")
+ return in, false
+ }
+ if in.Published && in.DiscountPercent != nil && in.EndsAt == nil {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "У акции со скидкой укажите последний день действия")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminPromos(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminPromos(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreatePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodePromo(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreatePromo(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "promo", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}})
+}
+
+func (a *API) adminUpdatePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodePromo(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdatePromo(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "promo", id, clientIP(r))
+ ok(w, map[string]any{"id": id, "slug": in.Slug}, nil)
+}
+
+func (a *API) adminDeletePromo(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeletePromo(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "promo", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_promos_test.go b/internal/api/admin_promos_test.go
new file mode 100644
index 0000000..6089c51
--- /dev/null
+++ b/internal/api/admin_promos_test.go
@@ -0,0 +1,92 @@
+package api
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSlugFromTitle(t *testing.T) {
+ cases := map[string]string{
+ "Я в сад": "ya-v-sad",
+ "Здоровый ЖКТ": "zdorovyy-zhkt",
+ "Мне год (1–2 года)": "mne-god-1-2-goda",
+ " Чек-ап «Я подросток»! ": "chek-ap-ya-podrostok",
+ "Подъезд, щука": "podezd-shchuka",
+ "EMSELLA 2026": "emsella-2026",
+ "!!!": "",
+ }
+ for title, want := range cases {
+ if got := slugFromTitle(title); got != want {
+ t.Errorf("slugFromTitle(%q) = %q, want %q", title, got, want)
+ }
+ }
+}
+
+func decodePromoBody(body string) (int, string) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/api/v1/admin/promos", strings.NewReader(body))
+ if _, valid := decodePromo(w, r); valid {
+ return 200, ""
+ }
+ return w.Code, w.Body.String()
+}
+
+func TestDecodePromoRules(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ wantCode int
+ wantText string
+ }{
+ {"valid published promo", `{"title":"Я в сад","discount_percent":20,"terms":"Скидка на программу чек-апа","ends_at":"2026-12-31","published":true}`, 200, ""},
+ {"draft without terms is allowed", `{"title":"Черновик","published":false}`, 200, ""},
+ {"no title", `{"title":" ","published":false}`, 422, "название"},
+ {"published without terms", `{"title":"Я в сад","published":true}`, 422, "условия"},
+ {"discount without end date", `{"title":"Я в сад","discount_percent":20,"terms":"x","published":true}`, 422, "последний день"},
+ {"discount out of range", `{"title":"Я в сад","discount_percent":150,"published":false}`, 422, "от 1 до 99"},
+ {"end before start", `{"title":"Я в сад","starts_at":"2026-10-01","ends_at":"2026-09-01","published":false}`, 422, "раньше начала"},
+ {"bad date", `{"title":"Я в сад","ends_at":"31.12.2026","published":false}`, 422, "ГГГГ-ММ-ДД"},
+ {"bad slug", `{"title":"Я в сад","slug":"Я в сад","published":false}`, 422, "латинские"},
+ }
+ for _, tc := range cases {
+ code, body := decodePromoBody(tc.body)
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(body, tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, body, tc.wantCode, tc.wantText)
+ }
+ }
+}
+
+func decodeCheckupBody(body string) (int, string, int) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/api/v1/admin/checkups", strings.NewReader(body))
+ in, valid := decodeCheckup(w, r)
+ if valid {
+ return 200, "", len(in.Items)
+ }
+ return w.Code, w.Body.String(), 0
+}
+
+func TestDecodeCheckupRules(t *testing.T) {
+ code, body, items := decodeCheckupBody(`{"name":"Здоровая девочка","audience":"kids","price":5000,"old_price":6000,"items":["УЗИ"," ","Ферритин"]}`)
+ if code != 200 || items != 2 {
+ t.Fatalf("valid checkup rejected or empty items kept: %d %s items=%d", code, body, items)
+ }
+
+ cases := []struct {
+ name string
+ body string
+ wantText string
+ }{
+ {"unknown audience", `{"name":"X","audience":"pets"}`, "для кого"},
+ {"old price not above price", `{"name":"X","audience":"adults","price":5000,"old_price":5000}`, "больше цены"},
+ {"old price without price", `{"name":"X","audience":"adults","old_price":5000}`, "больше цены"},
+ {"negative price", `{"name":"X","audience":"adults","price":-1}`, "отрицательной"},
+ }
+ for _, tc := range cases {
+ code, body, _ := decodeCheckupBody(tc.body)
+ if code != 422 || !strings.Contains(body, tc.wantText) {
+ t.Errorf("%s: got %d %s, want 422 containing %q", tc.name, code, body, tc.wantText)
+ }
+ }
+}
diff --git a/internal/api/admin_redirects.go b/internal/api/admin_redirects.go
new file mode 100644
index 0000000..268c1ae
--- /dev/null
+++ b/internal/api/admin_redirects.go
@@ -0,0 +1,143 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+// keepOldPath оставляет редирект со старого адреса, если в админке сменили адрес записи.
+// Ссылки из поисковиков, соцсетей и закладок продолжают работать.
+func (a *API) keepOldPath(ctx context.Context, oldPath, newPath string) {
+ if oldPath == "" || newPath == "" || oldPath == newPath {
+ return
+ }
+ if _, err := a.store.SaveRedirect(ctx, oldPath, newPath); err != nil {
+ slog.Error("save redirect after slug change", "from", oldPath, "to", newPath, "err", err)
+ }
+}
+
+func checkupWebPath(slug string) string { return "/checkup/" + slug + "/" }
+
+// doctorPathBySlug — канонический адрес опубликованного врача или "".
+func (a *API) doctorPathBySlug(ctx context.Context, slug string) string {
+ if slug == "" {
+ return ""
+ }
+ d, err := a.store.DoctorBySlug(ctx, slug)
+ if err != nil {
+ return ""
+ }
+ return doctorWebPath(*d)
+}
+
+// ---------- ручные редиректы и журнал 404 ----------
+
+type redirectRequest struct {
+ From string `json:"from_path"`
+ To string `json:"to_path"`
+}
+
+// redirectPath приводит адрес к виду «/путь/»: принимает и полную ссылку со старого домена.
+func redirectPath(raw string) (string, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", false
+ }
+ if strings.Contains(raw, "://") {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", false
+ }
+ raw = u.Path
+ }
+ raw = strings.SplitN(strings.SplitN(raw, "?", 2)[0], "#", 2)[0]
+ if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, " \t\r\n\\") {
+ return "", false
+ }
+ // файлы (/index.php, /404.php) оставляем как есть, разделы — со слэшем на конце
+ last := raw[strings.LastIndex(raw, "/")+1:]
+ if !strings.Contains(last, ".") {
+ raw = canonicalPagePath(raw)
+ }
+ return raw, len(raw) <= 300
+}
+
+func (a *API) adminRedirects(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminRedirects(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ missing, err := a.store.AdminNotFound(r.Context(), 100)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, map[string]any{"redirects": emptyIfNil(list), "not_found": emptyIfNil(missing)}, nil)
+}
+
+func (a *API) adminCreateRedirect(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ var req redirectRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ from, okFrom := redirectPath(req.From)
+ to, okTo := redirectPath(req.To)
+ if !okFrom || !okTo {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Адреса — пути на сайте, например /services/uzi/ (можно вставить полную ссылку)")
+ return
+ }
+ if from == to || from == "/" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Старый и новый адрес должны различаться, главную перенаправить нельзя")
+ return
+ }
+ for _, reserved := range []string{"/api/", "/admin", "/media/", "/assets/"} {
+ if strings.HasPrefix(from, reserved) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Служебные адреса перенаправлять нельзя")
+ return
+ }
+ }
+ id, err := a.store.SaveRedirect(r.Context(), from, to)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "redirect", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "from_path": from, "to_path": to}})
+}
+
+func (a *API) adminDeleteRedirect(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.DeleteRedirect(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "redirect", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// adminDeleteNotFound убирает адрес из журнала 404 (например, мусорный запрос бота).
+func (a *API) adminDeleteNotFound(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ path := r.URL.Query().Get("path")
+ if path == "" {
+ fail(w, http.StatusBadRequest, "bad_request", "Не указан адрес")
+ return
+ }
+ if err := a.store.DeleteNotFound(r.Context(), path); err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, map[string]any{"path": path}, nil)
+}
diff --git a/internal/api/admin_redirects_test.go b/internal/api/admin_redirects_test.go
new file mode 100644
index 0000000..f2bb963
--- /dev/null
+++ b/internal/api/admin_redirects_test.go
@@ -0,0 +1,23 @@
+package api
+
+import "testing"
+
+func TestRedirectPath(t *testing.T) {
+ good := map[string]string{
+ "/services/uzi": "/services/uzi/",
+ " /services/uzi/ ": "/services/uzi/",
+ "https://danamedical.ru/price/lech_bol/?a=1": "/price/lech_bol/",
+ "/404.php": "/404.php",
+ "/company/staff/#top": "/company/staff/",
+ }
+ for in, want := range good {
+ if got, ok := redirectPath(in); !ok || got != want {
+ t.Errorf("redirectPath(%q) = %q, %v; want %q", in, got, ok, want)
+ }
+ }
+ for _, in := range []string{"", "services/uzi", "//evil.example/x", "/a b/", `/a\b/`} {
+ if got, ok := redirectPath(in); ok {
+ t.Errorf("redirectPath(%q) accepted as %q", in, got)
+ }
+ }
+}
diff --git a/internal/api/admin_reports.go b/internal/api/admin_reports.go
new file mode 100644
index 0000000..7d917a8
--- /dev/null
+++ b/internal/api/admin_reports.go
@@ -0,0 +1,436 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "math"
+ "net/http"
+ "net/url"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "dana/internal/storage"
+)
+
+const maxReportFile = 10 << 20 // 10 МБ
+
+// Запрос на отчёт: книга 1С и необязательные файлы СберЗдоровья и ПроДокторов (до 10 МБ каждый).
+const maxReportRequest = maxReportFile + (1+maxProdoctorovFiles)*maxPartnerFile + (1 << 20)
+
+// Виды отчётов. Файл нужен для отчёта по каналам — это книга Excel с выгрузкой 1С и листами каналов.
+var reportKinds = map[string]struct {
+ title string
+ needFile bool
+}{
+ "phone_match": {"Отчёт по каналам", true},
+}
+
+// Этапы задания — как на странице «Отчёты».
+const (
+ stageUpload = iota
+ stageCheck
+ stageProcess
+ stageBuild
+ stageFinish
+ stagesTotal
+)
+
+// reportJobs — отмена идущих заданий. Задания короткие (секунды), поэтому живут в памяти процесса;
+// после перезапуска сервера незаконченные помечаются как прерванные.
+type reportJobs struct {
+ mu sync.Mutex
+ cancels map[int64]context.CancelFunc
+}
+
+var jobs = &reportJobs{cancels: map[int64]context.CancelFunc{}}
+
+func (j *reportJobs) add(id int64, c context.CancelFunc) {
+ j.mu.Lock()
+ j.cancels[id] = c
+ j.mu.Unlock()
+}
+func (j *reportJobs) done(id int64) { j.mu.Lock(); delete(j.cancels, id); j.mu.Unlock() }
+func (j *reportJobs) cancel(id int64) {
+ j.mu.Lock()
+ c := j.cancels[id]
+ j.mu.Unlock()
+ if c != nil {
+ c()
+ }
+}
+
+// FailInterruptedReports вызывается при старте сервера.
+func (a *API) FailInterruptedReports(ctx context.Context) error {
+ return a.store.FailInterruptedReports(ctx)
+}
+
+func (a *API) adminReports(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ clinic, valid := requestClinic(w, r)
+ if !valid {
+ return
+ }
+ list, err := a.store.Reports(r.Context(), clinic.Key, 50)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, nil)
+}
+
+func (a *API) adminReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ rep, err := a.store.Report(r.Context(), id)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, rep, nil)
+}
+
+// adminCreateReport принимает вид отчёта и книгу Excel, заводит задание
+// и считает его в фоне; страница опрашивает ход через GET /admin/reports/{id}.
+func (a *API) adminCreateReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ started := time.Now()
+ r.Body = http.MaxBytesReader(w, r.Body, maxReportRequest)
+ if err := r.ParseMultipartForm(maxReportFile); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Файл больше 10 МБ или запрос повреждён")
+ return
+ }
+ clinic, knownClinic := clinicFrom(r.FormValue("clinic"))
+ if !knownClinic {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестная клиника")
+ return
+ }
+ kind := r.FormValue("kind")
+ meta, known := reportKinds[kind]
+ if !known {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Неизвестный вид отчёта")
+ return
+ }
+ var data []byte
+ var name string
+ if meta.needFile {
+ f, hdr, err := r.FormFile("file")
+ if err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Загрузите книгу Excel: лист выгрузки 1С и листы каналов с номерами")
+ return
+ }
+ defer f.Close()
+ name = path.Base(strings.ReplaceAll(hdr.Filename, "\\", "/"))
+ if ext := strings.ToLower(path.Ext(name)); ext != ".xlsx" && ext != ".csv" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Нужен файл .xlsx или .csv")
+ return
+ }
+ if data, err = io.ReadAll(f); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Файл не дочитался — загрузите ещё раз")
+ return
+ }
+ }
+ uploads, err := readPartnerUploads(r)
+ if err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", err.Error())
+ return
+ }
+ // Даты из 1С — источник истины для всех каналов. Ручной диапазон остаётся
+ // запасным вариантом только для старых файлов без строки периода.
+ source := r.FormValue("source")
+ pbxWanted := source == "all" || source == "pbx"
+ bitrixWanted := source == "all" || source == "bitrix" || r.FormValue("bitrix") == "1"
+ // автовыгрузка из кабинетов площадок настроена только на кабинеты DANA
+ prodoctorovWanted := source == "all" && clinic.partnerCabinets && a.prodoctorov.Configured()
+ sberhealthWanted := source == "all" && clinic.partnerCabinets && a.sberhealth.Configured()
+ var period *pbxPeriod
+ if f, t := r.FormValue("pbx_from"), r.FormValue("pbx_to"); f != "" || t != "" {
+ pbxWanted = true
+ }
+ if pbxWanted || bitrixWanted || uploads.any() {
+ f, t := r.FormValue("pbx_from"), r.FormValue("pbx_to")
+ from, to, _, ok := detectPeriodFromFile(name, data)
+ if !ok && (f != "" || t != "") {
+ from, to, ok = statsRange(map[string][]string{"from": {f}, "to": {t}}, time.Now())
+ if !ok || to.Sub(from) > 93*24*time.Hour {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Период — даты ГГГГ-ММ-ДД, не длиннее трёх месяцев")
+ return
+ }
+ }
+ if !ok {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Не нашёл период в файле: в строке 2 должна быть запись «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ»")
+ return
+ }
+ period = &pbxPeriod{from: from, to: to}
+ }
+ id, err := a.store.CreateReport(r.Context(), clinic.Key, kind, meta.title, name, int64(len(data)), u.ID)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "report", id, clientIP(r))
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ jobs.add(id, cancel)
+ uploadMS := time.Since(started).Milliseconds()
+ go func() {
+ defer jobs.done(id)
+ defer cancel()
+ a.runReport(ctx, id, clinic, kind, name, data, uploadMS, period, pbxWanted, bitrixWanted, prodoctorovWanted, sberhealthWanted, uploads, u.ID)
+ }()
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminCancelReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ changed, err := a.store.CancelReport(r.Context(), id)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ jobs.cancel(id)
+ if !changed {
+ fail(w, http.StatusConflict, "not_running", "Отчёт уже завершён")
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "cancel", "report", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.DeleteReport(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "report", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDownloadReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ name, data, err := a.store.ReportResult(r.Context(), id)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ // в файле отчёта — телефоны и имена пациентов: каждое скачивание фиксируем в аудите
+ a.store.Audit(r.Context(), u.ID, "download", "report", id, clientIP(r))
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="report-%d.csv"; filename*=UTF-8''%s`, id, url.PathEscape(name)))
+ w.Header().Set("Cache-Control", "no-store")
+ _, _ = w.Write(data)
+}
+
+// ---------- расчёт ----------
+
+// progressTracker пишет этапы в базу: длительность каждого завершённого этапа и общий процент.
+type progressTracker struct {
+ a *API
+ ctx context.Context
+ id int64
+ stage int
+ start time.Time
+ stages []int64
+ last int
+ // bounds — где начинается каждый этап на шкале 0–100: доли по реальному объёму работы.
+ // По умолчанию этапы равные.
+ bounds []int
+}
+
+func (p *progressTracker) set(stage, inStage int) {
+ b := p.bounds
+ if len(b) != stagesTotal+1 {
+ b = []int{0, 20, 40, 60, 80, 100}
+ }
+ pct := b[stage] + (b[stage+1]-b[stage])*inStage/100
+ if pct < p.last {
+ pct = p.last // процент не должен идти назад — выгрузка Битрикс24/АТС до этапа «Проверка
+ // данных» уже могла продвинуть его дальше, чем начало этого этапа
+ }
+ if stage == p.stage && pct-p.last < 1 && inStage != 0 {
+ return
+ }
+ p.last = pct
+ if p.a == nil || p.a.store == nil { // в тестах — без базы
+ return
+ }
+ if err := p.a.store.ReportProgress(context.Background(), p.id, stage, pct, p.stages); err != nil {
+ slog.Warn("report progress", "id", p.id, "err", err)
+ }
+}
+
+// setExact — процент внутри этапа по обработанным строкам: done из total.
+func (p *progressTracker) setExact(stage, done, total int) {
+ if total <= 0 {
+ return
+ }
+ in := done * 100 / total
+ if in > 99 {
+ in = 99
+ }
+ p.set(stage, in)
+}
+
+// next закрывает текущий этап и начинает следующий.
+func (p *progressTracker) next() {
+ p.stages = append(p.stages, time.Since(p.start).Milliseconds())
+ p.start = time.Now()
+ p.stage++
+ p.set(p.stage, 0)
+}
+
+func (a *API) runReport(ctx context.Context, id int64, clinic *clinicDef, kind, name string, data []byte, uploadMS int64, period *pbxPeriod, pbxWanted, bitrixWanted, prodoctorovWanted, sberhealthWanted bool, uploads partnerUploads, adminID int64) {
+ p := &progressTracker{a: a, ctx: ctx, id: id, stage: stageCheck, start: time.Now(), stages: []int64{uploadMS}}
+ p.set(stageCheck, 0)
+ var (
+ result []byte
+ summary any
+ err error
+ )
+ switch kind {
+ case "phone_match":
+ o := matchOpts{}
+ if pbxWanted && period != nil {
+ var po matchOpts
+ if po, err = a.pbxMatchOpts(ctx, clinic.Key, period); err == nil {
+ o.pbxLists = mergeChannelLists(o.pbxLists, po.pbxLists)
+ o.pbxInfo = po.pbxInfo
+ }
+ }
+ if err == nil && bitrixWanted && period != nil {
+ var bo matchOpts
+ onProgress := func(phase string, done, total int) {
+ if total <= 0 {
+ return
+ }
+ base := 0
+ if phase == "contacts" {
+ base = 50
+ }
+ p.set(stageCheck, base+done*50/total)
+ }
+ if bo, err = a.bitrixMatchOpts(ctx, clinic, period.from, period.to, onProgress); err == nil {
+ o.pbxLists = mergeChannelLists(o.pbxLists, bo.pbxLists)
+ o.bitrixInfo = bo.bitrixInfo
+ }
+ }
+ if err == nil && (prodoctorovWanted || len(uploads.prodoctorov) > 0) {
+ if period == nil {
+ err = errors.New("Для ПроДокторов нужен период выгрузки 1С")
+ } else {
+ // файлы, приложенные пользователем, главнее скачивания с сервера (его блокирует защита кабинета)
+ var po matchOpts
+ if len(uploads.prodoctorov) > 0 {
+ po, err = prodoctorovUploadOptsFor(clinic, uploads.prodoctorov, period.from, period.to)
+ } else {
+ po, err = a.prodoctorovMatchOpts(ctx, period.from, period.to)
+ }
+ if err == nil {
+ o.pbxLists = mergeChannelLists(o.pbxLists, po.pbxLists)
+ o.prodoctorovInfo = po.prodoctorovInfo
+ }
+ }
+ }
+ if err == nil && (sberhealthWanted || len(uploads.sber) > 0) {
+ if period == nil {
+ err = errors.New("Для СберЗдоровья нужен период выгрузки 1С")
+ } else {
+ var so matchOpts
+ if len(uploads.sber) > 0 {
+ so, err = sberhealthUploadOpts(uploads.sber, period.from, period.to)
+ } else {
+ so, err = a.sberhealthMatchOpts(ctx, period.from, period.to)
+ }
+ if err == nil {
+ o.pbxLists = mergeChannelLists(o.pbxLists, so.pbxLists)
+ o.sberhealthInfo = so.sberhealthInfo
+ }
+ }
+ }
+ if err == nil {
+ result, summary, err = a.phoneMatchOpts(ctx, p, name, data, o)
+ }
+ }
+ status, errText := "done", ""
+ switch {
+ case errors.Is(ctx.Err(), context.Canceled):
+ return // отменили — статус уже «отменён»
+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
+ status, errText = "failed", "Расчёт шёл дольше 5 минут и остановлен"
+ case err != nil:
+ status, errText = "failed", err.Error()
+ }
+ if status == "done" {
+ p.stages = append(p.stages, time.Since(p.start).Milliseconds()) // «Завершение»
+ if kind == "phone_match" {
+ if sum, isSum := summary.(channelsSummary); isSum {
+ sum.Dashboard = a.saveWeeklyFromSummary(context.Background(), clinic.Key, name, data, sum, adminID)
+ summary = sum
+ }
+ }
+ }
+ stamp := time.Now().In(moscow).Format("2006-01-02")
+ resultName := "otchet-po-kanalam-" + stamp + ".csv"
+ if status != "done" {
+ result, resultName = nil, ""
+ }
+ if err := a.store.FinishReport(context.Background(), id, status, p.stages, summary, result, resultName, errText); err != nil {
+ slog.Error("finish report", "id", id, "err", err)
+ }
+}
+
+// csvBytes — CSV для Excel: UTF-8 с BOM и точкой с запятой.
+func csvBytes(rows [][]string) []byte {
+ var buf bytes.Buffer
+ buf.WriteString("\ufeff")
+ cw := csv.NewWriter(&buf)
+ cw.Comma = ';'
+ cw.UseCRLF = true
+ _ = cw.WriteAll(rows)
+ return buf.Bytes()
+}
+
+var priceRE = regexp.MustCompile(`-?\d+(?:[.,]\d+)?`)
+
+// parsePrice понимает «1 500», «1500,00», «1 500 ₽», «от 2000 руб.».
+func parsePrice(s string) (float64, bool) {
+ s = strings.NewReplacer(" ", "", " ", "", " ", "").Replace(s)
+ m := priceRE.FindString(s)
+ if m == "" {
+ return 0, false
+ }
+ v, err := strconv.ParseFloat(strings.ReplaceAll(m, ",", "."), 64)
+ if err != nil || v < 0 || v > 100_000_000 {
+ return 0, false
+ }
+ return v, true
+}
+
+func fmtRub(v float64) string {
+ if v == math.Trunc(v) {
+ return strconv.FormatFloat(v, 'f', 0, 64)
+ }
+ return strings.ReplaceAll(strconv.FormatFloat(v, 'f', 2, 64), ".", ",")
+}
diff --git a/internal/api/admin_services.go b/internal/api/admin_services.go
new file mode 100644
index 0000000..af05763
--- /dev/null
+++ b/internal/api/admin_services.go
@@ -0,0 +1,49 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+type newServiceRequest struct {
+ ParentID int64 `json:"parent_id"`
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Lead string `json:"lead"`
+}
+
+// adminCreateService заводит услугу скрытой: на сайт она попадёт, когда её заполнят и включат.
+func (a *API) adminCreateService(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ var req newServiceRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ in := storage.NewService{
+ ParentID: req.ParentID,
+ Slug: strings.ToLower(strings.TrimSpace(req.Slug)),
+ Name: strings.TrimSpace(req.Name),
+ Lead: strings.TrimSpace(req.Lead),
+ }
+ switch {
+ case in.Name == "" || len([]rune(in.Name)) > 200:
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название услуги (до 200 символов)")
+ return
+ case in.ParentID <= 0:
+ fail(w, http.StatusUnprocessableEntity, "validation", "Выберите направление")
+ return
+ case !slugRE.MatchString(in.Slug) || len(in.Slug) > 100:
+ fail(w, http.StatusUnprocessableEntity, "validation", "Адрес — латиница, цифры и дефис, например «priem-kardiologa»")
+ return
+ }
+ id, err := a.store.AdminCreateService(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "service", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
diff --git a/internal/api/admin_site.go b/internal/api/admin_site.go
new file mode 100644
index 0000000..455ee64
--- /dev/null
+++ b/internal/api/admin_site.go
@@ -0,0 +1,317 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+// ---------- Страницы ----------
+
+// Правовые страницы должны быть доступны всегда: на них ссылаются формы заявок
+// (согласие на обработку персональных данных) и баннер cookie.
+var requiredPages = map[string]bool{"legaldocs": true, "privacy": true, "consent": true, "cookie": true}
+
+type pageRequest struct {
+ Title string `json:"title"`
+ HTML string `json:"html"`
+ Published bool `json:"published"`
+ SEOTitle string `json:"seo_title"`
+ SEODesc string `json:"seo_description"`
+ SEOH1 string `json:"seo_h1"`
+}
+
+func (a *API) adminPages(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminPages(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminUpdatePage(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var req pageRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxPostBody)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос или слишком большой текст")
+ return
+ }
+ current, err := a.store.AdminPage(r.Context(), id)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ in := storage.PageInput{
+ Title: strings.TrimSpace(req.Title),
+ Published: req.Published,
+ SEOTitle: strings.TrimSpace(req.SEOTitle),
+ SEODesc: strings.TrimSpace(req.SEODesc),
+ SEOH1: strings.TrimSpace(req.SEOH1),
+ }
+ if in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите заголовок страницы")
+ return
+ }
+ if requiredPages[current.Slug] && !in.Published {
+ fail(w, http.StatusUnprocessableEntity, "validation",
+ "Правовые документы нельзя скрыть: на них ссылаются формы заявок и баннер cookie")
+ return
+ }
+ html := strings.TrimSpace(req.HTML)
+ if requiredPages[current.Slug] && strings.TrimSpace(tagRE.ReplaceAllString(html, "")) == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Текст правового документа не может быть пустым")
+ return
+ }
+ // Блоки, которые админка не редактирует (например, фото на странице «О клинике»),
+ // сохраняются как были; текстовые заменяются одним блоком из редактора.
+ var blocks []json.RawMessage
+ var old []json.RawMessage
+ _ = json.Unmarshal(current.Blocks, &old)
+ for _, b := range old {
+ var head struct {
+ Type string `json:"type"`
+ HTML *string `json:"html"`
+ }
+ if json.Unmarshal(b, &head) == nil && head.HTML == nil && head.Type != "html" {
+ blocks = append(blocks, b)
+ }
+ }
+ if html != "" {
+ b, _ := json.Marshal(map[string]string{"type": "html", "html": html})
+ blocks = append(blocks, b)
+ }
+ if blocks == nil {
+ blocks = []json.RawMessage{}
+ }
+ in.Blocks, _ = json.Marshal(blocks)
+ if err := a.store.AdminUpdatePage(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "page", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// ---------- Настройки ----------
+
+// Все настройки публичны (/api/v1/settings), поэтому админка пишет только известные
+// ключи: пароли и ключи сервисов сюда попасть не должны, они живут в переменных окружения.
+type legalSettings struct {
+ OrgName string `json:"org_name"`
+ OrgShort string `json:"org_short"`
+ INN string `json:"inn"`
+ OGRN string `json:"ogrn"`
+ Address string `json:"address"`
+ License string `json:"license"`
+ Email string `json:"email"`
+}
+
+type settingsRequest struct {
+ Email string `json:"email"`
+ MaxURL string `json:"max_url"`
+ Legal legalSettings `json:"legal"`
+}
+
+var (
+ emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
+ onlyDigitsRE = regexp.MustCompile(`^\d+$`)
+)
+
+func decodeSettings(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
+ var req settingsRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return nil, false
+ }
+ bad := func(msg string) (map[string]any, bool) {
+ fail(w, http.StatusUnprocessableEntity, "validation", msg)
+ return nil, false
+ }
+ trim := strings.TrimSpace
+ req.Email, req.MaxURL = trim(req.Email), trim(req.MaxURL)
+ l := &req.Legal
+ l.OrgName, l.OrgShort, l.INN, l.OGRN = trim(l.OrgName), trim(l.OrgShort), trim(l.INN), trim(l.OGRN)
+ l.Address, l.License, l.Email = trim(l.Address), trim(l.License), trim(l.Email)
+
+ if req.Email != "" && !emailRE.MatchString(req.Email) {
+ return bad("Проверьте адрес почты для сайта")
+ }
+ if req.MaxURL != "" {
+ u, err := url.Parse(req.MaxURL)
+ if err != nil || u.Scheme != "https" || !strings.Contains(u.Hostname(), ".") {
+ return bad("Ссылка на MAX — адрес вида https://max.ru/…")
+ }
+ }
+ // Реквизиты по закону должны быть на сайте медицинской организации — пустыми их не оставляем.
+ if l.OrgName == "" || l.OrgShort == "" || l.Address == "" || l.License == "" {
+ return bad("Заполните название организации, адрес и лицензию")
+ }
+ if !onlyDigitsRE.MatchString(l.INN) || (len(l.INN) != 10 && len(l.INN) != 12) {
+ return bad("ИНН — 10 или 12 цифр")
+ }
+ if !onlyDigitsRE.MatchString(l.OGRN) || (len(l.OGRN) != 13 && len(l.OGRN) != 15) {
+ return bad("ОГРН — 13 или 15 цифр")
+ }
+ if l.Email != "" && !emailRE.MatchString(l.Email) {
+ return bad("Проверьте почту в реквизитах")
+ }
+ return map[string]any{"email": req.Email, "max_url": req.MaxURL, "legal": req.Legal}, true
+}
+
+func (a *API) adminSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ m, err := a.store.Settings(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, m, nil)
+}
+
+func (a *API) adminUpdateSettings(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ values, valid := decodeSettings(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminSetSettings(r.Context(), values); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "settings", 0, clientIP(r))
+ ok(w, values, nil)
+}
+
+// ---------- Филиалы ----------
+
+type branchRequest struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Address string `json:"address"`
+ Phones []string `json:"phones"`
+ WorkHours []string `json:"work_hours"`
+ Lat *float64 `json:"lat"`
+ Lon *float64 `json:"lon"`
+ Sort int `json:"sort"`
+ Published bool `json:"published"`
+}
+
+func cleanList(items []string) []string {
+ out := []string{}
+ for _, s := range items {
+ if s = strings.TrimSpace(s); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func decodeBranch(w http.ResponseWriter, r *http.Request) (storage.BranchInput, bool) {
+ var req branchRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.BranchInput{}, false
+ }
+ in := storage.BranchInput{
+ Name: strings.TrimSpace(req.Name),
+ Address: strings.TrimSpace(req.Address),
+ Phones: cleanList(req.Phones),
+ WorkHours: cleanList(req.WorkHours),
+ Lat: req.Lat,
+ Lon: req.Lon,
+ Sort: req.Sort,
+ Published: req.Published,
+ }
+ if in.Sort == 0 {
+ in.Sort = 100
+ }
+ bad := func(msg string) (storage.BranchInput, bool) {
+ fail(w, http.StatusUnprocessableEntity, "validation", msg)
+ return in, false
+ }
+ if in.Name == "" || in.Address == "" {
+ return bad("Укажите название и адрес филиала")
+ }
+ slug, valid := resolveSlug(w, req.Slug, in.Name)
+ if !valid {
+ return in, false
+ }
+ in.Slug = slug
+ for _, p := range in.Phones {
+ // digitsRE (handlers_leads.go) совпадает с не-цифрами: после замены остаются только цифры
+ if n := len(digitsRE.ReplaceAllString(p, "")); n < 10 || n > 12 {
+ return bad("Телефон «" + p + "» — нужен номер целиком, например +7 928 962-00-94")
+ }
+ }
+ // Первый телефон первого филиала стоит в шапке сайта — без него показать нечего.
+ if in.Published && len(in.Phones) == 0 {
+ return bad("Укажите хотя бы один телефон филиала")
+ }
+ if (in.Lat == nil) != (in.Lon == nil) {
+ return bad("Укажите обе координаты или оставьте обе пустыми")
+ }
+ if in.Lat != nil && (*in.Lat < -90 || *in.Lat > 90 || *in.Lon < -180 || *in.Lon > 180) {
+ return bad("Координаты вне допустимого диапазона: широта −90…90, долгота −180…180")
+ }
+ return in, true
+}
+
+func (a *API) adminBranches(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminBranches(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeBranch(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateBranch(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "branch", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateBranch(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeBranch(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdateBranch(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "branch", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// ---------- Главный экран админки ----------
+
+func (a *API) adminDashboard(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ d, err := a.store.Dashboard(r.Context(), 14)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, d, nil)
+}
diff --git a/internal/api/admin_site_test.go b/internal/api/admin_site_test.go
new file mode 100644
index 0000000..918a534
--- /dev/null
+++ b/internal/api/admin_site_test.go
@@ -0,0 +1,70 @@
+package api
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+const validLegal = `"legal":{"org_name":"ООО «Дана Медикал»","org_short":"ООО «Дана Медикал»","inn":"0500020358","ogrn":"1240500017702","address":"Махачкала","license":"Л041-01172-05/03997730"}`
+
+func TestDecodeSettings(t *testing.T) {
+ cases := []struct {
+ name, body, wantText string
+ wantCode int
+ }{
+ {"valid", `{"email":"danaclinic@yandex.ru","max_url":"https://max.ru/dana",` + validLegal + `}`, "", 200},
+ {"no max link", `{"email":"",` + validLegal + `}`, "", 200},
+ {"bad email", `{"email":"danaclinic",` + validLegal + `}`, "почты", 422},
+ {"max over http", `{"max_url":"javascript:alert(1)",` + validLegal + `}`, "MAX", 422},
+ {"short inn", `{` + strings.Replace(validLegal, "0500020358", "05000", 1) + `}`, "ИНН", 422},
+ {"bad ogrn", `{` + strings.Replace(validLegal, "1240500017702", "12405000177", 1) + `}`, "ОГРН", 422},
+ {"no license", `{` + strings.Replace(validLegal, "Л041-01172-05/03997730", " ", 1) + `}`, "лицензию", 422},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("PUT", "/", strings.NewReader(tc.body))
+ values, valid := decodeSettings(w, r)
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ // в настройки пишутся только известные ключи
+ if valid && len(values) != 3 {
+ t.Errorf("%s: unexpected keys %v", tc.name, values)
+ }
+ }
+}
+
+func TestDecodeBranch(t *testing.T) {
+ cases := []struct {
+ name, body, wantText string
+ wantCode int
+ }{
+ {"valid", `{"name":"Ляхова, 22","address":"Махачкала","phones":[" +7 928 962-00-94 ",""],"lat":42.97,"lon":47.49,"published":true}`, "", 200},
+ {"hidden without phone", `{"name":"Новый","address":"Махачкала","published":false}`, "", 200},
+ {"published without phone", `{"name":"Новый","address":"Махачкала","published":true}`, "телефон", 422},
+ {"short phone", `{"name":"Новый","address":"Махачкала","phones":["962-00"]}`, "целиком", 422},
+ {"one coordinate", `{"name":"Новый","address":"Махачкала","lat":42.9}`, "обе координаты", 422},
+ {"coordinates out of range", `{"name":"Новый","address":"Махачкала","lat":142.9,"lon":47.5}`, "диапазона", 422},
+ {"no address", `{"name":"Новый","address":" "}`, "адрес", 422},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
+ in, valid := decodeBranch(w, r)
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ if tc.name == "valid" && (len(in.Phones) != 1 || in.Phones[0] != "+7 928 962-00-94" || in.Slug != "lyakhova-22") {
+ t.Errorf("valid: got %+v", in)
+ }
+ }
+}
diff --git a/internal/api/admin_specialties.go b/internal/api/admin_specialties.go
new file mode 100644
index 0000000..b5ef468
--- /dev/null
+++ b/internal/api/admin_specialties.go
@@ -0,0 +1,153 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+type specialtyRequest struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ NamePlural string `json:"name_plural"`
+ Sort int `json:"sort"`
+}
+
+func decodeSpecialty(w http.ResponseWriter, r *http.Request, withSlug bool) (storage.SpecialtyInput, bool) {
+ var req specialtyRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.SpecialtyInput{}, false
+ }
+ in := storage.SpecialtyInput{
+ Slug: strings.ToLower(strings.TrimSpace(req.Slug)),
+ Name: strings.TrimSpace(req.Name),
+ NamePlural: strings.TrimSpace(req.NamePlural),
+ Sort: req.Sort,
+ }
+ if in.Sort == 0 {
+ in.Sort = 500
+ }
+ if in.NamePlural == "" {
+ in.NamePlural = in.Name
+ }
+ if in.Name == "" {
+ in.Name = in.NamePlural
+ }
+ if in.Name == "" || len([]rune(in.Name)) > 100 || len([]rune(in.NamePlural)) > 100 {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название специальности (до 100 символов)")
+ return in, false
+ }
+ if withSlug && (!slugRE.MatchString(in.Slug) || len(in.Slug) > 100) {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Адрес — латиница, цифры и дефис, например «kardiologi»")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminSpecialties(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminSpecialties(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreateSpecialty(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeSpecialty(w, r, true)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateSpecialty(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "specialty", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
+
+func (a *API) adminUpdateSpecialty(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeSpecialty(w, r, false)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdateSpecialty(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "specialty", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminDeleteSpecialty(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteSpecialty(r.Context(), id); err != nil {
+ if errors.Is(err, storage.ErrInUse) {
+ fail(w, http.StatusConflict, "in_use", "В этой специальности есть врачи — сначала перенесите или удалите их")
+ return
+ }
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "specialty", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+type newDoctorRequest struct {
+ Slug string `json:"slug"`
+ LastName string `json:"last_name"`
+ FirstName string `json:"first_name"`
+ MiddleName string `json:"middle_name"`
+ Position string `json:"position"`
+ SpecialtyID int64 `json:"specialty_id"`
+}
+
+// adminCreateDoctor заводит врача скрытым; фото, биографию и прочее заполняют в карточке.
+func (a *API) adminCreateDoctor(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ var req newDoctorRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return
+ }
+ in := storage.NewDoctor{
+ Slug: strings.ToLower(strings.TrimSpace(req.Slug)),
+ LastName: strings.TrimSpace(req.LastName),
+ FirstName: strings.TrimSpace(req.FirstName),
+ MiddleName: strings.TrimSpace(req.MiddleName),
+ Position: strings.TrimSpace(req.Position),
+ SpecialtyID: req.SpecialtyID,
+ }
+ switch {
+ case in.LastName == "" || in.FirstName == "":
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите фамилию и имя")
+ return
+ case in.SpecialtyID <= 0:
+ fail(w, http.StatusUnprocessableEntity, "validation", "Выберите специальность — от неё зависит адрес страницы врача")
+ return
+ case !slugRE.MatchString(in.Slug) || len(in.Slug) > 100:
+ fail(w, http.StatusUnprocessableEntity, "validation", "Адрес — латиница, цифры и дефис, например «ivanov-ivan»")
+ return
+ }
+ id, err := a.store.AdminCreateDoctor(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "doctor", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id}})
+}
diff --git a/internal/api/admin_specialties_test.go b/internal/api/admin_specialties_test.go
new file mode 100644
index 0000000..c70fa3a
--- /dev/null
+++ b/internal/api/admin_specialties_test.go
@@ -0,0 +1,52 @@
+package api
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestDecodeSpecialtyRules(t *testing.T) {
+ cases := []struct {
+ name, body string
+ withSlug bool
+ wantCode int
+ wantText string
+ }{
+ {"plural only fills name", `{"name_plural":"Кардиологи","slug":"kardiologi"}`, true, 200, ""},
+ {"no name", `{"name_plural":" ","slug":"x"}`, true, 422, "название"},
+ {"cyrillic slug", `{"name_plural":"Кардиологи","slug":"кардиологи"}`, true, 422, "латиница"},
+ {"slug ignored on update", `{"name_plural":"Кардиологи"}`, false, 200, ""},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
+ in, valid := decodeSpecialty(w, r, tc.withSlug)
+ code := 200
+ if !valid {
+ code = w.Code
+ }
+ if code != tc.wantCode || (tc.wantText != "" && !strings.Contains(w.Body.String(), tc.wantText)) {
+ t.Errorf("%s: got %d %s, want %d containing %q", tc.name, code, w.Body.String(), tc.wantCode, tc.wantText)
+ }
+ if valid && (in.Name == "" || in.Sort != 500) {
+ t.Errorf("%s: defaults not applied: %+v", tc.name, in)
+ }
+ }
+}
+
+func TestCreateDoctorValidation(t *testing.T) {
+ a := &API{}
+ cases := []struct{ body, wantText string }{
+ {`{"last_name":"Иванов","slug":"ivanov","specialty_id":2}`, "фамилию и имя"},
+ {`{"last_name":"Иванов","first_name":"Иван","slug":"ivanov"}`, "специальность"},
+ {`{"last_name":"Иванов","first_name":"Иван","slug":"Иванов","specialty_id":2}`, "латиница"},
+ }
+ for _, tc := range cases {
+ w := httptest.NewRecorder()
+ a.adminCreateDoctor(w, httptest.NewRequest("POST", "/", strings.NewReader(tc.body)), nil)
+ if w.Code != 422 || !strings.Contains(w.Body.String(), tc.wantText) {
+ t.Errorf("%s: got %d %s", tc.body, w.Code, w.Body.String())
+ }
+ }
+}
diff --git a/internal/api/admin_stats.go b/internal/api/admin_stats.go
new file mode 100644
index 0000000..a4bb282
--- /dev/null
+++ b/internal/api/admin_stats.go
@@ -0,0 +1,67 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "dana/internal/storage"
+)
+
+var moscow = func() *time.Location {
+ if loc, err := time.LoadLocation("Europe/Moscow"); err == nil {
+ return loc
+ }
+ return time.FixedZone("MSK", 3*60*60)
+}()
+
+// statsRange разбирает ?from=YYYY-MM-DD&to=YYYY-MM-DD (обе даты включительно, по Москве).
+// По умолчанию — последние 30 дней. Период — не длиннее двух лет.
+func statsRange(q map[string][]string, now time.Time) (from, to time.Time, ok bool) {
+ get := func(k string) string {
+ if v := q[k]; len(v) > 0 {
+ return v[0]
+ }
+ return ""
+ }
+ today := time.Date(now.In(moscow).Year(), now.In(moscow).Month(), now.In(moscow).Day(), 0, 0, 0, 0, moscow)
+ to = today.AddDate(0, 0, 1)
+ from = today.AddDate(0, 0, -29)
+ if v := get("from"); v != "" {
+ t, err := time.ParseInLocation("2006-01-02", v, moscow)
+ if err != nil {
+ return from, to, false
+ }
+ from = t
+ }
+ if v := get("to"); v != "" {
+ t, err := time.ParseInLocation("2006-01-02", v, moscow)
+ if err != nil {
+ return from, to, false
+ }
+ to = t.AddDate(0, 0, 1)
+ }
+ if !to.After(from) || to.Sub(from) > 2*366*24*time.Hour {
+ return from, to, false
+ }
+ return from, to, true
+}
+
+func (a *API) adminStats(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ from, to, valid := statsRange(r.URL.Query(), time.Now())
+ if !valid {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный период: даты в формате ГГГГ-ММ-ДД, не длиннее двух лет")
+ return
+ }
+ branchID, _ := strconv.ParseInt(r.URL.Query().Get("branch_id"), 10, 64)
+ st, err := a.store.Stats(r.Context(), storage.StatsFilter{From: from, To: to, BranchID: branchID})
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, st, map[string]any{
+ "from": from.Format("2006-01-02"),
+ "to": to.AddDate(0, 0, -1).Format("2006-01-02"),
+ "days": int(to.Sub(from).Hours()/24 + 0.5),
+ })
+}
diff --git a/internal/api/admin_stats_test.go b/internal/api/admin_stats_test.go
new file mode 100644
index 0000000..462ee90
--- /dev/null
+++ b/internal/api/admin_stats_test.go
@@ -0,0 +1,27 @@
+package api
+
+import (
+ "testing"
+ "time"
+)
+
+func TestStatsRange(t *testing.T) {
+ now := time.Date(2026, 9, 21, 22, 30, 0, 0, time.UTC) // 22 сентября 01:30 по Москве
+ from, to, ok := statsRange(map[string][]string{}, now)
+ if !ok || from.Format("2006-01-02") != "2026-08-24" || to.Format("2006-01-02") != "2026-09-23" {
+ t.Fatalf("default range = %v..%v ok=%v", from, to, ok)
+ }
+ from, to, ok = statsRange(map[string][]string{"from": {"2026-09-01"}, "to": {"2026-09-01"}}, now)
+ if !ok || to.Sub(from) != 24*time.Hour {
+ t.Fatalf("one day range = %v..%v", from, to)
+ }
+ for _, q := range []map[string][]string{
+ {"from": {"01.09.2026"}},
+ {"from": {"2026-09-10"}, "to": {"2026-09-01"}},
+ {"from": {"2020-01-01"}, "to": {"2026-01-01"}},
+ } {
+ if _, _, ok := statsRange(q, now); ok {
+ t.Errorf("range %v accepted", q)
+ }
+ }
+}
diff --git a/internal/api/admin_vacancies.go b/internal/api/admin_vacancies.go
new file mode 100644
index 0000000..84f05c4
--- /dev/null
+++ b/internal/api/admin_vacancies.go
@@ -0,0 +1,124 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "dana/internal/storage"
+)
+
+type vacancyRequest struct {
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Duties string `json:"duties"`
+ Requirements string `json:"requirements"`
+ Conditions string `json:"conditions"`
+ Salary string `json:"salary"`
+ Sort int `json:"sort"`
+ Published bool `json:"published"`
+}
+
+// cleanLines убирает пустые строки и лишние пробелы: на сайте каждая строка — пункт списка.
+func cleanLines(s string) string {
+ var out []string
+ for _, line := range strings.Split(strings.ReplaceAll(s, "\r", ""), "\n") {
+ line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-•–—*"))
+ if line != "" {
+ out = append(out, line)
+ }
+ }
+ return strings.Join(out, "\n")
+}
+
+func decodeVacancy(w http.ResponseWriter, r *http.Request) (storage.VacancyInput, bool) {
+ var req vacancyRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256<<10)).Decode(&req); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный запрос")
+ return storage.VacancyInput{}, false
+ }
+ in := storage.VacancyInput{
+ Title: strings.TrimSpace(req.Title),
+ Description: strings.TrimSpace(req.Description),
+ Duties: cleanLines(req.Duties),
+ Requirements: cleanLines(req.Requirements),
+ Conditions: cleanLines(req.Conditions),
+ Salary: strings.TrimSpace(req.Salary),
+ Sort: req.Sort,
+ Published: req.Published,
+ }
+ if in.Sort == 0 {
+ in.Sort = 100
+ }
+ if in.Title == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Укажите название вакансии")
+ return in, false
+ }
+ slug, valid := resolveSlug(w, req.Slug, in.Title)
+ if !valid {
+ return in, false
+ }
+ in.Slug = slug
+ if in.Published && in.Description == "" && in.Duties == "" && in.Requirements == "" && in.Conditions == "" {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Чтобы показать вакансию на сайте, опишите её")
+ return in, false
+ }
+ return in, true
+}
+
+func (a *API) adminVacancies(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ list, err := a.store.AdminVacancies(r.Context())
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, map[string]any{"total": len(list)})
+}
+
+func (a *API) adminCreateVacancy(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ in, valid := decodeVacancy(w, r)
+ if !valid {
+ return
+ }
+ id, err := a.store.AdminCreateVacancy(r.Context(), in)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "create", "vacancy", id, clientIP(r))
+ writeJSON(w, http.StatusCreated, envelope{Data: map[string]any{"id": id, "slug": in.Slug}})
+}
+
+func (a *API) adminUpdateVacancy(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ in, valid := decodeVacancy(w, r)
+ if !valid {
+ return
+ }
+ if err := a.store.AdminUpdateVacancy(r.Context(), id, in); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "vacancy", id, clientIP(r))
+ ok(w, map[string]any{"id": id, "slug": in.Slug}, nil)
+}
+
+func (a *API) adminDeleteVacancy(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.AdminDeleteVacancy(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "vacancy", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
diff --git a/internal/api/admin_weekly.go b/internal/api/admin_weekly.go
new file mode 100644
index 0000000..0164f74
--- /dev/null
+++ b/internal/api/admin_weekly.go
@@ -0,0 +1,281 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "dana/internal/storage"
+)
+
+// Еженедельный/ежемесячный дашборд по каналам: отдельной загрузки файлов у него нет —
+// источник один, это обычный «Отчёт по каналам» (вкладка «Новый отчёт»). Если в загруженной
+// туда выгрузке 1С есть текст «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ», посчитанный результат
+// автоматически добавляется ещё и сюда, одной строкой истории. Эта страница — только
+// просмотр накопленных периодов и правка бюджета по каналам.
+
+// Dates in 1C are inclusive. Internally all integrations use [from, to).
+var periodRE = regexp.MustCompile(`(?i)период\s*:?\s*(?:с\s+)?(\d{1,2}\.\d{1,2}\.\d{4})\s*(?:-|–|—|по)\s*(\d{1,2}\.\d{1,2}\.\d{4})`)
+
+type reportFilePeriod struct {
+ From string `json:"from"`
+ To string `json:"to"` // inclusive, for the user
+ Label string `json:"label"`
+ Mode string `json:"mode"`
+}
+
+type reportDashboard struct {
+ reportFilePeriod
+ ID int64 `json:"id,omitempty"`
+ Warning string `json:"warning,omitempty"`
+}
+
+func describeReportPeriod(from, to time.Time) reportFilePeriod {
+ mode := "weeks"
+ if from.Day() == 1 && to.Equal(time.Date(from.Year(), from.Month()+1, 1, 0, 0, 0, 0, moscow)) {
+ mode = "months"
+ }
+ last := to.AddDate(0, 0, -1)
+ return reportFilePeriod{From: from.Format("2006-01-02"), To: last.Format("2006-01-02"),
+ Label: from.Format("02.01.2006") + "–" + last.Format("02.01.2006"), Mode: mode}
+}
+
+func parseReportPeriod(text string) (from, to time.Time, label string, ok bool) {
+ m := periodRE.FindStringSubmatch(strings.Join(strings.Fields(text), " "))
+ if m == nil {
+ return
+ }
+ f, err1 := time.ParseInLocation("2.1.2006", m[1], moscow)
+ t, err2 := time.ParseInLocation("2.1.2006", m[2], moscow)
+ if err1 != nil || err2 != nil || t.Before(f) {
+ return
+ }
+ end := t.AddDate(0, 0, 1)
+ if end.Sub(f) > 93*24*time.Hour {
+ return
+ }
+ return f, end, f.Format("02.01") + "–" + t.Format("02.01"), true
+}
+
+// Row 2 of the actual 1C sheet is authoritative, not the filename, channel sheets
+// or the report generation timestamp. Older exports may put it above the headers elsewhere.
+func detectPeriodFromFile(name string, data []byte) (from, to time.Time, label string, ok bool) {
+ sheets, err := readWorkbook(name, data)
+ if err != nil {
+ return
+ }
+ for _, s := range sheets {
+ if serviceSheets[strings.ToLower(strings.TrimSpace(s.Name))] {
+ continue
+ }
+ cols, is1C := find1CColumns(s.Rows)
+ if !is1C {
+ continue
+ }
+ if cols.header > 1 {
+ text := strings.Join(s.Rows[1], " ")
+ if strings.Contains(strings.ToLower(text), "период") {
+ return parseReportPeriod(text)
+ }
+ }
+ for i := 0; i < cols.header; i++ {
+ text := strings.Join(s.Rows[i], " ")
+ if strings.Contains(strings.ToLower(text), "период") {
+ return parseReportPeriod(text)
+ }
+ }
+ return
+ }
+ return
+}
+
+// saveWeeklyFromSummary — лучшая попытка: если в файле нашёлся период, кладёт уже посчитанную
+// сводку «Отчёта по каналам» ещё и в историю дашборда. Ошибки сюда не прерывают основной отчёт,
+// только пишутся в лог — для admin ничего не меняется, кроме того, что период не появится в дашборде.
+func (a *API) saveWeeklyFromSummary(ctx context.Context, clinic, name string, data []byte, sum channelsSummary, adminID int64) *reportDashboard {
+ from, to, label, found := detectPeriodFromFile(name, data)
+ if !found {
+ return nil
+ }
+ result := &reportDashboard{reportFilePeriod: describeReportPeriod(from, to)}
+ cc, err := a.weeklyCallCenter(ctx, clinic, from, to, sum)
+ if err != nil {
+ slog.Warn("weekly dashboard: колл-ц", "err", err)
+ }
+ if id, err := a.store.UpsertWeeklyReport(ctx, clinic, from, to, label, name, cc, weeklyChannelsFromSummary(sum), adminID); err != nil {
+ slog.Warn("weekly dashboard: сохранить период", "err", err)
+ result.Warning = "Расчёт готов, но период не сохранился в дашборде. Повторите загрузку файла."
+ } else {
+ result.ID = id
+ }
+ return result
+}
+
+// Read-only upload preview: no CRM/PBX requests and no database writes.
+func (a *API) adminReportPeriod(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxReportFile+(1<<20))
+ if err := r.ParseMultipartForm(maxReportFile); err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Файл больше 10 МБ или запрос повреждён")
+ return
+ }
+ if r.MultipartForm != nil {
+ defer r.MultipartForm.RemoveAll()
+ }
+ f, header, err := r.FormFile("file")
+ if err != nil {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Выберите выгрузку 1С")
+ return
+ }
+ defer f.Close()
+ data, err := io.ReadAll(f)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Файл не дочитался")
+ return
+ }
+ name := path.Base(strings.ReplaceAll(header.Filename, "\\", "/"))
+ from, to, _, found := detectPeriodFromFile(name, data)
+ if !found {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Не найден корректный период в строке 2 листа 1С: Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ")
+ return
+ }
+ ok(w, describeReportPeriod(from, to), nil)
+}
+
+func weeklyChannelsFromSummary(sum channelsSummary) []storage.WeeklyChannel {
+ channels := make([]storage.WeeklyChannel, 0, len(sum.Channels)+1)
+ for _, c := range sum.Channels {
+ channels = append(channels, storage.WeeklyChannel{
+ Key: weeklyChannelKey(c.Name), Title: c.Name,
+ Leads: c.Leads, Patients: c.Patients, Services: c.Services, PerPat: c.PerPat,
+ Revenue: c.Revenue, AvgCheck: c.AvgCheck,
+ })
+ }
+ channels = append(channels, storage.WeeklyChannel{
+ Key: "итого-интернет", Title: sum.Internet.Name,
+ Leads: sum.Internet.Leads, Patients: sum.Internet.Patients, Services: sum.Internet.Services,
+ PerPat: sum.Internet.PerPat, Revenue: sum.Internet.Revenue, AvgCheck: sum.Internet.AvgCheck,
+ })
+ return channels
+}
+
+// weeklyChannelKey — заголовок канала (как в книге) обратно в ключ knownChannels, для стабильной
+// привязки бюджета к каналу между неделями. Неизвестные каналы используют сам заголовок в нижнем регистре.
+func weeklyChannelKey(title string) string {
+ for k, d := range knownChannels {
+ if d.title == title {
+ return k
+ }
+ }
+ return strings.ToLower(strings.TrimSpace(title))
+}
+
+func (a *API) adminWeeklyReports(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ clinic, valid := requestClinic(w, r)
+ if !valid {
+ return
+ }
+ list, err := a.store.WeeklyReports(r.Context(), clinic.Key)
+ if err != nil {
+ handleErr(w, err)
+ return
+ }
+ ok(w, list, nil)
+}
+
+func (a *API) adminDeleteWeeklyReport(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ if err := a.store.DeleteWeeklyReport(r.Context(), id); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "delete", "weekly_report", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+func (a *API) adminSetWeeklyBudget(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ fail(w, http.StatusBadRequest, "bad_request", "Некорректный id")
+ return
+ }
+ var body struct {
+ Channel string `json:"channel"`
+ Amount float64 `json:"amount"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Channel == "" || body.Amount < 0 {
+ fail(w, http.StatusUnprocessableEntity, "validation", "Нужны channel и неотрицательный amount")
+ return
+ }
+ if err := a.store.SetWeeklyBudget(r.Context(), id, body.Channel, body.Amount); err != nil {
+ handleErr(w, err)
+ return
+ }
+ a.store.Audit(r.Context(), u.ID, "update", "weekly_report_budget", id, clientIP(r))
+ ok(w, map[string]any{"id": id}, nil)
+}
+
+// weeklyCallCenterSales — пациенты/услуги/выручка блока «Колл-ц»: совпадения номеров колл-центра
+// («Колл-центр (все номера)») с 1С, как в таблице «Показатели», а не вся выгрузка 1С за период.
+func weeklyCallCenterSales(sum channelsSummary) storage.WeeklyCallCenter {
+ title := knownChannels["все номера"].title
+ for _, c := range sum.Channels {
+ if c.Name == title {
+ return storage.WeeklyCallCenter{Patients: c.Patients, Services: c.Services, Revenue: c.Revenue, AvgCheck: c.AvgCheck}
+ }
+ }
+ return storage.WeeklyCallCenter{}
+}
+
+// weeklyCallCenter — блок «Колл-ц»: весь колл-центр за период, без привязки к рекламному каналу.
+// Пациенты/услуги/выручка — совпадения колл-центра из уже посчитанной сводки отчёта, звонки и время — из АТС.
+func (a *API) weeklyCallCenter(ctx context.Context, clinic string, from, to time.Time, sum channelsSummary) (storage.WeeklyCallCenter, error) {
+ cc := weeklyCallCenterSales(sum)
+ if !a.pbxFor(clinic).Configured() {
+ return cc, nil
+ }
+ calls, err := a.store.PBXCalls(ctx, clinic, from, to)
+ if err != nil {
+ return cc, err
+ }
+ uniq := map[string]bool{}
+ var talkSum, waitSum, answered int
+ for _, c := range calls {
+ cc.Total++
+ switch {
+ case isOutgoing(c):
+ cc.Outgoing++
+ default:
+ cc.Incoming++
+ if c.Client != "" {
+ uniq[c.Client] = true
+ }
+ if isMissed(c) {
+ cc.Missed++
+ } else {
+ answered++
+ talkSum += c.Duration
+ waitSum += c.Wait
+ }
+ }
+ }
+ cc.Unique = len(uniq)
+ if cc.Incoming > 0 {
+ cc.MissedPct = float64(cc.Missed) / float64(cc.Incoming) * 100
+ }
+ if answered > 0 {
+ cc.TalkSec = talkSum / answered
+ cc.WaitSec = waitSum / answered
+ }
+ return cc, nil
+}
diff --git a/internal/api/adminui.go b/internal/api/adminui.go
new file mode 100644
index 0000000..1c9d857
--- /dev/null
+++ b/internal/api/adminui.go
@@ -0,0 +1,18 @@
+package api
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed adminui/*
+var adminUIFS embed.FS
+
+// adminFS отдаёт файлы интерфейса админки, вшитые в бинарник.
+func adminFS() fs.FS {
+ sub, err := fs.Sub(adminUIFS, "adminui")
+ if err != nil {
+ panic(err)
+ }
+ return sub
+}
diff --git a/internal/api/adminui/admin.css b/internal/api/adminui/admin.css
new file mode 100644
index 0000000..de2780e
--- /dev/null
+++ b/internal/api/adminui/admin.css
@@ -0,0 +1,1275 @@
+/* Админка DANA в визуальном языке референса BioTrack: воздушная оболочка на молочно-розовом
+ градиенте, белые карточки с большими скруглениями, органическая лента навигации. */
+
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 300; font-display: swap;
+ src: url(fonts/montserrat-alternates-cyrillic-300-normal.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 300; font-display: swap;
+ src: url(fonts/montserrat-alternates-latin-300-normal.woff2) format('woff2'); }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 400; font-display: swap;
+ src: url(fonts/montserrat-alternates-cyrillic-400-normal.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 400; font-display: swap;
+ src: url(fonts/montserrat-alternates-latin-400-normal.woff2) format('woff2'); }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 500; font-display: swap;
+ src: url(fonts/montserrat-alternates-cyrillic-500-normal.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 500; font-display: swap;
+ src: url(fonts/montserrat-alternates-latin-500-normal.woff2) format('woff2'); }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 600; font-display: swap;
+ src: url(fonts/montserrat-alternates-cyrillic-600-normal.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
+@font-face { font-family: 'Montserrat Alternates'; font-weight: 600; font-display: swap;
+ src: url(fonts/montserrat-alternates-latin-600-normal.woff2) format('woff2'); }
+
+:root {
+ --bg-milk: #f6f6f5;
+ --bg-cool: #eeeeef;
+ --bg-powder: #efcfd2;
+ --card: #ffffff;
+ --text: #33393b;
+ --muted: #929793;
+ --muted-2: #b5b8b6;
+ --line: #efefec;
+ --field: #f6f6f4;
+ --blue: #8dcbe9;
+ --blue-deep: #6cacED;
+ --logo-blue: #a7c9ed;
+ --mint: #bfe8d6;
+ --mint-text: #3f8f6d;
+ --rose: #e78c9f;
+ --rose-soft: #fbe3e8;
+ --rose-text: #b8566c;
+ --sky-soft: #dff0f8;
+ --sky-text: #3f86a8;
+ --butter: #f4e9b2;
+ --sage: #eaeef0;
+ --danger: #c2495d;
+ --warn: #b7813a;
+ --shadow: 0 10px 30px rgba(51, 57, 59, 0.05), 0 1px 2px rgba(51, 57, 59, 0.03);
+ --shadow-lift: 0 16px 40px rgba(51, 57, 59, 0.08);
+ --r-card: 26px;
+ --r-field: 14px;
+ /* Montserrat Alternates (шрифт сайта DANA) — для крупного: заголовки, цифры, логотип.
+ У него стилизованные кириллические «т» и «д», в мелком тексте они читаются плохо. */
+ --font-display: 'Montserrat Alternates', 'Segoe UI', system-ui, sans-serif;
+ --font: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
+ --ease: cubic-bezier(.22, .8, .24, 1);
+ /* совместимость с классами, которые использует код разделов */
+ --accent: var(--text);
+ --accent-dark: #1f2426;
+}
+
+* { box-sizing: border-box; }
+/* hidden должен побеждать display: grid/flex у бейджей, подсказок и выпадающих окон */
+[hidden] { display: none !important; }
+html { -webkit-text-size-adjust: 100%; }
+body {
+ margin: 0;
+ min-height: 100vh;
+ font: 400 14px/1.5 var(--font);
+ color: var(--text);
+ background:
+ radial-gradient(1200px 900px at 100% 0%, rgba(239, 207, 210, .95), transparent 62%),
+ radial-gradient(900px 700px at 105% 100%, rgba(240, 214, 216, .7), transparent 60%),
+ linear-gradient(115deg, #f9f9f8 0%, #f1f1f1 38%, #f1e6e6 70%, #efd3d5 100%);
+ background-attachment: fixed;
+}
+button, input, select, textarea { font: inherit; color: inherit; }
+a { color: inherit; }
+:focus-visible { outline: 2px solid var(--blue-deep); outline-offset: 2px; }
+.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+/* ---------- вход ---------- */
+#login {
+ max-width: 380px; margin: 12vh auto 0; padding: 34px 32px 28px;
+ background: var(--card); border-radius: 30px; box-shadow: var(--shadow);
+}
+#login h2 { margin: 18px 0 20px; font-family: var(--font-display); font-weight: 400; font-size: 26px; letter-spacing: -0.03em; }
+#login label { display: block; margin-bottom: 14px; }
+#login span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; }
+.err { color: var(--danger); margin-top: 10px; min-height: 20px; font-size: 13px; }
+
+/* ---------- каркас ---------- */
+#app { display: flex; min-height: 100vh; padding: 18px 22px 18px 16px; gap: 22px; }
+.shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+
+/* органическая лента навигации: белая форма с плавными «перетяжками» по контуру */
+.side {
+ position: sticky; top: 18px; align-self: flex-start;
+ /* Keep tooltip descendants above the adjacent page's stacking contexts. */
+ z-index: 50;
+ width: 76px; height: calc(100vh - 36px); min-height: 560px; flex-shrink: 0;
+ display: flex; flex-direction: column; align-items: center;
+ padding: 22px 0 18px;
+ filter: drop-shadow(0 12px 28px rgba(51, 57, 59, 0.06));
+}
+.side__shape { position: absolute; inset: 0; width: 100%; height: 100%; z-index: -1; }
+.side__logo {
+ font-family: var(--font-display);
+ display: flex; flex-direction: column; align-items: center; line-height: .92;
+ font-weight: 600; font-size: 17px; letter-spacing: -0.04em; text-decoration: none;
+}
+.side__logo b { color: var(--logo-blue); font-weight: 600; }
+.side__logo span { color: var(--text); font-size: 13px; font-weight: 500; letter-spacing: -0.02em; }
+.side__nav { display: flex; flex-direction: column; gap: 14px; margin-top: 78px; }
+.side__foot { margin-top: auto; padding-top: 60px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
+.side__btn {
+ position: relative; width: 46px; height: 46px; border: 0; border-radius: 16px;
+ display: grid; place-items: center; background: transparent; color: #7e8481; cursor: pointer;
+ transition: background-color .25s var(--ease), color .25s var(--ease);
+}
+.side__btn svg { width: 21px; height: 21px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
+.side__btn:hover { color: var(--text); background: #f3f5f5; }
+.side__btn[aria-current="page"] { background: var(--sage); color: var(--text); }
+.side__badge {
+ position: absolute; top: 6px; right: 6px; min-width: 16px; height: 16px; padding: 0 4px;
+ border-radius: 8px; background: var(--rose); color: #fff; font-size: 9px; font-weight: 600;
+ display: grid; place-items: center; line-height: 1;
+}
+.side__btn .tip {
+ position: absolute; left: calc(100% + 12px); top: 50%; transform: translate(-4px, -50%);
+ padding: 7px 12px; border-radius: 12px; background: var(--card); color: var(--text);
+ font-size: 12px; white-space: nowrap; box-shadow: var(--shadow-lift);
+ opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s var(--ease); z-index: 30;
+}
+.side__btn:hover .tip, .side__btn:focus-visible .tip { opacity: 1; transform: translate(0, -50%); }
+.switch {
+ position: relative; width: 46px; height: 26px; border: 0; border-radius: 13px; padding: 0;
+ background: #e3e6e5; cursor: pointer; transition: background-color .25s;
+}
+.switch::after {
+ content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%;
+ background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.12); transition: transform .25s var(--ease);
+}
+.switch[aria-checked="true"] { background: var(--blue-deep); }
+.switch[aria-checked="true"]::after { transform: translateX(20px); }
+.switch .tip { left: calc(100% + 12px); }
+.switch:hover .tip, .switch:focus-visible .tip { opacity: 1; transform: translate(0, -50%); }
+.switch .tip {
+ position: absolute; top: 50%; transform: translate(-4px, -50%); padding: 7px 12px; border-radius: 12px;
+ background: var(--card); color: var(--text); font-size: 12px; white-space: nowrap; box-shadow: var(--shadow-lift);
+ opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s var(--ease); z-index: 30;
+}
+
+/* ---------- верхняя панель ---------- */
+.top { display: flex; align-items: center; gap: 12px; min-height: 58px; padding-left: 4px; position: relative; z-index: 20; }
+.search { position: relative; width: min(330px, 40vw); }
+.search__shape { display: flex; align-items: center; }
+.search input {
+ flex: 1; height: 42px; border: 0; border-radius: 21px 0 0 21px; padding: 0 18px;
+ background: var(--card); font-size: 12px; outline: none; min-width: 0;
+}
+.search input::placeholder { color: var(--muted); }
+/* «перетяжка» между полем и кругом с лупой, как у капсулы поиска в референсе */
+.search__neck { width: 22px; height: 42px; flex-shrink: 0; display: block; }
+.search__btn {
+ width: 46px; height: 46px; margin-left: -4px; border-radius: 50%; border: 0; cursor: pointer;
+ background: var(--card); display: grid; place-items: center; flex-shrink: 0;
+}
+.search__btn span { width: 34px; height: 34px; border-radius: 50%; background: var(--sage); display: grid; place-items: center; }
+.search__btn svg, .round svg { width: 17px; height: 17px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.search__results {
+ position: absolute; left: 0; top: calc(100% + 8px); width: min(460px, 86vw); max-height: 60vh; overflow: auto;
+ background: var(--card); border-radius: 20px; box-shadow: var(--shadow-lift); padding: 8px;
+}
+.search__results button {
+ display: flex; width: 100%; gap: 10px; align-items: baseline; text-align: left;
+ border: 0; background: none; padding: 9px 12px; border-radius: 12px; cursor: pointer;
+}
+.search__results button:hover, .search__results button:focus-visible { background: var(--field); }
+.search__results small { color: var(--muted); font-size: 11px; margin-left: auto; white-space: nowrap; }
+.search__empty { padding: 12px; color: var(--muted); font-size: 12px; }
+.top__spacer { flex: 1; }
+.round {
+ position: relative; width: 44px; height: 44px; border-radius: 50%; border: 0; cursor: pointer;
+ background: var(--card); display: grid; place-items: center; transition: box-shadow .25s, transform .25s var(--ease);
+}
+.round:hover { box-shadow: var(--shadow-lift); transform: translateY(-1px); }
+.round__dot { position: absolute; top: 10px; right: 11px; width: 7px; height: 7px; border-radius: 50%; background: var(--rose); box-shadow: 0 0 0 2px #fff; }
+.who { display: flex; align-items: center; gap: 12px; margin-left: 18px; }
+.who__avatar {
+ width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center;
+ background: linear-gradient(135deg, #dcebf5, #f4d9de); color: var(--text); font-weight: 500; font-size: 14px;
+ box-shadow: 0 0 0 3px #fff;
+}
+.who__name { font-weight: 500; font-size: 14px; line-height: 1.2; }
+.who__meta { color: var(--muted); font-size: 11px; margin-top: 3px; display: flex; gap: 10px; }
+.popover {
+ position: absolute; top: calc(100% + 10px); right: 0; width: 320px; max-height: 70vh; overflow: auto;
+ background: var(--card); border-radius: 22px; box-shadow: var(--shadow-lift); padding: 16px; z-index: 40;
+}
+.popover h4 { margin: 2px 4px 10px; font-weight: 500; font-size: 13px; }
+.popover__item {
+ display: flex; gap: 12px; align-items: center; width: 100%; padding: 10px; border: 0; background: none;
+ border-radius: 14px; text-align: left; cursor: pointer;
+}
+.popover__item:hover { background: var(--field); }
+.popover__item b { font-family: var(--font-display); font-weight: 500; font-size: 18px; min-width: 32px; letter-spacing: -0.02em; }
+.popover__item span { font-size: 12px; color: var(--text); }
+.popover__item small { display: block; color: var(--muted); font-size: 11px; }
+.popover__empty { color: var(--muted); font-size: 12px; padding: 8px 4px; }
+
+/* ---------- переключатель подразделов ---------- */
+.subtabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 18px; padding: 5px; width: fit-content; max-width: 100%;
+ background: rgba(255, 255, 255, .6); border-radius: 18px; backdrop-filter: blur(6px); }
+.subtabs:empty { display: none; }
+.subtabs button { border: 0; background: none; padding: 8px 16px; border-radius: 14px; cursor: pointer; color: var(--muted); font-size: 13px; }
+.subtabs button:hover { color: var(--text); }
+.subtabs button[aria-current="page"] { background: linear-gradient(100deg, #f08a3c, #f6a45e); color: #fff; box-shadow: 0 6px 16px rgba(240, 138, 60, .28); }
+.page-title { margin: 10px 0 4px; font-family: var(--font-display); font-weight: 500; font-size: 40px; letter-spacing: -0.04em; line-height: 1.05; }
+main { flex: 1; min-width: 0; }
+
+/* ---------- карточки, таблицы, формы (все разделы) ---------- */
+.card {
+ background: var(--card); border-radius: var(--r-card); padding: 22px 24px; margin-bottom: 18px;
+ box-shadow: var(--shadow); border: 0;
+}
+table { width: 100%; border-collapse: collapse; }
+th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
+th { white-space: nowrap; font-size: 11px; font-weight: 500; color: var(--muted); letter-spacing: 0; text-transform: none; }
+tr:last-child td { border-bottom: 0; }
+tbody tr { transition: background-color .2s; }
+tbody tr:hover { background: #fafaf8; }
+input, select, textarea {
+ padding: 9px 13px; border: 1px solid #ebebe7; border-radius: var(--r-field); background: #fff; width: 100%;
+ transition: border-color .2s, box-shadow .2s;
+}
+input:focus, select:focus, textarea:focus { outline: none; border-color: #d3d7d5; box-shadow: 0 0 0 4px rgba(141, 203, 233, .22); }
+/* Native controls retain keyboard/form behaviour; only their presentation changes. */
+input[type=checkbox], .rp-source-opt input[type=radio] {
+ appearance: none; width: 20px; min-width: 20px; height: 20px; flex: 0 0 20px;
+ padding: 0; margin: 0; border: 1.5px solid #c7d3df; border-radius: 6px;
+ background: #fff center / 14px 14px no-repeat; vertical-align: middle;
+ cursor: pointer; transition: background-color .18s, border-color .18s, box-shadow .18s;
+}
+.rp-source-opt input[type=radio] { border-radius: 50%; }
+input[type=checkbox]:checked, .rp-source-opt input[type=radio]:checked {
+ border-color: #4389e9; background-color: #4389e9;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m3.5 8 3 3 6-6' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+}
+input[type=checkbox]:indeterminate {
+ border-color: #4389e9; background-color: #4389e9;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8h8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
+}
+input[type=checkbox]:not(:disabled):hover, .rp-source-opt input[type=radio]:not(:disabled):hover { border-color: #4389e9; }
+input[type=checkbox]:focus-visible, .rp-source-opt input[type=radio]:focus-visible {
+ outline: 2px solid #4389e9; outline-offset: 3px; box-shadow: 0 0 0 4px #e9f2ff;
+}
+input[type=checkbox]:disabled, .rp-source-opt input[type=radio]:disabled { opacity: .45; cursor: not-allowed; }
+@media (forced-colors: active) {
+ input[type=checkbox], .rp-source-opt input[type=radio] { appearance: auto; background: none; accent-color: auto; }
+}
+
+button.btn {
+ background: var(--text); color: #fff; border: 0; padding: 9px 18px; border-radius: 999px; cursor: pointer;
+ font-size: 13px; transition: background-color .2s, transform .2s var(--ease), box-shadow .2s;
+}
+button.btn:hover { background: var(--accent-dark); box-shadow: 0 6px 16px rgba(51, 57, 59, .16); }
+button.btn:active { transform: translateY(1px); }
+button.btn.ghost { background: #fff; color: var(--text); box-shadow: inset 0 0 0 1px #e7e8e4; }
+button.btn.ghost:hover { background: var(--field); box-shadow: inset 0 0 0 1px #dcddd9; }
+button.btn.danger { background: #fff; color: var(--danger); box-shadow: inset 0 0 0 1px #f0dfe2; }
+button.btn.danger:hover { background: var(--rose-soft); }
+button.btn:disabled { opacity: .45; cursor: default; box-shadow: none; }
+.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
+.toolbar input[type=search] { max-width: 320px; }
+.toolbar select { max-width: 280px; }
+.spacer { flex: 1; }
+.muted { color: var(--muted); }
+.right { text-align: right; white-space: nowrap; }
+.pill {
+ display: inline-block; white-space: nowrap; font-size: 11px; padding: 3px 10px; border-radius: 999px;
+ background: #e6f6ee; color: var(--mint-text);
+}
+.pill.off { background: var(--rose-soft); color: var(--rose-text); }
+.pill.wait { background: #fbf3d9; color: var(--warn); }
+.pill.sky { background: var(--sky-soft); color: var(--sky-text); }
+.pill.grey { background: #eeefed; color: var(--muted); }
+.dir-row td { background: #fafaf7; font-weight: 500; }
+.note { margin: 0 0 14px; color: var(--muted); font-size: 12px; line-height: 1.55; }
+.warn { color: var(--warn); font-size: 12px; }
+h3.sub { font-weight: 500; font-size: 15px; margin: 2px 0 12px; }
+.pager { display: flex; gap: 10px; align-items: center; justify-content: flex-end; margin-top: 14px; }
+.num { width: 110px; } .sortnum { width: 70px; }
+.toast {
+ position: fixed; bottom: 22px; right: 22px; background: var(--text); color: #fff; padding: 12px 18px;
+ border-radius: 16px; opacity: 0; transition: .25s; pointer-events: none; z-index: 100; font-size: 13px;
+}
+.toast.show { opacity: 1; }
+.toast.bad { background: var(--danger); }
+
+dialog { border: 0; border-radius: 28px; padding: 0; max-width: 580px; width: 92%; box-shadow: 0 30px 80px rgba(51, 57, 59, .18); color: var(--text); }
+dialog.wide { max-width: 940px; }
+dialog::backdrop { background: rgba(240, 230, 231, .55); backdrop-filter: blur(6px); }
+.dlg-head { padding: 22px 26px 8px; font-family: var(--font-display); font-weight: 500; font-size: 17px; letter-spacing: -0.02em; }
+.dlg-body { padding: 14px 26px; max-height: 70vh; overflow: auto; }
+.dlg-foot { padding: 16px 26px 22px; display: flex; gap: 10px; justify-content: flex-end; }
+.field { margin-bottom: 14px; }
+.field span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; }
+.row2 { display: flex; gap: 12px; } .row2 > * { flex: 1; }
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; max-width: 900px; }
+.form-grid .wide { grid-column: 1 / -1; }
+.img-field { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.img-preview { width: 96px; height: 72px; object-fit: cover; border-radius: 14px; background: var(--field); }
+.img-field input[type=file] { width: auto; flex: 1; }
+.file-link { font-size: 13px; word-break: break-all; }
+.rv-text { white-space: pre-line; max-height: 4.6em; overflow: hidden; }
+.logo-cell img { max-width: 90px; max-height: 40px; object-fit: contain; }
+.doc-photo { width: 44px; height: 52px; object-fit: cover; border-radius: 14px; background: var(--field); display: block; }
+.checklist { border: 1px solid #ebebe7; border-radius: var(--r-field); overflow: hidden; }
+.checklist input[type=search] { border: 0; border-bottom: 1px solid var(--line); border-radius: 0; }
+.checklist-box { max-height: 340px; overflow: auto; padding: 6px 12px; }
+.checklist-group { font-weight: 500; margin: 12px 0 4px; display: flex; align-items: center; gap: 8px; }
+.checklist label { display: flex; gap: 8px; align-items: flex-start; padding: 3px 0 3px 16px; cursor: pointer; }
+.checklist label input { width: auto; margin-top: 3px; }
+.checklist .count { font-weight: 400; color: var(--muted); font-size: 11px; }
+.rte { border: 1px solid #ebebe7; border-radius: var(--r-field); background: #fff; overflow: hidden; }
+.rte-bar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); background: #fbfbfa; position: sticky; top: -14px; z-index: 1; }
+.rte-bar button { font-size: 12px; padding: 5px 10px; border: 0; border-radius: 10px; background: #fff; box-shadow: inset 0 0 0 1px #ebebe7; cursor: pointer; }
+.rte-bar button:hover { box-shadow: inset 0 0 0 1px var(--text); }
+.rte-bar .sep { width: 1px; background: var(--line); margin: 2px 4px; }
+.rte-area { min-height: 320px; padding: 14px 16px; outline: none; line-height: 1.65; font-family: 'Segoe UI', system-ui, sans-serif; }
+.rte-area:focus { box-shadow: inset 0 0 0 2px rgba(141, 203, 233, .5); }
+.rte-area h2 { font-size: 19px; margin: 18px 0 8px; } .rte-area h3 { font-size: 16px; margin: 14px 0 6px; }
+.rte-area p { margin: 0 0 10px; } .rte-area img { max-width: 100%; height: auto; border-radius: 12px; }
+.rte-area a { color: var(--sky-text); }
+.rte-src { display: block; width: 100%; min-height: 320px; border: 0; border-radius: 0; font: 12px/1.5 ui-monospace, Consolas, monospace; }
+
+/* ---------- главный экран ---------- */
+.dash { display: grid; grid-template-columns: minmax(0, 40fr) minmax(0, 60fr); gap: 10px 26px; align-items: start; }
+.dash__heart { position: relative; min-height: 640px; }
+.dash__right { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
+
+.summary { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 38px 6px 12px; flex-wrap: wrap; }
+.summary__title {
+ margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(46px, 5.4vw, 78px); line-height: .94; letter-spacing: -0.065em;
+}
+.summary__stats { display: flex; gap: clamp(18px, 3vw, 40px); padding-bottom: 14px; }
+.stat { background: none; border: 0; padding: 0; text-align: center; cursor: pointer; }
+.stat b { display: block; font-family: var(--font-display); font-weight: 500; font-size: clamp(22px, 2.1vw, 30px); letter-spacing: -0.04em; line-height: 1.1; }
+.stat span { display: block; color: var(--muted); font-size: 11px; margin-top: 4px; }
+.stat:hover b { color: #000; }
+
+.heart { position: absolute; inset: 0 0 0 0; }
+.heart__img { position: absolute; left: 4%; top: 7%; width: 88%; max-width: 560px; height: auto; object-fit: contain;
+ filter: drop-shadow(0 30px 40px rgba(150, 40, 50, .18)); }
+.heart__missing {
+ position: absolute; left: 6%; top: 12%; width: 80%; aspect-ratio: 5 / 6; border-radius: 48% 52% 46% 54% / 40% 42% 58% 60%;
+ border: 1.5px dashed #dcc4c8; background: radial-gradient(circle at 45% 40%, rgba(255,255,255,.7), rgba(255,255,255,.15) 70%);
+ display: grid; place-items: center; text-align: center; padding: 16%; color: var(--muted); font-size: 12px; line-height: 1.5;
+}
+.heart__missing > div { max-width: 52%; margin: 0 22% 8% 0; }
+.heart__missing b { display: block; color: var(--text); font-weight: 500; font-size: 14px; margin-bottom: 6px; }
+.heart__missing code { font-size: 11px; background: #fff; padding: 1px 6px; border-radius: 6px; }
+.marker {
+ position: absolute; width: 16px; height: 16px; margin: -8px 0 0 -8px; border-radius: 50%;
+ background: #fff; box-shadow: 0 0 0 7px rgba(255, 255, 255, .38), 0 2px 8px rgba(0,0,0,.12); z-index: 2;
+}
+.marker::after { content: ''; position: absolute; inset: -7px; border-radius: 50%; border: 1px solid rgba(255,255,255,.7); animation: pulse 3.2s var(--ease) infinite; }
+.marker__line { position: absolute; height: 1px; background: rgba(255,255,255,.75); transform-origin: 0 50%; z-index: 1; }
+@keyframes pulse { 0% { transform: scale(.8); opacity: .9; } 70% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } }
+.vital {
+ position: absolute; z-index: 3; min-width: 124px; padding: 14px 18px 13px; border: 0; cursor: pointer;
+ background: rgba(255, 255, 255, .94); border-radius: 22px; box-shadow: var(--shadow); text-align: center;
+ transition: transform .3s var(--ease), box-shadow .3s;
+}
+.vital:hover { transform: translateY(-3px); box-shadow: var(--shadow-lift); }
+.vital__label { display: block; color: var(--muted); font-size: 11px; }
+.vital__value { display: block; font-family: var(--font-display); font-weight: 400; font-size: 24px; letter-spacing: -0.04em; line-height: 1.25; margin-top: 2px; }
+.vital__unit { display: block; font-size: 11px; color: var(--text); }
+
+.trends { padding: 22px 26px 14px; }
+.trends__head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
+.trends__head h2 { margin: 0; font-weight: 500; font-size: 13px; }
+.trends__demo { font-size: 11px; color: var(--warn); background: #fbf3d9; border-radius: 999px; padding: 2px 10px; }
+.trends__legend { margin-left: auto; display: flex; gap: 14px; font-size: 11px; color: var(--muted); }
+.trends__legend i { display: inline-block; width: 18px; height: 8px; border-radius: 4px; margin-right: 6px; vertical-align: middle; }
+.chart { position: relative; margin-top: 10px; }
+.chart svg { display: block; width: 100%; height: auto; overflow: visible; }
+.chart__line { fill: none; stroke: #8b8f8d; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.chart__axis { font-size: 15px; fill: #a9ada9; font-family: var(--font); }
+.chart__frame { fill: none; stroke: #e2e3e0; stroke-width: 1; stroke-dasharray: 3 4; }
+.chart__label { font-size: 10px; fill: var(--muted); font-family: var(--font); }
+.chart__hit { fill: transparent; cursor: crosshair; }
+.chart__dot { fill: #fff; stroke: var(--text); stroke-width: 1.5; }
+.chart__guide { stroke: #d9dad7; stroke-width: 1; stroke-dasharray: 2 3; }
+.tooltip {
+ position: absolute; pointer-events: none; transform: translate(-50%, calc(-100% - 12px)); white-space: nowrap;
+ background: var(--card); border-radius: 14px; padding: 8px 12px; font-size: 11px; box-shadow: var(--shadow-lift); z-index: 5;
+}
+.tooltip b { display: block; font-weight: 500; font-size: 13px; }
+
+.leads-card { padding: 20px 22px 10px; overflow-x: auto; }
+.leads-table th { padding-top: 4px; }
+.leads-table td { font-size: 12.5px; padding: 12px; }
+.leads-table tbody tr { cursor: pointer; }
+.leads-table td small { display: block; font-size: 10px; color: var(--muted); }
+.leads-table .strong { font-weight: 500; }
+.leads-table .soft { color: var(--muted); }
+.card-link { border: 0; background: none; padding: 0; color: var(--muted); font-size: 12px; cursor: pointer; margin-left: auto; }
+.card-link:hover { color: var(--text); }
+.lead-detail dt { color: var(--muted); font-size: 11px; margin-top: 10px; }
+.lead-detail dd { margin: 2px 0 0; }
+.lead-detail { margin: 0 0 14px; }
+
+/* ---------- шапка: дата у календаря, подзаголовок раздела ---------- */
+.round--date { width: auto; border-radius: 22px; padding: 0 16px 0 14px; gap: 10px; grid-auto-flow: column; }
+.round--date svg { width: 19px; height: 19px; }
+.cal-date { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.2; text-align: left; }
+.cal-date b { font-weight: 500; font-size: 13px; white-space: nowrap; }
+.cal-date small { color: var(--muted); font-size: 11px; }
+.page-sub { margin: 2px 0 18px; color: var(--muted); font-size: 15px; }
+
+/* ---------- раздел «Врачи» ---------- */
+.dr-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 20px; align-items: start; }
+.dr-card { padding: 18px 18px 8px; }
+.dr-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
+.dr-search { position: relative; flex: 1 1 280px; max-width: 400px; }
+.dr-search > span { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); display: grid; pointer-events: none; }
+.dr-search svg { width: 17px; height: 17px; stroke: var(--muted); fill: none; stroke-width: 1.6; stroke-linecap: round; }
+.dr-search input { height: 44px; padding-left: 40px; border-radius: 14px; }
+#dr-filter { height: 44px; width: auto; min-width: 220px; border-radius: 14px; }
+.dr-count { color: var(--muted); font-size: 14px; }
+.dr-note {
+ display: flex; gap: 12px; align-items: center; margin-bottom: 10px; padding: 10px 12px 10px 14px;
+ background: #eef5fc; border-radius: 14px; color: #5b6b78; font-size: 12px; line-height: 1.5;
+}
+.dr-note p { margin: 0; flex: 1; }
+.dr-note__icon { display: grid; flex-shrink: 0; }
+.dr-note__icon svg { width: 20px; height: 20px; fill: #5b9cf0; stroke: #fff; stroke-width: 1.8; stroke-linecap: round; }
+.dr-note__close { border: 0; background: none; width: 30px; height: 30px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; flex-shrink: 0; }
+.dr-note__close:hover { background: rgba(255, 255, 255, .8); }
+.dr-note__close svg { width: 16px; height: 16px; stroke: var(--text); fill: none; stroke-width: 1.8; stroke-linecap: round; }
+.dr-scroll { overflow-x: auto; }
+.dr-table { min-width: 860px; }
+.dr-table thead th { background: #f8f9fa; padding-top: 12px; padding-bottom: 12px; font-size: 12px; }
+.dr-table thead th:first-child { border-radius: 12px 0 0 12px; }
+.dr-table thead th:last-child { border-radius: 0 12px 12px 0; }
+.dr-table th button { border: 0; background: none; padding: 0; font: inherit; color: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; }
+.dr-table th button svg { width: 14px; height: 14px; stroke: var(--muted-2); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.dr-table th[aria-sort="ascending"] button, .dr-table th[aria-sort="descending"] button { color: var(--text); }
+.dr-table th[aria-sort="ascending"] svg, .dr-table th[aria-sort="descending"] svg { stroke: var(--text); }
+.dr-table td { padding: 10px; }
+.dr-who { display: flex; gap: 14px; align-items: center; min-width: 240px; }
+.dr-photo { width: 52px; height: 52px; border-radius: 14px; object-fit: cover; object-position: 50% 15%; background: var(--field); flex-shrink: 0; }
+.dr-photo--empty { display: grid; place-items: center; color: var(--muted); font-size: 13px; font-weight: 500; }
+.dr-name { border: 0; background: none; padding: 0; text-align: left; font-weight: 500; font-size: 14px; cursor: pointer; }
+.dr-name:hover { color: #3a7bd5; }
+.dr-pos { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
+.chip { display: inline-block; white-space: nowrap; padding: 6px 12px; border-radius: 10px; background: #f3f4f5; font-size: 12px; }
+.dr-num { font-weight: 500; }
+.dr-branches { font-size: 12px; color: var(--muted); display: inline-block; max-width: 200px; }
+.pill.warn-pill { background: #fdf0e3; color: #d0772b; padding: 5px 12px; }
+.pill.dot { padding: 5px 12px 5px 10px; }
+.pill.dot::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; margin-right: 7px; vertical-align: 1px; }
+.dr-actions { white-space: nowrap; text-align: right; }
+.dr-actions > * { vertical-align: middle; }
+.soft-btn {
+ display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 0; border-radius: 12px;
+ background: #edf4fd; color: #3a6fb7; font-size: 12.5px; cursor: pointer; transition: background-color .2s;
+}
+.soft-btn:hover { background: #e0ecfb; }
+.soft-btn svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
+.icon-btn {
+ width: 38px; height: 38px; margin-left: 6px; border-radius: 50%; border: 1px solid #ebecea; background: #fff; cursor: pointer;
+ display: inline-grid; place-items: center; transition: background-color .2s, border-color .2s;
+}
+.icon-btn:hover { background: var(--field); border-color: #dcddd9; }
+.icon-btn svg { width: 16px; height: 16px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.icon-btn svg circle { fill: var(--text); stroke: none; }
+.dr-empty { padding: 28px 12px; text-align: center; }
+.menu { position: absolute; z-index: 60; min-width: 230px; padding: 6px; background: var(--card); border-radius: 16px; box-shadow: var(--shadow-lift); }
+.menu button { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 9px 12px; border-radius: 10px; cursor: pointer; font-size: 13px; }
+.menu button:hover, .menu button:focus-visible { background: var(--field); outline: none; }
+.menu button.danger { color: var(--danger); }
+
+.dr-side { display: flex; flex-direction: column; gap: 18px; position: sticky; top: 18px; }
+.dr-side .card { margin-bottom: 0; }
+.dr-quick { padding: 20px 18px; }
+.dr-quick h2 { margin: 0 0 14px 4px; font-weight: 600; font-size: 16px; }
+.quick {
+ display: flex; align-items: center; gap: 12px; width: 100%; height: 48px; padding: 0 16px; margin-top: 10px;
+ border: 1px solid #f0f1ef; border-radius: 14px; background: #fff; cursor: pointer; font-size: 14px; text-align: left;
+ transition: background-color .2s, box-shadow .2s, transform .2s var(--ease);
+}
+.quick:first-of-type { margin-top: 0; }
+.quick:hover { background: var(--field); }
+.quick svg { width: 20px; height: 20px; stroke: #3a6fb7; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
+.quick.primary { border: 0; color: #fff; background: linear-gradient(100deg, #3f86f2, #6aa8f5); box-shadow: 0 10px 22px rgba(63, 134, 242, .28); }
+.quick.primary:hover { box-shadow: 0 14px 26px rgba(63, 134, 242, .36); transform: translateY(-1px); }
+.quick.primary svg { stroke: #fff; }
+.dr-summary { padding: 20px 18px 18px; }
+.dr-summary__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.dr-summary__head span { font-size: 14px; }
+.dr-summary__head b { display: block; font-family: var(--font-display); font-weight: 500; font-size: 32px; letter-spacing: -0.04em; line-height: 1.1; }
+.dr-summary__icon { width: 56px; height: 56px; border-radius: 50%; background: #eaf2fd; display: grid; place-items: center; }
+.dr-summary__icon svg { width: 28px; height: 28px; fill: #5b9cf0; stroke: #5b9cf0; stroke-width: 1; }
+.dr-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
+.mini-stat { border: 0; border-radius: 14px; padding: 10px 10px 8px; text-align: left; cursor: pointer; transition: box-shadow .2s, transform .2s var(--ease); }
+.mini-stat span { display: flex; align-items: center; gap: 5px; font-size: 11px; white-space: nowrap; }
+.mini-stat i { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
+.mini-stat b { display: block; font-family: var(--font-display); font-weight: 500; font-size: 22px; letter-spacing: -0.03em; margin-top: 4px; color: var(--text); }
+.mini-stat:hover { transform: translateY(-1px); }
+.mini-stat[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px currentColor; }
+.mini-stat.ok { background: #eaf7f0; color: #2f7d5a; } .mini-stat.ok i { background: #3fb27f; }
+.mini-stat.warn { background: #fdf2e6; color: #b76a24; } .mini-stat.warn i { background: #f0913a; }
+.mini-stat.rose { background: #fdeef1; color: #b8566c; } .mini-stat.rose i { background: #e8708a; }
+
+.sp-dlg { max-width: 620px; }
+.sp-row { display: flex; align-items: center; gap: 8px; padding: 10px 4px; border-bottom: 1px solid var(--line); }
+.sp-row:last-child { border-bottom: 0; }
+.sp-row > div { flex: 1; min-width: 0; }
+.sp-row b { font-weight: 500; display: block; }
+.sp-row small { color: var(--muted); font-size: 11.5px; }
+.sp-row .icon-btn { margin-left: 0; }
+
+@media (max-width: 1280px) {
+ .dr-layout { grid-template-columns: minmax(0, 1fr); }
+ .dr-side { position: static; display: grid; grid-template-columns: 1fr 1fr; order: -1; }
+}
+@media (max-width: 760px) {
+ .dr-side { grid-template-columns: 1fr; }
+ .round--date .cal-date { display: none; }
+ .round--date { width: 44px; padding: 0; }
+}
+
+/* ---------- «Слайды на главной» ---------- */
+.bn-hero {
+ position: relative; overflow: hidden; min-height: 260px; margin: 4px 0 -34px; padding: 34px 28px 64px;
+ border-radius: 30px 30px 0 0;
+ background: linear-gradient(100deg, rgba(246, 248, 252, 1) 0%, rgba(233, 241, 250, .9) 45%, rgba(214, 229, 246, .7) 100%);
+}
+.bn-hero.has-photo { background: url(assets/hall.webp) right 42% / 68% auto no-repeat, #f5f7fa; }
+.bn-hero--services.has-photo { background: url(assets/lobby.webp) right 48% / 68% auto no-repeat, #f5f7fa; }
+/* левая часть остаётся светлой: текст читается поверх фото */
+.bn-hero::before {
+ content: ""; position: absolute; inset: 0; pointer-events: none;
+ background: linear-gradient(90deg, rgba(245, 247, 250, 1) 0%, rgba(245, 247, 250, 1) 32%, rgba(245, 247, 250, .6) 40%, rgba(245, 247, 250, 0) 50%);
+}
+.bn-hero::after {
+ content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 70px; pointer-events: none;
+ background: linear-gradient(to bottom, rgba(247, 248, 250, 0), rgba(247, 248, 250, .9));
+}
+.bn-hero > * { position: relative; z-index: 1; }
+.bn-hero__text { max-width: 520px; }
+.bn-hero h1 { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(34px, 3.6vw, 52px); letter-spacing: -0.05em; line-height: 1.02; }
+.bn-hero__sub { margin: 6px 0 14px; font-size: clamp(16px, 1.4vw, 20px); color: #4d5558; }
+.bn-hero__note { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
+.bn-add {
+ position: absolute; top: 0; right: 0; display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 26px 0 22px;
+ border: 0; border-radius: 0 0 0 22px; color: #fff; font-size: 14px; cursor: pointer;
+ background: linear-gradient(100deg, #3f86f2, #6aa8f5); box-shadow: 0 10px 22px rgba(63, 134, 242, .28);
+ transition: box-shadow .2s, filter .2s;
+}
+.bn-add:hover { box-shadow: 0 14px 26px rgba(63, 134, 242, .36); filter: brightness(1.04); }
+.bn-add svg { width: 18px; height: 18px; stroke: #fff; fill: none; stroke-width: 1.8; stroke-linecap: round; }
+.bn-card { position: relative; z-index: 2; padding: 18px 18px 10px; }
+.bn-toolbar { display: flex; align-items: center; gap: 12px; margin: 0 4px 12px; flex-wrap: wrap; }
+.bn-toolbar b { font-weight: 500; font-size: 15px; }
+.bn-toolbar span { font-size: 12px; }
+#bn-filter { width: auto; min-width: 220px; height: 42px; border-radius: 14px; }
+.bn-table { min-width: 1000px; }
+.bn-table thead th { font-size: 12px; }
+.bn-table td { padding: 10px 8px; }
+.bn-table tbody tr.dragging { opacity: .45; }
+.bn-table tbody tr.drop td { box-shadow: inset 0 2px 0 var(--blue-deep); }
+.grip { border: 0; background: none; width: 28px; height: 36px; border-radius: 8px; cursor: grab; display: grid; place-items: center; color: var(--muted); }
+.grip:hover { background: var(--field); color: var(--text); }
+.grip svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; }
+.bn-thumb { width: 220px; aspect-ratio: 20 / 7; border-radius: 12px; overflow: hidden; background: var(--field); position: relative; }
+.bn-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.bn-thumb--made { color: #fff; padding: 10px 12px; display: flex; flex-direction: column; justify-content: center; }
+.bn-thumb--made b { font-weight: 600; font-size: 13px; line-height: 1.15; max-width: 62%; }
+.bn-thumb--made small { font-size: 10px; opacity: .85; margin-top: 4px; max-width: 62%; }
+.bn-thumb__doc { position: absolute; right: 0; bottom: 0; width: 40% !important; height: 100% !important; object-fit: cover; object-position: 50% 20%; }
+.bn-title b { font-weight: 500; font-size: 13.5px; display: block; max-width: 220px; }
+.bn-small { font-size: 12.5px; }
+.bn-link { max-width: 170px; word-break: break-all; }
+.bn-dates { white-space: pre-line; }
+.bn-sort { display: inline-grid; place-items: center; min-width: 40px; height: 34px; padding: 0 8px; border-radius: 10px; background: #f3f4f5; font-size: 13px; }
+.pill.bn-pill { padding: 7px 14px; font-size: 12px; }
+.bn-edit {
+ display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 12px; border: 1px solid #e7e8e4;
+ background: #fff; cursor: pointer; font-size: 13px; transition: background-color .2s;
+}
+.bn-edit:hover { background: var(--field); }
+.bn-edit svg { width: 16px; height: 16px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+
+@media (max-width: 900px) {
+ .bn-hero { padding: 26px 20px 60px; }
+ .bn-hero::before { background: linear-gradient(90deg, rgba(247, 248, 250, 1) 0%, rgba(247, 248, 250, .9) 55%, rgba(247, 248, 250, .4) 100%); }
+ .bn-thumb { width: 170px; }
+}
+@media (max-width: 760px) {
+ .bn-add { position: relative; border-radius: 999px; margin-top: 18px; }
+ .bn-hero { border-radius: 24px 24px 0 0; }
+}
+
+/* ---------- «Услуги»: направления-аккордеон ---------- */
+.hero-subtabs .subtabs { margin: 18px 0 0; background: rgba(255, 255, 255, .75); }
+.bn-hero--services { padding-bottom: 58px; }
+/* общие шапки с фото у разделов с вкладками («Контент сайта», «Отзывы», «Настройки») */
+.bn-hero--group { padding-bottom: 58px; }
+.bn-hero--lobby.has-photo { background: url(assets/lobby.webp) right 48% / 68% auto no-repeat, #f5f7fa; }
+.hero-body > .card:first-child { position: relative; z-index: 2; }
+.sv-card { padding: 18px 18px 14px; }
+.sv-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
+.sv-toolbar select { height: 44px; width: auto; min-width: 190px; border-radius: 14px; }
+.sv-count { color: var(--muted); font-size: 13px; }
+.sv-count u { text-decoration-color: #c9ccc8; text-underline-offset: 3px; color: var(--text); }
+.sv-toolbar .bn-edit svg { width: 17px; height: 17px; }
+.sv-group { border: 1px solid #f0f1ef; border-radius: 20px; margin-bottom: 10px; background: #fcfcfc; transition: box-shadow .2s, background-color .2s; }
+.sv-group.open { background: #fff; box-shadow: 0 8px 22px rgba(51, 57, 59, .05); padding-bottom: 8px; }
+.sv-top { display: flex; align-items: center; gap: 6px; padding-right: 12px; }
+.sv-head { flex: 1; display: flex; align-items: center; gap: 16px; padding: 10px 12px 10px 10px; border: 0; background: none; cursor: pointer; text-align: left; border-radius: 20px; min-width: 0; }
+.sv-icon { width: 58px; height: 58px; border-radius: 16px; display: grid; place-items: center; flex-shrink: 0; }
+.sv-icon svg { width: 28px; height: 28px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
+.sv-head__text { flex: 1; min-width: 0; }
+.sv-head__text b { display: block; font-weight: 600; font-size: 16px; }
+.sv-head__text small { color: var(--muted); font-size: 12.5px; }
+.sv-chev { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; }
+.sv-chev svg { width: 18px; height: 18px; stroke: var(--text); fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: transform .25s var(--ease); }
+.sv-group.open .sv-chev svg { transform: rotate(180deg); }
+.sv-head:hover .sv-chev { background: var(--field); }
+.sv-dir-edit { margin-left: 0; opacity: 0; transition: opacity .2s; }
+.sv-group:hover .sv-dir-edit, .sv-dir-edit:focus-visible { opacity: 1; }
+.sv-table { min-width: 900px; }
+.sv-table thead th { background: #f8f9fa; padding-top: 10px; padding-bottom: 10px; font-size: 12px; }
+.sv-table td { padding: 8px 8px; font-size: 13px; }
+.sv-table .sv-name { font-weight: 500; }
+.sv-path { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sv-table .bn-edit { height: 36px; }
+.sv-table .icon-btn { width: 36px; height: 36px; }
+.sv-table tbody tr.dragging { opacity: .45; }
+.sv-table tbody tr.drop td { box-shadow: inset 0 2px 0 var(--blue-deep); }
+.sv-table, .sv-group .dr-scroll { margin: 0 8px; width: calc(100% - 16px); }
+
+@media (max-width: 760px) {
+ .sv-toolbar .dr-search { flex-basis: 100%; max-width: none; }
+ .sv-toolbar select { flex: 1 1 40%; min-width: 0; }
+ .sv-icon { width: 46px; height: 46px; }
+ .sv-dir-edit { opacity: 1; }
+}
+
+.legal-row { display: flex; align-items: center; gap: 14px; padding: 12px 4px; border-bottom: 1px solid var(--line); }
+.legal-row:last-child { border-bottom: 0; }
+.legal-row > div { flex: 1; min-width: 0; }
+.legal-row b { display: block; font-weight: 500; }
+.legal-row small { color: var(--muted); font-size: 12px; }
+.legal-row small a { color: var(--sky-text); }
+
+/* ---------- «Статистика» ---------- */
+.bn-hero--stats.has-photo { background: url(assets/hall.webp) right 40% / 62% auto no-repeat, #f5f7fa; }
+.bn-hero--stats { padding-bottom: 28px; margin-bottom: 18px; border-radius: 30px; }
+.bn-hero--stats::after { display: none; }
+.st-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
+.st-presets { display: flex; gap: 4px; padding: 5px; background: rgba(255, 255, 255, .8); border-radius: 18px; backdrop-filter: blur(6px); }
+.st-presets button { border: 0; background: none; padding: 9px 16px; border-radius: 14px; cursor: pointer; color: var(--muted); font-size: 13px; }
+.st-presets button:hover { color: var(--text); }
+.st-presets button[aria-pressed="true"] { background: linear-gradient(100deg, #3f86f2, #6aa8f5); color: #fff; box-shadow: 0 6px 16px rgba(63, 134, 242, .28); }
+.st-dates { display: flex; align-items: center; gap: 8px; padding: 0 14px; height: 48px; background: rgba(255, 255, 255, .9); border-radius: 16px; }
+.st-dates svg { width: 18px; height: 18px; stroke: var(--text); fill: none; stroke-width: 1.6; stroke-linecap: round; display: block; }
+.st-dates input { border: 0; padding: 4px; width: 132px; background: transparent; }
+#st-branch { width: auto; min-width: 200px; height: 48px; border-radius: 16px; margin-left: auto; background: rgba(255, 255, 255, .92); }
+.st-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; margin-bottom: 14px; }
+.kpi { display: flex; gap: 14px; align-items: flex-start; padding: 18px; margin: 0; }
+.kpi__icon { width: 48px; height: 48px; border-radius: 14px; display: grid; place-items: center; flex-shrink: 0; }
+.kpi__icon svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.kpi__label { display: block; font-size: 13px; }
+.kpi__value { font-family: var(--font-display); font-weight: 500; font-size: 30px; letter-spacing: -0.04em; line-height: 1.2; margin-right: 8px; }
+.kpi__delta { font-size: 12px; color: var(--muted); white-space: nowrap; }
+.kpi__delta.up { color: #2f9a6a; } .kpi__delta.down { color: var(--danger); }
+.kpi small { display: block; color: var(--muted); font-size: 11.5px; margin-top: 2px; }
+.st-grid2 { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 14px; }
+.st-grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
+.st-card { margin-bottom: 14px; padding: 20px 22px; min-width: 0; }
+.st-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
+.st-head h2 { margin: 0; font-weight: 600; font-size: 16px; }
+.st-head select { width: auto; height: 38px; border-radius: 12px; }
+.st-chart svg { display: block; width: 100%; height: auto; }
+.st-grid { stroke: #eef0f1; stroke-width: 1; }
+.st-axis { font-size: 11px; fill: #a3a8ac; font-family: var(--font); }
+.st-line { fill: none; stroke: #4f8ff0; stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; }
+.st-guide { stroke: #c9d3dd; stroke-dasharray: 3 3; }
+.st-dot { fill: #fff; stroke: #4f8ff0; stroke-width: 2.4; }
+.st-donut { display: flex; align-items: center; gap: 22px; }
+.st-donut svg { width: 170px; height: 170px; flex-shrink: 0; }
+.st-donut__n { font-family: var(--font-display); font-weight: 500; font-size: 26px; fill: var(--text); }
+.st-donut__u { font-size: 11px; fill: var(--muted); }
+.st-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 0; }
+.st-legend li { display: grid; grid-template-columns: 12px minmax(0, 1fr) 44px 36px; gap: 10px; align-items: center; padding: 6px 0; font-size: 13px; }
+.st-legend i { width: 11px; height: 11px; border-radius: 50%; }
+.st-legend span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.st-legend em, .st-bar em { font-style: normal; color: var(--muted); text-align: right; font-size: 12.5px; }
+.st-legend b, .st-bar b { font-weight: 500; text-align: right; }
+.st-bar { display: grid; grid-template-columns: minmax(90px, 1.1fr) minmax(60px, 1.3fr) 40px 32px; gap: 10px; align-items: center; padding: 8px 0; font-size: 13px; }
+.st-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.st-bar__track { height: 12px; border-radius: 6px; background: #eef1f4; overflow: hidden; }
+.st-bar__track i { display: block; height: 100%; border-radius: 6px; background: linear-gradient(90deg, #4f8ff0, #8bbaf5); }
+.st-top th { background: #f8f9fa; font-size: 12px; }
+.st-top td { font-size: 13px; padding: 9px 10px; }
+.st-empty { color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center; margin: 0; }
+.st-note { color: var(--muted); font-size: 11.5px; margin: 10px 0 0; line-height: 1.5; }
+.st-footnote { margin: 4px 6px 20px; }
+.st-tip { pointer-events: none; }
+
+@media (max-width: 1280px) {
+ .st-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .st-grid3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@media (max-width: 1024px) {
+ .st-grid2 { grid-template-columns: minmax(0, 1fr); }
+ #st-branch { margin-left: 0; }
+}
+@media (max-width: 760px) {
+ .st-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+ .kpi { flex-direction: column; gap: 8px; padding: 14px; }
+ .st-grid3 { grid-template-columns: minmax(0, 1fr); }
+ .st-donut { flex-direction: column; align-items: stretch; }
+ .st-donut svg { margin: 0 auto; }
+ .st-presets { overflow-x: auto; max-width: 100%; }
+ .st-dates input { width: 118px; }
+}
+
+/* ---------- «Чек-апы» ---------- */
+.ck-card { padding: 18px 18px 10px; }
+.ck-table { min-width: 860px; }
+.ck-table thead th { background: #f8f9fa; padding-top: 12px; padding-bottom: 12px; font-size: 12px; }
+.ck-table thead th:first-child { border-radius: 12px 0 0 12px; } .ck-table thead th:last-child { border-radius: 0 12px 12px 0; }
+.ck-table td { padding: 8px 10px; font-size: 13.5px; }
+.ck-prog { display: flex; align-items: center; gap: 16px; min-width: 300px; }
+.ck-thumb { width: 112px; height: 62px; border-radius: 12px; object-fit: cover; object-position: 80% 70%; background: var(--field); flex-shrink: 0; display: block; }
+.ck-thumb--empty { display: grid; place-items: center; color: var(--muted); font-size: 11px; }
+.ck-price { font-weight: 500; display: block; }
+.ck-old { font-size: 12px; }
+#ck-sort, #ck-aud { height: 44px; width: auto; min-width: 180px; border-radius: 14px; }
+
+/* ---------- «Дашборд врачей»: пастель — голубой, мятный, персиковый, пудровый ---------- */
+#tab-doctors-dash { --dd-ink: #243a5e; --dd-soft: #7d8aa3; --dd-line: #e6ecf5; }
+.dd-top { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; margin: 6px 0 16px; }
+.dd-title { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: 30px; letter-spacing: -0.04em; color: var(--dd-ink); }
+.dd-top [data-subtabs-slot] .subtabs { margin: 0; }
+#dd-spec { width: auto; min-width: 230px; height: 44px; border-radius: 16px; border-color: var(--dd-line); background: rgba(255, 255, 255, .9); }
+.dd-board {
+ display: grid; grid-template-columns: minmax(0, 40fr) minmax(0, 60fr); gap: 0; margin-bottom: 18px;
+ border-radius: 34px; overflow: hidden; box-shadow: 0 18px 50px rgba(64, 98, 150, .08);
+ background: linear-gradient(135deg, #f6f9fe 0%, #eef4fc 55%, #fbf2f2 100%);
+ border: 1px solid rgba(255, 255, 255, .9);
+}
+.dd-intro { padding: 34px 34px 30px; border-right: 1px solid var(--dd-line); }
+.dd-intro h2 { margin: 0; font-family: var(--font-display); font-weight: 500; font-size: clamp(34px, 3.4vw, 50px); letter-spacing: -0.05em; line-height: 1.02; color: var(--dd-ink); }
+.dd-desc { margin: 16px 0 28px; max-width: 420px; color: var(--dd-soft); font-size: 13.5px; line-height: 1.6; }
+.dd-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+.dd-stat { border: 0; background: none; padding: 0; text-align: left; cursor: pointer; color: var(--dd-ink); }
+.dd-stat__icon { width: 50px; height: 50px; border-radius: 50%; display: grid; place-items: center; margin-bottom: 14px; box-shadow: 0 8px 18px rgba(64, 98, 150, .08); transition: transform .25s var(--ease); }
+.dd-stat:hover .dd-stat__icon { transform: translateY(-2px); }
+.dd-stat__icon svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
+.dd-stat b { font-family: var(--font-display); font-weight: 500; font-size: 26px; letter-spacing: -0.04em; }
+.dd-stat__of { font-size: 11px; color: var(--dd-soft); margin-left: 4px; }
+.dd-stat__label { display: block; font-size: 12px; color: var(--dd-soft); margin-top: 4px; line-height: 1.35; }
+.dd-overview { padding: 30px 30px 22px; min-width: 0; background: rgba(255, 255, 255, .55); }
+.dd-overview__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
+.dd-overview h2 { margin: 0 0 10px; font-family: var(--font-display); font-weight: 500; font-size: 24px; letter-spacing: -0.03em; color: var(--dd-ink); }
+.dd-overview #dd-spec { height: 34px; min-width: 0; padding: 4px 30px 4px 12px; font-size: 12px; border-radius: 12px; }
+.dd-total { padding: 10px 16px; border-radius: 18px; background: rgba(255, 255, 255, .85); box-shadow: 0 6px 18px rgba(64, 98, 150, .06); }
+.dd-total b { display: block; font-family: var(--font-display); font-weight: 500; font-size: 24px; letter-spacing: -0.03em; color: var(--dd-ink); line-height: 1.15; }
+.dd-total span { font-size: 11.5px; color: var(--dd-soft); }
+.dd-chart { position: relative; margin-top: 8px; }
+.dd-chart svg { display: block; width: 100%; height: auto; }
+.dd-grid { stroke: #e7edf5; stroke-width: 1; }
+.dd-axis { font-size: 10.5px; fill: #a0abbf; font-family: var(--font); }
+.dd-cat { font-size: 11.5px; fill: #3d4f6e; font-family: var(--font); }
+.dd-val { font-size: 11px; fill: #8f9bb2; font-family: var(--font); }
+.dd-guide { stroke: #c4d0e1; stroke-dasharray: 3 3; }
+.dd-hill { transition: opacity .2s; }
+.dd-chart svg:hover .dd-hill { opacity: .55; }
+.dd-chart svg:hover .dd-hill.on { opacity: 1; }
+.dd-tip { transform: translate(-50%, calc(-100% - 10px)); }
+.dd-tip__row { display: block; font-size: 11px; color: var(--muted); }
+.dd-attention { display: grid; grid-template-columns: auto repeat(3, minmax(0, 1fr)); gap: 10px; align-items: center; margin-top: 6px; }
+.dd-attention > div { display: contents; }
+.dd-attention h3 { margin: 0 6px 0 0; font-size: 13px; font-weight: 600; color: var(--dd-ink); }
+.dd-att { display: grid; grid-template-columns: 34px 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; width: 100%; text-align: left;
+ border: 0; background: rgba(255, 255, 255, .85); border-radius: 18px; padding: 10px 12px; margin-bottom: 0; cursor: pointer;
+ box-shadow: 0 6px 16px rgba(64, 98, 150, .06); transition: transform .2s var(--ease), box-shadow .2s; }
+.dd-att:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(64, 98, 150, .1); }
+.dd-att__icon { grid-row: span 2; width: 34px; height: 34px; border-radius: 50%; background: #fdeee2; color: #c9803f; display: grid; place-items: center; }
+.dd-att__icon svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.dd-att b { font-family: var(--font-display); font-weight: 500; font-size: 20px; color: var(--dd-ink); line-height: 1.1; }
+.dd-att span:last-child { font-size: 11.5px; color: var(--dd-soft); line-height: 1.3; }
+.dd-details-card { border-radius: 30px; padding: 26px 28px 18px; }
+.dd-h2 { margin: 0 0 12px; font-family: var(--font-display); font-weight: 500; font-size: 22px; letter-spacing: -0.03em; color: #243a5e; }
+.dd-row { display: grid; grid-template-columns: minmax(170px, 1.35fr) minmax(120px, 1.2fr) minmax(170px, 1.5fr) 44px 130px; gap: 16px; align-items: center;
+ width: 100%; padding: 13px 6px; border: 0; border-bottom: 1px solid #eef2f7; background: none; text-align: left; font-size: 13px; color: #243a5e; }
+button.dd-row { cursor: pointer; border-radius: 12px; }
+button.dd-row:hover { background: #f7f9fd; }
+.dd-row:last-child { border-bottom: 0; }
+.dd-row__label { font-weight: 500; }
+.dd-row__bar { height: 8px; border-radius: 4px; background: #edf2f8; overflow: hidden; }
+.dd-row__bar i { display: block; height: 100%; border-radius: 4px; }
+.dd-row__text { color: #7d8aa3; }
+.dd-row__pct { text-align: right; color: #7d8aa3; font-variant-numeric: tabular-nums; }
+.dd-row__st { justify-self: start; font-size: 11.5px; padding: 4px 12px; border-radius: 999px; white-space: nowrap; }
+.dd-row.ok .dd-row__bar i { background: linear-gradient(90deg, #8fd8b8, #6cc59f); }
+.dd-row.ok .dd-row__st { background: #e3f5ec; color: #2f8a62; }
+.dd-row.warn .dd-row__bar i { background: linear-gradient(90deg, #f7c9a0, #f0ad78); }
+.dd-row.warn .dd-row__st { background: #fdeee1; color: #b86f32; }
+.dd-row.empty .dd-row__st { background: #eef2f8; color: #8f9bb2; }
+.dd-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 16px 0 24px; padding: 12px; border-radius: 26px;
+ background: rgba(255, 255, 255, .7); backdrop-filter: blur(6px); box-shadow: 0 10px 30px rgba(64, 98, 150, .06); }
+.dd-act { flex: 1 1 150px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; height: 46px; border: 0; border-radius: 16px;
+ background: #fff; color: #243a5e; font-size: 13px; cursor: pointer; transition: background-color .2s, transform .2s var(--ease); }
+.dd-act:hover { background: #eef4fc; transform: translateY(-1px); }
+.dd-act svg { width: 18px; height: 18px; stroke: #4a7fc4; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+
+@media (max-width: 1280px) {
+ .dd-board { grid-template-columns: minmax(0, 1fr); }
+ .dd-intro { border-right: 0; border-bottom: 1px solid var(--dd-line); }
+}
+@media (max-width: 900px) {
+ .dd-attention { grid-template-columns: minmax(0, 1fr); }
+ .dd-row { grid-template-columns: minmax(0, 1fr) 44px; gap: 6px 12px; }
+ .dd-row__bar { grid-column: 1 / -1; grid-row: 2; }
+ .dd-row__text { grid-column: 1; grid-row: 3; }
+ .dd-row__st { grid-column: 2; grid-row: 3; justify-self: end; }
+}
+@media (max-width: 760px) {
+ .dd-intro, .dd-overview { padding: 22px 18px; }
+ .dd-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+ .dd-board { border-radius: 26px; }
+ .dd-details-card { padding: 20px 16px; }
+}
+
+/* ---------- «Отчёты» ---------- */
+#tab-reports { --rp-ink: #1f3558; --rp-soft: #7a879d; --rp-line: #e4eaf3; --rp-blue: #3f8df0; }
+.rp-head { position: relative; margin: 8px 0 18px; }
+.rp-head h1 { margin: 0; font-family: var(--font-display); font-weight: 600; font-size: 38px; letter-spacing: -0.04em; color: var(--rp-ink); }
+#tab-reports { position: relative; isolation: isolate; }
+/* атмосферные ленты: правый верхний угол, за карточками, не ловят клики */
+.rp-waves { position: absolute; top: -84px; right: -22px; width: min(820px, 64%); height: 420px; z-index: -1; pointer-events: none; }
+#tab-reports > :not(.rp-waves) { position: relative; z-index: 1; }
+.rp-tabs { display: inline-flex; gap: 4px; padding: 5px; margin-bottom: 18px; background: #fff; border-radius: 18px; box-shadow: var(--shadow); }
+.rp-tabs button { border: 0; background: none; padding: 10px 22px; border-radius: 14px; cursor: pointer; font-size: 13.5px; color: var(--rp-ink); }
+.rp-tabs button:hover { background: #f4f7fb; }
+.rp-tabs button[aria-selected="true"] { background: #e3eefc; color: #2f6fd0; }
+/* клиника отчётов: у DANA и «Очевидно» свои звонки, отчёты и дашборд */
+.rp-head:has(.rp-clinic) { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 18px; }
+.rp-clinic { display: inline-flex; gap: 4px; padding: 4px; background: #fff; border-radius: 14px; box-shadow: var(--shadow); }
+.rp-clinic button { border: 0; background: none; padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 13.5px; color: var(--rp-ink); }
+.rp-clinic button:hover { background: #f4f7fb; }
+.rp-clinic button[aria-pressed="true"] { background: #e3eefc; color: #2f6fd0; font-weight: 600; }
+.rp-share { width: min(560px, calc(100vw - 32px)); border: 0; border-radius: 20px; padding: 24px; box-shadow: var(--shadow); color: var(--rp-ink); }
+.rp-share::backdrop { background: rgba(20, 30, 50, .35); }
+.rp-share h2 { margin: 0 0 8px; font-size: 20px; }
+.rp-share__state { font-weight: 600; }
+.rp-share__out { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; }
+.rp-share__url { flex: 1 1 260px; min-width: 0; padding: 9px 12px; border: 1px solid #d6deea; border-radius: 10px; font: inherit; font-size: 13px; }
+.rp-share__out p { flex-basis: 100%; margin: 0; }
+.rp-share__actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
+.rp-grid { display: grid; grid-template-columns: minmax(0, 36fr) minmax(0, 64fr); gap: 18px; align-items: stretch; margin-bottom: 18px; }
+.rp-grid .card { margin: 0; border-radius: 26px; }
+.rp-upload { display: flex; flex-direction: column; gap: 12px; padding: 22px; align-self: start; }
+.rp-drop { min-height: 230px; border: 1.5px dashed #b9d0f0; border-radius: 20px; background: linear-gradient(180deg, #f8fbff, #f3f7fd);
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 22px; text-align: center; cursor: pointer;
+ color: var(--rp-ink); transition: border-color .2s, background-color .2s; }
+.rp-drop:hover, .rp-drop.over { border-color: var(--rp-blue); background: #eef5ff; }
+.rp-drop__icon svg { width: 44px; height: 44px; stroke: var(--rp-blue); fill: none; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
+.rp-drop b { font-size: 17px; font-weight: 600; margin-top: 6px; }
+.rp-drop small { color: var(--rp-soft); font-size: 12.5px; }
+.rp-drop__name { display: inline-flex; align-items: center; gap: 12px; font-size: 16px; word-break: break-all; }
+.rp-ok { width: 24px; height: 24px; border-radius: 50%; background: #3fbd82; display: inline-grid; place-items: center; flex-shrink: 0; }
+.rp-ok svg { width: 15px; height: 15px; stroke: #fff; fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
+.rp-link { align-self: center; display: inline-flex; align-items: center; gap: 8px; border: 0; background: none; color: #2f7be0; cursor: pointer; font-size: 13.5px; padding: 4px 8px; border-radius: 10px; }
+.rp-link:hover { background: #eef5ff; }
+.rp-link svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+.rp-partners { display: flex; flex-direction: column; gap: 10px; width: 100%; }
+.rp-partner { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; border: 1px solid #e4ecf7; border-radius: 12px; background: #fafcff; }
+.rp-partner__head { display: flex; flex-direction: column; gap: 2px; }
+.rp-partner__head b { font-size: 13.5px; font-weight: 600; }
+.rp-partner__head small { color: var(--rp-soft); font-size: 12px; line-height: 1.4; }
+.rp-partner__files { display: flex; flex-direction: column; gap: 4px; }
+.rp-partner__file { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12.5px; }
+.rp-partner__file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rp-partner__file button { border: 0; background: none; color: var(--rp-soft); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 6px; }
+.rp-partner .rp-link { align-self: flex-start; padding-left: 0; }
+.rp-hint { margin: 0; color: var(--rp-soft); font-size: 12px; line-height: 1.5; text-align: center; }
+.rp-start { display: flex; align-items: center; justify-content: center; gap: 10px; height: 54px; border: 0; border-radius: 18px; cursor: pointer; color: #fff; font-size: 15px;
+ background: linear-gradient(95deg, #2f7fe8 0%, #4aa0f3 60%, #8fb9f0 82%, #f2b58e 100%); box-shadow: 0 14px 26px rgba(47, 127, 232, .28); transition: transform .2s var(--ease), box-shadow .2s, opacity .2s; }
+.rp-start:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 18px 32px rgba(47, 127, 232, .34); }
+.rp-start:disabled { opacity: .45; cursor: default; box-shadow: none; }
+.rp-start svg { width: 16px; height: 16px; fill: #fff; }
+.rp-progress { padding: 24px 26px 22px; display: flex; flex-direction: column; gap: 16px;
+ background: linear-gradient(135deg, #ffffff 0%, #fbfcff 55%, #fdf5f7 100%); }
+.rp-progress__head { display: flex; justify-content: space-between; gap: 14px; }
+.rp-progress h2 { margin: 0; font-size: 19px; font-weight: 600; color: var(--rp-ink); }
+.rp-progress__head p { margin: 4px 0 0; color: var(--rp-soft); font-size: 13px; }
+.rp-clock { display: inline-flex; align-items: center; gap: 8px; color: var(--rp-soft); font-size: 14px; white-space: nowrap; height: 24px; }
+.rp-clock svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; }
+.rp-progress__body { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 26px; align-items: center; }
+.rp-ring { position: relative; width: 190px; height: 190px; }
+.rp-ring svg { width: 100%; height: 100%; }
+#rp-ring-bar { transition: stroke-dasharray .45s var(--ease); }
+.rp-ring__text { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.rp-ring__text b { font-family: var(--font-display); font-weight: 600; font-size: 34px; letter-spacing: -0.03em; color: var(--rp-ink); }
+.rp-ring__text span { font-size: 13px; color: var(--rp-soft); }
+.rp-stages { list-style: none; margin: 0; padding: 0 0 0 22px; border-left: 1px solid var(--rp-line); display: flex; flex-direction: column; gap: 14px; }
+.rp-stage { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 14px; align-items: start; }
+.rp-stage b { display: block; font-weight: 500; font-size: 14px; color: var(--rp-ink); }
+.rp-stage small { display: block; font-size: 12px; color: var(--rp-soft); min-height: 1em; }
+.rp-stage time { font-size: 13px; color: var(--rp-soft); font-variant-numeric: tabular-nums; }
+.rp-stage__mark { width: 22px; height: 22px; border-radius: 50%; border: 2px solid #cfd6e0; display: grid; place-items: center; margin-top: 1px; background: #fff; }
+.rp-stage.done .rp-stage__mark { background: #3fbd82; border-color: #3fbd82; }
+.rp-stage.done .rp-stage__mark svg { width: 14px; height: 14px; stroke: #fff; fill: none; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; }
+.rp-stage.now .rp-stage__mark { border: 5px solid var(--rp-blue); animation: rp-pulse 1.4s ease-in-out infinite; }
+.rp-stage.stop .rp-stage__mark { border-color: var(--rose); }
+.rp-stage.wait b { color: #5d6b82; }
+@keyframes rp-pulse { 50% { box-shadow: 0 0 0 6px rgba(63, 141, 240, .15); } }
+.rp-result { display: flex; flex-direction: column; gap: 12px; }
+.rp-download { display: inline-flex; align-items: center; gap: 10px; align-self: flex-start; padding: 11px 18px; border-radius: 14px; text-decoration: none;
+ background: #e8f6ee; color: #227a52; font-size: 13.5px; }
+.rp-download:hover { background: #dcf1e5; }
+.rp-download svg, .rp-btn-ghost svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+.rp-btn-ghost { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; height: 40px; padding: 0 16px; border-radius: 12px; border: 1px solid #e3e7ee; background: #fff; cursor: pointer; font-size: 13px; color: var(--rp-ink); }
+.rp-btn-ghost:hover { background: #f6f8fb; }
+.rp-sum { font-size: 12.5px; }
+.rp-sum th { font-size: 11.5px; background: #f7f9fc; padding: 8px 10px; }
+.rp-sum td { padding: 7px 10px; }
+.rp-sum__total td { color: var(--rp-soft); }
+.rp-note { margin: 0; font-size: 13px; color: var(--rp-soft); line-height: 1.5; }
+.rp-control { margin: 0; font-size: 12.5px; padding: 8px 12px; border-radius: 12px; background: #f4f6fa; color: var(--rp-soft); }
+.rp-control.ok { background: #e8f6ee; color: #227a52; }
+.rp-control.bad { background: var(--rose-soft); color: var(--rose-text); }
+.rp-sum__total td { font-weight: 500; }
+.rp-info { display: flex; gap: 14px; align-items: center; margin-top: auto; padding: 14px 18px; border-radius: 18px; background: linear-gradient(90deg, #fdeef3, #fbeff6); }
+.rp-info svg { width: 28px; height: 28px; stroke: #7065d8; fill: none; stroke-width: 1.5; stroke-linecap: round; display: block; }
+.rp-info p { margin: 0; font-size: 12.5px; color: #6f6680; line-height: 1.5; }
+.rp-info b { display: block; font-weight: 500; font-size: 13.5px; color: var(--rp-ink); }
+.rp-recent { border-radius: 26px; padding: 20px 22px 12px; }
+.rp-recent__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
+.rp-recent h2 { margin: 0; font-size: 18px; font-weight: 600; color: var(--rp-ink); }
+.rp-table { min-width: 760px; }
+.rp-table thead th { background: #f4f7fb; font-size: 12px; padding-top: 11px; padding-bottom: 11px; }
+.rp-table thead th:first-child { border-radius: 12px 0 0 12px; } .rp-table thead th:last-child { border-radius: 0 12px 12px 0; }
+.rp-table td { font-size: 13.5px; padding: 10px 12px; }
+.rp-file { color: var(--rp-soft); }
+.rp-file svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 1.5; vertical-align: -3px; margin-right: 8px; }
+.rp-table .bn-edit { height: 36px; text-decoration: none; color: var(--rp-ink); }
+.rp-table .bn-edit svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 1.8; }
+.rp-table .icon-btn svg circle { fill: var(--text); }
+
+@media (max-width: 1100px) {
+ .rp-grid { grid-template-columns: minmax(0, 1fr); }
+}
+@media (max-width: 640px) {
+ .rp-progress__body { grid-template-columns: minmax(0, 1fr); justify-items: center; }
+ .rp-stages { width: 100%; border-left: 0; padding-left: 0; }
+ .rp-waves { width: 100%; top: -70px; right: -12px; opacity: .7; }
+ .rp-tabs { display: flex; overflow-x: auto; }
+ .rp-tabs button { padding: 10px 14px; white-space: nowrap; }
+ .rp-head h1 { font-size: 30px; }
+}
+
+/* нижнее меню телефона: 9 разделов и выход должны помещаться в ширину 360 px */
+@media (max-width: 460px) {
+ #app .side { left: 6px; right: 6px; padding: 0 6px; }
+ #app .side__nav { gap: 0; min-width: 0; }
+ #app .side .side__btn { width: 32px; height: 40px; border-radius: 12px; flex-shrink: 1; }
+ #app .side .side__btn svg { width: 19px; height: 19px; }
+ #app .side__foot { margin-left: 0; }
+}
+
+/* ---------- «Звонки АТС» ---------- */
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.px-card { border-radius: 26px; padding: 22px 24px 16px; }
+.px-card h2 { margin: 0 0 10px; font-size: 18px; font-weight: 600; color: #1f3558; }
+.px-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; }
+.px-head h2 { margin: 0; }
+.px-bad { color: var(--rose-text) !important; }
+/* карточка АТС */
+.px-ats { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 20px; align-items: center; padding: 20px 24px; }
+.px-ats__icon { width: 72px; height: 72px; border-radius: 22px; display: grid; place-items: center; background: #e3f6ec; color: #1f9d63; }
+.px-ats__icon svg { width: 32px; height: 32px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
+.px-ats__body h2 { margin: 0 0 6px; font-size: 19px; }
+.px-conn { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 14px; color: #33475f; }
+.px-dot { width: 9px; height: 9px; border-radius: 50%; background: #22b07d; box-shadow: 0 0 0 3px rgba(34, 176, 125, .16); }
+.px-conn.off .px-dot { background: #c9ced6; box-shadow: none; }
+.px-copy { border: 0; background: none; padding: 4px; border-radius: 8px; color: #6f7f94; cursor: pointer; display: grid; }
+.px-copy:hover { background: #eef3fa; color: #1f3558; }
+.px-copy svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
+.px-state { margin: 0; font-size: 12.5px; line-height: 1.55; max-width: 780px; }
+.px-ats #px-check { align-self: start; }
+.px-ats .px-setup { grid-column: 1 / -1; margin: 10px 0 0; }
+.px-setup { background: #f4f8fe; border-radius: 16px; padding: 14px 18px; font-size: 13px; line-height: 1.55; }
+.px-setup p { margin: 0 0 8px; }
+.px-setup pre { margin: 0 0 8px; padding: 10px 14px; background: #fff; border-radius: 12px; font-size: 12.5px; overflow-x: auto; }
+/* загрузка звонков */
+.px-syncbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 22px; font-size: 13px; color: #33475f; }
+.px-note { font-size: 12px; }
+.px-btn { height: 44px; padding: 0 26px; border: 0; border-radius: 14px; color: #fff; font-size: 14px; cursor: pointer;
+ background: linear-gradient(100deg, #2f76f0, #4f95f6); box-shadow: 0 8px 18px rgba(47, 118, 240, .25); transition: filter .2s, box-shadow .2s; }
+.px-btn:hover { filter: brightness(1.05); box-shadow: 0 10px 22px rgba(47, 118, 240, .32); }
+.px-btn:disabled { opacity: .55; cursor: default; }
+.px-show { height: 44px; padding: 0 20px; border: 1px solid #e3eaf5; border-radius: 14px; background: #f4f8fe; color: #2f76f0; font-size: 14px; cursor: pointer; }
+.px-show:hover { background: #e9f1fd; }
+.px-period { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+/* поля периода и календарь */
+.px-range { position: relative; display: flex; align-items: center; gap: 8px; }
+.px-dash { color: #8b98aa; }
+.px-date { display: inline-flex; align-items: center; gap: 10px; height: 44px; min-width: 150px; padding: 0 14px; border: 1px solid #e3e8ef;
+ border-radius: 14px; background: #fff; color: #1f3558; font-size: 14px; cursor: pointer; transition: border-color .2s, box-shadow .2s; }
+.px-date:hover, .px-range.open .px-date.active { border-color: #b9d0f4; box-shadow: 0 0 0 3px rgba(63, 134, 242, .1); }
+.px-date.empty span { color: #9aa6b6; }
+.px-date svg { width: 17px; height: 17px; fill: none; stroke: #5b6b80; stroke-width: 1.7; stroke-linecap: round; }
+.dp { position: absolute; top: calc(100% + 10px); right: 0; z-index: 40; width: 380px; max-width: calc(100vw - 32px); padding: 18px 18px 14px;
+ border-radius: 22px; background: #fff; box-shadow: 0 22px 50px rgba(31, 53, 88, .16), 0 2px 8px rgba(31, 53, 88, .06); }
+.px-syncbar .dp { left: 0; right: auto; }
+.dp__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
+.dp__head b { font-size: 17px; font-weight: 600; color: #1f3558; }
+.dp__nav { width: 40px; height: 40px; border: 0; border-radius: 50%; background: #eef4fd; color: #2f76f0; display: grid; place-items: center; cursor: pointer; }
+.dp__nav:hover { background: #e0ebfb; }
+.dp__nav svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.dp__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px 4px; }
+.dp__wd { display: grid; place-items: center; height: 34px; border-radius: 10px; font-size: 13px; color: #3b4a5e; font-weight: 500; }
+.dp__day { height: 40px; border: 0; border-radius: 12px; background: none; color: #1f3558; font-size: 14.5px; cursor: pointer; transition: background-color .15s, color .15s; }
+.dp__day:hover { background: #eef4fd; }
+/* выходные — серые (Сб, Вс) */
+.dp__wd.dp__we { background: #f0f1f3; color: #6b7280; }
+.dp__day.dp__we { background: #f4f5f7; color: #6b7280; }
+.dp__day.dp__we:hover { background: #e7e9ec; }
+.dp__day.dp__out { color: #b3bcc8; }
+.dp__day.dp__today { box-shadow: inset 0 0 0 1.5px #b9d0f4; }
+.dp__day.dp__in { background: #dfeafc; color: #1f3558; }
+.dp__day.dp__start, .dp__day.dp__end { background: #1565e6; color: #fff; font-weight: 600; box-shadow: 0 6px 14px rgba(21, 101, 230, .28); }
+.dp__day.dp__end:not(.dp__start) { background: #3f86f2; }
+.dp__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; }
+.dp__link { border: 0; background: none; color: #2f76f0; font-size: 14px; cursor: pointer; padding: 8px 4px; }
+.dp__today-btn { border: 0; border-radius: 999px; padding: 9px 22px; background: #e6effc; color: #2f76f0; font-size: 14px; cursor: pointer; }
+.dp__today-btn:hover { background: #d9e7fb; }
+/* карточки показателей */
+.px-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 14px; margin: 4px 0 16px; }
+.px-kpi { position: relative; display: flex; flex-direction: column; min-width: 0; padding: 16px 16px 8px; border: 1px solid #edf1f6; border-radius: 20px; background: #fff; overflow: hidden; }
+.px-kpi__top { display: flex; align-items: flex-start; gap: 10px; }
+.px-kpi__row { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
+.px-kpi__icon { flex-shrink: 0; width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; color: var(--c);
+ background: color-mix(in srgb, var(--c) 13%, #fff); }
+.px-kpi__icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+.px-kpi__main { min-width: 0; flex: 1; }
+.px-kpi__value { display: block; font-size: 25px; font-weight: 600; letter-spacing: -.02em; color: #14233a; line-height: 1.15; }
+.px-kpi__label { display: block; font-size: 13px; color: #33475f; margin-top: 2px; }
+.px-kpi__hint { display: block; margin: 10px 0 6px; font-size: 11.5px; color: #6f7f94; line-height: 1.4; min-height: 16px; }
+.px-trend { display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0; font-size: 12px; font-weight: 600; color: #8b98aa; white-space: nowrap; }
+.px-trend svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
+.px-trend.good { color: #1f9d63; }
+.px-trend.bad { color: #e0485f; }
+.px-kpis:has(+ .px-cmp:not([hidden])) { margin-bottom: 8px; }
+.px-cmp { margin: 0 2px 14px; font-size: 12px; color: #6f7f94; }
+.px-spark { display: block; width: 100%; height: 40px; margin-top: auto; }
+/* таблица каналов */
+.px-table { min-width: 640px; }
+.px-channels { min-width: 900px; }
+.px-table td small { font-size: 11px; }
+.px-lost { color: #e0485f; font-weight: 600; }
+.px-total td { font-weight: 600; background: #f3f6fb; }
+.px-total td:first-child { border-radius: 12px 0 0 12px; }
+.px-total td:last-child { border-radius: 0 12px 12px 0; }
+.px-ch { display: inline-flex; align-items: center; gap: 8px; padding: 5px 12px 5px 10px; border-radius: 999px; background: var(--ch-soft);
+ color: color-mix(in srgb, var(--ch) 82%, #1f3558); font-weight: 600; font-size: 13px; white-space: nowrap; }
+.px-ch::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--ch); }
+.px-menu-cell { position: relative; width: 44px; text-align: right; }
+.px-more { width: 34px; height: 34px; border: 0; border-radius: 10px; background: none; color: #3b4a5e; cursor: pointer; display: inline-grid; place-items: center; }
+.px-more:hover { background: #eef3fa; }
+.px-more svg { width: 20px; height: 20px; fill: currentColor; }
+.px-pop { position: absolute; right: 8px; top: calc(100% - 4px); z-index: 30; min-width: 220px; padding: 6px; border-radius: 14px; background: #fff;
+ box-shadow: 0 16px 36px rgba(31, 53, 88, .16); display: flex; flex-direction: column; }
+.px-pop button { border: 0; background: none; text-align: left; padding: 10px 12px; border-radius: 10px; font-size: 13.5px; color: #1f3558; cursor: pointer; }
+.px-pop button:hover, .px-pop button:focus-visible { background: #eef4fd; }
+.px-foot { display: flex; align-items: flex-start; gap: 10px; margin: 14px 2px 4px; font-size: 12.5px; color: #6f7f94; line-height: 1.5; }
+.px-foot span { flex-shrink: 0; color: #2f76f0; }
+.px-foot svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }
+.px-table select { height: 36px; border-radius: 10px; min-width: 180px; }
+.px-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
+.px-grid .card { margin: 0 0 18px; }
+.px-grid .px-head { margin-bottom: 10px; }
+.rp-source-opt { display: grid; gap: 10px; }
+.rp-file-period { margin: 0; padding: 12px 14px; border: 1px solid #dbe9fa; border-radius: 13px; background: #f1f7ff; color: #355e91; font-size: 13px; line-height: 1.5; text-align: center; }
+.rp-file-period[data-state=warning] { border-color: #f1dfbf; background: #fff8ed; color: #8c652d; }
+.rp-pbx-opt {
+ --rp-source-color: #4389e9; --rp-source-tint: #eaf3ff;
+ --rp-source-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='8' y='3' width='8' height='5' rx='1.5'/%3E%3Crect x='2' y='16' width='8' height='5' rx='1.5'/%3E%3Crect x='14' y='16' width='8' height='5' rx='1.5'/%3E%3Cpath d='M12 8v4M6 16v-4h12v4'/%3E%3C/g%3E%3C/svg%3E");
+ display: grid; grid-template-columns: 42px minmax(0, 1fr) 20px; align-items: start; gap: 12px;
+ padding: 16px; border: 1px solid #e5ebf1; border-radius: 18px; background: #fff;
+ cursor: pointer; font-size: 13px; box-shadow: 0 2px 6px rgba(31, 53, 88, .025);
+ transition: border-color .18s, background-color .18s, box-shadow .18s;
+}
+.rp-pbx-opt:has(input[value=bitrix]) {
+ --rp-source-color: #8973cf; --rp-source-tint: #f0ecfb;
+ --rp-source-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='9' cy='8' r='3'/%3E%3Cpath d='M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 4v2'/%3E%3C/g%3E%3C/svg%3E");
+}
+.rp-pbx-opt:has(input[value=pbx]) {
+ --rp-source-color: #4d9c7d; --rp-source-tint: #eaf6ef;
+ --rp-source-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m8 3 3 5-3 2a14 14 0 0 0 6 6l2-3 5 3-1 4c-1 2-6 0-10-4S4 7 4 5l4-2Z' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+}
+.rp-pbx-opt::before {
+ content: ''; grid-column: 1; grid-row: 1; width: 42px; height: 42px; border-radius: 13px;
+ background: var(--rp-source-tint) var(--rp-source-icon) center / 23px 23px no-repeat;
+}
+.rp-pbx-opt > span { grid-column: 2; grid-row: 1; min-width: 0; }
+.rp-source-opt .rp-pbx-opt input { grid-column: 3; grid-row: 1; margin-top: 2px; }
+.rp-pbx-opt b { display: block; font-weight: 600; font-size: 14px; color: #24384e; line-height: 1.4; }
+.rp-pbx-opt small { display: block; margin-top: 5px; color: #6f7f90; font-size: 12px; line-height: 1.55; }
+.rp-pbx-opt:hover { border-color: #bfd5ef; background: #fbfdff; }
+.rp-pbx-opt:has(input:checked) { border-color: #83b3ed; background: #f1f7ff; box-shadow: 0 0 0 1px #d7e8fc; }
+.rp-pbx-opt:has(input:focus-visible) { outline: 2px solid #4389e9; outline-offset: 3px; }
+.rp-pbx-opt.px-off, .rp-pbx-opt:has(input:disabled) { opacity: .55; cursor: not-allowed; }
+@media (max-width: 460px) {
+ .rp-pbx-opt { padding: 13px; gap: 10px; grid-template-columns: 32px minmax(0, 1fr) 20px; }
+ .rp-pbx-opt::before { width: 32px; height: 36px; background-size: 21px 21px; }
+}
+
+.rp-pbx-dates { display: flex; align-items: center; gap: 8px; justify-content: center; }
+.rp-pbx-dates input { width: 150px; height: 40px; border-radius: 12px; }
+.card.wk-board { --wk-channel-width: 220px; --wk-metric-width: 205px; margin-top: 12px; padding: 8px; border-radius: 20px; background: #fdfdfc; box-shadow: none; }
+[data-rp-view="weekly"] #wk-status { flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 0; padding: 14px 18px; border-radius: 18px; background: #fdfdfc; box-shadow: none; }
+[data-rp-view="weekly"] #wk-status h2 { font-family: var(--font); font-size: 16px; }
+[data-rp-view="weekly"] #wk-status p { max-width: 740px; color: #87919b; font-size: 12px; }
+[data-rp-view="weekly"] #wk-filter { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
+[data-rp-view="weekly"] #wk-filter input { width: 144px; height: 36px; background: #f3f4f4; border-color: transparent; border-radius: 9px; font-size: 13px; }
+.wk-board .dr-scroll { max-height: calc(100vh - 260px); min-height: 280px; overflow: auto; border-radius: 14px; scrollbar-width: thin; scrollbar-color: #d7ddde #f6f6f5; }
+.wk-table { width: max-content; min-width: 100%; border-collapse: separate; border-spacing: 2px; table-layout: auto; font-family: 'Segoe UI', Arial, sans-serif; }
+.wk-table th, .wk-table td { white-space: nowrap; text-align: right; border: 0; }
+.wk-table tbody tr:hover { background: transparent; }
+.wk-table th { height: 42px; padding: 7px 10px; }
+.wk-table td { height: 32px; padding: 5px 10px; border-radius: 5px; background: linear-gradient(135deg, #f6f6f6, #f2f3f3); color: #263649; font-size: 14px; line-height: 1.2; }
+.wk-table thead th { position: sticky; top: 0; z-index: 8; padding: 7px 10px; background: #f1f2f3; border-radius: 10px; color: #142236; font-size: 14px; font-weight: 600; }
+.wk-table .wk-brand { left: 0; z-index: 10; min-width: calc(var(--wk-channel-width) + var(--wk-metric-width) + 2px); text-align: left; padding: 0 20px 8px; border-radius: 0; background: #fdfdfc; }
+.wk-brand span { display: block; font-size: 24px; font-weight: 400; letter-spacing: -.025em; line-height: 1.1; }
+.wk-brand small { display: block; margin-top: 2px; color: #8b959e; font-size: 13px; font-weight: 400; }
+.wk-table .wk-period { min-width: 110px; text-align: center; }
+.wk-period span { display: block; }
+.wk-table .wk-metric { position: sticky; left: calc(var(--wk-channel-width) + 2px); z-index: 3; width: var(--wk-metric-width); min-width: var(--wk-metric-width); max-width: var(--wk-metric-width); white-space: normal; line-height: 1.25; text-align: left; font-weight: 400; }
+.wk-table .wk-channel { position: sticky; left: 0; z-index: 5; width: var(--wk-channel-width); min-width: var(--wk-channel-width); max-width: var(--wk-channel-width); white-space: normal; vertical-align: middle; text-align: left; padding: 20px; border-radius: 13px; background: linear-gradient(125deg, var(--wk-tint), #fafaf8); color: #17283b; box-shadow: none; }
+.wk-channel--phone { --wk-tint: #eef4f0; --wk-accent: #93bfa7; }
+.wk-channel--pin { --wk-tint: #fbf4eb; --wk-accent: #efb26e; }
+.wk-channel--ya { --wk-tint: #fcf1ed; --wk-accent: #ed6659; }
+.wk-channel--web { --wk-tint: #eff4f1; --wk-accent: #83b8a2; }
+.wk-channel--source { --wk-tint: #f2f4f8; --wk-accent: #6d9de0; }
+.wk-channel__icon { display: grid; width: 48px; height: 48px; margin-bottom: 9px; place-items: center; background: transparent; color: var(--wk-accent); }
+.wk-channel__icon img { display: block; width: 32px; height: 32px; object-fit: contain; }
+.wk-channel__icon svg { display: block; width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+.wk-channel__icon--brand { background: transparent; color: var(--wk-source-color, #637a91); box-shadow: none; }
+.wk-channel__icon--banner svg { width: 32px; height: 32px; }
+.wk-channel__title { display: block; font-size: 18px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
+.wk-channel__hint { display: block; margin-top: 8px; color: #566575; font-size: 13px; line-height: 1.4; }
+.wk-table .wk-value { min-width: 110px; font-variant-numeric: tabular-nums; }
+.wk-table .wk-heat { background: var(--wk-heat); }
+.wk-table .wk-budget { display: block; width: 90px; max-width: 100%; margin-left: auto; height: 22px; border: 1px solid transparent; border-radius: 4px; text-align: right; padding: 0; background: transparent; color: #40546a; font-size: inherit; font-family: inherit; font-variant-numeric: tabular-nums; appearance: textfield; }
+.wk-budget::-webkit-inner-spin-button, .wk-budget::-webkit-outer-spin-button { appearance: none; margin: 0; }
+.wk-budget::placeholder { color: #89949d; text-align: center; opacity: 1; }
+.wk-table .wk-budget:hover, .wk-table .wk-budget:focus { background: #fff; border-color: #d5dfdd; }
+.wk-table .wk-gap td { height: 10px; padding: 0; background: #fdfdfc; }
+.wk-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; border: 0; background: transparent; color: #9aa6b4; cursor: pointer; font-size: 14px; line-height: 1; padding: 1px; border-radius: 5px; opacity: 0; transition: opacity .15s; }
+.wk-period:hover .wk-del, .wk-del:focus-visible { opacity: 1; }
+.wk-del:hover { background: #fdecea; color: #c0392b; }
+@media (max-width: 760px) {
+ .card.wk-board { --wk-channel-width: 112px; --wk-metric-width: 136px; margin: 12px -4px 0; padding: 6px; border-radius: 18px; }
+ .wk-board .dr-scroll { max-height: calc(100dvh - 190px); }
+ [data-rp-view="weekly"] #wk-filter { width: 100%; }
+ [data-rp-view="weekly"] #wk-filter input { flex: 1; min-width: 0; }
+ [data-rp-view="weekly"] #wk-filter-reset { flex-basis: 100%; text-align: left; }
+ .wk-table .wk-brand { padding: 2px 10px 8px; }
+ .wk-brand span { font-size: 20px; }
+ .wk-brand small { font-size: 11px; }
+ .wk-table .wk-channel { padding: 12px; }
+ .wk-table td, .wk-table thead th { font-size: 12px; }
+ .wk-channel__icon { width: 38px; height: 38px; }
+ .wk-channel__icon svg { width: 21px; height: 21px; }
+ .wk-channel__icon img { width: 28px; height: 28px; }
+ .wk-channel__title { font-size: 13px; }
+ .wk-channel__hint { font-size: 11px; }
+ .wk-table .wk-period, .wk-table .wk-value { min-width: 96px; }
+ .wk-table .wk-budget { width: 76px; }
+ .wk-del { opacity: 1; font-size: 12px; }
+}
+@media (max-width: 1400px) { .px-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+@media (max-width: 1100px) { .px-grid { grid-template-columns: 1fr; } }
+@media (max-width: 760px) {
+ .px-ats { grid-template-columns: auto 1fr; }
+ .px-ats #px-check { grid-column: 1 / -1; justify-self: start; }
+ .px-ats__icon { width: 56px; height: 56px; border-radius: 18px; }
+ .px-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+ .px-kpi { padding: 12px 12px 6px; }
+ .px-kpi__icon { width: 38px; height: 38px; }
+ .px-kpi__value { font-size: 21px; }
+ .px-kpi__top { flex-wrap: wrap; }
+ .px-range { flex-wrap: nowrap; width: 100%; }
+ .px-date { min-width: 0; flex: 1; padding: 0 10px; gap: 6px; font-size: 13px; }
+ .px-period { width: 100%; }
+ .px-show, .px-btn { width: 100%; }
+ .dp, .px-syncbar .dp { left: 0; right: 0; width: auto; max-width: none; padding: 14px 12px 10px; }
+ .dp__day { height: 38px; }
+}
+
+/* ---------- плавное появление ---------- */
+.reveal { opacity: 0; transform: translateY(10px); animation: reveal .7s var(--ease) forwards; }
+@keyframes reveal { to { opacity: 1; transform: none; } }
+.chart__line.draw { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); animation: draw 1.4s var(--ease) .2s forwards; }
+@keyframes draw { to { stroke-dashoffset: 0; } }
+
+html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="off"] *::after { animation: none !important; transition: none !important; }
+html[data-motion="off"] .reveal { opacity: 1; transform: none; }
+html[data-motion="off"] .chart__line.draw { stroke-dashoffset: 0; }
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation: none !important; transition: none !important; }
+ .reveal { opacity: 1; transform: none; }
+ .chart__line.draw { stroke-dashoffset: 0; }
+}
+
+/* ---------- адаптивность ---------- */
+@media (max-width: 1180px) {
+ #app { padding: 14px 16px 14px 12px; gap: 16px; }
+ .dash { gap: 10px 18px; }
+ .dash__heart { min-height: 560px; }
+ .summary { padding-top: 22px; }
+ .summary__stats { gap: 18px; }
+ .who__meta { display: none; }
+ .vital { min-width: 110px; padding: 11px 14px; }
+ .vital__value { font-size: 21px; }
+ /* «Врач или услуга» — второстепенная колонка: на планшете уступает место остальным */
+ .leads-table th:nth-child(5), .leads-table td:nth-child(5) { display: none; }
+ .leads-table td, .leads-table th { padding-left: 8px; padding-right: 8px; }
+}
+/* одна колонка — только на узких экранах: на планшете композиция референса сохраняется */
+@media (max-width: 900px) {
+ .dash { grid-template-columns: minmax(0, 1fr); }
+ .dash__heart { min-height: 520px; max-width: 620px; width: 100%; margin: 0 auto; order: 2; }
+ .dash__right { display: contents; }
+ .summary { order: 1; padding-top: 18px; }
+ .trends { order: 3; } .leads-card { order: 4; }
+ .who__meta { display: none; }
+}
+@media (max-width: 760px) {
+ #app { flex-direction: column; padding: 10px 12px 80px; gap: 10px; }
+ .side {
+ position: fixed; left: 10px; right: 10px; bottom: 10px; top: auto; width: auto; height: 64px; min-height: 0;
+ flex-direction: row; padding: 0 12px; z-index: 50; background: #fff; border-radius: 26px; filter: none; box-shadow: var(--shadow-lift);
+ }
+ .side__shape, .side__logo, .side .switch { display: none; }
+ .side__nav { flex-direction: row; margin: 0; gap: 2px; flex: 1; justify-content: space-between; }
+ .side__foot { margin: 0 0 0 4px; padding-top: 0; flex-direction: row; }
+ .side__btn { width: 40px; height: 40px; }
+ .side__btn .tip { display: none; }
+ .top { flex-wrap: wrap; }
+ .search { width: 100%; order: 3; }
+ .who { margin-left: 4px; }
+ .summary__title { font-size: 44px; }
+ .summary__stats { width: 100%; justify-content: space-between; }
+ .dash__heart { min-height: 440px; }
+ .vital { min-width: 104px; padding: 10px 12px; }
+ .vital__value { font-size: 20px; }
+ .card { padding: 18px 16px; border-radius: 22px; }
+ .leads-card, .card { overflow-x: auto; }
+ .leads-table { min-width: 640px; }
+ .trends__legend { margin-left: 0; width: 100%; }
+ .page-title { font-size: 28px; }
+}
diff --git a/internal/api/adminui/assets/README.txt b/internal/api/adminui/assets/README.txt
new file mode 100644
index 0000000..81980d0
--- /dev/null
+++ b/internal/api/adminui/assets/README.txt
@@ -0,0 +1,4 @@
+heart.webp — прозрачный рендер сердца для главного экрана админки (1025×1400, альфа-канал).
+Если файл убрать, на его месте появится пометка «Нужен 3D-рендер сердца».
+hall.webp — фото холла клиники в шапке раздела «Слайды на главной».
+lobby.webp — фото ресепшена в шапке раздела «Услуги и цены» → «Услуги».
diff --git a/internal/api/adminui/assets/channels/2gis.png b/internal/api/adminui/assets/channels/2gis.png
new file mode 100644
index 0000000000000000000000000000000000000000..d391ec661a477329f7c9907629d58d4dd4897ace
GIT binary patch
literal 4236
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
+
+
diff --git a/internal/api/adminui/assets/channels/google.png b/internal/api/adminui/assets/channels/google.png
new file mode 100644
index 0000000000000000000000000000000000000000..17d3f981b3949bf1b65309d4812cc55401caf025
GIT binary patch
literal 1319
zcmV+?1=#wDP)}
z^UWEB9Kp;503H^R`vFt}CHvVs_9);g%Q
z-oeb}Lnamxx7K=_);gLiu}vZot@T!B&KvBkCu^-w50%&^k>8oQ%#KUhkk1j3eMB@W
z)2Ag}I6GmktsylY;Et7>s(5~q|>^N1)mASE0k@@6J^@T*VJz2%KTlgGyE4I4H4!;YL`M3|O>#RpO3UE9dm)T*#j%p>)=LOC97eSHY%*r}vd7@9
zT^sXff|;XAsS0x`VqWu{6Vb}_&33)!Nl0JF@}WtXU%M90x$|rj3m-p%?rodkT2zJn
z)oc2#5Hs&E45LPgh>w}uh-i4i8K`>+!S9b)J{VRp1HR35P~L?2*+YT>D7UBIn0Nv~
zrBo4#$g*^D>1~0lVmn-SbtHpn#7Gou+%_P&iCoBIV-mRmETy=%r)JQ;3osbh2h&
zejdsx@I1E~hZ>ak*J|Tv?*zcf>oHkqL{+pAPA4Pe!zLHDL@#F^uf@!GYK8kKMTrM$Jc72_Ngr
zN}$E8Kx4W^72cQpy|-@yo@gz_p7TFrusGJNvuu;cC6zvbMvC`vsRYKZKm-nK?YR>h
zy5}GS1Mi>u0@p%aIg|H<1YaMGSZueAA{1p*z~nY7u$!5awNiN{_|UTs--Jq%ft?{g
zYJb@bf4F-<@`wgDe%pu6fOW$?6g%-*QoM)a-Rqa>&J{CP!R<(@a}0l6Z^6_1Ud6%I
z-)=~Lw(A0(-@OYBEn$m&gh%0#*;#w&bEQ;I;yNxDktU+lPTPCoAl4tLvn47iD8_=K
z*_fDLWR?+EdavQcl_oS^Jp+Nj2$jLz{vuTLU-{nf_;4(ml3IUej>NCy=H#N7-768j
zl1{Yq%y+0e`5A@+rM<{&e*w?Vt;EWEofaQ6zik-Cx>#I#8_vu}iD*W8aQ$D0u&Ln#
z1fzrNiVS6-rs65o%zQMHypf3JE2TnqO=+2k93~=*8np$l;=RV5*nja5c%Y^fz}zv@
zQ9J7yOf9rrGv@g1M6^&T)snEO%#TH67BkmpCSl@opd*$(Nj@9VF9KT-kz0|Nv@Q7kcce0V+*qq=40X(K1@!Eh_xI8ftmBP*6*0ltD7JW
zYpvfB5$l;AWFlo+>yNe8fk6-lwAS01`Hq3c&+eIToQT;sEX{bH`{HiDd9fs(=cdA$
dB9qIw{{Y>{ewQAhP=5db002ovPDHLkV1jMLiZK8H
literal 0
HcmV?d00001
diff --git a/internal/api/adminui/assets/channels/instagram.png b/internal/api/adminui/assets/channels/instagram.png
new file mode 100644
index 0000000000000000000000000000000000000000..9b3aac0062c170d46952f79248483c6c35d2d73a
GIT binary patch
literal 17025
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