Compare commits

..

13 Commits

Author SHA1 Message Date
poturaevpetr 9a186d6563 update contacts screen 2026-04-28 13:44:35 +05:00
PoturaevPetr 077f4bc7c9 Merge branch 'main' of https://git.pirogov.ai/arazor72/mobile-app-prototip 2026-04-28 01:09:47 +05:00
PoturaevPetr b3e61a30a5 ports for prod 2026-04-28 01:09:40 +05:00
poturaevpetr 7f09363811 add pages contacts 2026-04-28 01:08:18 +05:00
AR 15 M4 25552690c6 Add app roadmap and strategy pages; link from Tweaks
- public/roadmap.html — карта развития приложения по фазам (0/1/1.5/2)
- public/strategy.html — полный текст концепции развития (встреча 23 апр 2026)
- Tweaks panel: секция «Документы» со ссылкой на карту развития
- Roadmap: кнопка «Концепция развития» ведёт на strategy.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:59:00 +05:00
AR 15 M4 5e50fd5679 Add priorities meeting doc (23 Apr 2026) with PPTX deck
MEETING_2026-04-23.md — product roadmap proposal: gentleman's set (Phase 1
transactional base + Phase 1.5 communication overlay), 10 business segments
matrix (primary/returning lens), four segment groups A/C/B/D.

build_deck.py — python-pptx builder generating 18-slide 16:9 deck styled
to the clinic's design system (teal, PT Sans).

Also expands README with design system tokens and screens overview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:15:53 +05:00
poturaevpetr c7dbbd85f9 add docker setting 2026-04-22 19:18:09 +05:00
AR 15 M4 754722c5ae Add README with project description and run instructions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:35:58 +05:00
AR 15 M4 e9a8171252 Sprints 7–9: electronic medical card, splash home, plate design system
- Sprint 7: Electronic patient card (MedcardScreen rewritten with hero
  passport, 5 tabs, bidirectional links with past appointments)
- Sprint 8: 5th home variant "Светлая плитка" (HomeSplashScreen)
- Sprint 9: Tweaks "Дизайн" section (Клод / Прозрачная карточка) with
  plate versions of Profile, Appts, Appt details and Medcard in
  screens-plate.jsx; fallback to Клод for other screens

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:19:55 +05:00
arazor72 bf1c78ff14 Sprint 6: Таймлайн X home variant for chronic patients
Fourth home variant alongside Карточки / Лента / Таймлайн,
labeled "Таймлайн X" in Tweaks "Главный экран". Designed for
patients with chronic conditions (not post-op).

data.js: new `chronic` object — condition, stage, doctor,
metrics (days since last flare-up, compliance %, flare-ups this
year), daily + scheduled tasks, lifestyle recommendations, and
5-visit observation history.

HomeTimelineXScreen sections:
- Health status hero with soft gradient primary-100 → warm-100,
  success-tinted stable-state chip (green dot), three narrow-font
  metrics
- Today's tasks card: daily habits with streak + 🔥, scheduled
  procedures with dates
- Ask-a-question promotion: AI card (teal gradient) + doctor
  card (with avatar) side by side
- Vertical observation timeline: 5 past visits with doctor initial,
  color-coded by type (diagnosis/procedure/therapy/flareup/checkup)
- Upcoming appointment, lifestyle recommendations carousel with
  emoji, book CTA, article carousel

Softened hero from dark teal gradient with white text to light
primary-100 → warm-100 gradient with dark text — stable-state
signal, not clinical verdict.

Following Sprint 5 convention: matching entry in src/docs.js under
key `home:timelineX` with title, category, goal, tasks, rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:01:24 +05:00
arazor72 a9d669e397 Sprint 5: in-prototype screen documentation
Shared data layer + two access points for design-review docs.

- src/docs.js — SCREEN_DOCS dictionary keyed by screen id with
  title, category, goal, tasks[], rationale[], and optional
  variants note; helpers getScreenDoc(screenId, ctx) resolves home
  variants (home:cards / home:list / home:feed) and compound routes
  (doctor:id, article:id, chat:id, etc.); getAllDocs groups by
  category; resolveRouteForDoc maps doc key back to a concrete
  navigable route

- Toggle "Описания" in Tweaks + plashka above the phone in single
  layout: card with category, full-width title, and full goal text
  (line-clamp removed so whole sentence is readable); tap opens a
  full modal with tasks, rationale, and variants

- Live sync: PhoneApp reports top-of-stack via onCurrentChange prop,
  App tracks innerScreen state so the plashka follows the real nav
  inside the phone (clicking "Записаться" on home now updates the
  plashka to the booking screen)

- DocsScreen route "docs" in Tweaks screen selector — categorized
  list of all ~30 screens with collapsible inline descriptions and
  an "Открыть экран" CTA per row

- Convention: SPRINTS.md "Правила разработки" + memory note — when
  editing any src/screens/* file, update the matching entry in
  src/docs.js to keep in-prototype documentation in sync

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:41:15 +05:00
arazor72 61f7e5776d Sprint 4: developer design-system screens
Two service screens accessible via Tweaks screen selector:

- DEV · Палитра — every color role grouped (primary / accent / warm /
  status / text / surfaces) with swatch, role name, CSS var name,
  hex code, and usage note. Key-color banner at top. Dynamic values
  (primary/accent/warm/success-50/fg-4/p300) recompute from
  ctx.palette when the user flips the color in Tweaks.

- DEV · Примеры — rendered components (buttons, chips, surfaces,
  status blocks, text hierarchy, avatars, badges, inputs, segmented
  control, shadow/radius scale) each labeled with the CSS vars it
  uses, so devs can read the theme visually.

Plumbed the active palette object via ctx.palette from App.jsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:02:00 +05:00
arazor72 6700e96476 Complete Sprint 2 (chat hub) and Sprint 3 (color palettes)
Sprint 2 — chat hub:
- Chat tab becomes a list of three conversations: AI assistant
  (featured, gradient card), doctor Syndaev, and clinic administrator
- data.js: new `chats` array with kind, participants, message history,
  online/unread state, time of last message, AI suggestions
- screens-chats.jsx: ChatsListScreen and ChatConversationScreen with
  per-kind UI — AI gets suggestion chips + AI-badge, doctor gets
  video-call button, operator gets phone button
- Recovery surgeon chat button routes to chat:doctor-syndaev directly
- Tab bar auto-hides on pushed chat:<id> routes
- ChatTabScreen removed from screens-misc.jsx

Sprint 3 — color palettes:
- ACCENT_OPTIONS extended with accent/accentDark/accent50,
  p300/success50/fg4 so palette switches change the full theme
  (primary + warm + accent + muted + success)
- New palette "Лагуна" from the design-system screenshot: primary
  #29AEE3 (sky blue), accent #FFA39C (coral), warm #E9E4D4 (beige)
- New palette "Бриз": Лагуна variant with primary #63BAC3 (muted
  teal) and the bright sky blue #29AEE3 demoted to p300
- All 9 screenshot colors wired: #f2fee6→success-50, #93908f→fg-4,
  #63bac3→p300 (visible as border on Clinic Stats card in Home V2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:41:52 +05:00
30 changed files with 8141 additions and 184 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.md
.DS_Store
*.log
design.tar.gz
design_extracted
.dockerignore
Dockerfile
docker-compose*.yml
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS production
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+285
View File
@@ -0,0 +1,285 @@
# Приоритеты развития мобильного приложения — встреча 23 апр 2026
Клиника УГН (ЛОР, сурдология, аллергология, хирургия). Цель встречи — определить порядок развития функций: что закрываем как базу для всех пациентов, какие сегменты углубляем и в каком порядке.
## 1. Контекст и критерии приоритизации
**Текущие функции:** список врачей, запись на приём, ближайший приём, чат с оператором, профиль, семейный профиль, контакты.
### Ключевые факты о потоке
- **2 из 3 пациентов клиники — повторные.** Значительная доля — **повторные внутри одного лечения**: после первого визита (острое заболевание или обострение хронического) ещё 1–3+ приёма, процедуры, контроль.
- **Бизнес-сегментация (из аналитики клиники)** — 10 сегментов, лидеры по выручке: амбулаторный поток (~120 млн), взрослая хирургия «заблокированный нос» (~30 млн), детская аденоидная хирургия (~20–30 млн), сурдология (~20 млн).
### Продуктовая логика: что делает приложение
**Приложение — инструмент удержания и углубления, а не привлечения.** Первичного пациента клиника получает через сайт, SEO, рекламу, сарафан. Приложение устанавливают уже пришедшие — в момент записи или на первом визите. Поэтому приоритет развития — **повторные во всех сегментах**.
Это совпадает с фактом «2 из 3 — повторные» и означает, что:
- **Первичные сценарии в приложении** закрываются универсальным минимумом (запись + контакты + цены + AI-помощник, который знает услуги). Нет смысла строить специализированные первичные потоки для каждого из 10 сегментов.
- **Повторные сценарии** — специфичны по сегменту и требуют отдельных модулей (бегунок, аудиограмма, АСИТ-дневник и т.п.).
- **Исключения** — сегменты, где «первичный в приложении» ≠ «первый визит»: после первого визита пациент уходит, и вернуть его в клинику может только приложение.
- **Сурдология** — после визита и демо пациент уходит думать 2–3 месяца о покупке аппарата.
- **Хирургия (FESS, детские аденоиды, вазотомия)** — после пред-операционного приёма у хирурга пациент часто уходит **думать, решаться ли на операцию вообще**. Эти «сомневающиеся» — отдельная работа по возврату. Вторая часть — те, кто решился, уходят в 6-недельную подготовку (бегунок).
- **АСИТ** — после назначения впереди 3–5-летний курс, есть сценарии «не начал» и «бросил в первые месяцы».
### Три слоя работы
1. **Фаза 1 · Транзакционная база** — детерминированные функции для любого пациента: запись, ближайший приём, чат с оператором, **план лечения, результаты/медкарта, заказ справок**. Без LLM и прямого чата с врачом. Минимум рисков, быстрый запуск.
2. **Фаза 1.5 · Коммуникационная надстройка** — чат с медицинским консьержем (дежурным врачом/фельдшером) и AI-помощник в shadow-mode. Отделено от Фазы 1, чтобы регламенты и безопасность не задерживали релиз базы.
3. **Фаза 2 · Сегментные модули** — углубление по приоритетным группам сегментов (A → C → B → D).
### Критерии приоритета
| Критерий | Что меряем |
|---|---|
| **Охват** | Сколько пациентов клиники затронуто (в абсолюте) |
| **Глубина пользы** | Насколько закрывает реальную боль (есть ли альтернатива без нас) |
| **Частота касаний** | Как часто функция возвращает пользователя в приложение |
| **Бизнес-эффект** | Влияние на возвратность, средний чек, удержание, вклад в выручку |
| **Сложность** | Вторичная ось: усилия (вкл. зависимости от МИС и контента) |
---
## 2. Текущие функции — утилита и приоритет
### 🟢 Высокий приоритет (ядро ценности)
**Запись на приём** — главный транзакционный поток. Экономит 3–8 минут разговора, 24/7, ближайшие окна. Единственная функция, которая приводит новых пациентов через приложение. Усилить: запись из плана лечения в 1 клик (§3), запись из маршрутной карты пред-опа (§4).
**Ближайший приём** — «что со мной сейчас». Снимает тревогу «когда, куда, к кому». Перед визитом открывается 3–10 раз. Усилить: чек-лист подготовки, маршрут до кабинета, тригеры 24ч/2ч/15мин.
### 🟡 Средний приоритет (поддержка)
**Список врачей / карточка врача** — инструмент выбора при первом визите. Повторный пациент почти не открывает. Усилить: соцдоказательства, фильтр «по моему диагнозу».
**Чат с оператором** — замена звонка в регистратуру. Ограничен одним собеседником. Основное развитие — в §3.
**Профиль** — точка идентификации. Редко открывается, но критичен для персонализации.
### 🔴 Низкий приоритет (ниша)
**Семейный профиль** — высокая польза для 20–30% (родители с детьми, взрослые с пожилыми), становится критичным для сегмента «дети с аденоидами» и «сложные хроники — ЧБД».
**Контакты** — функция «галочка», 1–2 открытия за всё время.
### Вывод
Ядро «Запись + Ближайший приём» работает. Слабое место — **нет причины открывать приложение между визитами**. Это и закрывает джентльменский набор.
---
## 3. Джентльменский набор — базовые функции для всех пациентов
Минимум, который должен быть у любого пациента клиники, вне зависимости от диагноза и сегмента. **Приоритет до любых сегментных модулей.**
Набор сознательно разделён на две подфазы по риску и зависимостям: **транзакционная база** (детерминированные функции с понятным MVP) и **коммуникационная надстройка** (LLM и асинхронные каналы, требующие регламентов и безопасности). Это позволяет выпустить базу быстро и безопасно, а надстройку строить поверх уже стабильного фундамента.
### Фаза 1 · Транзакционная база
Функции, где пациент **что-то получает или делает** — без LLM и без диалога. Минимизируют риски, быстрее в запуск, дают те самые «не удалю это приложение»-причины.
| Функция | Статус | Что в ней |
|---|---|---|
| Запись на приём | ✅ есть | Запись 24/7, ближайшие окна. |
| Ближайший приём | ✅ есть | Что, где, когда — снимает тревогу перед визитом. |
| Чат с оператором | ✅ есть | Справки, переносы, счета, расписание (человеческий канал). |
| Статьи / база знаний | ✅ есть (Спринт 1) | Уже есть. Источник правды для будущего RAG. |
| **План лечения с приёма** | ❌ нет — фундамент | После каждого приёма структурированный чеклист: диагноз, назначения (препарат + доза + курс + календарь), ссылки на процедуры самопомощи (Группа A), контрольный приём, запись на следующий визит в 1 клик. Живой объект с напоминаниями. Источник правды для всех следующих надстроек. |
| **Результаты обследований и медкарта** | ❌ нет | Пациент видит свои анализы, аудиограммы, снимки, заключения **без звонка в клинику**. Статус каждого результата: готов / в работе / годен до [дата]. Критично для Группы C (пред-оп): срок годности анализов виден в одном месте. |
| **Заказ справок и финансовых документов** | ❌ нет | Справка для налогового вычета (13%), справки работодателю, копии заключений, счета. Заказ в 1 клик, готовая в приложении или с самовывозом. Снимает нагрузку с администраторов и даёт сильный retention-якорь даже у разовых пациентов. |
### Фаза 1.5 · Коммуникационная надстройка
Функции, где идёт **живой или AI-диалог** по поводу лечения. Строятся поверх уже работающей базы (плана лечения, медкарты, статей) и требуют отдельных регламентов — по ответственности, SLA, проверке качества.
| Функция | Статус | Что в ней | Ключевой риск |
|---|---|---|---|
| **Чат с медицинским консьержем** | ❌ нет | **Не прямой чат с лечащим врачом.** Дежурный врач / фельдшер / медсестра отвечает по протоколам на 80% рутины («можно ли совмещать с X», «нормально ли, что третий день болит», «когда повторно сдать анализ»), эскалирует клинически значимое лечащему. Асинхронно, SLA — X часов в рабочее время. | Перегрузка врачей и SLA-хаос, если пустить пациентов напрямую к хирургу. Консьерж-слой — основной буфер. Нужен регламент тарификации/компенсации врачам за эскалированные вопросы. |
| **AI-помощник (RAG 24/7)** | ❌ нет | Знает базу знаний клиники, статьи, план лечения пациента, историю приёмов. Объясняет назначение, ищет ответ в статьях, оценивает «норма или срочно» и эскалирует к консьержу/врачу при тревожных признаках. | Галлюцинация LLM в медицинском контексте = юридический и репутационный риск. **Запускаем в shadow-mode**: ответы сначала идут через консьержа для валидации, метрики точности собираются, и только после набора статистики — переход в прямой режим для безопасных категорий вопросов. |
### Почему это ядро, а не сегмент
- **Работает на 100% пациентов** — без разделения по сегменту.
- **Усиливает любое сегментное направление**: бегунок, слухопротезирование, АСИТ — всё опирается на план лечения, медкарту и тот же чат-канал.
- **Закрывает главный пробел транзакционной модели** — «что делать между визитами».
- **Частично закрывает самый массовый сегмент** (амбулаторный + хроники). Полностью закрыть их может только отдельный модуль «Процедуры самопомощи» (Группа A, §4.3) — это первый сегментный модуль после Фазы 1.5.
### Порядок работ внутри набора
**Фаза 1:**
1. **План лечения с приёма** — фундамент. Без структурированных назначений не работает ничего поверх (ни AI, ни напоминания, ни процедуры самопомощи).
2. **Результаты / медкарта** — следующий retention-якорь.
3. **Заказ справок** — быстрый win для большой части пациентов, снимает нагрузку с администраторов.
**Фаза 1.5** (после стабилизации Фазы 1):
4. **Чат с медицинским консьержем** — инфраструктура канала + регламент ответов.
5. **AI-помощник на RAG (shadow-mode)** — сначала ответы валидируются консьержем, потом постепенный переход в прямой режим для безопасных категорий.
### Критическая зависимость от МИС
«План лечения» — это структурированные данные (препарат, доза, частота, курс, процедура). Два сценария:
- **МИС отдаёт назначения по API структурированно** — план лечения собирается автоматически из данных приёма. Предпочтительно.
- **МИС отдаёт только PDF-заключение** — план лечения MVP стартует с **ручного ввода врачом в виджет** (шаблоны по нозологиям + чеклисты + автоподстановка из предыдущего приёма). Интеграция с МИС — отдельной задачей позже.
Ответ на этот вопрос определяет сроки и стоимость Фазы 1. Добавлен в §6 как первоочередной.
---
## 4. Сегменты пациентов через призму приложения
### 4.1. Фундаментальное деление: первичный vs повторный
| Линза | Первичный в приложении | Повторный в приложении |
|---|---|---|
| **Главный job** | «Куда мне обратиться и как записаться» | «Что мне делать сейчас по моему лечению» |
| **Источник** | Попадает через сайт/рекламу/сарафан | Приложение уже установлено, уведомление/сам открывает |
| **Частота открытия** | 1–3 раза до визита | Ежедневно в активном эпизоде |
| **Что нужно в приложении** | Базовый минимум (запись, цены, контакты, врачи) | Специализированный модуль сегмента |
| **Приоритет развития** | Низкий (веб и реклама работают лучше) | ● Высокий (2/3 потока) |
**Ключевое следствие.** Специализированных «первичных» модулей под каждый сегмент строить не нужно. Исключения — сегменты, где «после первого визита» начинается длинный путь: сурдология, хирургия, АСИТ. Там «первичный» в терминах клиники ≠ «первичный» в терминах приложения.
### 4.2. Матрица 10 сегментов × первичный / повторный
Из 10 сегментов бизнес-аналитики выделяем: объём × чек × что нужно в приложении для первичного × что нужно для повторного × итоговый приоритет приложения.
| # | Сегмент (из аналитики) | Объём / Вклад | Первичный в приложении | Повторный в приложении (главная работа) | Приоритет в App |
|---|---|---|---|---|:-:|
| 1 | **Взрослые «заблокированный нос»** (полипы, перегородка, FESS) | 300 оп/год · 100т · 30 млн | Джентльменский + материалы о методах (FESS/лазер), кейсы пациентов, калькулятор, **возвратные push для сомневающихся после пред-оп приёма** | Пред-оп бегунок (6 нед) → восстановление → контроль 3/6/12 мес | ● **Высокий** (Группа C) |
| 2 | **Амбулаторный поток** (острые и хроники ЛОР) | Тысячи/мес · 120 млн | Джентльменский | План лечения + эпизод лечения + дневник симптомов (§3) **+ модуль процедур самопомощи: библиотека техник (промывание носа физраствором, полоскание горла, ингаляции), напоминания, трекер комплаенса** | ● **Высший** (Группа A — отдельный модуль) |
| 3 | **Родители детей с аденоидами** | 400500 оп/год · 60т · 20–30 млн | Джентльменский + семейный профиль + подготовка ребёнка + **материалы «нужна ли операция» и возврат сомневающихся родителей** | Пред-оп бегунок (детская версия) → восстановление. Далее — редко. | ● **Высокий** (Группа C) |
| 4 | **Потеря слуха (сурдология)** | 20 млн/год | **Нестандартный первичный:** аудиограмма в профиле, аудио-демо, каталог моделей, калькулятор, шеринг близкому, возвратные push | Паспорт аппарата, сервисный календарь, расходники, калибровка раз в год, дневник адаптации | ● **Высокий** (Группа B — уникальный модуль) |
| 5 | **Сложные хроники** (иммунология/аллергология, ЧБД) | Высокий, часть 120 млн · длинный LTV | Джентльменский + семейный (ЧБД) + запись на консилиум | Модуль процедур самопомощи (Группа A): промывания, полоскания, ингаляции у ЧБД. Плюс специализация: **АСИТ-трекер** (дневник симптомов + пыльцевой календарь + навигатор побочки) | ● **Высокий** (Группа A + Группа D) |
| 6 | **Зависимость от капель (вазотомия)** | Высокий объём · входной в хирургию | Джентльменский + материалы «как слезть с капель» + **возврат сомневающихся после пред-оп приёма** | Пред-оп бегунок (компактная версия) → восстановление | ● **Высокий** (Группа C) |
| 7 | **Пульмонология** (кашель/астма) | Средний · сезонный | Джентльменский | Дневник астмы, напоминания об ингаляторах, контроль триггеров (пересекается с АСИТ) | ◐ **Средний** (частично Группа D) |
| 8 | **Социально активные храпуны** | Низкий · высокая платёжеспособность | Джентльменский + образовательный контент о сомнологии | Сомнологический чек-up, СИПАП-трекер (если назначен) | ○ **Низкий** |
| 9 | **Фониатрия** (голос) | Очень низкий · срочный | Джентльменский + срочная запись | Короткое окно, низкая повторность | ○ **Низкий** |
| 10 | **Check-up и Второе мнение** | Единичный · высокая маржа | Джентльменский + позиционирование бренда Оленевой | Разовая услуга, минимальная повторность | ○ **Низкий** |
### 4.3. Четыре группы сегментов для повторных
**Группа A. Амбулаторный поток + хроники — процедуры самопомощи** — сегменты 2 + 5 (повторная часть). Самый массовый сегмент × ежедневная повторность.
Джентльменский набор (план лечения + чат с врачом + AI) закрывает коммуникацию и напоминания, **но не закрывает саму регулярную работу пациента между приёмами**:
- Промывание носа физраствором (при хр. рините, синусите, после операций)
- Полоскание горла (при хр. тонзиллите)
- Ингаляции
- Гимнастика слуховой трубы (при евстахиите)
- Голосовые упражнения (фониатрия)
- Туалет уха (при хр. отите)
**Что нужно в модуле:**
- **Библиотека техник** — короткие видео и пошаговые инструкции «как правильно». Ошибки в технике (сильный напор при промывании, не тот раствор) — частая причина, почему «не помогает».
- **Напоминания** — утро/вечер по схеме, привязаны к плану лечения.
- **Трекер комплаенса** — ежедневные отметки, стрики, сводка «сколько дней подряд».
- **Дневник симптомов в связке** — «промываю 5 дней, насморк уменьшился с 4 до 2» — главный мотиватор продолжать.
- **Сводка для врача** — перед контрольным приёмом врач видит, что пациент делал, как часто, с какой динамикой симптомов.
**Это отдельный модуль, не продолжение Фазы 1.** Он опирается на план лечения как на источник назначений, но требует собственного контента (библиотека техник) и UX (трекер).
**Группа B. Сурдология** — сегмент 4. Уникальный набор функций (аудиограмма, аудио-демо, каталог, паспорт аппарата). Пожизненная повторность. Высокий чек. **Отдельный модуль, не пересекается с другими.**
**Группа C. Пред-операционная подготовка + восстановление** — сегменты 1 + 3 + 6 (одна механика для трёх сегментов). **Две фазы, как у Группы B:**
- **Кандидаты на операцию** — после пред-оп приёма ушли «думать». Возврат через материалы, кейсы пациентов, чат с хирургом, возвратные push-триггеры (3/7/21 день), прозрачность по цене/рассрочке.
- **Решившиеся** — бегунок → чек-лист дня операции → операция → восстановление. Окно 6–12 недель.
**Один модуль закрывает три сегмента бизнес-аналитики** (FESS, детские аденоиды, вазотомия).
**Группа D. АСИТ + контроль астмы** — часть сегмента 5 + сегмент 7. Ежедневный трекер, дневник симптомов, навигатор побочки. **Самый сложный, требует верификации врачом.**
### 4.4. Что остаётся за кадром
Сегменты 8 (храпуны), 9 (фониатрия), 10 (check-up) — низкая повторность и/или низкий охват. Закрываются базовым джентльменским набором, специализированных модулей в ближайшем горизонте не требуют.
---
## 5. Порядок внедрения
### Фаза 1. Транзакционная база (§3)
План лечения → Результаты / медкарта → Заказ справок.
**Эффект:** пациент видит свои назначения, результаты и может получить документы без звонка в клинику. Это и есть базовые retention-якоря: «не удалю это приложение». Массовый амбулаторный + хроники покрыты в части информирования и документов.
**Критический параметр сроков** — структурированность назначений в МИС (см. §6 вопрос 1). От этого зависит, собирается ли план лечения автоматически или врач заполняет его вручную в виджете.
### Фаза 1.5. Коммуникационная надстройка (§3)
Чат с медицинским консьержем → AI-помощник (shadow-mode).
**Зачем отдельная подфаза:** эти функции несут организационные и юридические риски (SLA, выгорание врачей, галлюцинации LLM) и могут надолго задержать релиз, если класть в Фазу 1. Выделение в 1.5 позволяет: собрать транзакционную базу быстро и безопасно, отработать регламент консьержа отдельно, запустить AI сначала под валидацией человека.
### Фаза 2. Сегментные модули — порядок
Рекомендуемая последовательность:
**1. Группа A (Процедуры самопомощи хроников) — первым**
- **Самый массовый охват** — тысячи пациентов в месяц (сегмент 2 + 5).
- Напрямую опирается на план лечения из Фазы 1 — продолжение той же инфраструктуры.
- Средняя сложность: контент (видео-инструкции, 10–15 техник) + простой трекер + дневник симптомов.
- Бизнес-эффект: рост комплаенса → меньше обострений → меньше экстренных приёмов и операций, на которые хроники срываются, когда дома не помогает.
- Эффект заметен пациенту сразу — ежедневная польза.
**2. Группа C (Пред-операционная подготовка) — вторым**
- Закрывает 3 сегмента (1, 3, 6) одним модулем.
- Быстрый MVP — маршрутная карта с чекбоксами, сроки годности, автозапись. Восстановление уже есть в прототипе.
- Прямой измеримый бизнес-эффект: снижение переносов операций из-за просроченных анализов + возврат сомневающихся кандидатов на операцию.
- Вклад сегментов в выручку: ~50–60 млн (хирургия), плюс косвенно вазотомия как конвертер в большую хирургию.
**3. Группа B (Сурдология) — третьим**
- Изолированный сегмент с уникальными функциями.
- Пожизненное удержание × высокий чек × растущий рынок (старение).
- Две половины: возврат кандидатов после демо (высокая конверсия в деньги) + обслуживание после покупки (лояльность и LTV).
- Можно запускать параллельно с C после Фазы 1, если есть ресурс.
**4. Группа D (АСИТ + астма) — четвёртым**
- Самая высокая глубина пользы (влияет на медисход), но самая высокая ответственность.
- Требует верификации контента аллергологом/пульмонологом клиники.
- Можно начать готовить контент и интеграции параллельно с A/C/B, выпустить позже.
### Что не берём в план сейчас
- Храпуны (8), фониатрия (9), check-up (10) — специализированных модулей в горизонте планирования не делаем.
- Отдельные «первичные» модули под сегменты — не делаем. Первичный путь = джентльменский набор + базовые функции (запись, врачи, цены, контакты), которые уже есть.
### Что нужно сделать вне Фаз, уже сейчас
- **Добавить аллерголога-иммунолога в список врачей** (есть специализация, нет конкретного врача в данных). Предусловие для Группы D.
---
## 6. Вопросы к обсуждению на встрече
**Первоочередной (определяет сроки Фазы 1):**
1. **МИС и структурированные назначения.** Отдаёт ли МИС по API назначения структурированно (препарат, доза, частота, курс) или только PDF-заключением? От этого зависит: план лечения собирается автоматически или врач заполняет вручную в виджете. Там же: API для результатов анализов, сроков годности, расписания.
**По процессам клиники:**
2. **Медицинский консьерж — кто в роли?** Дежурный врач / фельдшер / медсестра? Кто держит SLA? Как компенсируется эскалация вопроса лечащему врачу?
3. **SLA чата** — целевое время ответа в рабочее время (1ч / 4ч / день)?
4. **Справки и финдокументы** — текущий процесс заказа через администратора; что готовы автоматизировать в первую очередь (ФНС-справка, выписки, счета)?
**По метрикам (для оценки эффекта):**
5. Подтвердить **«2 из 3 — повторные»** — уникальные пациенты в год или визиты? Меняет оценку охвата Группы A.
6. **Процент повторных, не доходящих до контроля** — метрика успеха Фазы 1.
7. **Сурдология** — кандидатов/мес, % возврата за аппаратом. От этого зависит приоритет Группы B.
8. **Переносы операций** — частая причина просроченные анализы? Усиливает Группу C.
**По ресурсам Фазы 1.5 и 2:**
9. Готов ли **аллерголог** верифицировать контент для АСИТ-трекера? Без этого D не запускаем.
10. База знаний / статьи — достаточно ли материала для RAG или формировать отдельно?
11. Политика по **AI shadow-mode**: кто утверждает категории вопросов для постепенного перевода в прямой режим?
---
## Приложение. Короткая сводка для слайда
**Что предлагаем:**
1. **Фаза 1** — транзакционная база для всех: план лечения, результаты/медкарта, заказ справок + уже существующие запись, ближайший приём, чат с оператором, статьи. Быстрый MVP, минимум рисков.
2. **Фаза 1.5** — коммуникационная надстройка: чат с медицинским консьержем (не прямой с врачом) и AI-помощник в shadow-mode. Запускается после стабилизации базы.
3. **Фаза 2** — четыре сегментных модуля в порядке: A (процедуры самопомощи хроников — массовый) → C (пред-оп подготовка — 3 сегмента разом) → B (сурдология — высокий чек) → D (АСИТ + астма — с верификацией врачом).
4. **Первичные в приложении** = базовый минимум, который уже есть. Новых первичных модулей не строим — они приходят через веб. Исключения — сегменты, где после первого визита пациент «уходит думать»: сурдология, хирургия, АСИТ.
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
# mobile-app-prototip
Прототип мобильного приложения ПЦС ПТ (React + Vite). Содержит набор экранов и дизайн-систему, разбитые по спринтам (см. `SPRINTS.md`).
## Запуск
```bash
npm install
npm run dev
```
Сборка и предпросмотр:
```bash
npm run build
npm run preview
```
## Дизайн-система
Токены в `src/tokens.css`.
Основные цвета:
- `#2BB4A8` — primary (teal), бренд и основные CTA
- `#E04E44` — accent (красный), выделенные действия
- `#F5EDDF` — warm hero, тёплые приветственные блоки
- `#2E9B6B` — success, `#E8A13C` — warning, `#D94141` — danger
Шрифты: **PT Sans** (основной), **PT Sans Narrow** (display/заголовки).
## Экраны
Описания всех экранов — в `src/docs.js`. Ключевые группы и цели пользователя:
- **Главная** (`home:*`) — варианты стартового экрана: Карточки, Лента, Таймлайн, Таймлайн X, Светлая плитка. Цель: быстрый старт — запись или ближайший приём.
- **Запись** (`booking-*`) — 4-шаговый флоу: специализация → врач → дата/время → подтверждение → успех.
- **Врачи** (`doctors`, `doctor`) — список и карточка врача для решения «записаться».
- **Приёмы и медкарта** (`appts`, `appt`, `medcard`, `results`, `result*`) — контроль визитов, заключения, анализы, амбулаторная карта.
- **Восстановление и тесты** (`recovery`, `audiotest`) — трекер после операции и скрининг слуха.
- **Чаты** (`chat`, `chat:ai`, `chat:doctor-*`, `chat:operator`) — AI-помощник, врач, администратор.
- **Сервис** (`profile`, `qr`, `telemed`, `notifications`, `search`, `contacts`, `prices`, `articles`, `article`) — профиль, идентификация, видеозвонок, уведомления, поиск, контент.
- **DEV/Docs** (`dev-colors`, `dev-examples`, `docs`) — палитра, примеры компонентов и гайд по экранам.
+148 -4
View File
@@ -9,6 +9,13 @@
- По завершении спринта — короткое резюме в блоке **Итоги** - По завершении спринта — короткое резюме в блоке **Итоги**
- Нерешённое переносится в следующий спринт - Нерешённое переносится в следующий спринт
## Правила разработки
- **Документация = часть прототипа.** При любом изменении экрана в `src/screens/*.jsx` — проверить и обновить соответствующую запись в `src/docs.js` (`goal` / `tasks` / `rationale` / `variants`). Эти описания показываются в плашке над телефоном и на экране «Документация», поэтому устаревшие формулировки мешают коллегам на ревью.
- Новый экран → добавить запись в `SCREEN_DOCS`, выбрать категорию из `ORDER``getAllDocs`), для compound-маршрутов (`something:id`) прописать кейс в `resolveRouteForDoc`.
- Мелкие CSS-правки (цвета, отступы, иконки) документировать не нужно — в `docs.js` описывается intent, а не пиксели.
- Варианты экрана с ctx (home cards/list/feed) — отдельная запись на каждый вариант (`home:cards`, `home:list`, `home:feed`).
--- ---
## Спринт 1 · 19 апр 2026 ## Спринт 1 · 19 апр 2026
@@ -32,11 +39,23 @@ _заполнить в конце спринта_
--- ---
## Спринт 2 · даты TBD ## Спринт 2 · 19 апр 2026
**Цель:** _TBD_ **Цель:** превратить одиночный чат в центр всех коммуникаций с клиникой — AI-помощник, врач, администратор.
### Идеи-кандидаты **Итоги.** Закрыт. Чат стал списком из трёх диалогов (AI-помощник, врач, администратор), каждая карточка ведёт в отдельную конверсацию с разными UI-акцентами. Таббар автоматически скрывается в подэкранах `chat:<id>`.
### План
- [x] Экран `chat` — список диалогов: AI-помощник (featured), врач, администратор
- [x] Данные: три чата в `data.js` (kind, участники, сообщения, online, непрочитанные, время последнего сообщения)
- [x] AI-помощник: расширенный диалог с напоминаниями о лекарствах, чипсы с подсказками ответов
- [x] Чат с регистратурой: запросы справок, переносы приёмов, счета
- [x] Экран `chat:<id>` — конверсация: разный аватар/статус/UI-акценты для AI, врача, оператора
- [x] Видеозвонок-кнопка только у врача; suggested replies только у AI
- [x] Переадресация: сурджен в «Восстановлении» → `chat:doctor-syndaev`
- [x] Tweaks: добавить три варианта чата в список экранов
### Задел на Спринт 3
- [ ] Экран онбординга (первый запуск) - [ ] Экран онбординга (первый запуск)
- [ ] Пустые состояния для всех вкладок - [ ] Пустые состояния для всех вкладок
- [ ] Анимации переходов между экранами - [ ] Анимации переходов между экранами
@@ -47,8 +66,133 @@ _заполнить в конце спринта_
- [ ] Форма обратной связи / отзыв о враче - [ ] Форма обратной связи / отзыв о враче
- [ ] Тёмная тема - [ ] Тёмная тема
### Итоги
_заполнить в конце спринта_
---
## Спринт 3 · 20 апр 2026
**Цель:** применить новую палитру из дизайн-системы (скрин от 20.04) и добавить её как 4-й вариант в Tweaks.
Входные цвета: `#ffffff #fffde4 #f2fee6 #d4f6f8 #e9e4d4 #ffa39c #63bac3 #29aee3 #93908f` — sky-blue primary + coral accent + warm pastels.
### План ### План
- [ ] _выбрать и зафиксировать задачи на спринт_ - [x] Расширить схему `ACCENT_OPTIONS`: добавить `accent`, `accentDark`, `accent50` (чтобы палитра меняла и красный акцент, а не только primary + warm)
- [x] Обновить `applyTheme` — устанавливать `--c-accent`, `--c-accent-dark`, `--c-accent-50` из палитры
- [x] Проставить accent-поля в существующих палитрах (тил/терра/марин) → сохранить текущий красный `#E04E44`
- [x] Добавить 4-ю палитру **Лагуна**: primary `#29AEE3`, accent `#FFA39C`, warm `#E9E4D4`
- [x] Пристроить три оставшихся цвета из скрина: `#f2fee6``success-50`, `#93908f``fg-4`, `#63bac3``primary-300` (с видимым применением в бордере Clinic Stats card на Home V2)
- [x] Добавить 5-ю палитру **Бриз** — вариант Лагуны с приглушённым primary `#63BAC3` (яркий `#29AEE3` переехал в p300, warm/accent/success/fg-4 наследуются от Лагуны)
- [ ] Визуальная проверка всех экранов в новой палитре: кнопки, чипы, CTA, прогресс восстановления, успех-галочка, таббар-бейджи
---
## Спринт 4 · 20 апр 2026
**Цель:** служебные экраны для разработчиков — визуализация дизайн-системы прямо в прототипе, чтобы переключая палитру сразу видеть все цвета и их применение.
### План
- [x] Пробросить текущую палитру через `ctx.palette` (чтобы dev-экраны могли прочитать все поля)
- [x] Экран **DEV · Палитра**: таблица всех цветов (primary / warm / accent / status / neutrals / text), у каждой строки — свотч, название роли, CSS-переменная, hex-код, краткое описание применения
- [x] Экран **DEV · Примеры**: реальные компоненты (кнопки, чипы, карточки, текстовая иерархия, статусы) с подписями «какая CSS-переменная используется»
- [x] Добавить оба экрана в SCREEN_OPTIONS Tweaks
- [x] Динамический рендер: при переключении палитры значения hex обновляются автоматически
---
## Спринт 6 · 20 апр 2026
**Цель:** вариант Главной для пациентов с хроническими заболеваниями — поддержание понимания «что со мной происходит», ежедневных задач по здоровью и связи с врачом/AI-помощником. Выведено как **Таймлайн X** (X = «хроник»).
### План
- [x] Добавить в `data.js` блок `chronic` — диагноз, стадия, ключевые метрики (дни без обострений, комплаенс, обострения в году), ежедневные/плановые задачи, рекомендации, история наблюдения (visits)
- [x] Экран `HomeTimelineXScreen` в `screens-home.jsx`: health-status hero, задачи сегодня (daily + scheduled), промо-блок связи (AI + врач), вертикальный таймлайн истории, ближайший приём, рекомендации горизонтально, CTA записи, статьи
- [x] Добавить `timelineX` в `HOME_OPTIONS` (App.jsx) и в HOME map (PhoneApp.jsx)
- [x] Описание в `src/docs.js` под ключом `home:timelineX` (соблюдение конвенции из Спринта 5)
---
## Спринт 9 · 21 апр 2026
**Цель:** ввести переключатель дизайн-системы в Tweaks (раздел **«Дизайн»**: «Клод» / «Прозрачная карточка») и начать перерабатывать экраны в стиле «Светлой плитки» (primary-50 карточки, круглые teal-иконки, крупные h1, тонкие бордеры primary-100, минимум градиентов).
**Идея.** «Клод» — текущий нейтральный дизайн (Spacious gradient + разные tint-карточки). «Прозрачная карточка» — унифицированный стиль на базе «Светлой плитки»: каждая сущность в primary-50 карточке со скруглением 16, шапки — белая кнопка-назад в primary-50 кружке, плитка-первее-списка.
### Инфраструктура
- [x] `TWEAKS_DEFAULT.design = 'claude'` + `DESIGN_OPTIONS` (App.jsx)
- [x] Группа «Дизайн» в TweaksPanel (2 кнопки)
- [x] `ctx.design` прокинут в PhoneApp.renderScreen
- [x] В plate-режиме главная всегда = `HomeSplashScreen` (homeVariant форсится на `splash`)
- [x] Механизм fallback: экраны без plate-версии показываются в Клоде
### Plate-версии экранов
- [x] Главная — `HomeSplashScreen` (уже готов из Спринта 8, базовый эталон)
- [x] Профиль — `ProfilePlateScreen` (паспорт + 2 быстрые кнопки QR/Карта + 4 секции списком plate-карточек)
- [x] Приёмы — `ApptsPlateScreen` (табы как pill-сегмент, карточка приёма с «Активно», чипы даты/времени/адреса)
- [x] Детали приёма — `ApptDetailPlateScreen` (крупное время в plate-hero + заключение с белыми вложенными pane)
- [x] Электронная карта — `MedcardPlateScreen` (паспорт + pill-табы + все 5 табов в plate-стиле)
### Задел на следующий спринт
- [ ] Врачи (список + карточка врача) в plate-стиле
- [ ] Флоу записи (specs/doctor/time/confirm/success) в plate-стиле
- [ ] Чаты (список + конверсация) в plate-стиле
- [ ] Результаты (список + аудио + эндоскопия) в plate-стиле
- [ ] Восстановление, тест слуха — в plate-стиле
- [ ] Уведомления, QR — в plate-стиле
- [ ] Статьи, контакты, цены, поиск — в plate-стиле
- [ ] docs.js — отдельные записи для plate-вариантов если стилистика существенно отличается от Клода
### Итоги
_заполнить в конце спринта_
---
## Спринт 8 · 21 апр 2026
**Цель:** добавить в Tweaks 5-й вариант главной по макету Stitch — приветственный экран с тёплой карточкой записи и плиткой 2×2 «Полезная информация».
**Итоги.** Закрыт. Реализован `HomeSplashScreen` (screens-home.jsx) + вспомогательный `SplashTile`. В процессе название варианта переименовано из «Сплэш» → **«Светлая плитка»** (id=`splash`) — более понятно на русском. Имя пациента тянется из `patient.shortName`, ближайший приём из `appointments`. Доступ: Tweaks → «Главный экран» → «Светлая плитка». Стал базой для дизайн-системы «Прозрачная карточка» в Спринте 9.
### План
- [x] `HomeSplashScreen` в `screens-home.jsx`: шапка (аватар + «Главная» + колокольчик), крупный h1 «Добрый день, {имя}!», поиск врача, секция «Записи на прием» (ближайшая + мои приёмы + warm-CTA записи), «Услуги и консультации» 2×1, «Полезная информация» 2×2
- [x] Вспомогательный компонент `SplashTile` — круглая teal-иконка + sub/main
- [x] Имя пациента — из `patient.shortName` (единый источник правды с Профилем и Медкартой)
- [x] Подключить в `HOME_OPTIONS` (App.jsx) и в HOME-map (PhoneApp.jsx) как `splash` → «Светлая плитка»
- [x] Описание `home:splash` в `src/docs.js` (цель, задачи, design-решения, варианты)
---
## Спринт 7 · 21 апр 2026
**Цель:** превратить экран «Медкарта» в полноценную электронную карту пациента — единую точку правды о здоровье, связанную двусторонне с карточками прошедших приёмов и с Профилем.
### План
- [x] Расширить `data.js`: блок `patient` (ФИО, ДР, полис, СНИЛС, № карты, первый визит, лечащий врач) + блок `medcard` (allergies с severity/reaction, chronicConditions с МКБ-кодом, vaccinations, surgeries, prescriptions active/past)
- [x] Дополнить `appointments` past-визиты полями `diagnosis`, `diagnosisCode`, `conclusion`, `prescriptions[]`, `resultIds[]` + добавить визит `a5` (септопластика Синдяева 12 апр)
- [x] Переписать `MedcardScreen`: hero-паспорт с QR-кнопкой, 5 табов (Общее / Посещения / Назначения / Прививки / Операции), карточки посещений ведут в `appt:<id>`
- [x] `ApptDetailScreen`: блок «Заключение» теперь тянет данные из `a.diagnosis/conclusion/prescriptions/diagnosisCode`, добавлены CTA «В медкарте» и «Результаты обследований»
- [x] `ApptsTabScreen`: на вкладке «Прошедшие» primary-CTA «Электронная карта» (вместо «Записаться»)
- [x] `ProfileTabScreen`: шапка читает `patient`, пункт «Электронная карта» с featured-стилем и счётчиками (посещения / аллергии / диагнозы), новый чип «Карта №XXX» рядом с QR
- [x] Обновить `docs.js`: `medcard` и `appt` — отразить двустороннюю связь и новую структуру карты
### Итоги
_заполнить в конце спринта_
---
## Спринт 5 · 20 апр 2026
**Цель:** документация прототипа внутри самого прототипа — чтобы на ревью с коллегами можно было сразу увидеть цель и design-решения по любому экрану.
### План
- [x] Общий слой данных `src/docs.js` — словарь описаний по screen-id: title, category, goal, tasks[], rationale[], variants
- [x] Helper `getScreenDoc(screenId, ctx)` — резолвит варианты home (cards/list/feed) и compound-экраны
- [x] Toggle «Описания» в Tweaks (вкл/выкл)
- [x] Плашка-описание над телефоном в режиме single (только когда тоггл ON) — category + title + 1 строка goal
- [x] Тап по плашке → модал-оверлей поверх сцены с полным описанием (задачи, design-решения, варианты, CTA «Закрыть»)
- [x] Отдельный экран `docs` (Вариант 4) — список всех экранов по категориям с collapsible-описаниями и кнопкой «Открыть экран»
- [x] Наполнить описаниями все ~30 экранов прототипа
### Итоги ### Итоги
_заполнить в конце спринта_ _заполнить в конце спринта_
+913
View File
@@ -0,0 +1,913 @@
"""Собирает MEETING_2026-04-23.pptx из содержания MEETING_2026-04-23.md.
Запуск: python3 build_deck.py
Выход: MEETING_2026-04-23.pptx в корне проекта
"""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.oxml.ns import qn
from copy import deepcopy
# ---------- Бренд клиники ----------
PRIMARY = RGBColor(0x2B, 0xB4, 0xA8)
PRIMARY_DARK = RGBColor(0x1C, 0x8A, 0x80)
PRIMARY_50 = RGBColor(0xE8, 0xF7, 0xF5)
ACCENT = RGBColor(0xE0, 0x4E, 0x44)
WARM = RGBColor(0xF5, 0xED, 0xDF)
SUCCESS = RGBColor(0x2E, 0x9B, 0x6B)
WARNING = RGBColor(0xE8, 0xA1, 0x3C)
DANGER = RGBColor(0xD9, 0x41, 0x41)
FG = RGBColor(0x1F, 0x29, 0x37)
FG2 = RGBColor(0x4B, 0x55, 0x63)
FG3 = RGBColor(0x9C, 0xA3, 0xAF)
BG = RGBColor(0xFF, 0xFF, 0xFF)
SOFT = RGBColor(0xF3, 0xF4, 0xF6)
FONT_SANS = "PT Sans"
FONT_DISPLAY = "PT Sans Narrow"
# ---------- Геометрия 16:9 ----------
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
SW = prs.slide_width
SH = prs.slide_height
BLANK_LAYOUT = prs.slide_layouts[6] # Blank
MARGIN_L = Inches(0.6)
MARGIN_R = Inches(0.6)
MARGIN_T = Inches(0.45)
CONTENT_W = SW - MARGIN_L - MARGIN_R
FOOTER_TXT = "Клиника УГН · Приложение · 23 апр 2026"
# ---------- Примитивы ----------
def add_rect(slide, x, y, w, h, fill=None, line=None, line_w=None):
shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
shp.shadow.inherit = False
if fill is None:
shp.fill.background()
else:
shp.fill.solid()
shp.fill.fore_color.rgb = fill
if line is None:
shp.line.fill.background()
else:
shp.line.color.rgb = line
if line_w is not None:
shp.line.width = line_w
return shp
def add_text(slide, x, y, w, h, text, *, size=16, bold=False, color=FG,
font=FONT_SANS, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
line_spacing=None):
tb = slide.shapes.add_textbox(x, y, w, h)
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = Emu(0)
tf.margin_top = tf.margin_bottom = Emu(0)
tf.vertical_anchor = anchor
lines = text.split("\n")
for i, ln in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
if line_spacing is not None:
p.line_spacing = line_spacing
r = p.add_run()
r.text = ln
r.font.name = font
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
return tb
def add_rich(slide, x, y, w, h, runs, *, align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP, size=14, line_spacing=1.15):
"""runs = [(text, {'bold':bool,'color':RGB,'size':int,'font':str}), ...]"""
tb = slide.shapes.add_textbox(x, y, w, h)
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = Emu(0)
tf.margin_top = tf.margin_bottom = Emu(0)
tf.vertical_anchor = anchor
paragraphs = [[]]
for text, style in runs:
if text == "\n":
paragraphs.append([])
else:
paragraphs[-1].append((text, style))
for i, para in enumerate(paragraphs):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
p.line_spacing = line_spacing
for text, style in para:
r = p.add_run()
r.text = text
r.font.name = style.get("font", FONT_SANS)
r.font.size = Pt(style.get("size", size))
r.font.bold = style.get("bold", False)
r.font.color.rgb = style.get("color", FG)
return tb
def footer(slide, idx, total):
add_rect(slide, 0, SH - Inches(0.32), SW, Inches(0.32), fill=PRIMARY_50)
add_text(slide, MARGIN_L, SH - Inches(0.3), Inches(8), Inches(0.25),
FOOTER_TXT, size=10, color=FG2)
add_text(slide, SW - Inches(1.2), SH - Inches(0.3), Inches(0.6), Inches(0.25),
f"{idx} / {total}", size=10, color=FG2, align=PP_ALIGN.RIGHT)
def title_bar(slide, title, eyebrow=None):
"""Верхняя полоска: маленькая подпись категории + крупный заголовок."""
y = MARGIN_T
if eyebrow:
add_text(slide, MARGIN_L, y, CONTENT_W, Inches(0.3),
eyebrow.upper(), size=11, bold=True, color=PRIMARY,
font=FONT_SANS)
y += Inches(0.34)
add_text(slide, MARGIN_L, y, CONTENT_W, Inches(0.6),
title, size=28, bold=True, color=FG, font=FONT_DISPLAY)
y += Inches(0.7)
# Тонкая линия
add_rect(slide, MARGIN_L, y, Inches(1.2), Inches(0.04), fill=PRIMARY)
return y + Inches(0.25)
def new_slide():
return prs.slides.add_slide(BLANK_LAYOUT)
# ---------- Стилизованная таблица ----------
def add_table(slide, x, y, w, h, data, *,
header=True, col_widths=None, row_heights=None,
header_fill=PRIMARY, header_fg=BG, body_fg=FG,
alt_fill=PRIMARY_50, size=11, header_size=11):
rows, cols = len(data), len(data[0])
tbl_shp = slide.shapes.add_table(rows, cols, x, y, w, h).table
if col_widths:
for i, cw in enumerate(col_widths):
tbl_shp.columns[i].width = cw
if row_heights:
for i, rh in enumerate(row_heights):
tbl_shp.rows[i].height = rh
for ri, row in enumerate(data):
for ci, cell_val in enumerate(row):
cell = tbl_shp.cell(ri, ci)
cell.margin_left = cell.margin_right = Inches(0.08)
cell.margin_top = cell.margin_bottom = Inches(0.05)
# Fill
if header and ri == 0:
cell.fill.solid(); cell.fill.fore_color.rgb = header_fill
elif alt_fill and ri % 2 == 0 and not (header and ri == 0):
cell.fill.solid(); cell.fill.fore_color.rgb = alt_fill
else:
cell.fill.solid(); cell.fill.fore_color.rgb = BG
# Text
tf = cell.text_frame
tf.word_wrap = True
tf.clear()
lines = str(cell_val).split("\n")
for i, ln in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = PP_ALIGN.LEFT
r = p.add_run()
r.text = ln
r.font.name = FONT_SANS
if header and ri == 0:
r.font.size = Pt(header_size)
r.font.bold = True
r.font.color.rgb = header_fg
else:
r.font.size = Pt(size)
r.font.color.rgb = body_fg
return tbl_shp
# ============================================================
# СЛАЙДЫ
# ============================================================
def slide_title():
s = new_slide()
# Фоновая тёплая полоса слева
add_rect(s, 0, 0, Inches(4.5), SH, fill=WARM)
# Teal-акцент
add_rect(s, Inches(4.5), 0, Inches(0.12), SH, fill=PRIMARY)
# Левый блок — мета
add_text(s, Inches(0.7), Inches(0.7), Inches(3.5), Inches(0.3),
"КЛИНИКА УГН", size=12, bold=True, color=PRIMARY_DARK)
add_text(s, Inches(0.7), Inches(1.1), Inches(3.5), Inches(0.3),
"Мобильное приложение", size=13, color=FG2)
add_text(s, Inches(0.7), SH - Inches(1.2), Inches(3.5), Inches(0.3),
"Встреча", size=12, color=FG3)
add_text(s, Inches(0.7), SH - Inches(0.9), Inches(3.5), Inches(0.4),
"23 апреля 2026", size=18, bold=True, color=FG, font=FONT_DISPLAY)
# Правый блок — заголовок
add_text(s, Inches(5.1), Inches(2.2), Inches(7.6), Inches(1.0),
"Приоритеты развития", size=48, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, Inches(5.1), Inches(3.2), Inches(7.6), Inches(0.8),
"мобильного приложения", size=36, color=FG2, font=FONT_DISPLAY)
# Подзаголовок
add_rect(s, Inches(5.1), Inches(4.5), Inches(0.8), Inches(0.04), fill=PRIMARY)
add_text(s, Inches(5.1), Inches(4.7), Inches(7.6), Inches(1.5),
"Джентльменский набор для всех\nи четыре группы сегментов повторных",
size=20, color=FG2, font=FONT_DISPLAY)
return s
def slide_agenda():
s = new_slide()
y = title_bar(s, "Повестка", "Что обсуждаем")
items = [
("1", "Контекст и критерии приоритизации",
"Факты о потоке пациентов, 10 бизнес-сегментов, рамка оценки"),
("2", "Текущие функции — что есть",
"Приоритеты использования и точки усиления"),
("3", "Джентльменский набор для всех пациентов",
"Транзакционная база (Фаза 1) + коммуникационная надстройка (Фаза 1.5)"),
("4", "10 сегментов × первичный/повторный в приложении",
"Матрица и 4 группы сегментных модулей"),
("5", "Порядок внедрения и решение",
"Фаза 1 → Фаза 1.5 → Фаза 2 (A → C → B → D). Что выбираем сегодня"),
]
row_h = Inches(0.95)
for i, (num, title, sub) in enumerate(items):
yy = y + row_h * i
# кружок-номер
circle = s.shapes.add_shape(MSO_SHAPE.OVAL, MARGIN_L, yy,
Inches(0.6), Inches(0.6))
circle.shadow.inherit = False
circle.fill.solid(); circle.fill.fore_color.rgb = PRIMARY
circle.line.fill.background()
tf = circle.text_frame
tf.margin_left = tf.margin_right = Emu(0)
tf.margin_top = tf.margin_bottom = Emu(0)
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
r = p.add_run(); r.text = num
r.font.name = FONT_DISPLAY; r.font.size = Pt(20); r.font.bold = True
r.font.color.rgb = BG
add_text(s, MARGIN_L + Inches(0.9), yy + Inches(0.02),
Inches(11), Inches(0.4), title, size=18, bold=True,
color=FG, font=FONT_DISPLAY)
add_text(s, MARGIN_L + Inches(0.9), yy + Inches(0.45),
Inches(11), Inches(0.4), sub, size=13, color=FG2)
return s
def slide_facts():
s = new_slide()
y = title_bar(s, "Ключевые факты о потоке", "Контекст")
# Большая статистика слева
add_text(s, MARGIN_L, y + Inches(0.2), Inches(5), Inches(1.6),
"2 из 3", size=88, bold=True, color=PRIMARY, font=FONT_DISPLAY)
add_text(s, MARGIN_L, y + Inches(1.8), Inches(5.5), Inches(0.7),
"пациентов клиники — повторные",
size=18, color=FG, font=FONT_DISPLAY)
add_text(s, MARGIN_L, y + Inches(2.4), Inches(5.5), Inches(1.4),
"Значительная доля — повторные внутри одного эпизода лечения: "
"после первого визита ещё 1–3+ приёма, процедуры, контроль.",
size=13, color=FG2)
# Правый блок — выручка
bx = Inches(7.0); bw = Inches(5.7)
add_rect(s, bx, y + Inches(0.1), bw, Inches(4.8),
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(0.5))
add_text(s, bx + Inches(0.3), y + Inches(0.3), bw - Inches(0.6), Inches(0.4),
"ВЫРУЧКА ~200 МЛН / ГОД · 10 СЕГМЕНТОВ", size=11, bold=True,
color=PRIMARY_DARK)
lead_rows = [
("Амбулаторный поток", "тысячи/мес", "~120 млн"),
("Взрослая хирургия (FESS, полипы, перегородка)", "300 оп/год", "~30 млн"),
("Детская аденоидная хирургия", "400500 оп/год", "~2030 млн"),
("Сурдология (слуховые аппараты)", "высокая конверсия", "~20 млн"),
("Хроники, ЧБД, вазотомия, пульмо, храпуны, фониатрия, check-up", "6 сегментов", "остальное"),
]
ty = y + Inches(0.8)
for name, vol, rev in lead_rows:
add_text(s, bx + Inches(0.3), ty, Inches(3.5), Inches(0.3),
name, size=12, bold=True, color=FG)
add_text(s, bx + Inches(0.3), ty + Inches(0.28), Inches(3.5), Inches(0.24),
vol, size=10, color=FG3)
add_text(s, bx + bw - Inches(1.8), ty, Inches(1.5), Inches(0.4),
rev, size=14, bold=True, color=ACCENT, align=PP_ALIGN.RIGHT,
font=FONT_DISPLAY)
ty += Inches(0.76)
return s
def slide_frame():
s = new_slide()
y = title_bar(s, "Приложение = удержание, не привлечение", "Продуктовая рамка")
# Две колонки
colw = Inches(6.15)
gap = Inches(0.23)
# Левая
lx = MARGIN_L
add_rect(s, lx, y + Inches(0.1), colw, Inches(4.7),
fill=SOFT, line=None)
add_text(s, lx + Inches(0.3), y + Inches(0.25), colw - Inches(0.6), Inches(0.4),
"ПЕРВИЧНЫЕ", size=12, bold=True, color=FG3)
add_text(s, lx + Inches(0.3), y + Inches(0.6), colw - Inches(0.6), Inches(0.6),
"Приходят через веб", size=22, bold=True, color=FG, font=FONT_DISPLAY)
add_rich(s, lx + Inches(0.3), y + Inches(1.3), colw - Inches(0.6), Inches(3.4), [
("Главный job: ", {"bold": True, "color": FG}),
("«куда мне обратиться, как записаться»", {}),
("\n", {}),
("Источник: ", {"bold": True, "color": FG}),
("сайт, SEO, реклама, сарафан", {}),
("\n", {}),
("В приложении: ", {"bold": True, "color": FG}),
("базовый минимум, который уже есть", {}),
("\n", {}),
("Приоритет развития: ", {"bold": True, "color": FG}),
("низкий — веб работает лучше", {}),
], size=13, line_spacing=1.3)
# Правая (основная)
rx = lx + colw + gap
add_rect(s, rx, y + Inches(0.1), colw, Inches(4.7),
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(1))
add_text(s, rx + Inches(0.3), y + Inches(0.25), colw - Inches(0.6), Inches(0.4),
"ПОВТОРНЫЕ · 2 ИЗ 3", size=12, bold=True, color=PRIMARY_DARK)
add_text(s, rx + Inches(0.3), y + Inches(0.6), colw - Inches(0.6), Inches(0.6),
"Приоритет приложения", size=22, bold=True, color=FG, font=FONT_DISPLAY)
add_rich(s, rx + Inches(0.3), y + Inches(1.3), colw - Inches(0.6), Inches(3.4), [
("Главный job: ", {"bold": True, "color": FG}),
("«что делать сейчас по моему лечению»", {}),
("\n", {}),
("Источник: ", {"bold": True, "color": FG}),
("приложение уже установлено, push / сам открывает", {}),
("\n", {}),
("В приложении: ", {"bold": True, "color": FG}),
("специализированный модуль сегмента", {}),
("\n", {}),
("Приоритет развития: ", {"bold": True, "color": FG}),
("высокий — закрывает 2/3 потока", {}),
], size=13, line_spacing=1.3)
# Сноска-исключения
add_text(s, MARGIN_L, y + Inches(5.1), CONTENT_W, Inches(0.4),
"Исключения: сурдология, хирургия, АСИТ — после первого визита пациент «уходит думать», и вернуть его может только приложение.",
size=11, color=FG2)
return s
def slide_current_functions():
s = new_slide()
y = title_bar(s, "Текущие функции — утилита и приоритет", "Что есть сегодня")
# Три колонки: зелёная, жёлтая, красная
groups = [
("ЯДРО ЦЕННОСТИ", SUCCESS, [
("Запись на приём", "Единственная функция, приводящая новых."),
("Ближайший приём", "3–10 открытий перед визитом. Самый частый экран."),
]),
("ПОДДЕРЖКА", WARNING, [
("Список врачей", "Помогает при первом визите, повторный не открывает."),
("Чат с оператором", "Ограничен одним собеседником — основное развитие в §3."),
("Профиль", "Редко открывается, но критичен для персонализации."),
]),
("НИША", DANGER, [
("Семейный профиль", "Критичен для 2030% (дети, пожилые)."),
("Контакты", "1–2 открытия за всё время. Функция «галочка»."),
]),
]
cw = (CONTENT_W - Inches(0.5)) / 3
for i, (label, col, items) in enumerate(groups):
cx = MARGIN_L + (cw + Inches(0.25)) * i
add_rect(s, cx, y, cw, Inches(0.5), fill=col)
add_text(s, cx + Inches(0.3), y + Inches(0.1), cw - Inches(0.6), Inches(0.3),
label, size=12, bold=True, color=BG)
yy = y + Inches(0.7)
for title, desc in items:
add_text(s, cx, yy, cw, Inches(0.35), title,
size=15, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, cx, yy + Inches(0.38), cw, Inches(0.9), desc,
size=11, color=FG2)
yy += Inches(1.15)
# Подвал — вывод
add_rect(s, MARGIN_L, Inches(6.55), CONTENT_W, Inches(0.55),
fill=WARM)
add_text(s, MARGIN_L + Inches(0.3), Inches(6.66), CONTENT_W - Inches(0.6), Inches(0.4),
"Слабое место: нет причины открывать приложение между визитами. Это и закрывает джентльменский набор.",
size=13, bold=True, color=FG, font=FONT_DISPLAY)
return s
def slide_three_layers():
s = new_slide()
y = title_bar(s, "Три слоя работы", "План")
box_h = Inches(1.75)
gap = Inches(0.15)
# Слой 1
y1 = y + Inches(0.1)
add_rect(s, MARGIN_L, y1, CONTENT_W, box_h,
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(1))
add_text(s, MARGIN_L + Inches(0.3), y1 + Inches(0.2), Inches(3), Inches(0.3),
"ФАЗА 1 · ТРАНЗАКЦИОННАЯ БАЗА", size=11, bold=True, color=PRIMARY_DARK)
add_text(s, MARGIN_L + Inches(0.3), y1 + Inches(0.5), Inches(11), Inches(0.5),
"Всем пациентам · быстрый MVP · минимум рисков",
size=18, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, MARGIN_L + Inches(0.3), y1 + Inches(1.0), CONTENT_W - Inches(0.6), Inches(0.7),
"План лечения · Результаты и медкарта · Заказ справок · "
"(+ уже есть: запись, ближайший приём, чат с оператором, статьи)",
size=13, color=FG2)
# Слой 1.5
y15 = y1 + box_h + gap
add_rect(s, MARGIN_L, y15, CONTENT_W, box_h,
fill=WARM, line=WARNING, line_w=Pt(1))
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.2), Inches(4), Inches(0.3),
"ФАЗА 1.5 · КОММУНИКАЦИОННАЯ НАДСТРОЙКА", size=11, bold=True, color=WARNING)
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.5), Inches(11), Inches(0.5),
"Поверх стабильной базы · отдельный регламент и безопасность",
size=18, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(1.0), CONTENT_W - Inches(0.6), Inches(0.7),
"Чат с медицинским консьержем (не прямой с лечащим) · AI-помощник на RAG в shadow-mode",
size=13, color=FG2)
# Слой 2
y2 = y15 + box_h + gap
add_rect(s, MARGIN_L, y2, CONTENT_W, box_h,
fill=SOFT, line=FG3, line_w=Pt(0.5))
add_text(s, MARGIN_L + Inches(0.3), y2 + Inches(0.2), Inches(3), Inches(0.3),
"ФАЗА 2 · СЕГМЕНТНЫЕ МОДУЛИ", size=11, bold=True, color=FG3)
add_text(s, MARGIN_L + Inches(0.3), y2 + Inches(0.5), Inches(11), Inches(0.5),
"Углубление по группам сегментов",
size=18, bold=True, color=FG, font=FONT_DISPLAY)
# 4 мини-плитки
tiles = [("A", "Самопомощь"), ("C", "Пред-оп"), ("B", "Сурдология"), ("D", "АСИТ + астма")]
tw = (CONTENT_W - Inches(0.6) - Inches(0.15) * 3) / 4
for i, (code, name) in enumerate(tiles):
tx = MARGIN_L + Inches(0.3) + (tw + Inches(0.15)) * i
ty = y2 + Inches(1.05)
add_rect(s, tx, ty, tw, Inches(0.55), fill=BG, line=PRIMARY, line_w=Pt(0.75))
add_text(s, tx + Inches(0.15), ty + Inches(0.08), Inches(0.4), Inches(0.4),
code, size=18, bold=True, color=PRIMARY, font=FONT_DISPLAY)
add_text(s, tx + Inches(0.55), ty + Inches(0.13), tw - Inches(0.6), Inches(0.4),
name, size=11, bold=True, color=FG, font=FONT_DISPLAY)
return s
def slide_phase1_base():
s = new_slide()
y = title_bar(s, "Фаза 1 · Транзакционная база", "Джентльменский набор, часть 1")
add_text(s, MARGIN_L, y + Inches(0.0), CONTENT_W, Inches(0.4),
"Детерминированные функции без LLM и прямого врачебного диалога. Быстрый MVP, минимум рисков.",
size=13, color=FG2)
data = [
["Функция", "Статус", "Суть"],
["Запись на приём", "Есть", "Запись 24/7, ближайшие окна."],
["Ближайший приём", "Есть", "Что, где, когда — снимает тревогу перед визитом."],
["Чат с оператором", "Есть", "Справки, переносы, счета, расписание (человеческий канал)."],
["Статьи / база знаний", "Есть", "Источник правды для будущего RAG."],
["План лечения с приёма", "Нет — фундамент", "Структурированные назначения, календарь, напоминания, ссылки на процедуры."],
["Результаты и медкарта", "Нет — новое", "Анализы, аудиограммы, снимки, заключения. Срок годности виден — критично для пред-опа."],
["Заказ справок и финдокументов", "Нет — новое", "ФНС-вычет 13%, справки работодателю, счета. Снимает нагрузку с администраторов."],
]
add_table(s, MARGIN_L, y + Inches(0.55),
CONTENT_W, Inches(4.6),
data,
col_widths=[Inches(3.3), Inches(2.3), CONTENT_W - Inches(5.6)],
size=12, header_size=12)
# Порядок работ
add_rect(s, MARGIN_L, Inches(6.25), CONTENT_W, Inches(0.85),
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(0.5))
add_rich(s, MARGIN_L + Inches(0.3), Inches(6.35), CONTENT_W - Inches(0.6), Inches(0.7), [
("Порядок внутри Фазы 1: ", {"bold": True, "color": PRIMARY_DARK}),
("1) План лечения — фундамент · ", {}),
("2) Результаты / медкарта · ", {}),
("3) Заказ справок. ", {}),
("Критическая зависимость — МИС: ", {"bold": True, "color": FG}),
("отдаёт ли API структурированные назначения или план заполняет врач в виджете (см. вопрос №1).", {}),
], size=12, line_spacing=1.3)
return s
def slide_phase1_5_overlay():
s = new_slide()
y = title_bar(s, "Фаза 1.5 · Коммуникационная надстройка", "Джентльменский набор, часть 2")
add_text(s, MARGIN_L, y + Inches(0.0), CONTENT_W, Inches(0.6),
"Живой диалог по поводу лечения. Строится поверх стабильной Фазы 1, требует отдельных регламентов.",
size=13, color=FG2)
# Две большие карточки
cw = (CONTENT_W - Inches(0.3)) / 2
cy = y + Inches(0.7)
ch = Inches(4.5)
# Консьерж
lx = MARGIN_L
add_rect(s, lx, cy, cw, ch, fill=BG, line=PRIMARY, line_w=Pt(1))
add_rect(s, lx, cy, cw, Inches(0.55), fill=PRIMARY)
add_text(s, lx + Inches(0.3), cy + Inches(0.12), cw - Inches(0.6), Inches(0.4),
"ЧАТ С МЕДИЦИНСКИМ КОНСЬЕРЖЕМ", size=12, bold=True, color=BG)
add_text(s, lx + Inches(0.3), cy + Inches(0.75), cw - Inches(0.6), Inches(0.5),
"Не прямой с лечащим врачом",
size=18, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, lx + Inches(0.3), cy + Inches(1.3), cw - Inches(0.6), Inches(2.4),
"Дежурный врач / фельдшер / медсестра отвечает по протоколам "
"на 80% рутины: «можно ли совмещать с X», «нормально ли, что третий день болит», "
"«когда повторно сдать анализ».\n\n"
"Эскалация лечащему — только клинически значимое.\n\n"
"Буфер SLA и защита от выгорания врачей.",
size=12, color=FG2)
add_rect(s, lx + Inches(0.3), cy + ch - Inches(0.85), cw - Inches(0.6), Inches(0.7),
fill=WARM)
add_rich(s, lx + Inches(0.45), cy + ch - Inches(0.75), cw - Inches(0.9), Inches(0.55), [
("Риск: ", {"bold": True, "color": DANGER}),
("перегрузка врачей. ", {}),
("Митигация: ", {"bold": True, "color": SUCCESS}),
("консьерж-слой + тарификация эскалаций.", {}),
], size=11, line_spacing=1.3)
# AI
rx = lx + cw + Inches(0.3)
add_rect(s, rx, cy, cw, ch, fill=BG, line=WARNING, line_w=Pt(1))
add_rect(s, rx, cy, cw, Inches(0.55), fill=WARNING)
add_text(s, rx + Inches(0.3), cy + Inches(0.12), cw - Inches(0.6), Inches(0.4),
"AI-ПОМОЩНИК (RAG, 24/7)", size=12, bold=True, color=BG)
add_text(s, rx + Inches(0.3), cy + Inches(0.75), cw - Inches(0.6), Inches(0.5),
"Запуск в shadow-mode",
size=18, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, rx + Inches(0.3), cy + Inches(1.3), cw - Inches(0.6), Inches(2.4),
"Знает: базу знаний клиники, статьи, план лечения пациента, медкарту, историю приёмов.\n\n"
"Объясняет назначения, ищет ответ в статьях, оценивает «норма или срочно», "
"эскалирует консьержу/врачу.\n\n"
"Отвечает ночью, когда человеческие каналы недоступны.",
size=12, color=FG2)
add_rect(s, rx + Inches(0.3), cy + ch - Inches(0.85), cw - Inches(0.6), Inches(0.7),
fill=WARM)
add_rich(s, rx + Inches(0.45), cy + ch - Inches(0.75), cw - Inches(0.9), Inches(0.55), [
("Риск: ", {"bold": True, "color": DANGER}),
("галлюцинация LLM = юр./репутация. ", {}),
("Митигация: ", {"bold": True, "color": SUCCESS}),
("shadow-mode, валидация через консьержа, метрики точности перед прямым режимом.", {}),
], size=11, line_spacing=1.3)
return s
def slide_primary_vs_returning():
s = new_slide()
y = title_bar(s, "Первичный vs повторный в приложении", "Фундаментальное деление")
data = [
["Линза", "Первичный в приложении", "Повторный в приложении"],
["Главный job",
"«Куда обратиться и как записаться»",
"«Что делать сейчас по моему лечению»"],
["Источник",
"Сайт, SEO, реклама, сарафан",
"Уведомление, пациент сам открывает"],
["Частота открытия",
"13 раза до визита",
"Ежедневно в активном эпизоде"],
["Что нужно в приложении",
"Базовый минимум (есть)",
"Специализированный модуль сегмента"],
["Приоритет развития",
"Низкий — веб работает лучше",
"Высокий — 2/3 потока"],
]
add_table(s, MARGIN_L, y + Inches(0.2),
CONTENT_W, Inches(4.4),
data,
col_widths=[Inches(3.0), Inches(4.55), Inches(4.55)],
size=13, header_size=12,
header_fill=FG)
add_rect(s, MARGIN_L, Inches(6.1), CONTENT_W, Inches(1.0),
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(0.5))
add_rich(s, MARGIN_L + Inches(0.3), Inches(6.2), CONTENT_W - Inches(0.6), Inches(0.85), [
("Исключения: ", {"bold": True, "color": PRIMARY_DARK}),
("сегменты, где «первичный в приложении» ≠ «первый визит» — сурдология, хирургия, АСИТ. "
"После первого визита пациент уходит, и вернуть его может только приложение. "
"Эти сегменты требуют отдельных «первичных» сценариев удержания.", {}),
], size=13, line_spacing=1.3)
return s
def slide_segment_matrix():
s = new_slide()
y = title_bar(s, "10 сегментов × первичный / повторный", "Матрица")
header = ["#", "Сегмент", "Объём · вклад", "Повторный — что нужно", "Группа"]
rows = [
["1", "Заблокированный нос (FESS, полипы)", "300 оп · 30 млн",
"Пред-оп бегунок → восстановление → контроль 3/6/12 мес", "C"],
["2", "Амбулаторный поток (острые + хроники)", "тысячи/мес · 120 млн",
"План лечения + эпизод + процедуры самопомощи", "A"],
["3", "Дети с аденоидами", "400500 оп · 2030 млн",
"Детский бегунок → восстановление (семейный профиль)", "C"],
["4", "Потеря слуха (сурдология)", "20 млн",
"Паспорт аппарата, сервисный календарь, расходники, калибровка", "B"],
["5", "Сложные хроники, ЧБД, аллергия", "часть 120 млн · LTV",
"Процедуры самопомощи + АСИТ-трекер + пыльцевой календарь", "A+D"],
["6", "Зависимость от капель (вазотомия)", "высокий объём",
"Пред-оп бегунок (компактный) → восстановление", "C"],
["7", "Пульмонология (кашель/астма)", "средний · сезонный",
"Дневник астмы, напоминания об ингаляторах", "D"],
["8", "Социально активные храпуны", "низкий · высокий чек",
"Сомнологический чек-up, СИПАП-трекер", ""],
["9", "Фониатрия (голос)", "очень низкий",
"Короткое окно, низкая повторность", ""],
["10", "Check-up и второе мнение", "единичный",
"Разовая услуга, минимальная повторность", ""],
]
data = [header] + rows
# раскраска групп через цвет текста в последней колонке делается потом
tbl = add_table(s, MARGIN_L, y + Inches(0.1),
CONTENT_W, Inches(5.6),
data,
col_widths=[Inches(0.4), Inches(3.4), Inches(2.2), Inches(5.5), Inches(0.8)],
size=10, header_size=11)
# Подкрасить колонку «Группа»
group_colors = {"A": PRIMARY_DARK, "B": PRIMARY_DARK, "C": PRIMARY_DARK,
"D": PRIMARY_DARK, "A+D": PRIMARY_DARK, "": FG3}
for ri, row in enumerate(rows, start=1):
cell = tbl.cell(ri, 4)
tf = cell.text_frame
for p in tf.paragraphs:
for r in p.runs:
r.font.bold = True
r.font.color.rgb = group_colors.get(row[4], FG)
r.font.size = Pt(12)
return s
def slide_groups_overview():
s = new_slide()
y = title_bar(s, "Четыре группы сегментов для повторных", "Укрупнение")
# 4 плитки 2×2
tiles = [
("A", "Процедуры самопомощи",
"Амбулаторный + хроники (2, 5ч)",
"Библиотека техник, напоминания, трекер, дневник.\nСамый массовый охват."),
("C", "Пред-оп подготовка",
"FESS + аденоиды + вазотомия (1, 3, 6)",
"Две фазы: возврат сомневающихся; бегунок → восстановление.\nОдин модуль — три сегмента."),
("B", "Сурдология",
"Потеря слуха (4)",
"Две фазы: возврат после демо-аппарата; обслуживание аппарата.\nИзолированный модуль, высокий чек."),
("D", "АСИТ + астма",
"Часть аллергии + пульмо (5ч, 7)",
"Ежедневный трекер, пыльцевой календарь, навигатор побочки.\nВысокая ответственность."),
]
tw = (CONTENT_W - Inches(0.3)) / 2
th = (Inches(5.0) - Inches(0.3)) / 2
for i, (code, name, segments, desc) in enumerate(tiles):
col = i % 2
row = i // 2
tx = MARGIN_L + (tw + Inches(0.3)) * col
ty = y + Inches(0.1) + (th + Inches(0.3)) * row
add_rect(s, tx, ty, tw, th, fill=BG, line=PRIMARY, line_w=Pt(1))
# Круг с буквой
circ = s.shapes.add_shape(MSO_SHAPE.OVAL, tx + Inches(0.3), ty + Inches(0.3),
Inches(0.8), Inches(0.8))
circ.shadow.inherit = False
circ.fill.solid(); circ.fill.fore_color.rgb = PRIMARY
circ.line.fill.background()
tf = circ.text_frame
tf.margin_left = tf.margin_right = Emu(0); tf.margin_top = tf.margin_bottom = Emu(0)
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
r = p.add_run(); r.text = code
r.font.name = FONT_DISPLAY; r.font.size = Pt(28); r.font.bold = True
r.font.color.rgb = BG
# Название
add_text(s, tx + Inches(1.3), ty + Inches(0.3), tw - Inches(1.5), Inches(0.5),
name, size=20, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, tx + Inches(1.3), ty + Inches(0.8), tw - Inches(1.5), Inches(0.3),
segments, size=11, color=FG3)
# Описание
add_text(s, tx + Inches(0.3), ty + Inches(1.5), tw - Inches(0.6),
th - Inches(1.7), desc, size=12, color=FG2)
return s
def slide_group_detail(code, color, title, caption, what_it_does, why_priority):
s = new_slide()
y = title_bar(s, title, f"Группа {code}")
# Крупный код слева
add_rect(s, MARGIN_L, y, Inches(1.4), Inches(5.0), fill=PRIMARY_50)
add_text(s, MARGIN_L, y + Inches(1.5), Inches(1.4), Inches(2.0),
code, size=120, bold=True, color=PRIMARY, font=FONT_DISPLAY,
align=PP_ALIGN.CENTER)
# Правый контент
rx = MARGIN_L + Inches(1.6)
rw = CONTENT_W - Inches(1.6)
add_text(s, rx, y + Inches(0.0), rw, Inches(0.4),
caption, size=13, color=FG3)
# Что делает
add_text(s, rx, y + Inches(0.45), rw, Inches(0.35),
"ЧТО В МОДУЛЕ", size=11, bold=True, color=PRIMARY_DARK)
add_text(s, rx, y + Inches(0.8), rw, Inches(2.8),
what_it_does, size=13, color=FG)
# Почему приоритет
add_rect(s, rx, y + Inches(3.7), rw, Inches(1.3),
fill=WARM)
add_text(s, rx + Inches(0.3), y + Inches(3.8), Inches(5), Inches(0.3),
"ПОЧЕМУ В ЭТОМ ПОРЯДКЕ", size=11, bold=True, color=FG2)
add_text(s, rx + Inches(0.3), y + Inches(4.1), rw - Inches(0.6), Inches(1.1),
why_priority, size=13, color=FG)
return s
def slide_group_A():
return slide_group_detail(
"A", PRIMARY,
"Процедуры самопомощи для хроников",
"Амбулаторный поток + сложные хроники · сегменты 2 и 5",
"• Библиотека техник (видео + шаги): промывание носа физраствором, полоскание горла, "
"ингаляции, гимнастика слуховой трубы, голосовые упражнения, туалет уха.\n"
"• Напоминания утром/вечером, привязка к плану лечения.\n"
"• Трекер комплаенса: ежедневные отметки, стрики, сводка.\n"
"• Дневник симптомов в связке: «промываю 5 дней, насморк 4 → 2».\n"
"• Сводка для врача перед контрольным приёмом.",
"Самый массовый охват (тысячи/мес) × ежедневная повторность. "
"Напрямую опирается на план лечения из Фазы 1. "
"Контент-ориентированный MVP (видео + простой трекер). "
"Эффект заметен пациенту сразу."
)
def slide_group_C():
return slide_group_detail(
"C", PRIMARY,
"Пред-операционная подготовка + восстановление",
"FESS + дети-аденоиды + вазотомия · сегменты 1, 3, 6",
"Две фазы:\n"
"1) Кандидаты на операцию — ушли «думать» после пред-оп приёма. Возврат через материалы, "
"кейсы пациентов, чат с хирургом, возвратные push (3/7/21 день), прозрачность цены/рассрочки.\n"
"2) Решившиеся — маршрутная карта («бегунок») с чекбоксами, сроки годности анализов, "
"автозапись из пункта, загрузка сторонних результатов, чек-лист дня операции, "
"договор и оплата в приложении → передача в «Восстановление».",
"Один модуль закрывает 3 сегмента. Быстрый MVP (маршрутная карта — простой список с состояниями). "
"Прямой бизнес-эффект: меньше переносов операций + возврат сомневающихся кандидатов. "
"Вклад сегментов: ~50–60 млн."
)
def slide_group_B():
return slide_group_detail(
"B", PRIMARY,
"Сурдология — слухопротезирование",
"Потеря слуха · сегмент 4",
"Две фазы, зеркально Группе C:\n"
"1) Кандидаты — после первого визита и демо уходят думать 2–3 месяца. "
"Аудиограмма в профиле, аудио-демо «до/после» в любых наушниках, каталог моделей с фильтром "
"по аудиограмме, калькулятор стоимости (ФСС, рассрочка, трейд-ин), шеринг близкому, "
"истории пациентов, возвратные push 3/7/21 день.\n"
"2) Пользователи аппарата — паспорт аппарата, календарь обслуживания, расходники, "
"навигатор неполадок, ежегодная калибровка.",
"Пожизненный LTV × высокий чек (60–180 тыс.) × растущий рынок (старение). "
"Изолированный модуль — не пересекается с другими. "
"Можно запускать параллельно с C после Фазы 1."
)
def slide_group_D():
return slide_group_detail(
"D", PRIMARY,
"АСИТ + контроль астмы",
"Часть аллергии + пульмонология · сегменты 5ч и 7",
"• Календарь курса (3–5 лет) с маркером «вы здесь».\n"
"• Ежедневный приём СЛИТ с push, стрики.\n"
"• Журнал инъекций ПКИТ.\n"
"• Дневник симптомов + пыльцевой календарь региона.\n"
"• Навигатор побочки с эскалацией к врачу / скорой.\n"
"• Экстренная связь, аптечка дома.\n"
"• Автошеринг дневника врачу перед плановым визитом.",
"Самая высокая глубина пользы — прямое влияние на медицинский исход (удержание = успех лечения). "
"Но самая высокая ответственность: требует верификации контента аллергологом/пульмонологом. "
"Запускаем последним; контент можно готовить параллельно."
)
def slide_roadmap():
s = new_slide()
y = title_bar(s, "Порядок внедрения", "Дорожная карта")
# Фаза 1 бокс
p1_h = Inches(0.9)
add_rect(s, MARGIN_L, y + Inches(0.05), CONTENT_W, p1_h,
fill=PRIMARY_50, line=PRIMARY, line_w=Pt(1))
add_text(s, MARGIN_L + Inches(0.3), y + Inches(0.15), Inches(3), Inches(0.3),
"ФАЗА 1 · ТРАНЗАКЦИОННАЯ БАЗА", size=11, bold=True, color=PRIMARY_DARK)
add_text(s, MARGIN_L + Inches(0.3), y + Inches(0.48), CONTENT_W - Inches(0.6), Inches(0.4),
"План лечения → Результаты и медкарта → Заказ справок",
size=14, bold=True, color=FG, font=FONT_DISPLAY)
# Фаза 1.5 бокс
y15 = y + Inches(0.05) + p1_h + Inches(0.15)
add_rect(s, MARGIN_L, y15, CONTENT_W, p1_h,
fill=WARM, line=WARNING, line_w=Pt(1))
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.1), Inches(4), Inches(0.3),
"ФАЗА 1.5 · КОММУНИКАЦИОННАЯ НАДСТРОЙКА", size=11, bold=True, color=WARNING)
add_text(s, MARGIN_L + Inches(0.3), y15 + Inches(0.43), CONTENT_W - Inches(0.6), Inches(0.4),
"Чат с медицинским консьержем → AI-помощник (shadow-mode)",
size=14, bold=True, color=FG, font=FONT_DISPLAY)
# Фаза 2 — 4 шага
y2 = y15 + p1_h + Inches(0.25)
add_text(s, MARGIN_L, y2, Inches(6), Inches(0.3),
"ФАЗА 2 · СЕГМЕНТНЫЕ МОДУЛИ", size=11, bold=True, color=FG2)
# 4 карточки горизонтально со стрелками
steps = [
("1", "A", "Процедуры самопомощи", "Массовый охват · продолжение плана лечения"),
("2", "C", "Пред-оп подготовка", "3 сегмента одним модулем · быстрый эффект"),
("3", "B", "Сурдология", "Высокий чек · пожизненное удержание"),
("4", "D", "АСИТ + астма", "Верификация врачом · высокая польза"),
]
step_y = y2 + Inches(0.35)
step_h = Inches(2.0)
step_w = (CONTENT_W - Inches(0.4) * 3) / 4
for i, (num, code, name, sub) in enumerate(steps):
sx = MARGIN_L + (step_w + Inches(0.4)) * i
add_rect(s, sx, step_y, step_w, step_h, fill=BG, line=PRIMARY, line_w=Pt(1))
# Номер и код группы
add_text(s, sx + Inches(0.2), step_y + Inches(0.2), Inches(0.8), Inches(0.4),
f"Шаг {num}", size=11, color=FG3)
add_text(s, sx + Inches(0.2), step_y + Inches(0.45), step_w - Inches(0.4), Inches(0.6),
code, size=36, bold=True, color=PRIMARY, font=FONT_DISPLAY)
add_text(s, sx + Inches(0.2), step_y + Inches(1.0), step_w - Inches(0.4), Inches(0.4),
name, size=13, bold=True, color=FG, font=FONT_DISPLAY)
add_text(s, sx + Inches(0.2), step_y + Inches(1.4), step_w - Inches(0.4), Inches(0.55),
sub, size=10, color=FG2)
# Стрелка
if i < 3:
arr_x = sx + step_w + Inches(0.05)
arr = s.shapes.add_shape(MSO_SHAPE.RIGHT_ARROW, arr_x,
step_y + step_h / 2 - Inches(0.15),
Inches(0.3), Inches(0.3))
arr.shadow.inherit = False
arr.fill.solid(); arr.fill.fore_color.rgb = PRIMARY
arr.line.fill.background()
return s
def slide_questions():
s = new_slide()
y = title_bar(s, "Вопросы к обсуждению", "Что уточнить у клиники")
questions = [
("Поток", "«2 из 3 повторных» — уникальные пациенты в год или визиты?"),
("Метрики", "Процент повторных, не доходящих до контрольного приёма?"),
("Врачи", "Готовность врачей к SLA по асинхронному чату и в каком окне?"),
("МИС", "API: структурированные назначения, сроки годности анализов, расписание?"),
("Сурдо", "Сколько кандидатов/мес, % возврата за аппаратом?"),
("Хирургия", "Частая причина переноса операций — просроченные анализы?"),
("Аллерго", "Готов ли аллерголог верифицировать контент АСИТ-трекера?"),
("Контент", "Достаточно ли материалов для RAG-базы?"),
]
col_h = Inches(0.6)
for i, (tag, q) in enumerate(questions):
col = i % 2
row = i // 2
qx = MARGIN_L + (CONTENT_W / 2 + Inches(0.15)) * col
qy = y + Inches(0.2) + (col_h + Inches(0.4)) * row
qw = CONTENT_W / 2 - Inches(0.15)
add_rect(s, qx, qy, Inches(1.0), col_h, fill=PRIMARY)
add_text(s, qx, qy, Inches(1.0), col_h, tag,
size=12, bold=True, color=BG, font=FONT_DISPLAY,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
add_rect(s, qx + Inches(1.0), qy, qw - Inches(1.0), col_h,
fill=PRIMARY_50)
add_text(s, qx + Inches(1.2), qy, qw - Inches(1.2), col_h, q,
size=12, color=FG, anchor=MSO_ANCHOR.MIDDLE)
return s
def slide_final():
s = new_slide()
# Большой финал: что решить сегодня
add_rect(s, 0, 0, SW, SH, fill=PRIMARY)
add_text(s, MARGIN_L, Inches(0.8), CONTENT_W, Inches(0.5),
"ЧТО РЕШАЕМ СЕГОДНЯ", size=14, bold=True, color=PRIMARY_50)
add_text(s, MARGIN_L, Inches(1.4), CONTENT_W, Inches(1.2),
"Подтвердить порядок:", size=36, bold=True, color=BG, font=FONT_DISPLAY)
add_text(s, MARGIN_L, Inches(2.4), CONTENT_W, Inches(1.8),
"Фаза 1 · транзакционная база\n→ Фаза 1.5 · консьерж + AI\n→ Фаза 2: A → C → B → D",
size=26, bold=True, color=BG, font=FONT_DISPLAY, line_spacing=1.3)
# Три пункта
items = [
("1", "С чего начинаем Фазу 1?", "План лечения — фундамент. Зависит от МИС-API."),
("2", "Когда стартует Фаза 1.5?", "После стабилизации базы. AI в shadow-mode."),
("3", "Параллельно", "Контент для D, добавить аллерголога в справочники."),
]
y = Inches(4.8)
for i, (num, q, a) in enumerate(items):
ix = MARGIN_L + (CONTENT_W / 3) * i
iw = CONTENT_W / 3 - Inches(0.3)
add_text(s, ix, y, Inches(0.5), Inches(0.5),
num, size=36, bold=True, color=WARM, font=FONT_DISPLAY)
add_text(s, ix + Inches(0.6), y + Inches(0.05), iw - Inches(0.6), Inches(0.5),
q, size=14, bold=True, color=BG, font=FONT_DISPLAY)
add_text(s, ix + Inches(0.6), y + Inches(0.6), iw - Inches(0.6), Inches(1.2),
a, size=12, color=PRIMARY_50)
# Подпись
add_text(s, MARGIN_L, SH - Inches(0.7), CONTENT_W, Inches(0.4),
"Клиника УГН · мобильное приложение · 23 апр 2026",
size=11, color=PRIMARY_50)
return s
# ============================================================
# СБОРКА
# ============================================================
slides = [
slide_title(),
slide_agenda(),
slide_facts(),
slide_frame(),
slide_current_functions(),
slide_three_layers(),
slide_phase1_base(),
slide_phase1_5_overlay(),
slide_primary_vs_returning(),
slide_segment_matrix(),
slide_groups_overview(),
slide_group_A(),
slide_group_C(),
slide_group_B(),
slide_group_D(),
slide_roadmap(),
slide_questions(),
slide_final(),
]
# Футер на всех слайдах, кроме титульного и финального (у них свой фон)
total = len(slides)
for idx, s in enumerate(slides, start=1):
if idx == 1 or idx == total:
continue
footer(s, idx, total)
out = "MEETING_2026-04-23.pptx"
prs.save(out)
print(f"OK {out} ({total} slides)")
+7
View File
@@ -0,0 +1,7 @@
services:
web:
build: .
image: pcs-pt-mobile:local
ports:
- "3630:80"
restart: unless-stopped
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 256;
location / {
try_files $uri $uri/ /index.html;
}
}
+76 -1
View File
@@ -8,8 +8,11 @@
"name": "pcs-pt-mobile", "name": "pcs-pt-mobile",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"leaflet": "^1.9.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
@@ -739,6 +742,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1375,6 +1398,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1491,6 +1520,20 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1501,6 +1544,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.2", "version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+4 -1
View File
@@ -9,8 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"leaflet": "^1.9.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
+1479
View File
File diff suppressed because it is too large Load Diff
+1259
View File
File diff suppressed because it is too large Load Diff
+154 -36
View File
@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FitWrap } from './FitWrap.jsx';
import { IOSDevice } from './frames/IOSDevice.jsx'; import { IOSDevice } from './frames/IOSDevice.jsx';
import { AndroidDevice } from './frames/AndroidDevice.jsx'; import { AndroidDevice } from './frames/AndroidDevice.jsx';
import { PhoneApp } from './PhoneApp.jsx'; import { PhoneApp } from './PhoneApp.jsx';
import { getScreenDoc } from './docs.js';
const TWEAKS_DEFAULT = { const TWEAKS_DEFAULT = {
homeVariant: 'cards', homeVariant: 'cards',
@@ -14,8 +16,15 @@ const TWEAKS_DEFAULT = {
device: 'ios', device: 'ios',
scale: 'auto', scale: 'auto',
showIntro: true, showIntro: true,
docsEnabled: false,
design: 'claude',
}; };
const DESIGN_OPTIONS = [
{ id: 'claude', lb: 'Клод' },
{ id: 'plate', lb: 'Прозрачная карточка' },
];
const SCALE_OPTIONS = [ const SCALE_OPTIONS = [
{ id: 'auto', lb: 'Авто' }, { id: 'auto', lb: 'Авто' },
{ id: '0.5', lb: '50%' }, { id: '0.5', lb: '50%' },
@@ -27,6 +36,8 @@ const HOME_OPTIONS = [
{ id: 'cards', lb: 'Карточки' }, { id: 'cards', lb: 'Карточки' },
{ id: 'list', lb: 'Лента' }, { id: 'list', lb: 'Лента' },
{ id: 'feed', lb: 'Таймлайн' }, { id: 'feed', lb: 'Таймлайн' },
{ id: 'timelineX', lb: 'Таймлайн X' },
{ id: 'splash', lb: 'Светлая плитка' },
]; ];
const DOC_OPTIONS = [ const DOC_OPTIONS = [
{ id: 'rich', lb: 'Карточки+' }, { id: 'rich', lb: 'Карточки+' },
@@ -38,9 +49,11 @@ const DENSITY_OPTIONS = [
{ id: 'compact', lb: 'Плотно' }, { id: 'compact', lb: 'Плотно' },
]; ];
const ACCENT_OPTIONS = [ const ACCENT_OPTIONS = [
{ id: 'teal', lb: 'Тил', primary: '#1F8F85', darker: '#166B63', dark: '#0F4A44', p50: '#E3F4F2', p100: '#C7E8E4', p200: '#9BD6CE', warm50: '#FDF8EE', warm100: '#F5EDDF' }, { id: 'teal', lb: 'Тил', primary: '#1F8F85', darker: '#166B63', dark: '#0F4A44', p50: '#E3F4F2', p100: '#C7E8E4', p200: '#9BD6CE', p300: '#9ED8D1', warm50: '#FDF8EE', warm100: '#F5EDDF', accent: '#E04E44', accentDark: '#B63D35', accent50: '#FCF1F0', success50: '#E8F5EE', fg4: '#9AA7B4' },
{ id: 'terra', lb: 'Терра', primary: '#C77A4C', darker: '#A65C33', dark: '#7F4426', p50: '#FBEFE4', p100: '#F5DDC9', p200: '#EBC19F', warm50: '#F4F7F3', warm100: '#E5ECE4' }, { id: 'terra', lb: 'Терра', primary: '#C77A4C', darker: '#A65C33', dark: '#7F4426', p50: '#FBEFE4', p100: '#F5DDC9', p200: '#EBC19F', p300: '#D9A07A', warm50: '#F4F7F3', warm100: '#E5ECE4', accent: '#E04E44', accentDark: '#B63D35', accent50: '#FCF1F0', success50: '#E8F5EE', fg4: '#9AA7B4' },
{ id: 'marine', lb: 'Марин', primary: '#3C6EA8', darker: '#23538B', dark: '#193C66', p50: '#E4EDF8', p100: '#C8DAEE', p200: '#9DBDDE', warm50: '#FBF6EE', warm100: '#F2E8D5' }, { id: 'marine', lb: 'Марин', primary: '#3C6EA8', darker: '#23538B', dark: '#193C66', p50: '#E4EDF8', p100: '#C8DAEE', p200: '#9DBDDE', p300: '#7FA8D4', warm50: '#FBF6EE', warm100: '#F2E8D5', accent: '#E04E44', accentDark: '#B63D35', accent50: '#FCF1F0', success50: '#E8F5EE', fg4: '#9AA7B4' },
{ id: 'laguna', lb: 'Лагуна',primary: '#29AEE3', darker: '#1E8FBD', dark: '#155E7A', p50: '#EDF9FD', p100: '#D4F6F8', p200: '#9FDDEB', p300: '#63BAC3', warm50: '#FFFDF0', warm100: '#E9E4D4', accent: '#FFA39C', accentDark: '#E07B73', accent50: '#FFEDEA', success50: '#F2FEE6', fg4: '#93908F' },
{ id: 'briz', lb: 'Бриз', primary: '#63BAC3', darker: '#4A9DA6', dark: '#2F6670', p50: '#F0F9FB', p100: '#D8ECEF', p200: '#A3D4DB', p300: '#29AEE3', warm50: '#FFFDF0', warm100: '#E9E4D4', accent: '#FFA39C', accentDark: '#E07B73', accent50: '#FFEDEA', success50: '#F2FEE6', fg4: '#93908F' },
]; ];
const FONT_OPTIONS = [ const FONT_OPTIONS = [
{ id: 'manrope', lb: 'Manrope', base: '"Manrope", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, { id: 'manrope', lb: 'Manrope', base: '"Manrope", system-ui, sans-serif', narrow: '"Oswald", sans-serif' },
@@ -64,7 +77,10 @@ const SCREEN_OPTIONS = [
{ id: 'result:r2', lb: 'Эндоскопия носоглотки' }, { id: 'result:r2', lb: 'Эндоскопия носоглотки' },
{ id: 'recovery', lb: 'Восстановление' }, { id: 'recovery', lb: 'Восстановление' },
{ id: 'audiotest', lb: 'Тест слуха' }, { id: 'audiotest', lb: 'Тест слуха' },
{ id: 'chat', lb: 'Чат' }, { id: 'chat', lb: 'Чаты · список' },
{ id: 'chat:ai', lb: 'Чат: AI-помощник' },
{ id: 'chat:doctor-syndaev', lb: 'Чат: Синдяев' },
{ id: 'chat:operator', lb: 'Чат: администратор' },
{ id: 'profile', lb: 'Профиль' }, { id: 'profile', lb: 'Профиль' },
{ id: 'qr', lb: 'QR' }, { id: 'qr', lb: 'QR' },
{ id: 'telemed', lb: 'Телемед' }, { id: 'telemed', lb: 'Телемед' },
@@ -78,6 +94,9 @@ const SCREEN_OPTIONS = [
{ id: 'search', lb: 'Поиск' }, { id: 'search', lb: 'Поиск' },
{ id: 'contacts', lb: 'Контакты' }, { id: 'contacts', lb: 'Контакты' },
{ id: 'prices', lb: 'Цены' }, { id: 'prices', lb: 'Цены' },
{ id: 'dev-colors', lb: 'DEV · Палитра' },
{ id: 'dev-examples', lb: 'DEV · Примеры' },
{ id: 'docs', lb: 'Документация' },
]; ];
function applyTheme(tw) { function applyTheme(tw) {
@@ -92,13 +111,19 @@ function applyTheme(tw) {
r.setProperty('--c-primary-200', a.p200); r.setProperty('--c-primary-200', a.p200);
r.setProperty('--c-warm-50', a.warm50); r.setProperty('--c-warm-50', a.warm50);
r.setProperty('--c-warm-100', a.warm100); r.setProperty('--c-warm-100', a.warm100);
r.setProperty('--c-accent', a.accent);
r.setProperty('--c-accent-dark', a.accentDark);
r.setProperty('--c-accent-50', a.accent50);
r.setProperty('--c-primary-300', a.p300);
r.setProperty('--c-success-50', a.success50);
r.setProperty('--c-fg-4', a.fg4);
r.setProperty('--font-base', f.base); r.setProperty('--font-base', f.base);
r.setProperty('--font-narrow', f.narrow); r.setProperty('--font-narrow', f.narrow);
document.body.style.fontFamily = f.base; document.body.style.fontFamily = f.base;
} }
function Phone({ device = 'ios', screen, ctx, label, sublabel }) { function Phone({ device = 'ios', screen, ctx, label, sublabel, onCurrentChange }) {
const content = <PhoneApp initialScreen={screen} ctx={ctx} />; const content = <PhoneApp initialScreen={screen} ctx={ctx} onCurrentChange={onCurrentChange} />;
const frame = device === 'android' const frame = device === 'android'
? <AndroidDevice>{content}</AndroidDevice> ? <AndroidDevice>{content}</AndroidDevice>
: <IOSDevice>{content}</IOSDevice>; : <IOSDevice>{content}</IOSDevice>;
@@ -138,6 +163,11 @@ function TweaksPanel({ tw, setTw, onClose }) {
</select> </select>
)} )}
{group('Устройство', opts([{id:'ios',lb:'iOS'},{id:'android',lb:'Android'}], 'device'))} {group('Устройство', opts([{id:'ios',lb:'iOS'},{id:'android',lb:'Android'}], 'device'))}
{group('Описания', [
<button key="on" onClick={() => setTw({ ...tw, docsEnabled: true })} className={tw.docsEnabled ? 'on' : ''}>Вкл</button>,
<button key="off" onClick={() => setTw({ ...tw, docsEnabled: false })} className={!tw.docsEnabled ? 'on' : ''}>Выкл</button>,
])}
{group('Дизайн', opts(DESIGN_OPTIONS, 'design'))}
{tw.layout === 'single' && group('Масштаб', opts(SCALE_OPTIONS, 'scale'))} {tw.layout === 'single' && group('Масштаб', opts(SCALE_OPTIONS, 'scale'))}
{group('Компоновка', opts([ {group('Компоновка', opts([
{ id:'single', lb:'1 телефон' }, { id:'single', lb:'1 телефон' },
@@ -159,38 +189,114 @@ function TweaksPanel({ tw, setTw, onClose }) {
)) ))
)} )}
{group('Шрифт', opts(FONT_OPTIONS, 'font'))} {group('Шрифт', opts(FONT_OPTIONS, 'font'))}
<div className="tweaks-section">
<div className="label">Документы</div>
<a href="/roadmap.html" target="_blank" rel="noopener noreferrer" style={{
display: 'block', padding: '7px 10px', borderRadius: 8,
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
fontSize: 12, fontWeight: 700, textDecoration: 'none', textAlign: 'center',
}}>
Карта развития приложения
</a>
</div>
</div> </div>
); );
} }
function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) { function DocPlashka({ doc, onOpen }) {
const outerRef = useRef(null); if (!doc) return null;
const [autoScale, setAutoScale] = useState(1);
useEffect(() => {
const outer = outerRef.current;
if (!outer) return;
const stage = outer.parentElement;
if (!stage) return;
const measure = () => {
const padding = 48;
const availW = stage.clientWidth - padding;
const availH = stage.clientHeight - padding;
const s = Math.min(availW / w, availH / h, 1);
setAutoScale(Math.max(s, 0.3));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(stage);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [w, h]);
const scale = userScale === 'auto' ? autoScale : parseFloat(userScale);
return ( return (
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}> <button onClick={onOpen} style={{
width: 402, maxWidth: '92%',
background: '#fff', borderRadius: 14,
padding: '14px 16px',
boxShadow: '0 4px 14px rgba(15,30,40,0.08)',
border: '1px solid var(--c-primary-200)',
display: 'flex', gap: 12, alignItems: 'flex-start', textAlign: 'left',
cursor: 'pointer',
}}>
<div style={{ <div style={{
width: w, height: h, transformOrigin: 'top left', width: 32, height: 32, borderRadius: 8,
transform: `scale(${scale})`, background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
}}>{children}</div> display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 700, flexShrink: 0,
}}>i</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', textTransform: 'uppercase', letterSpacing: .6, fontWeight: 700, marginBottom: 3 }}>{doc.category}</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4, color: 'var(--c-fg-1)' }}>{doc.title}</div>
<div style={{ fontSize: 12, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>{doc.goal}</div>
</div>
<span style={{
padding: '4px 10px', borderRadius: 999, background: 'var(--c-primary-darker)', color: '#fff',
fontSize: 11, fontWeight: 700, flexShrink: 0,
}}>Детали</span>
</button>
);
}
function DocModal({ doc, onClose }) {
if (!doc) return null;
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, background: 'rgba(15,30,40,0.5)',
backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 200, padding: '24px 20px',
}}>
<div onClick={e => e.stopPropagation()} style={{
background: '#fff', borderRadius: 20, padding: 22,
maxWidth: 480, width: '100%', maxHeight: '85vh', overflowY: 'auto',
boxShadow: '0 30px 80px rgba(15,30,40,0.35)',
position: 'relative',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 14 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 700, flexShrink: 0,
}}>i</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', textTransform: 'uppercase', letterSpacing: .6, fontWeight: 700, marginBottom: 2 }}>{doc.category}</div>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--c-fg-1)' }}>{doc.title}</div>
</div>
<button onClick={onClose} style={{
width: 32, height: 32, borderRadius: 999, background: 'var(--c-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, border: '1px solid var(--c-border)',
}}>×</button>
</div>
<div style={{ padding: 12, background: 'var(--c-primary-50)', borderRadius: 10, marginBottom: 16, fontSize: 14, color: 'var(--c-fg-1)', lineHeight: 1.55 }}>
{doc.goal}
</div>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginBottom: 8 }}>Задачи пользователя</div>
<ul style={{ margin: '0 0 18px', padding: 0, listStyle: 'none' }}>
{doc.tasks.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 10, padding: '4px 0', fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-primary-darker)', marginTop: 9, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginBottom: 8 }}>Design-решения</div>
<ul style={{ margin: '0 0 10px', padding: 0, listStyle: 'none' }}>
{doc.rationale.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 10, padding: '4px 0', fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-warm-text)', marginTop: 9, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
{doc.variants && (
<div style={{ marginTop: 14, padding: 12, background: 'var(--c-warm-50)', borderRadius: 10, fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<strong>Варианты.</strong> {doc.variants}
</div>
)}
</div>
</div> </div>
); );
} }
@@ -199,10 +305,17 @@ export default function App() {
const [tw, setTw] = useState(TWEAKS_DEFAULT); const [tw, setTw] = useState(TWEAKS_DEFAULT);
const [panelOpen, setPanelOpen] = useState(true); const [panelOpen, setPanelOpen] = useState(true);
const [introVisible, setIntroVisible] = useState(tw.showIntro !== false); const [introVisible, setIntroVisible] = useState(tw.showIntro !== false);
const [docModal, setDocModal] = useState(null);
const [innerScreen, setInnerScreen] = useState(tw.screen);
useEffect(() => { applyTheme(tw); }, [tw.accent, tw.font]); useEffect(() => { applyTheme(tw); }, [tw.accent, tw.font]);
const ctx = { homeVariant: tw.homeVariant, docVariant: tw.docVariant, density: tw.density }; // Sync inner screen when Tweaks changes the initial screen
useEffect(() => { setInnerScreen(tw.screen); }, [tw.screen]);
const palette = ACCENT_OPTIONS.find(a => a.id === tw.accent) || ACCENT_OPTIONS[0];
const ctx = { homeVariant: tw.homeVariant, docVariant: tw.docVariant, density: tw.density, palette, design: tw.design };
const currentDoc = tw.docsEnabled ? getScreenDoc(innerScreen, ctx) : null;
const content = useMemo(() => { const content = useMemo(() => {
if (tw.layout === 'home3') { if (tw.layout === 'home3') {
@@ -248,11 +361,14 @@ export default function App() {
); );
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
{currentDoc && <DocPlashka doc={currentDoc} onOpen={() => setDocModal(currentDoc)} />}
<FitWrap userScale={tw.scale}> <FitWrap userScale={tw.scale}>
<Phone device={tw.device} screen={tw.screen} ctx={ctx} /> <Phone device={tw.device} screen={tw.screen} ctx={ctx} onCurrentChange={setInnerScreen} />
</FitWrap> </FitWrap>
</div>
); );
}, [tw, ctx.homeVariant, ctx.docVariant, ctx.density]); }, [tw, ctx.homeVariant, ctx.docVariant, ctx.density, currentDoc]);
const stageClass = tw.layout === 'single' ? 'stage' : 'stage grid-mode'; const stageClass = tw.layout === 'single' ? 'stage' : 'stage grid-mode';
@@ -273,6 +389,8 @@ export default function App() {
{!panelOpen && ( {!panelOpen && (
<button className="tweaks-fab" onClick={() => setPanelOpen(true)} title="Tweaks"></button> <button className="tweaks-fab" onClick={() => setPanelOpen(true)} title="Tweaks"></button>
)} )}
{docModal && <DocModal doc={docModal} onClose={() => setDocModal(null)} />}
</div> </div>
); );
} }
+46
View File
@@ -0,0 +1,46 @@
import React, { useEffect, useMemo } from 'react';
import { MapContainer, Marker, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
function InvalidateOnMount() {
const map = useMap();
useEffect(() => {
map.invalidateSize();
const id = window.setTimeout(() => map.invalidateSize(), 120);
return () => window.clearTimeout(id);
}, [map]);
return null;
}
function makePinIcon(pinColor) {
const safe = /^#[0-9A-Fa-f]{6}$/.test(pinColor) ? pinColor : '#E04E44';
return L.divIcon({
className: 'clinic-leaflet-pin-wrap',
html: `<svg width="26" height="34" viewBox="0 0 24 32" aria-hidden="true"><path d="M12 0C5 0 0 5 0 12c0 8 12 20 12 20s12-12 12-20c0-7-5-12-12-12z" fill="${safe}"/><circle cx="12" cy="12" r="4.5" fill="#fff"/></svg>`,
iconSize: [26, 34],
iconAnchor: [13, 34],
});
}
/** OSM + один маркер; center — [lat, lng] в формате Leaflet */
export function ClinicLeafletMap({ center, pinColor = '#E04E44' }) {
const icon = useMemo(() => makePinIcon(pinColor), [pinColor]);
return (
<MapContainer
center={center}
zoom={17}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
dragging
doubleClickZoom={false}
>
<InvalidateOnMount />
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={center} icon={icon} />
</MapContainer>
);
}
+608
View File
@@ -0,0 +1,608 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { I } from './icons.jsx';
import { IOSDevice } from './frames/IOSDevice.jsx';
import { FitWrap } from './FitWrap.jsx';
import { ClinicLeafletMap } from './ClinicLeafletMap.jsx';
/** Палитра и toolbar синхронизированы с clinic-ecosystem mobile-app */
const oc = {
pageBg: '#FFFFFF',
headerBg: '#FFFFFF',
card: '#FFFFFF',
border: '#E3EBEB',
fg: '#172121',
mutedFg: '#6F7D7D',
primary: '#4c9a9a',
/** Телефон и «Записаться на приём» как primary из mobile-app */
phoneBtnBg: '#4c9a9a',
phoneBtnFg: '#FFFFFF',
phoneBtnShadow: '0 4px 14px rgba(76, 154, 154, 0.24)',
bookBtnBg: '#4c9a9a',
bookBtnFg: '#FFFFFF',
chatBorder: '#D8E3E3',
chatFg: '#4A5A5A',
openBadgeBg: '#4c9a9a',
openBadgeFg: '#FFFFFF',
/** Блок «Почему нас выбирают» */
hWhy: '#4A6E6E',
whyWrapBg: '#F4F8F8',
/** Звезда над заголовком «Почему нас выбирают» */
whyHeaderStar: '#4c9a9a',
/** Иконки Email / Веб-сайт */
whyInfoIconBg: '#F7FAFA',
whyInfoIconFg: '#4c9a9a',
whyRowLabel: '#4c9a9a',
whyRowValue: '#2F3B3B',
statCardValue: '#4c9a9a',
statCardLabel: '#6B7A7A',
noteBg: '#ECF6F6',
noteBorder: '#CFE3E3',
noteFg: '#2C5F62',
routeBtnBg: '#EEF3F3',
routeBtnFg: '#4A5A5A',
teal: '#4c9a9a',
tealDark: '#4c9a9a',
};
function MiniBuilding() {
return (
<div style={{
width: '100%', height: '100%',
background: 'linear-gradient(180deg, #D4EBE6 0%, #E8F5F2 50%, #B8D4C8 100%)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 5,
}}>
<div style={{
width: '68%', height: '58%',
background: '#E5DAC8',
borderTopLeftRadius: 3, borderTopRightRadius: 3,
position: 'relative',
boxShadow: 'inset 0 -10px 0 #7A5F3A',
}}>
<div style={{
position: 'absolute', top: '16%', left: '50%', transform: 'translateX(-50%)',
padding: '3px 8px', background: '#166B63', color: '#fff', fontSize: 7, fontWeight: 800,
borderRadius: 3, letterSpacing: 0.4,
}}>OCLINICA</div>
<div style={{
position: 'absolute', inset: '40% 10% 20%',
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 3,
}}>
{[1, 0, 1, 1, 0, 1, 0, 1, 1, 0].map((l, i) => (
<div key={i} style={{ background: l ? '#F5D78A' : '#2A4544', borderRadius: 1, minHeight: 4 }} />
))}
</div>
</div>
</div>
);
}
function NavRouteIcon({ size = 18, color = oc.teal }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18l-5-5 5-5M4 13h12.5a4.5 4.5 0 014.5 4.5V19" />
</svg>
);
}
/** Глифы VK / YouTube — геометрия Simple Icons, единый цвет в интерфейсе */
const SI_VK_PATH =
'm9.489.004.729-.003h3.564l.73.003.914.01.433.007.418.011.403.014.388.016.374.021.36.025.345.03.333.033c1.74.196 2.933.616 3.833 1.516.9.9 1.32 2.092 1.516 3.833l.034.333.029.346.025.36.02.373.025.588.012.41.013.644.009.915.004.98-.001 3.313-.003.73-.01.914-.007.433-.011.418-.014.403-.016.388-.021.374-.025.36-.03.345-.033.333c-.196 1.74-.616 2.933-1.516 3.833-.9.9-2.092 1.32-3.833 1.516l-.333.034-.346.029-.36.025-.373.02-.588.025-.41.012-.644.013-.915.009-.98.004-3.313-.001-.73-.003-.914-.01-.433-.007-.418-.011-.403-.014-.388-.016-.374-.021-.36-.025-.345-.03-.333-.033c-1.74-.196-2.933-.616-3.833-1.516-.9-.9-1.32-2.092-1.516-3.833l-.034-.333-.029-.346-.025-.36-.02-.373-.025-.588-.012-.41-.013-.644-.009-.915-.004-.98.001-3.313.003-.73.01-.914.007-.433.011-.418.014-.403.016-.388.021-.374.025-.36.03-.345.033-.333c.196-1.74.616-2.933 1.516-3.833.9-.9 2.092-1.32 3.833-1.516l.333-.034.346-.029.36-.025.373-.02.588-.025.41-.012.644-.013.915-.009ZM6.79 7.3H4.05c.13 6.24 3.25 9.99 8.72 9.99h.31v-3.57c2.01.2 3.53 1.67 4.14 3.57h2.84c-.78-2.84-2.83-4.41-4.11-5.01 1.28-.74 3.08-2.54 3.51-4.98h-2.58c-.56 1.98-2.22 3.78-3.8 3.95V7.3H10.5v6.92c-1.6-.4-3.62-2.34-3.71-6.92Z';
const SI_YOUTUBE_BG_PATH =
'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z';
const SI_YOUTUBE_PLAY_PATH = 'M9.545 15.568V8.432L15.818 12l-6.273 3.568z';
function SocialVkIcon({ size = 26, color = oc.whyHeaderStar }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
<path fill={color} d={SI_VK_PATH} />
</svg>
);
}
function SocialYoutubeIcon({ size = 26, color = oc.whyHeaderStar, playColor = '#fff' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
<path fill={color} d={SI_YOUTUBE_BG_PATH} />
<path fill={playColor} d={SI_YOUTUBE_PLAY_PATH} />
</svg>
);
}
function RoundChatTabIcon({ size = 24, sw = 1.9, style }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" style={style}>
<circle cx="12" cy="11.5" r="7.5" />
<path d="M8.8 18.1l-2.1 2.4.5-3.4" />
</svg>
);
}
function OclinicaTabBar() {
const items = [
{ label: 'Главная', Icon: I.home, to: '/' },
{ label: 'Приемы', Icon: I.calendar, to: '/appointments' },
{ label: 'Чат', Icon: RoundChatTabIcon, to: '/chat' },
{ label: 'Профиль', Icon: I.profile, to: '/profile' },
];
const tab = ({ label, Icon, to }) => (
<Link
key={label}
to={to}
style={{
flex: 1,
textDecoration: 'none',
color: 'inherit',
}}
>
<div
style={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 1,
minHeight: 56,
color: oc.mutedFg,
fontSize: 12,
fontWeight: 500,
}}
>
<Icon size={24} sw={1.9} style={{ color: oc.mutedFg }} />
<span>{label}</span>
</div>
</Link>
);
return (
<div style={{ display: 'flex', gap: 8, padding: '8px 16px calc(12px + env(safe-area-inset-bottom, 0px))' }}>
{items.map(tab)}
</div>
);
}
/** Конверт — заливка заданным зелёным */
function WhyMailGlyph({ size = 22, color = oc.whyInfoIconFg }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden style={{ display: 'block' }}>
<path
fill={color}
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
/>
</svg>
);
}
/** Глобус — контур тем же зелёным */
function WhyGlobeGlyph({ size = 22, color = oc.whyInfoIconFg }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden style={{ display: 'block' }}>
<circle cx="12" cy="12" r="9.25" stroke={color} strokeWidth="2" />
<ellipse cx="12" cy="12" rx="4.2" ry="9.25" stroke={color} strokeWidth="1.75" />
<path d="M2.5 12h19" stroke={color} strokeWidth="1.75" strokeLinecap="round" />
<path
d="M6.5 6.5c1.4 1.9 2.2 4.1 2.2 5.5s-.8 3.6-2.2 5.5M17.5 6.5c-1.4 1.9-2.2 4.1-2.2 5.5s.8 3.6 2.2 5.5"
stroke={color}
strokeWidth="1.55"
strokeLinecap="round"
/>
</svg>
);
}
/** Девятиконечная звезда + белая галочка по центру (блок «Почему нас выбирают») */
/** Внешний ≈8.35, внутренний ≈7.22 — чуть крупнее, лучи по-прежнему короткие */
const WHY_NINE_POINT_STAR_D =
'M12.000,3.650L14.469,5.215L17.367,5.604L18.253,8.390L20.223,10.550L19.110,13.254L19.231,16.175L16.641,17.531L14.856,19.846L12.000,19.220L9.144,19.846L7.359,17.531L4.769,16.175L4.890,13.254L3.777,10.550L5.747,8.390L6.633,5.604L9.531,5.215Z';
function WhyHeaderStarGlyph({ size = 52, color = oc.whyHeaderStar }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
aria-hidden
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<path fill={color} d={WHY_NINE_POINT_STAR_D} />
<path
transform="translate(12,12) scale(0.52) translate(-12,-12)"
d="M8.05 12.05l2.75 2.75 5.85-6.45"
fill="none"
stroke="#fff"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatPill({ value, label, suffix }) {
return (
<div style={{
flex: 1, minWidth: 0,
background: oc.card,
borderRadius: 12,
padding: '10px 4px 11px',
textAlign: 'center',
boxShadow: '0 1px 3px rgba(15,30,40,0.06)',
border: '1px solid #E8EAED',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3,
fontSize: 16, fontWeight: 800, color: oc.statCardValue, fontFamily: 'var(--font-narrow)', lineHeight: 1.1,
}}>
<span>{value}</span>
{suffix}
</div>
<div style={{
fontSize: 8, fontWeight: 800, letterSpacing: 0.7, color: oc.statCardLabel, marginTop: 5,
lineHeight: 1.2, textTransform: 'uppercase',
}}>{label}</div>
</div>
);
}
function InfoRow({ glyph, label, children }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
background: oc.card,
borderRadius: 14,
padding: '12px 14px',
marginBottom: 10,
border: '1px solid #E8EAED',
boxShadow: '0 1px 4px rgba(15,30,40,0.05)',
}}>
<div style={{
width: 46, height: 46, borderRadius: '50%', background: oc.whyInfoIconBg,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
border: '1px solid #eef2f0',
}}>
{glyph}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: oc.whyRowLabel, marginBottom: 3, fontWeight: 700, letterSpacing: 0.2,
}}>{label}</div>
<div style={{ fontSize: 15, fontWeight: 600, color: oc.whyRowValue, letterSpacing: 0.02, lineHeight: 1.35 }}>
{children}
</div>
</div>
</div>
);
}
function ClinicCirclePhoto({ imageUrl, href }) {
const [useFallback, setUseFallback] = React.useState(false);
const img = !useFallback ? (
<img
src={imageUrl}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center',
display: 'block',
}}
onError={() => setUseFallback(true)}
/>
) : (
<MiniBuilding />
);
if (href) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
width: '100%',
height: '100%',
textDecoration: 'none',
color: 'inherit',
}}
>
{img}
</a>
);
}
return img;
}
const FOOTER_H = 92;
/** Выше внутренних слоёв Leaflet (~400–700), чтобы хром не перекрывался картой при скролле */
const Z_SCROLL = 0;
const Z_HEADER = 2000;
const Z_FOOTER_BAR = 2100;
function ContactsPhoneBody() {
const navigate = useNavigate();
const goBack = () => {
if (window.history.length > 1) navigate(-1);
else navigate('/');
};
const clinics = [
{
id: 'zvezda',
street: 'ул. Газеты Звезда, 31А',
hours: 'Пн–Пт 09:00 21:00\nСб–Вс 09:00 19:00',
note: 'Каждый 4-ый четверг месяца до 17:00',
mapPosition: [58.008116, 56.246041],
mapPinColor: '#E04E44',
circleImageSrc: 'https://avatars.mds.yandex.net/get-altay/1363018/2a00000164698e13e4695cde8053e9eed99f/L_height',
circleHref: 'https://yandex.ru/maps/org/klinika_ukho_gorlo_nos_imeni_professora_ye_n_olenevoy/1747301334/',
},
{
id: 'tsetkin',
street: 'ул. Клары Цеткин, 9',
hours: 'Пн–Сб 09:00 17:00\nВс — выходной',
note: null,
mapPosition: [57.987262, 56.246448],
mapPinColor: '#D94A3D',
circleImageSrc: 'https://lh5.googleusercontent.com/proxy/FtM-XTbdVjWSzmAYLN1W_b69pueujUj2Gv6Yr7RqwYwQJOrisuUt_YI6qIXyaO9kZa3BZmQQrFpcJcfbcJUvdMGJx-s7UM3PYrnLsYtZcJ8jdVllLCU',
circleHref: 'http://job.oclinica.ru/vakansiya-administratora',
},
];
return (
<div style={{
position: 'absolute', inset: 0,
background: oc.pageBg,
overflow: 'hidden',
fontFamily: 'var(--font-base), "Inter", system-ui, sans-serif',
color: oc.fg,
}}>
<div style={{
position: 'absolute', inset: 0,
overflowY: 'auto', overflowX: 'hidden',
paddingTop: 58,
paddingBottom: FOOTER_H,
zIndex: Z_SCROLL,
isolation: 'isolate',
}}>
<header style={{
display: 'grid',
gridTemplateColumns: '42px 1fr 42px',
alignItems: 'center',
padding: '10px 12px 11px',
position: 'sticky', top: 0, zIndex: Z_HEADER,
background: oc.headerBg,
borderBottom: `1px solid ${oc.border}`,
}}>
<button type="button" onClick={goBack} className="press" style={{
width: 38, height: 38, borderRadius: 999, background: '#F4F7F7',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: `1px solid ${oc.border}`,
}}>
<I.chevL size={20} style={{ color: oc.fg }} />
</button>
<h1 style={{ margin: 0, fontSize: 17, fontWeight: 600, textAlign: 'center', color: oc.fg }}>Контакты</h1>
<span />
</header>
<div style={{ padding: '14px 16px 20px' }}>
<a href="tel:+73422070303" className="press" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
width: '100%', padding: '15px 16px', borderRadius: 16, marginBottom: 10,
background: oc.phoneBtnBg, color: oc.phoneBtnFg, textDecoration: 'none',
fontSize: 17, fontWeight: 800, letterSpacing: 0.2,
boxShadow: oc.phoneBtnShadow,
border: '1px solid rgba(255,255,255,0.45)',
}}>
<I.phone size={21} sw={2} style={{ color: oc.phoneBtnFg }} />
8(342) 207-03-03
</a>
<Link to="/" className="press" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 9,
width: '100%', padding: '14px 16px', borderRadius: 16, marginBottom: 20,
background: oc.card, color: oc.chatFg,
border: `1.5px solid ${oc.chatBorder}`, textDecoration: 'none', fontWeight: 700, fontSize: 15,
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}>
<I.chat size={20} />
Написать в чат
</Link>
<h2 style={{ fontSize: 16, fontWeight: 800, margin: '0 0 12px', color: '#1F2A37', letterSpacing: 0.02 }}>Наши клиники</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{clinics.map(c => (
<article key={c.id} style={{
background: oc.card,
borderRadius: 18,
overflow: 'hidden',
boxShadow: '0 2px 14px rgba(15,30,40,0.07)',
border: '1px solid #ECECEC',
}}>
<div style={{ padding: 12, paddingBottom: 4 }}>
<div style={{
borderRadius: 14, overflow: 'hidden', position: 'relative',
aspectRatio: '16 / 10',
isolation: 'isolate',
}}>
<ClinicLeafletMap key={c.id} center={c.mapPosition} pinColor={c.mapPinColor} />
<div style={{
position: 'absolute', top: 10, left: 10, zIndex: 500,
fontSize: 9, fontWeight: 900, letterSpacing: 0.7,
padding: '6px 11px', borderRadius: 8,
background: oc.openBadgeBg, color: oc.openBadgeFg,
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}>ОТКРЫТО</div>
<div style={{
position: 'absolute', right: 10, bottom: 10,
width: 88, height: 88, borderRadius: '50%',
overflow: 'hidden',
border: '4px solid #fff',
boxShadow: '0 6px 18px rgba(15,30,40,0.14)',
zIndex: 600,
background: '#fff',
}}>
<ClinicCirclePhoto imageUrl={c.circleImageSrc} href={c.circleHref} />
</div>
</div>
</div>
<div style={{ padding: '16px 16px 16px' }}>
<div style={{ fontSize: 17, fontWeight: 800, lineHeight: 1.25, marginBottom: 12, color: '#1F2A37' }}>
{c.street}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 800, color: '#6B7A89', letterSpacing: 0.4 }}>Режим работы</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', marginBottom: c.note ? 10 : 14 }}>
<I.clock size={18} style={{ color: '#8A96A0', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, color: '#5A6B7B', lineHeight: 1.5, whiteSpace: 'pre-line', fontWeight: 500 }}>
{c.hours}
</div>
</div>
{c.note && (
<div style={{
fontSize: 12, fontWeight: 600, color: oc.noteFg, lineHeight: 1.45,
padding: '11px 13px', borderRadius: 12,
background: oc.noteBg, border: `1px solid ${oc.noteBorder}`,
marginBottom: 14,
}}>
{c.note}
</div>
)}
<button type="button" className="press" style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
padding: '13px 14px', borderRadius: 14,
background: oc.routeBtnBg, border: '1px solid #E0E3E6',
fontSize: 14, fontWeight: 700, color: oc.routeBtnFg,
}}>
<NavRouteIcon size={19} color={oc.teal} />
Построить маршрут
</button>
</div>
</article>
))}
</div>
<div style={{
marginTop: 22,
background: oc.whyWrapBg,
borderRadius: 20,
padding: '18px 12px 16px',
border: '1px solid #DDE0E4',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.7)',
}}>
<div style={{ textAlign: 'center', marginBottom: 14 }}>
<div style={{ marginBottom: 10, lineHeight: 0 }}>
<WhyHeaderStarGlyph size={52} color={oc.whyHeaderStar} />
</div>
<h2 style={{
margin: 0, fontSize: 20, fontWeight: 700, color: oc.hWhy,
letterSpacing: 0.08, fontFamily: 'var(--font-base)', lineHeight: 1.25,
}}>Почему нас выбирают</h2>
</div>
<div style={{
display: 'flex', flexDirection: 'row', gap: 8,
marginBottom: 14,
}}>
<StatPill value="20+" label="лет опыта" />
<StatPill value="50+" label="врачей" />
<StatPill value="100k+" label="пациентов" />
<StatPill value="4.9" label="рейтинг" suffix={<span style={{ fontSize: 13, color: oc.statCardValue, lineHeight: 1 }}></span>} />
</div>
<InfoRow glyph={<WhyMailGlyph size={23} />} label="Email">
<a href="mailto:mail@oclinica.ru" style={{ color: oc.whyRowValue, textDecoration: 'none', fontWeight: 600 }}>
mail@oclinica.ru
</a>
</InfoRow>
<InfoRow glyph={<WhyGlobeGlyph size={23} />} label="Веб-сайт">
<a href="https://perm.oclinica.ru" target="_blank" rel="noreferrer" style={{ color: oc.whyRowValue, textDecoration: 'none', fontWeight: 600 }}>
perm.oclinica.ru
</a>
</InfoRow>
<div style={{ display: 'flex', gap: 14, marginTop: 6, justifyContent: 'center' }}>
<a
href="https://vk.com"
target="_blank"
rel="noopener noreferrer"
className="press"
aria-label="ВКонтакте"
style={{
width: 50, height: 50, borderRadius: '50%', background: '#fff',
border: '1px solid #E4E4E4', boxShadow: '0 2px 10px rgba(0,0,0,0.07)',
display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none',
}}
>
<SocialVkIcon size={26} />
</a>
<a
href="https://www.youtube.com"
target="_blank"
rel="noopener noreferrer"
className="press"
aria-label="YouTube"
style={{
width: 50, height: 50, borderRadius: '50%', background: '#fff',
border: '1px solid #E4E4E4', boxShadow: '0 2px 10px rgba(0,0,0,0.07)',
display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none',
}}
>
<SocialYoutubeIcon size={26} />
</a>
</div>
</div>
<div style={{ marginTop: 10 }}>
<Link to="/" className="press" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
width: '100%', padding: '15px 16px', borderRadius: 16, textDecoration: 'none',
background: oc.bookBtnBg,
color: oc.bookBtnFg, fontWeight: 800, fontSize: 16, letterSpacing: 0.02,
boxShadow: '0 6px 16px rgba(76, 154, 154, 0.25)',
border: '1px solid rgba(76,154,154,0.2)',
}}>
<I.calendar size={21} sw={2} style={{ color: oc.bookBtnFg }} />
Записаться на приём
</Link>
</div>
<div style={{ height: 20 }} />
</div>
</div>
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: Z_FOOTER_BAR,
background: oc.card,
borderTop: `1px solid ${oc.border}`,
boxShadow: 'none',
}}>
<OclinicaTabBar />
</div>
</div>
);
}
export default function ContactsRoutePage() {
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div className="stage">
<FitWrap userScale="auto">
<IOSDevice>
<ContactsPhoneBody />
</IOSDevice>
</FitWrap>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useRef, useState } from 'react';
export function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) {
const outerRef = useRef(null);
const [autoScale, setAutoScale] = useState(1);
useEffect(() => {
const outer = outerRef.current;
if (!outer) return;
const stage = outer.closest('.stage') || outer.parentElement;
if (!stage) return;
const measure = () => {
const padding = 48;
const availW = stage.clientWidth - padding;
const availH = stage.clientHeight - padding;
const s = Math.min(availW / w, availH / h, 1);
setAutoScale(Math.max(s, 0.3));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(stage);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [w, h]);
const scale = userScale === 'auto' ? autoScale : parseFloat(userScale);
return (
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}>
<div style={{
width: w, height: h, transformOrigin: 'top left',
transform: `scale(${scale})`,
}}>{children}</div>
</div>
);
}
+36 -7
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { TabBar } from './components.jsx'; import { TabBar } from './components.jsx';
import { HomeCardsScreen, HomeListScreen, HomeFeedScreen } from './screens/screens-home.jsx'; import { HomeCardsScreen, HomeListScreen, HomeFeedScreen, HomeTimelineXScreen, HomeSplashScreen } from './screens/screens-home.jsx';
import { import {
BookingSpecsScreen, BookingDoctorScreen, BookingTimeScreen, BookingSpecsScreen, BookingDoctorScreen, BookingTimeScreen,
BookingConfirmScreen, BookingSuccessScreen, BookingConfirmScreen, BookingSuccessScreen,
@@ -10,16 +10,34 @@ import {
ApptsTabScreen, ApptDetailScreen, ApptsTabScreen, ApptDetailScreen,
ResultsScreen, ResultAudioScreen, ResultEndoscopyScreen, ResultsScreen, ResultAudioScreen, ResultEndoscopyScreen,
RecoveryScreen, AudioTestScreen, RecoveryScreen, AudioTestScreen,
ChatTabScreen, ProfileTabScreen, QRScreen, ProfileTabScreen, QRScreen,
TelemedScreen, MedcardScreen, NotificationsScreen, TelemedScreen, MedcardScreen, NotificationsScreen,
} from './screens/screens-misc.jsx'; } from './screens/screens-misc.jsx';
import { ChatsListScreen, ChatConversationScreen } from './screens/screens-chats.jsx';
import { ArticlesScreen, ArticleDetailScreen } from './screens/screens-articles.jsx'; import { ArticlesScreen, ArticleDetailScreen } from './screens/screens-articles.jsx';
import { HomeV2Screen, SearchScreen, ContactsScreen, PricesScreen } from './screens/screens-v2.jsx'; import { HomeV2Screen, SearchScreen, ContactsScreen, PricesScreen } from './screens/screens-v2.jsx';
import { DevColorsScreen, DevExamplesScreen } from './screens/screens-dev.jsx';
import { DocsScreen } from './screens/screens-docs.jsx';
import { ProfilePlateScreen, ApptsPlateScreen, ApptDetailPlateScreen, MedcardPlateScreen } from './screens/screens-plate.jsx';
function renderScreen(screenId, nav, ctx) { function renderScreen(screenId, nav, ctx) {
const parts = screenId.split(':'); const parts = screenId.split(':');
const id = parts[0]; const id = parts[0];
const HOME = { cards: HomeCardsScreen, list: HomeListScreen, feed: HomeFeedScreen }[ctx.homeVariant] || HomeCardsScreen; const plate = ctx.design === 'plate';
// В plate-режиме главная всегда «Светлая плитка», независимо от homeVariant
const homeVariant = plate ? 'splash' : ctx.homeVariant;
const HOME = { cards: HomeCardsScreen, list: HomeListScreen, feed: HomeFeedScreen, timelineX: HomeTimelineXScreen, splash: HomeSplashScreen }[homeVariant] || HomeCardsScreen;
// Plate-подмены (fallback на Клод если plate-версии нет)
if (plate) {
switch (id) {
case 'profile': return <ProfilePlateScreen nav={nav} ctx={ctx} />;
case 'appts': return <ApptsPlateScreen nav={nav} />;
case 'appt': return <ApptDetailPlateScreen nav={nav} apptId={parts[1]} />;
case 'medcard': return <MedcardPlateScreen nav={nav} />;
}
}
switch (id) { switch (id) {
case 'home': return <HOME nav={nav} ctx={ctx} />; case 'home': return <HOME nav={nav} ctx={ctx} />;
case 'home-v2': return <HomeV2Screen nav={nav} ctx={ctx} />; case 'home-v2': return <HomeV2Screen nav={nav} ctx={ctx} />;
@@ -37,7 +55,9 @@ function renderScreen(screenId, nav, ctx) {
case 'result': return <ResultEndoscopyScreen nav={nav} resultId={parts[1]} />; case 'result': return <ResultEndoscopyScreen nav={nav} resultId={parts[1]} />;
case 'recovery': return <RecoveryScreen nav={nav} />; case 'recovery': return <RecoveryScreen nav={nav} />;
case 'audiotest': return <AudioTestScreen nav={nav} />; case 'audiotest': return <AudioTestScreen nav={nav} />;
case 'chat': return <ChatTabScreen nav={nav} />; case 'chat': return parts[1]
? <ChatConversationScreen nav={nav} chatId={parts[1]} />
: <ChatsListScreen nav={nav} />;
case 'profile': return <ProfileTabScreen nav={nav} ctx={ctx} />; case 'profile': return <ProfileTabScreen nav={nav} ctx={ctx} />;
case 'qr': return <QRScreen nav={nav} />; case 'qr': return <QRScreen nav={nav} />;
case 'telemed': return <TelemedScreen nav={nav} />; case 'telemed': return <TelemedScreen nav={nav} />;
@@ -48,17 +68,26 @@ function renderScreen(screenId, nav, ctx) {
case 'search': return <SearchScreen nav={nav} />; case 'search': return <SearchScreen nav={nav} />;
case 'contacts': return <ContactsScreen nav={nav} />; case 'contacts': return <ContactsScreen nav={nav} />;
case 'prices': return <PricesScreen nav={nav} />; case 'prices': return <PricesScreen nav={nav} />;
case 'dev-colors': return <DevColorsScreen nav={nav} ctx={ctx} />;
case 'dev-examples': return <DevExamplesScreen nav={nav} ctx={ctx} />;
case 'docs': return <DocsScreen nav={nav} />;
default: return <div style={{padding: 40, textAlign: 'center'}}>Экран не найден: {screenId}</div>; default: return <div style={{padding: 40, textAlign: 'center'}}>Экран не найден: {screenId}</div>;
} }
} }
const TAB_IDS = ['home', 'appts', 'doctors', 'chat', 'profile']; const TAB_IDS = ['home', 'appts', 'doctors', 'chat', 'profile'];
export function PhoneApp({ initialScreen, ctx }) { export function PhoneApp({ initialScreen, ctx, onCurrentChange }) {
const [stack, setStack] = useState([initialScreen]); const [stack, setStack] = useState([initialScreen]);
useEffect(() => { setStack([initialScreen]); }, [initialScreen]); useEffect(() => { setStack([initialScreen]); }, [initialScreen]);
const current = stack[stack.length - 1];
useEffect(() => {
if (onCurrentChange) onCurrentChange(current);
}, [current, onCurrentChange]);
const nav = useMemo(() => ({ const nav = useMemo(() => ({
push: (id) => setStack(s => [...s, id]), push: (id) => setStack(s => [...s, id]),
pop: () => setStack(s => s.length > 1 ? s.slice(0, -1) : s), pop: () => setStack(s => s.length > 1 ? s.slice(0, -1) : s),
@@ -66,9 +95,9 @@ export function PhoneApp({ initialScreen, ctx }) {
reset:() => setStack(['home']), reset:() => setStack(['home']),
}), []); }), []);
const current = stack[stack.length - 1];
const rootId = current.split(':')[0]; const rootId = current.split(':')[0];
const tabId = rootId === 'home-v2' ? 'home' : (TAB_IDS.includes(rootId) ? rootId : null); const hasSubId = current.includes(':');
const tabId = hasSubId ? null : (rootId === 'home-v2' ? 'home' : (TAB_IDS.includes(rootId) ? rootId : null));
const showTabBar = tabId !== null; const showTabBar = tabId !== null;
const modalScreens = ['qr', 'telemed', 'booking-success', 'audiotest']; const modalScreens = ['qr', 'telemed', 'booking-success', 'audiotest'];
+6
View File
@@ -345,3 +345,9 @@ input, select, textarea { font-family: inherit; }
@keyframes pulse { 0%{opacity:.5;transform:scale(.95)} 100%{opacity:0;transform:scale(1.15)} } @keyframes pulse { 0%{opacity:.5;transform:scale(.95)} 100%{opacity:0;transform:scale(1.15)} }
@keyframes blink { 50% { opacity: .3 } } @keyframes blink { 50% { opacity: .3 } }
/* Leaflet: divIcon marker without default box */
.leaflet-div-icon.clinic-leaflet-pin-wrap {
border: none !important;
background: transparent !important;
}
+146 -2
View File
@@ -2,6 +2,48 @@
// (oclinica.ru/lor) — врачи, услуги, цены. // (oclinica.ru/lor) — врачи, услуги, цены.
export const CLINIC_DATA = { export const CLINIC_DATA = {
patient: {
fullName: 'Разорвина Анна Сергеевна',
shortName: 'Анна Сергеевна',
init: 'АС',
birthDate: '14 июня 1983',
age: 42,
sex: 'Женский',
height: 168,
weight: 62,
bloodType: 'II (A), Rh+',
phone: '+7 (912) 485-••-••',
email: 'arazor72@gmail.com',
policy: 'ОМС 5590 1234 5678 9012',
snils: '123-456-789 01',
cardNumber: 'УГН-2014-00482',
firstVisit: '14 фев 2014',
primaryDoctorId: 'makarova',
},
medcard: {
allergies: [
{ id: 'a1', name: 'Пенициллин', severity: 'high', reaction: 'крапивница, отёк Квинке', noted: '2019' },
{ id: 'a2', name: 'Пыльца берёзы', severity: 'mid', reaction: 'ринит, слезотечение', noted: '2021' },
],
chronicConditions: [
{ id: 'c1', name: 'Хронический тонзиллит', stage: 'Компенсированная форма', diagnosed: '2022', doctorId: 'makarova', code: 'J35.0' },
{ id: 'c2', name: 'Искривление носовой перегородки', stage: 'Прооперировано', diagnosed: '2025', doctorId: 'syndaev', code: 'J34.2' },
],
vaccinations: [
{ id: 'v1', name: 'Грипп (Совигрипп)', date: '12 окт 2025', lot: 'SV-2025-44' },
{ id: 'v2', name: 'Пневмококк (Превенар 13)', date: '4 мая 2024', lot: 'PV-0424' },
{ id: 'v3', name: 'COVID-19 (Спутник V)', date: '18 сен 2022', lot: 'SP-5582' },
],
surgeries: [
{ id: 's1', name: 'Септопластика', date: '12 апр 2026', doctorId: 'syndaev', anesthesia: 'Общая', outcome: 'Без осложнений', apptId: null },
{ id: 's2', name: 'Тонзиллотомия (частичная)', date: '22 мар 2018', doctorId: 'makarova', anesthesia: 'Местная', outcome: 'Ремиссия 6 лет' },
],
prescriptions: [
{ id: 'm1', name: 'Аква Марис', dose: '4 раза в день', course: 'с 12 апр, 14 дней', prescribedBy: 'syndaev', active: true, forApptId: null },
{ id: 'm2', name: 'Амоксиклав 625 мг', dose: '2 раза в день', course: 'с 12 апр, 7 дней', prescribedBy: 'syndaev', active: true, forApptId: null },
{ id: 'm3', name: 'Назонекс', dose: '2 впрыск./сут', course: '14 дней', prescribedBy: 'makarova', active: false, forApptId: 'a3' },
],
},
clinic: { clinic: {
name: 'Клиника УГН', name: 'Клиника УГН',
full: 'Клиника ухо, горло, нос им. проф. Е.Н. Оленевой', full: 'Клиника ухо, горло, нос им. проф. Е.Н. Оленевой',
@@ -49,8 +91,24 @@ export const CLINIC_DATA = {
appointments: [ appointments: [
{ id: 'a1', status: 'upcoming', doctor: 'semerikova', date: '21 апр', weekday: 'понедельник', time: '16:00', room: 'Каб. 204', address: 'tsetkin', type: 'Первичный приём' }, { id: 'a1', status: 'upcoming', doctor: 'semerikova', date: '21 апр', weekday: 'понедельник', time: '16:00', room: 'Каб. 204', address: 'tsetkin', type: 'Первичный приём' },
{ id: 'a2', status: 'upcoming', doctor: 'torsunova', date: '25 апр', weekday: 'пятница', time: '11:00', room: 'Каб. 118', address: 'tsetkin', type: 'Аудиометрия' }, { id: 'a2', status: 'upcoming', doctor: 'torsunova', date: '25 апр', weekday: 'пятница', time: '11:00', room: 'Каб. 118', address: 'tsetkin', type: 'Аудиометрия' },
{ id: 'a3', status: 'past', doctor: 'makarova', date: '8 апр', weekday: 'среда', time: '10:30', room: 'Каб. 202', address: 'tsetkin', type: 'Первичный приём', hasReport: true }, { id: 'a3', status: 'past', doctor: 'makarova', date: '8 апр', year: 2026, weekday: 'среда', time: '10:30', room: 'Каб. 202', address: 'tsetkin', type: 'Первичный приём', hasReport: true,
{ id: 'a4', status: 'past', doctor: 'zykin', date: '28 мар', weekday: 'пятница', time: '15:00', room: 'Каб. 210', address: 'tsetkin', type: 'Консультация', hasReport: true }, diagnosis: 'Хронический риносинусит, обострение',
diagnosisCode: 'J32.4',
conclusion: 'Слизистая гиперемирована, отёчна. Выделений гнойных нет. Рекомендую медикаментозное лечение, контрольный осмотр через 2 недели.',
prescriptions: ['Аква Марис — промывание 4 р/д, 14 дней', 'Назонекс — 2 впрыск./сут, 14 дней'],
resultIds: ['r1', 'r2'] },
{ id: 'a4', status: 'past', doctor: 'zykin', date: '28 мар', year: 2026, weekday: 'пятница', time: '15:00', room: 'Каб. 210', address: 'tsetkin', type: 'Консультация', hasReport: true,
diagnosis: 'Состояние после ОРВИ, осложнений нет',
diagnosisCode: 'J06.9',
conclusion: 'Жалоб активно нет. Осмотр ЛОР-органов без патологии. Голосовая функция сохранна.',
prescriptions: ['Полоскание солевым раствором по необходимости'],
resultIds: ['r4'] },
{ id: 'a5', status: 'past', doctor: 'syndaev', date: '12 апр', year: 2026, weekday: 'воскресенье', time: '09:00', room: 'Опер. 1', address: 'tsetkin', type: 'Операция: септопластика', hasReport: true,
diagnosis: 'Искривление носовой перегородки',
diagnosisCode: 'J34.2',
conclusion: 'Септопластика. Восстановление функции носового дыхания. Осложнений нет. На 14 дней — стационар/амбулаторно по схеме.',
prescriptions: ['Амоксиклав 625 мг × 2 р/д, 7 дней', 'Аква Марис 4 р/д, 14 дней', 'Нурофен при боли'],
resultIds: [] },
], ],
results: [ results: [
{ id: 'r1', name: 'Аудиограмма', date: '8 апр 2026', doctor: 'makarova', status: 'ready', kind: 'audio' }, { id: 'r1', name: 'Аудиограмма', date: '8 апр 2026', doctor: 'makarova', status: 'ready', kind: 'audio' },
@@ -168,6 +226,92 @@ export const CLINIC_DATA = {
], ],
}, },
], ],
chats: [
{
id: 'ai',
kind: 'ai',
name: 'Умный помощник',
subtitle: 'Бот клиники УГН · отвечает мгновенно',
icon: '✨',
pinned: true,
lastMessage: 'Отлично! Отметил приём ✓',
lastTime: 'Сейчас',
unread: 0,
online: true,
messages: [
{ from: 'ai', t: 'Добрый день, Анна! Я помощник клиники УГН. Могу подсказать с записью, напомнить о лекарствах, объяснить заключение врача или помочь с тестом слуха.', tm: '09:00' },
{ from: 'me', t: 'Когда следующий контроль?', tm: '09:12' },
{ from: 'ai', t: 'Следующий контрольный осмотр — на 10-й день после операции, это 22 апреля. К этому дню запланирована эндоскопия полости носа у Синдяева А.В.', tm: '09:12' },
{ from: 'ai', t: '⏰ Напоминание: приём Амоксиклава в 20:00 — через 2 часа', tm: '18:00' },
{ from: 'me', t: 'Принял', tm: '20:03' },
{ from: 'ai', t: 'Отлично! Отметил приём ✓ Следующая доза — завтра в 08:00. Осталось 3 дня курса.', tm: '20:03' },
],
suggestions: ['Что показала аудиограмма?', 'Можно ли в баню?', 'Перенести приём 21 апреля'],
},
{
id: 'doctor-syndaev',
kind: 'doctor',
doctorId: 'syndaev',
lastMessage: 'Отлично, жду. Если что-то изменится — напишите.',
lastTime: '14:15',
unread: 2,
online: true,
messages: [
{ from: 'doc', t: 'Добрый день, Анна! Как самочувствие после операции?', tm: '14:02' },
{ from: 'me', t: 'Здравствуйте! В целом хорошо, немного саднит в носу по утрам.', tm: '14:08' },
{ from: 'doc', t: 'Это нормально на 6-й день. Продолжайте промывания Аква Марис 4 раза в день.', tm: '14:10' },
{ from: 'doc', t: 'Выходите на осмотр сегодня? Я свободен после 15:00.', tm: '14:11' },
{ from: 'me', t: 'Да, буду в 16:00 как запланировано.', tm: '14:14' },
{ from: 'doc', t: 'Отлично, жду. Если что-то изменится — напишите.', tm: '14:15' },
],
},
{
id: 'operator',
kind: 'operator',
name: 'Администратор',
subtitle: 'Регистратура · Пн–Вс 9:00–21:00',
icon: '📞',
lastMessage: 'Справка отправлена на почту.',
lastTime: 'Вчера',
unread: 0,
online: false,
messages: [
{ from: 'op', t: 'Добрый день, Анна! Это регистратура Клиники УГН. Чем могу помочь?', tm: 'Вчера, 11:30' },
{ from: 'me', t: 'Здравствуйте. Нужна справка о прохождении аудиометрии для работы.', tm: 'Вчера, 11:45' },
{ from: 'op', t: 'Подготовим за час. Подъехать в клинику за оригиналом или отправить PDF на почту?', tm: 'Вчера, 11:48' },
{ from: 'me', t: 'На почту, пожалуйста.', tm: 'Вчера, 11:50' },
{ from: 'op', t: 'Готово. Справка отправлена на arazor72@gmail.com. Если нужен оригинал с печатью — забирайте на ресепшене в любое время.', tm: 'Вчера, 12:40' },
],
},
],
chronic: {
condition: 'Хронический тонзиллит',
stage: 'Компенсированная форма',
diagnosed: '2022',
doctorId: 'makarova',
daysSinceLastFlareup: 118,
complianceScore: 87,
flareupsThisYear: 1,
currentTasks: [
{ id: 't1', text: 'Полоскание горла (утро/вечер)', type: 'daily', done: true, streak: 12 },
{ id: 't2', text: 'Витамин D 2000 МЕ', type: 'daily', done: false, streak: 0 },
{ id: 't3', text: 'Промывание миндалин в клинике', type: 'scheduled', nextDate: '23 апр' },
{ id: 't4', text: 'Плановый ЛОР-осмотр', type: 'scheduled', nextDate: 'Июль 2026' },
],
recommendations: [
{ icon: '❄️', title: 'Избегать переохлаждения', sub: 'Особенно горла и шеи' },
{ icon: '💧', title: 'Пить 1.5–2 л воды в день', sub: 'Увлажнение слизистой' },
{ icon: '🏃', title: 'Умеренные физнагрузки', sub: 'Укрепление иммунитета' },
{ icon: '🥦', title: 'Витамин C + цинк', sub: 'В рационе ежедневно' },
],
pastVisits: [
{ id: 'v1', date: '15 окт 2023', title: 'Диагноз подтверждён', doctorId: 'makarova', type: 'diagnosis' },
{ id: 'v2', date: '12 дек 2023', title: 'Первое промывание миндалин', doctorId: 'makarova', type: 'procedure' },
{ id: 'v3', date: '8 мар 2024', title: 'Курс физиотерапии · 10 сеансов', doctorId: 'makarova', type: 'therapy' },
{ id: 'v4', date: '14 дек 2024', title: 'Обострение — курс антибиотиков', doctorId: 'makarova', type: 'flareup' },
{ id: 'v5', date: '8 апр 2026', title: 'Плановый осмотр', doctorId: 'makarova', type: 'checkup' },
],
},
recovery: { recovery: {
op: 'Септопластика', op: 'Септопластика',
surgeon: 'syndaev', surgeon: 'syndaev',
+682
View File
@@ -0,0 +1,682 @@
// Описания экранов прототипа — для коллаборативного ревью.
// Читается двумя UI-слоями:
// 1. Плашка над телефоном в Tweaks-режиме «Описания»
// 2. Экран `docs` (список всех экранов с разворачивающимися описаниями)
export const SCREEN_DOCS = {
'home:cards': {
title: 'Главная 1 · Карточки',
category: 'Главная',
goal: 'Быстрый старт: записаться или увидеть ближайший приём. Приветствие + один доминирующий CTA + фасетная сетка входов.',
tasks: [
'Нажать «Записаться» и попасть в 4-шаговый флоу',
'Увидеть ближайший приём и открыть его детали',
'Выбрать специализацию (browse) или быстрое действие (телемед, тест слуха)',
'Прочитать статью врача',
],
rationale: [
'Бежевый градиент шапки — тёплый контакт вместо стерильного белого',
'Единственная доминирующая CTA тёмный teal + тень — видна сразу',
'Сетка 3×2 специализаций — browse-режим для пользователя, не знающего врача',
'Статьи горизонтально — низкий приоритет, не давят вертикально',
],
variants: 'В Tweaks «Главный экран»: Карточки / Лента / Таймлайн — три подхода к приоритизации контента',
},
'home:list': {
title: 'Главная 1 · Лента',
category: 'Главная',
goal: 'Утилитарная навигация: одна компактная карточка со всеми разделами как в iOS Settings. Для частых пользователей, которым не нужно browse.',
tasks: [
'Быстро открыть Медкарту / Анализы / Чат с одного тапа',
'Увидеть ближайший приём (компактно сверху)',
'Попасть на запись через первый выделенный пункт',
],
rationale: [
'Одна плотная карточка-список — минимум визуального шума',
'CTA «Записаться» выделена фоном primary-50 среди остальных',
'Бейджи справа для счётчиков (непрочитанные в чате)',
'Без градиентов и крупных CTA — подчёркнуто утилитарно',
],
},
'home:feed': {
title: 'Главная 1 · Таймлайн',
category: 'Главная',
goal: 'Пациент-центричная лента: восстановление, лекарства, ближайший приём — всё, что влияет на самочувствие сегодня. Для послеоперационных.',
tasks: [
'Увидеть прогресс восстановления с процентом и «что сегодня»',
'Отметить приём лекарства (one-tap)',
'Перейти к ближайшему приёму',
'Прочитать релевантные статьи',
],
rationale: [
'Первый блок — прогресс операции, а не CTA: для послеоперационных это важнее',
'Вопрос «Как Ваше самочувствие?» вместо нейтрального приветствия — эмпатия',
'Лекарство с кнопкой «Принял» — one-tap action',
'Accent-CTA (красная) для записи — выделяется на фоне тёплых карточек',
],
},
'home:timelineX': {
title: 'Главная 1 · Таймлайн X',
category: 'Главная',
goal: 'Для пациентов с хроническими заболеваниями: поддержание общего понимания «что со мной происходит», ежедневные задачи по здоровью, история наблюдения и лёгкий вход в связь с врачом или AI-помощником.',
tasks: [
'Увидеть текущее состояние: диагноз, стадия, ключевые метрики (дни без обострений, комплаенс)',
'Отметить выполненные ежедневные задачи (полоскание, витамины)',
'Увидеть запланированные процедуры и осмотры с датами',
'Быстро спросить AI-помощника или написать лечащему врачу',
'Пролистать историю наблюдения (визиты, обострения, курсы)',
'Записаться на следующий осмотр',
],
rationale: [
'Светлый health-status hero (primary-100 → warm-100) — спокойная справка о состоянии, не клинический приговор. Статус «Компенсированная форма» с зелёной точкой + success-цветом сигнализирует «всё стабильно».',
'Задачи разделены на daily (с streak-счётчиком и огнём 🔥) и scheduled (с датой) — разная природа действий',
'Промо-блок связи (AI + врач) вынесен отдельным блоком с градиентной AI-карточкой — снять психологический барьер обращения',
'Вертикальный таймлайн визитов с инициалами врача и цветовой семантикой по типу (диагноз/процедура/терапия/обострение/осмотр) — continuity of care одним взглядом',
'Рекомендации emoji-карточками горизонтально — лёгкое lifestyle-чтение, не давит на верхние задачи',
],
},
'home:splash': {
title: 'Главная 1 · Светлая плитка',
category: 'Главная',
goal: 'Приветственный дом с крупной типографикой и тёплыми карточками. Фокус на первой записи на приём: ближайшая запись → мои приёмы → запись новой. Ниже — услуги и полезная информация 2×N.',
tasks: [
'Увидеть персональное приветствие и открыть поиск врача',
'Попасть в ближайшую запись (фото врача, дата, время, локация, «Активно»)',
'Перейти в «Мои приёмы» из контекстной карточки',
'Нажать тёплый CTA «Записаться на приём → Выбрать удобное время»',
'Открыть связаться с врачом в чате одним тапом',
'Пролистать полезную информацию (статья дня, все статьи, цены, контакты)',
],
rationale: [
'Минималистичная шапка (аватар + «Главная» + колокольчик) вместо градиента — акцент сразу на h1-приветствии',
'Ближайшая запись как выделенная primary-50 карточка с caps-label «БЛИЖАЙШАЯ ЗАПИСЬ» + «★ Активно» — ясный статус и сканируемая структура',
'Чипы даты/времени белым поверх primary-50 — читаемость и тактильная приглашённость к нажатию',
'Тёплая warm-100 карточка записи с декоративными кругами и белой вложенной кнопкой — единственный визуальный «горячий» CTA на экране, притягивает глаз',
'Сетки 2×1 и 2×2 с одинаковыми тайлами (круглая teal-иконка, sub/main тексты) — ритмичная, предсказуемая плитка, легко сканируется',
'Все 8 CTA-карточек видны без скролла или в 1 свайп — плотная, но не перегруженная сетка',
],
variants: 'В Tweaks «Главный экран»: Карточки / Лента / Таймлайн / Таймлайн X / Светлая плитка — разные приоритеты контента.',
},
'home-v2': {
title: 'Главная 2',
category: 'Главная',
goal: 'Search-first layout: универсальная поисковая строка вверху — врач, симптом, услуга, дата. Для пользователя, который точно знает, что ищет.',
tasks: [
'Ввести симптом или ФИО врача → получить релевантный результат',
'Попасть в Контакты или Цены через тайлы',
'Увидеть статистику клиники (формирование доверия)',
],
rationale: [
'Поисковая строка вверху + AI-бейдж — сигнал «умный поиск»',
'Тайлы Контакты/Цены/Анализы/Восстановление — частые разделы в один тап',
'Градиентная карточка статистики — эмоциональный контакт с клиникой',
'Бордер primary-300 на stats-карточке — демонстрация «живого» цвета в палитре Бриз',
],
},
'doctors': {
title: 'Врачи · список',
category: 'Врачи и запись',
goal: 'Список всех врачей клиники с поиском, фильтрами и тремя вариантами представления карточек.',
tasks: [
'Найти врача по ФИО или специализации',
'Отфильтровать по «свободно сегодня», «кандидаты наук», «детские»',
'Открыть карточку врача → записаться',
],
rationale: [
'Пиллы-фильтры горизонтально скроллятся — помещаются все на узких экранах',
'Три варианта карточек (rich/list/photo) — под разные привычки сканирования',
'Пиллы вынесены из карточки — общий фильтр для всего списка',
],
variants: 'В Tweaks «Карточки врачей»: Карточки+ / Список / Плитка',
},
'doctor': {
title: 'Карточка врача',
category: 'Врачи и запись',
goal: 'Подробная карточка врача: регалии, рейтинг, цены, расписание, отзывы. Всё для решения «записаться — не записаться».',
tasks: [
'Увидеть аватар, имя, специализацию, рейтинг, стоимость',
'Прочитать образование и специализацию',
'Посмотреть расписание (таб)',
'Прочитать отзывы (таб)',
'Нажать «Записаться» внизу → сразу на выбор времени',
],
rationale: [
'Три равных stat-карточки: опыт, рейтинг, цена — ключевая инфа сразу',
'Сегментед-контрол для вкладок — компактно, iOS-паттерн',
'Фиксированная CTA внизу с backdrop-blur — не теряется при прокрутке',
'Чип «К.м.н.» warm-цветом — выделяет научную степень',
],
},
'booking-specs': {
title: 'Запись: специализация',
category: 'Флоу записи',
goal: 'Шаг 1 из 4: выбор направления. Для пользователей, которые не знают конкретного врача.',
tasks: [
'Переключиться между «Взрослому / Ребёнку / Онлайн»',
'Выбрать специализацию из сетки 2×2',
'Перейти к списку врачей специализации',
],
rationale: [
'Сегментед 3 варианта сразу — быстрый split аудитории',
'Крупные карточки специализаций с иконкой, названием, кол-вом врачей, минимальной ценой',
'«Шаг 1 из 4» в шапке — прогресс флоу виден',
],
},
'booking-doctor': {
title: 'Запись: выбор врача',
category: 'Флоу записи',
goal: 'Шаг 2 из 4: список врачей выбранной специализации с поиском и фильтрами.',
tasks: [
'Найти конкретного врача по ФИО',
'Отфильтровать по «свободно сегодня», «детские»',
'Выбрать врача → перейти к выбору времени',
],
rationale: [
'Та же логика, что и в tab «Врачи», но filter по специализации уже применён',
'Rich-карточка с ближайшим временем приёма — дополнительный фильтр «когда»',
],
},
'booking-time': {
title: 'Запись: дата и время',
category: 'Флоу записи',
goal: 'Шаг 3 из 4: выбор даты и времени. Ключевой шаг флоу — тут пользователь принимает финальное решение.',
tasks: [
'Увидеть выбранного врача (повтор сверху)',
'Выбрать дату из горизонтального скролла (7 дней)',
'Выбрать время из трёх групп (Утро/День/Вечер)',
'Нажать «Выбрать · [дата], [время]» → подтверждение',
],
rationale: [
'Дата как горизонтальные тайлы 54px — mobile-friendly, помещаются 5-6 на экране',
'Слоты в сетке 4 колонки — легко сканировать',
'Занятые слоты disabled с зачёркиванием — чтобы пользователь не пробовал',
'CTA закреплена снизу с текущим выбором — пользователь всегда знает, что выберет',
],
},
'booking-confirm': {
title: 'Запись: подтверждение',
category: 'Флоу записи',
goal: 'Шаг 4 из 4: финальный обзор перед подтверждением. Выбор формата (очно/онлайн) и комментарий для врача.',
tasks: [
'Проверить врача, дату, время, адрес',
'Выбрать очно или онлайн',
'Написать комментарий для врача (симптомы)',
'Увидеть стоимость',
'Подтвердить запись',
],
rationale: [
'Вся инфа в одной карточке-таблице — одним взглядом',
'Формат приёма — 2 крупных варианта, выбор чёткий',
'Комментарий опциональный, но есть — врач лучше готовится',
'Warm-плашка про отмену за 3 часа — ожидание поставлено сразу',
],
},
'booking-success': {
title: 'Запись: успех',
category: 'Флоу записи',
goal: 'Визуальное подтверждение успешной записи с ключевой информацией для встречи.',
tasks: [
'Убедиться, что запись состоялась',
'Запомнить адрес и кабинет',
'Перейти к «Моим приёмам» или на главную',
],
rationale: [
'Полноэкранный modal (таббар скрыт) — фокус на событии',
'Большая primary-галочка с ореолом — эмоциональный момент',
'Адрес выделен отдельно — пользователь часто возвращается смотреть',
'Два выхода: appts (для чекинга) и главная (для продолжения)',
],
},
'appts': {
title: 'Мои приёмы',
category: 'Приёмы и результаты',
goal: 'Предстоящие и прошедшие приёмы. Основная точка контроля для активных пациентов.',
tasks: [
'Переключиться между предстоящими и прошедшими',
'Открыть детали приёма',
'Записаться на новый приём (CTA внизу)',
],
rationale: [
'Сегментед с счётчиком предстоящих — важно знать, сколько запланировано',
'Разный фон карточек: предстоящие — градиент primary-100, прошедшие — белый',
'Чип «Заключение» на прошедших приёмах с готовым документом',
],
},
'appt': {
title: 'Детали приёма',
category: 'Приёмы и результаты',
goal: 'Полная карточка приёма: дата/время, врач, адрес, контакты, заключение с диагнозом, назначениями и связкой с медкартой (для прошедших).',
tasks: [
'Увидеть дату, время, тип приёма',
'Открыть карточку врача',
'Посмотреть адрес на карте',
'Позвонить в клинику',
'Отменить или перенести (для предстоящих)',
'Прочитать заключение, диагноз с кодом МКБ, назначения (для прошедших)',
'Перейти в электронную карту или к результатам обследований',
],
rationale: [
'Крупное время 42px monospace-narrow — главное, что пациент ищет',
'Адрес отдельной секцией с кнопкой карты — частый re-check',
'Кнопка «Отменить» приглушённым danger — чтобы случайно не нажать',
'Перенос primary — предполагаемое действие',
'Блок заключения: диагноз+код МКБ, conclusion-текст, список назначений с иконкой — та же структура, что в «Посещениях» медкарты',
'CTA «В медкарте» и «Результаты» — двунаправленная связь с электронной картой',
],
},
'results': {
title: 'Анализы и обследования',
category: 'Приёмы и результаты',
goal: 'Список всех анализов и обследований. Часть медкарты, но с фокусом на конкретные документы.',
tasks: [
'Увидеть все результаты с датой и врачом',
'Отфильтровать по типу',
'Открыть результат (аудио/эндоскопия/лаб)',
],
rationale: [
'Статус «Готово» / «В работе» — чип цветом справа',
'Разные иконки для типов результата (audio/image/lab) — быстрая семантика',
'Pending-результаты приглушены opacity: .7 — нельзя открыть',
],
},
'result-audio': {
title: 'Аудиограмма',
category: 'Приёмы и результаты',
goal: 'Экран просмотра аудиограммы: график, заключение сурдолога, рекомендация.',
tasks: [
'Увидеть график слуха для двух ушей',
'Прочитать заключение',
'Получить рекомендацию по контролю',
'Скачать PDF',
],
rationale: [
'SVG-график с сеткой, легендой (правое/левое), зелёной зоной «норма» — стандарт аудиологии',
'Круги + сплошная линия для правого уха, крестики + пунктир для левого — международная нотация',
'Рекомендация выделена primary-50 фоном — подсказка действия',
],
},
'result': {
title: 'Эндоскопия носоглотки',
category: 'Приёмы и результаты',
goal: 'Просмотр результата эндоскопического обследования: снимки, диагноз, рекомендации.',
tasks: [
'Увидеть кто и когда провёл исследование',
'Просмотреть 4 снимка (сетка 2×2)',
'Открыть любой снимок в полноэкранном режиме',
'Прочитать диагноз и заключение',
'Выполнить рекомендации (нумерованный список)',
'Скачать PDF или обсудить с врачом',
],
rationale: [
'CSS-мокапы эндоскопических снимков: радиальный градиент + блик — имитация медицинского изображения',
'Диагноз отдельной карточкой с чипом warm — самое важное',
'Рекомендации нумерованы — чёткий порядок действий',
'Полноэкранный просмотр с точками-навигацией — удобный UX для галерей',
],
},
'recovery': {
title: 'Восстановление',
category: 'Здоровье',
goal: 'Трекер восстановления после операции: прогресс по дням, лекарства, контрольные осмотры.',
tasks: [
'Увидеть процент восстановления',
'Отметить приём лекарства',
'Увидеть план восстановления по дням (таймлайн)',
'Связаться с хирургом в чате (chat:doctor-syndaev)',
],
rationale: [
'Тёмная primary-карточка вверху — «важно», вне обычной иерархии',
'Прогресс-бар на белом фоне контрастно на тёмном',
'Лекарства с счётчиком доз (2/4) — пользователь видит прогресс',
'Таймлайн с линией между шагами, галочки для сделанных, активный день с кольцом',
],
},
'audiotest': {
title: 'Тест слуха',
category: 'Здоровье',
goal: 'Трёхминутный тест слуха в приложении. Скрининг, а не диагноз.',
tasks: [
'Прочитать инструкции (наушники, тихое место, не торопиться)',
'Пройти тест (кнопка «Слышу» при каждом тоне)',
'Увидеть результат по каждому уху',
'Записаться к сурдологу, если нужно',
],
rationale: [
'Три стадии: intro → test → done — чёткая структура',
'Крупная анимированная волна (pulse) — объект фокуса внимания',
'Большая кнопка «Слышу» — one-tap действие, выбор быстрый',
'В результате разные цвета по ушам (success/warning) — сразу видно где норма',
'CTA «Записаться к сурдологу» — естественное продолжение',
],
},
'chat': {
title: 'Чаты · список',
category: 'Коммуникации',
goal: 'Центр всех коммуникаций с клиникой: AI-помощник, врач, администратор.',
tasks: [
'Открыть чат с AI-помощником для быстрого вопроса',
'Продолжить диалог с врачом',
'Написать в регистратуру',
'Начать новый чат с другим врачом',
],
rationale: [
'AI выделен featured-карточкой с градиентом и AI-бейджем — показать как новая возможность',
'Остальные в обычном списке с аватаром, online-точкой, бейджем непрочитанных',
'Непрочитанные выделены fg-1 текстом и accent-бейджем — сразу видно',
'Плашка снизу: чат с врачом ограничен 14 днями — ожидания пользователя правильные',
],
},
'chat:ai': {
title: 'Чат: AI-помощник',
category: 'Коммуникации',
goal: 'Приватный диалог с AI-помощником клиники: напоминания, помощь с записью, объяснение результатов.',
tasks: [
'Задать вопрос о расписании или операции',
'Получать напоминания о лекарствах',
'Подтверждать приём препарата',
'Использовать suggested-reply чипсы для быстрых ответов',
],
rationale: [
'Сообщения от AI — gradient primary-50 → primary-100 + бордер primary-200 — субтильное отличие от человека',
'Suggested-replies над инпутом — снижает барьер начала диалога',
'Плейсхолдер «Спросите что-нибудь» вместо «Сообщение» — AI-контекст',
],
},
'chat:doctor-syndaev': {
title: 'Чат: врач',
category: 'Коммуникации',
goal: 'Приватный чат с лечащим врачом. Продолжение консультации после приёма.',
tasks: [
'Рассказать о самочувствии между приёмами',
'Уточнить режим лечения',
'Согласовать перенос приёма',
'Нажать кнопку видеозвонка для срочной консультации',
],
rationale: [
'Статус «Онлайн · отвечает 5 мин» — ожидания пациента поставлены',
'Кнопка video в хедере только у врача — эскалация в видеозвонок',
'Обычные iOS-bubbles — привычный паттерн',
],
},
'chat:operator': {
title: 'Чат: администратор',
category: 'Коммуникации',
goal: 'Общение с регистратурой: справки, переносы, оплата, документы.',
tasks: [
'Заказать медицинскую справку',
'Перенести приём',
'Уточнить счёт к оплате',
'Позвонить по кнопке в хедере',
],
rationale: [
'Часы работы в подписи — пользователь знает, когда ждать ответ',
'Кнопка phone в хедере — альтернатива чату',
'Иконка 📞 на аватаре — тип коммуникации визуально закодирован',
],
},
'profile': {
title: 'Профиль',
category: 'Сервис',
goal: 'Профиль пациента с группировкой по темам: здоровье, оплата, клиника, настройки.',
tasks: [
'Увидеть имя, возраст, телефон',
'Перейти к медкарте, анализам, лекарствам',
'Проверить способы оплаты и бонусы',
'Показать QR пациента',
],
rationale: [
'Градиентная карточка профиля с аватаром и QR-чипом — личное пространство',
'Секции-группы как в iOS Settings — знакомый паттерн',
'Бейджи на некоторых пунктах (Серебро у бонусов) — достижения',
],
},
'qr': {
title: 'QR пациента',
category: 'Сервис',
goal: 'QR-код пациента для быстрой идентификации на ресепшене. Полноэкранный modal.',
tasks: [
'Показать QR сотруднику',
'Увидеть номер пациента',
],
rationale: [
'Тёмный primary-gradient фон — пользователь воспринимает как важный билет',
'Большой QR на белой карточке — максимальный контраст для сканера',
'Таббар скрыт — фокус на QR',
'Плашка внизу: код обновляется каждые 60 сек — сигнал безопасности',
],
},
'telemed': {
title: 'Видеозвонок',
category: 'Коммуникации',
goal: 'Видеозвонок с врачом. Full-screen экран «в разговоре».',
tasks: [
'Вести разговор с врачом',
'Свернуть (кнопка вниз)',
'Завершить звонок (красная кнопка)',
'Переключить микрофон / видео / чат',
],
rationale: [
'Тёмный фон — стандартная метафора видеозвонка',
'Self-preview окошко в углу — пользователь видит себя',
'Красный таймер с мигающей точкой — идёт запись/звонок',
'Крупные контрольные кнопки снизу — критичные действия большими хит-зонами',
],
},
'medcard': {
title: 'Электронная карта пациента',
category: 'Здоровье',
goal: 'Полная амбулаторная карта в телефоне: паспорт пациента, аллергии, диагнозы, история посещений с заключениями, активные назначения, прививки, операции.',
tasks: [
'Увидеть паспорт: ФИО, ДР, № карты, полис, группа крови',
'Проверить аллергии и их реакции перед назначением препарата',
'Просмотреть хронические диагнозы с кодом МКБ и лечащим врачом',
'Открыть посещение → попасть в детали приёма с заключением и назначениями',
'Увидеть активный курс лекарств и перейти к расписанию приёма',
'Просмотреть историю прививок с партиями и операций с исходами',
],
rationale: [
'Hero-блок с паспортом + QR + № карты — сразу понятен контекст («моя карта»)',
'Сегмент-табы (Общее / Посещения / Назначения / Прививки / Операции) — разделяем 5 разных типов данных без вертикального скролла в одну ленту',
'Аллергии в первой секции Общего — критическая инфа видна без переключения табов',
'Чипсы severity (Опасная/Средняя/Лёгкая) — быстрое считывание риска',
'Каждое посещение — тап-область с заключением и связью «Открыть» на карточку приёма (двунаправленная навигация)',
'Назначения разделены на активные (с крупной иконкой-таблеткой) и завершённые (компактный список) — фокус на актуальном',
'Операции с плашкой «Исход» в success-tone — позитивное закрытие эпизода',
],
variants: 'Данные пациента едины с Профилем (patient в data.js) — изменение в карте отражается в шапке Профиля, QR и шапке Приёмов.',
},
'notifications': {
title: 'Уведомления',
category: 'Сервис',
goal: 'Лента уведомлений: напоминания, готовые заключения, сообщения, акции.',
tasks: [
'Просмотреть последние уведомления',
'Увидеть время каждого',
],
rationale: [
'Разные tint (primary/warning/warm) по типу уведомления — семантика цветом',
'Первая строка с временем справа — стандарт ленты',
],
},
'articles': {
title: 'Статьи врачей · список',
category: 'Информация',
goal: 'Список всех статей врачей. Образовательный контент, формирует доверие к клинике.',
tasks: [
'Отфильтровать по тегу (Дети / Операции / Беременность / Слух)',
'Открыть статью',
],
rationale: [
'Крупные hero-карточки с emoji и лидом — стимулируют нажатие',
'Теги как pill-фильтры сверху — выбор по интересу',
],
},
'article': {
title: 'Статья врача',
category: 'Информация',
goal: 'Детальная статья с разметкой: лид, подзаголовки, списки, callout-плашки, карточка автора.',
tasks: [
'Прочитать статью',
'Увидеть автора с контактом',
'Записаться к автору',
'Открыть связанные статьи',
],
rationale: [
'Hero с заголовком занимает 72% ширины, emoji 82px справа — журнальный ритм',
'Плавающие back/bookmark кнопки поверх hero — не мешают картинке',
'Callout разных tone (danger/warn/info) — визуально маркированные предупреждения',
'Author card + CTA записи к автору — конверсия из чтения в действие',
],
},
'search': {
title: 'Поиск',
category: 'Информация',
goal: 'Универсальный поиск по врачам, услугам, симптомам, статьям, приёмам.',
tasks: [
'Ввести ФИО / симптом / услугу / дату',
'Получить релевантные результаты по типам',
'Использовать предложенные чипы-запросы или симптомы',
'Перейти на выбранный результат',
],
rationale: [
'Autofocus на инпуте — сразу можно печатать',
'Пустое состояние с популярными запросами — помогает начать',
'Группировка результатов по типам (Врачи, Услуги, Симптомы…) — лёгкий скан',
'Date-detection (сегодня, завтра, апр) показывает приёмы — smart-поведение',
],
},
'contacts': {
title: 'Контакты',
category: 'Информация',
goal: 'Адреса клиник, телефон, часы работы, маршруты.',
tasks: [
'Позвонить в клинику',
'Написать в чат',
'Увидеть адреса с мокапом здания и карты',
'Построить маршрут',
],
rationale: [
'Primary-darker карточка сверху с телефоном 26px narrow — главный контакт',
'CSS-мокапы здания и карты для каждого адреса — прототипное решение без реальных изображений',
'Кнопки «Маршрут / позвонить / видео» в каждой карточке — действия по локации',
],
},
'prices': {
title: 'Цены',
category: 'Информация',
goal: 'Прайс-лист всех услуг с поиском, категориями, группировкой, диапазоном цен.',
tasks: [
'Найти услугу по названию',
'Отфильтровать по категории',
'Увидеть сгруппированные цены',
'Записаться прямо с услуги',
],
rationale: [
'Сводка найдено / от-до — сразу понятен разброс цен',
'Категории как pill-фильтры, группировка в карточках',
'Цены narrow-шрифтом — акцент на числе',
],
},
'dev-colors': {
title: 'DEV · Палитра',
category: 'DEV',
goal: 'Служебный экран для разработчиков. Все цвета дизайн-системы с hex, ролями, применением.',
tasks: [
'Увидеть hex любой CSS-переменной',
'Переключать палитру в Tweaks и наблюдать изменения',
'Понять, где используется конкретный цвет',
'Перейти к примерам применения',
],
rationale: [
'Ключевая полоса из 8 цветов сверху — палитра видна сразу',
'Группировка по ролям (primary / warm / accent / status / text / surfaces)',
'Каждая строка: свотч + имя + css-var + hex + описание — полная информация',
'Подпись «динамически» у групп, меняющихся при переключении палитры',
],
},
'dev-examples': {
title: 'DEV · Примеры',
category: 'DEV',
goal: 'Готовые компоненты дизайн-системы с указанием CSS-переменных, которые они используют.',
tasks: [
'Посмотреть, как выглядит любая кнопка/чип/карточка',
'Прочитать, какие vars внутри',
'Сверить с экраном DEV · Палитра',
],
rationale: [
'Каждый пример помечен VarTag-монокодами',
'Секции по типам: кнопки / чипы / поверхности / статусы / текст / аватары / формы / тени',
],
},
'docs': {
title: 'Документация',
category: 'DEV',
goal: 'Единый дизайн-гайд прототипа. Список всех экранов с описаниями — цели, задачи, design-решения.',
tasks: [
'Просмотреть все экраны по категориям',
'Развернуть описание экрана inline',
'Перейти к реальному экрану для интерактивного просмотра',
],
rationale: [
'Группировка по категориям облегчает навигацию для ревью',
'Описания collapsed-by-default, чтобы список оставался обозримым',
'Кнопка «Открыть экран» на каждой карточке — быстрый переход в демо-режим',
'Работает в паре с toggle «Описания» в Tweaks (плашка над телефоном)',
],
},
};
// Резолвер: по текущему screenId и ctx отдаёт правильное описание.
// Учитывает: варианты главной (homeVariant), compound-маршруты (doctor:id,
// appt:id, result:id, article:id, chat:id).
export function getScreenDoc(screenId, ctx) {
if (!screenId) return null;
const parts = screenId.split(':');
const base = parts[0];
// home с вариантом: home:cards / home:list / home:feed
if (base === 'home') {
const v = ctx?.homeVariant || 'cards';
return SCREEN_DOCS[`home:${v}`] || SCREEN_DOCS['home:cards'];
}
// chat с конкретным id (ai / doctor-syndaev / operator) — отдельные описания
if (base === 'chat' && parts[1]) {
return SCREEN_DOCS[screenId] || SCREEN_DOCS['chat'];
}
// Generic compound: doctor:xxx → doctor, article:xxx → article и т.д.
const compounds = ['doctor', 'appt', 'result', 'article', 'booking-doctor', 'booking-time', 'booking-confirm'];
if (compounds.includes(base)) {
return SCREEN_DOCS[base] || null;
}
return SCREEN_DOCS[screenId] || SCREEN_DOCS[base] || null;
}
// Для экрана «Документация»: список всех уникальных описаний, сгруппированных
// по категориям. Возвращает упорядоченный массив для рендера.
export function getAllDocs() {
const ORDER = ['Главная', 'Врачи и запись', 'Флоу записи', 'Приёмы и результаты', 'Здоровье', 'Коммуникации', 'Информация', 'Сервис', 'DEV'];
const groups = new Map();
for (const [key, doc] of Object.entries(SCREEN_DOCS)) {
if (!groups.has(doc.category)) groups.set(doc.category, []);
groups.get(doc.category).push({ key, ...doc });
}
return ORDER.filter(c => groups.has(c)).map(c => ({ category: c, items: groups.get(c) }));
}
// screenId для навигации из docs screen. Учитывает compound-маршруты.
export function resolveRouteForDoc(key) {
// home:cards → переводим в home + homeVariant cards (но через простой nav.set
// мы не можем поменять homeVariant — это в Tweaks). Поэтому home:* → 'home'.
if (key.startsWith('home:')) return 'home';
// chat:ai / chat:doctor-syndaev / chat:operator — полноценные маршруты
if (key.startsWith('chat:')) return key;
// generic compounds: нужно подставить пример id
if (key === 'doctor') return 'doctor:syndaev';
if (key === 'appt') return 'appt:a1';
if (key === 'result') return 'result:r2';
if (key === 'article') return 'article:otitis-kids';
if (key === 'booking-doctor') return 'booking-doctor:ent';
if (key === 'booking-time') return 'booking-time:syndaev';
if (key === 'booking-confirm') return 'booking-confirm:syndaev:1:16:00';
return key;
}
+2
View File
@@ -42,4 +42,6 @@ export const I = {
volume: (p) => <Icon {...p}><path d="M4 9h4l5-4v14l-5-4H4V9z"/><path d="M16 8a5 5 0 010 8M19 5a9 9 0 010 14"/></Icon>, volume: (p) => <Icon {...p}><path d="M4 9h4l5-4v14l-5-4H4V9z"/><path d="M16 8a5 5 0 010 8M19 5a9 9 0 010 14"/></Icon>,
arrow: (p) => <Icon {...p}><path d="M5 12h14M13 6l6 6-6 6"/></Icon>, arrow: (p) => <Icon {...p}><path d="M5 12h14M13 6l6 6-6 6"/></Icon>,
menu: (p) => <Icon {...p}><path d="M4 7h16M4 12h16M4 17h16"/></Icon>, menu: (p) => <Icon {...p}><path d="M4 7h16M4 12h16M4 17h16"/></Icon>,
mail: (p) => <Icon {...p}><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></Icon>,
globe: (p) => <Icon {...p}><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2v20M5 5c2.5 2 13.5 2 16 0M5 19c2.5-2 13.5-2 16 0"/></Icon>,
}; };
+8 -1
View File
@@ -1,11 +1,18 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import App from './App.jsx'; import App from './App.jsx';
import ContactsRoutePage from './ContactsRoutePage.jsx';
import './tokens.css'; import './tokens.css';
import './app.css'; import './app.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<App /> <BrowserRouter>
<Routes>
<Route path="/contacts" element={<ContactsRoutePage />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
+268
View File
@@ -0,0 +1,268 @@
import React from 'react';
import { I } from '../icons.jsx';
import { CLINIC_DATA } from '../data.js';
import { Avatar } from '../components.jsx';
//
// Chats list the main chat tab
//
export function ChatsListScreen({ nav }) {
const { chats, doctors } = CLINIC_DATA;
const ai = chats.find(c => c.kind === 'ai');
const rest = chats.filter(c => c.kind !== 'ai');
const subjectFor = (c) => {
if (c.kind === 'doctor') {
const d = doctors.find(x => x.id === c.doctorId);
return {
avatar: <Avatar init={d.init} size={48} />,
title: d.name.split(' ').slice(0, 2).join(' '),
subtitle: d.spec,
};
}
const bg = c.kind === 'operator' ? 'var(--c-warm-100)' : 'var(--c-primary-100)';
return {
avatar: (
<div style={{
width: 48, height: 48, borderRadius: 999, background: bg,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22,
}}>{c.icon}</div>
),
title: c.name,
subtitle: c.subtitle,
};
};
return (
<div style={{ paddingBottom: 100 }}>
<div style={{ padding: '12px 20px 12px' }}>
<h1 className="h-screen" style={{ marginBottom: 14 }}>Чаты</h1>
<div style={{
background: '#fff', borderRadius: 14, padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
border: '1px solid var(--c-border)',
}}>
<I.search size={18} style={{ color: 'var(--c-fg-4)' }} />
<input placeholder="Найти в чатах..." style={{ flex: 1, border: 0, outline: 0, fontSize: 15, background: 'transparent' }} />
</div>
</div>
{/* AI — featured */}
{ai && (
<div style={{ padding: '0 16px 18px' }}>
<button onClick={() => nav.push('chat:' + ai.id)} className="press" style={{
width: '100%', padding: 16,
background: 'linear-gradient(135deg, var(--c-primary-darker), var(--c-primary-dark))',
color: '#fff', borderRadius: 20, textAlign: 'left',
display: 'flex', gap: 14, alignItems: 'center',
boxShadow: '0 10px 28px rgba(22,107,99,.22)',
}}>
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'rgba(255,255,255,0.18)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 26,
}}></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<span style={{ fontSize: 15, fontWeight: 700 }}>{ai.name}</span>
<span style={{ padding: '2px 6px', borderRadius: 5, background: 'rgba(255,255,255,0.25)', fontSize: 9, fontWeight: 700, letterSpacing: .5 }}>AI</span>
</div>
<div style={{ fontSize: 13, opacity: .85, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ai.lastMessage}
</div>
</div>
<I.chev size={18} style={{ color: 'rgba(255,255,255,0.8)', flexShrink: 0 }} />
</button>
</div>
)}
{/* Other chats */}
<div style={{ padding: '0 16px' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '0 4px 10px' }}>Все диалоги · {rest.length}</div>
<div className="card" style={{ padding: 0 }}>
{rest.map((c, i) => {
const s = subjectFor(c);
return (
<React.Fragment key={c.id}>
<button onClick={() => nav.push('chat:' + c.id)} className="press" style={{
width: '100%', padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'center', textAlign: 'left',
}}>
<div style={{ position: 'relative', flexShrink: 0 }}>
{s.avatar}
{c.online && <span style={{
position: 'absolute', bottom: 1, right: 1,
width: 12, height: 12, borderRadius: 999, background: 'var(--c-success)',
border: '2px solid #fff',
}} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, marginBottom: 2 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.title}</div>
<div className="sub" style={{ fontSize: 11, flexShrink: 0, color: c.unread > 0 ? 'var(--c-primary-darker)' : 'var(--c-fg-3)', fontWeight: c.unread > 0 ? 700 : 400 }}>{c.lastTime}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 13, color: c.unread > 0 ? 'var(--c-fg-1)' : 'var(--c-fg-3)', fontWeight: c.unread > 0 ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>{c.lastMessage}</div>
{c.unread > 0 && (
<span style={{
background: 'var(--c-accent)', color: '#fff', fontSize: 11, fontWeight: 700,
padding: '2px 7px', borderRadius: 999, flexShrink: 0, minWidth: 20, textAlign: 'center',
}}>{c.unread}</span>
)}
</div>
</div>
</button>
{i < rest.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 76 }} />}
</React.Fragment>
);
})}
</div>
<button onClick={() => nav.push('doctors')} className="btn-g" style={{
marginTop: 14, width: '100%', padding: 14, fontSize: 14,
}}>
<I.plus size={18} /> Новый чат с врачом
</button>
<div style={{ marginTop: 14, padding: 12, background: 'var(--c-primary-50)', borderRadius: 12, display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<I.shield size={18} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 2 }} />
<div className="sub" style={{ fontSize: 12, lineHeight: 1.5 }}>
Чаты с врачом доступны в течение 14 дней после приёма. Экстренные случаи по телефону (342) 207-03-03.
</div>
</div>
</div>
</div>
);
}
//
// Chat conversation
//
export function ChatConversationScreen({ nav, chatId }) {
const chat = CLINIC_DATA.chats.find(c => c.id === chatId) || CLINIC_DATA.chats[0];
const isAI = chat.kind === 'ai';
const isOp = chat.kind === 'operator';
const isDoc = chat.kind === 'doctor';
const doc = isDoc ? CLINIC_DATA.doctors.find(d => d.id === chat.doctorId) : null;
const title = isDoc ? doc.name.split(' ').slice(0, 2).join(' ') : chat.name;
const subtitle = isDoc
? (chat.online ? 'Онлайн · отвечает 5 мин' : 'Был(а) в сети недавно')
: isAI
? (chat.online ? 'Онлайн · отвечает мгновенно' : chat.subtitle)
: chat.subtitle;
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<div style={{
padding: '12px 16px 10px', display: 'flex', gap: 10, alignItems: 'center',
background: 'var(--c-bg)', borderBottom: '1px solid var(--c-border)',
flexShrink: 0,
}}>
<button onClick={() => nav.pop()} className="press" style={{
width: 38, height: 38, borderRadius: 999, background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'var(--sh-sm)', border: '1px solid var(--c-border)', flexShrink: 0,
}}>
<I.chevL size={20} />
</button>
<div style={{ position: 'relative', flexShrink: 0 }}>
{isDoc && <Avatar init={doc.init} size={42} />}
{!isDoc && (
<div style={{
width: 42, height: 42, borderRadius: 999,
background: isAI
? 'linear-gradient(135deg, var(--c-primary-darker), var(--c-primary-dark))'
: 'var(--c-warm-100)',
color: isAI ? '#fff' : 'var(--c-warm-text)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20,
}}>{chat.icon}</div>
)}
{chat.online && <span style={{
position: 'absolute', bottom: 0, right: 0,
width: 11, height: 11, borderRadius: 999, background: 'var(--c-success)',
border: '2px solid var(--c-bg)',
}} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
{isAI && <span style={{ padding: '1px 6px', borderRadius: 5, background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)', fontSize: 9, fontWeight: 700, letterSpacing: .5 }}>AI</span>}
</div>
<div className="sub" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
{chat.online && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-success)' }} />}
{subtitle}
</div>
</div>
{isDoc && (
<button onClick={() => nav.push('telemed')} className="btn-s" style={{ padding: 0, width: 38, height: 38, borderRadius: 999, flexShrink: 0 }}>
<I.video size={16} />
</button>
)}
{isOp && (
<button className="btn-s" style={{ padding: 0, width: 38, height: 38, borderRadius: 999, flexShrink: 0 }}>
<I.phone size={16} />
</button>
)}
</div>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{chat.messages.map((m, i) => {
const mine = m.from === 'me';
return (
<div key={i} style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
<div style={{
background: mine
? 'var(--c-primary-darker)'
: isAI
? 'linear-gradient(135deg, #F2FAF9, #E3F4F2)'
: '#fff',
color: mine ? '#fff' : 'var(--c-fg-1)',
padding: '10px 14px', borderRadius: 16,
borderBottomRightRadius: mine ? 4 : 16,
borderBottomLeftRadius: mine ? 16 : 4,
fontSize: 14, lineHeight: 1.5,
boxShadow: mine ? 'none' : 'var(--sh-sm)',
border: !mine && isAI ? '1px solid var(--c-primary-200)' : 'none',
}}>{m.t}</div>
<div className="sub" style={{ fontSize: 11, marginTop: 3, textAlign: mine ? 'right' : 'left', padding: '0 4px' }}>{m.tm}</div>
</div>
);
})}
</div>
{/* Suggested replies (AI only) */}
{isAI && chat.suggestions && (
<div style={{ padding: '4px 16px 8px', display: 'flex', gap: 8, overflowX: 'auto', flexShrink: 0 }} className="noscroll">
{chat.suggestions.map((s, i) => (
<button key={i} style={{
flexShrink: 0, padding: '8px 14px', borderRadius: 999, fontSize: 13,
background: '#fff', color: 'var(--c-primary-darker)',
border: '1px solid var(--c-primary-200)', fontWeight: 600,
}}>{s}</button>
))}
</div>
)}
{/* Input */}
<div style={{ padding: '10px 16px 100px', borderTop: '1px solid var(--c-border)', background: '#fff', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={{ width: 38, height: 38, borderRadius: 999, background: 'var(--c-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.plus size={20} />
</button>
<div style={{
flex: 1, background: 'var(--c-bg)', borderRadius: 999,
padding: '10px 16px', fontSize: 14, color: 'var(--c-fg-4)',
}}>
{isAI ? 'Спросите что-нибудь...' : 'Сообщение...'}
</div>
<button style={{ width: 38, height: 38, borderRadius: 999, background: 'var(--c-primary-darker)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.mic size={20} />
</button>
</div>
</div>
</div>
);
}
+411
View File
@@ -0,0 +1,411 @@
import React from 'react';
import { I } from '../icons.jsx';
import { Avatar, ScreenHeader } from '../components.jsx';
//
// DEV · Colors palette reference
// Shows every design-system color with role, CSS var, hex, and usage.
// Dynamic values (primary/accent/warm/etc.) come from ctx.palette and
// update automatically when user switches the accent in Tweaks.
//
function ColorRow({ c }) {
return (
<div style={{
display: 'flex', gap: 12, alignItems: 'center',
padding: '10px 14px', borderBottom: '1px solid var(--c-divider)',
}}>
<div style={{
width: 54, height: 40, borderRadius: 8, flexShrink: 0,
background: c.hex, border: '1px solid rgba(0,0,0,0.08)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-fg-1)' }}>{c.name}</div>
<div style={{ fontSize: 10.5, color: 'var(--c-fg-3)', fontFamily: 'SF Mono, Menlo, Consolas, monospace', marginTop: 1 }}>{c.var}</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 2, lineHeight: 1.4 }}>{c.use}</div>
</div>
<div style={{
fontSize: 11, fontFamily: 'SF Mono, Menlo, Consolas, monospace',
color: 'var(--c-fg-2)', fontWeight: 700,
background: 'var(--c-bg)', padding: '4px 6px', borderRadius: 5,
flexShrink: 0,
}}>{c.hex.toUpperCase()}</div>
</div>
);
}
function ColorGroup({ title, note, colors }) {
return (
<div style={{ marginBottom: 16 }}>
<div style={{ padding: '0 4px 8px' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)' }}>{title} · {colors.length}</div>
{note && <div className="sub" style={{ fontSize: 11, marginTop: 3 }}>{note}</div>}
</div>
<div className="card" style={{ padding: 0 }}>
{colors.map((c, i) => (
<div key={c.name} style={{ borderBottom: i === colors.length - 1 ? 0 : undefined }}>
<ColorRow c={c} />
</div>
))}
</div>
</div>
);
}
export function DevColorsScreen({ nav, ctx }) {
const p = ctx.palette;
const groups = [
{
title: 'Primary · динамически',
note: 'Основная brand-гамма. Меняется при переключении палитры в Tweaks.',
colors: [
{ name: 'primary', var: '--c-primary', hex: p.primary, use: 'CTA, активные иконки, прогресс' },
{ name: 'primary-darker', var: '--c-primary-darker', hex: p.darker, use: 'Активное состояние, таббар, hover' },
{ name: 'primary-dark', var: '--c-primary-dark', hex: p.dark, use: 'Глубокие фоны: QR, градиент восстановления' },
{ name: 'primary-50', var: '--c-primary-50', hex: p.p50, use: 'Очень лёгкие подложки, фон AI-чата' },
{ name: 'primary-100', var: '--c-primary-100', hex: p.p100, use: 'Фоны иконок в плитках, чипы, hero статей' },
{ name: 'primary-200', var: '--c-primary-200', hex: p.p200, use: 'Бордеры, hover-стейты, рамки карточек' },
{ name: 'primary-300', var: '--c-primary-300', hex: p.p300, use: 'Промежуточный primary, декоративные бордеры' },
],
},
{
title: 'Accent · динамически',
colors: [
{ name: 'accent', var: '--c-accent', hex: p.accent, use: 'Красная CTA на таймлайне, бейджи непрочитанных, булавка' },
{ name: 'accent-dark', var: '--c-accent-dark', hex: p.accentDark, use: 'Hover accent-кнопок' },
{ name: 'accent-50', var: '--c-accent-50', hex: p.accent50, use: 'Фон hero «Беременность», плашки danger' },
],
},
{
title: 'Warm · динамически',
colors: [
{ name: 'warm-50', var: '--c-warm-50', hex: p.warm50, use: 'Плашки-предупреждения, subtle warm bg' },
{ name: 'warm-100', var: '--c-warm-100', hex: p.warm100, use: 'hero статей «Дети», чип «К.м.н.», профиль' },
{ name: 'warm-200', var: '--c-warm-200', hex: '#E8DCC5', use: 'Акценты beige, не меняется по палитре' },
{ name: 'warm-text', var: '--c-warm-text', hex: '#7A6A2E', use: 'Текст внутри warm-чипов, не меняется' },
],
},
{
title: 'Статус',
colors: [
{ name: 'success', var: '--c-success', hex: '#2E9B6B', use: 'Онлайн-индикаторы, success-иконки' },
{ name: 'success-50', var: '--c-success-50', hex: p.success50, use: 'Фон «Готово» чипов, финал теста слуха · динамически' },
{ name: 'warning', var: '--c-warning', hex: '#E8A13C', use: 'Иконки лекарств, «В работе» статус' },
{ name: 'warning-50', var: '--c-warning-50', hex: '#FBEFD8', use: 'Фон уведомлений-напоминаний' },
{ name: 'danger', var: '--c-danger', hex: '#D94141', use: 'Критические ошибки, аллергии (красные чипы)' },
],
},
{
title: 'Текст',
colors: [
{ name: 'fg-1', var: '--c-fg-1', hex: '#17242E', use: 'Основной текст, заголовки' },
{ name: 'fg-2', var: '--c-fg-2', hex: '#3E4C5D', use: 'Основной текст в body статей' },
{ name: 'fg-3', var: '--c-fg-3', hex: '#6B7A89', use: 'Приглушённый текст, sub-заголовки, подписи' },
{ name: 'fg-4', var: '--c-fg-4', hex: p.fg4, use: 'Плейсхолдеры, disabled, разделители · динамически' },
],
},
{
title: 'Поверхности / бордеры',
colors: [
{ name: 'bg', var: '--c-bg', hex: '#F7F9FB', use: 'Фон приложения (внутри рамки)' },
{ name: 'bg-card', var: '--c-bg-card', hex: '#FFFFFF', use: 'Фон карточек, модалок, инпутов' },
{ name: 'border', var: '--c-border', hex: '#EAF0F3', use: 'Бордер карточек, инпутов, кнопок-ghost' },
{ name: 'border-strong', var: '--c-border-strong', hex: '#D8E1E6', use: 'Усиленный бордер, disabled-точки плана' },
{ name: 'divider', var: '--c-divider', hex: '#F0F4F6', use: 'Разделители строк внутри карточек' },
],
},
];
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="DEV · Палитра" subtitle={`Активна: ${p.lb}`} onBack={() => nav.pop()} />
{/* Header banner with current palette key colors */}
<div style={{ padding: '0 20px 16px' }}>
<div className="card" style={{ padding: 14, background: 'var(--c-primary-50)', border: '1px solid var(--c-primary-200)' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--c-primary-darker)', textTransform: 'uppercase', letterSpacing: .6, marginBottom: 8 }}>Ключевые цвета</div>
<div style={{ display: 'flex', gap: 6 }}>
{[p.primary, p.darker, p.dark, p.p100, p.warm100, p.accent, p.success50, p.p300].map((c, i) => (
<div key={i} style={{
flex: 1, height: 36, borderRadius: 6, background: c,
border: '1px solid rgba(0,0,0,0.08)',
}} />
))}
</div>
</div>
</div>
<div style={{ padding: '0 16px' }}>
{groups.map(g => <ColorGroup key={g.title} {...g} />)}
<div style={{
marginTop: 8, padding: 12, borderRadius: 10,
background: 'var(--c-warning-50)', display: 'flex', gap: 10, alignItems: 'flex-start',
}}>
<I.shield size={18} style={{ color: 'var(--c-warning)', flexShrink: 0, marginTop: 1 }} />
<div className="sub" style={{ fontSize: 12, lineHeight: 1.5 }}>
Экран служебный. Переключите палитру в Tweaks динамические значения (primary / accent / warm / success-50 / fg-4 / p300) пересчитываются автоматически.
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
<button onClick={() => nav.push('dev-examples')} className="btn-p" style={{ flex: 1 }}>
Примеры применения <I.arrow size={16} />
</button>
</div>
</div>
</div>
);
}
//
// DEV · Usage examples
// Real rendered components with labels pointing to CSS vars used.
//
function ExampleBlock({ title, hint, children }) {
return (
<div style={{ marginBottom: 16 }}>
<div style={{ padding: '0 4px 8px' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)' }}>{title}</div>
</div>
<div className="card" style={{ padding: 14 }}>
{children}
</div>
{hint && <div className="sub" style={{ fontSize: 11, padding: '6px 4px 0', lineHeight: 1.5 }}>{hint}</div>}
</div>
);
}
function VarTag({ children }) {
return (
<code style={{
display: 'inline-block', padding: '1px 6px', borderRadius: 4,
background: 'var(--c-bg)', border: '1px solid var(--c-border)',
fontFamily: 'SF Mono, Menlo, Consolas, monospace', fontSize: 10.5,
color: 'var(--c-fg-2)',
}}>{children}</code>
);
}
function ExampleRow({ children, label }) {
return (
<div style={{ padding: '10px 0', borderBottom: '1px solid var(--c-divider)' }}>
<div style={{ marginBottom: 8 }}>{children}</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
{label}
</div>
</div>
);
}
export function DevExamplesScreen({ nav }) {
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="DEV · Примеры" subtitle="Где какой цвет" onBack={() => nav.pop()} />
<div style={{ padding: '0 16px' }}>
{/* BUTTONS */}
<ExampleBlock title="Кнопки">
<ExampleRow label={<><VarTag>--c-primary</VarTag> фон · <VarTag>#fff</VarTag> текст</>}>
<button className="btn-p">Записаться на приём</button>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-accent</VarTag> фон · для красной CTA</>}>
<button className="btn-p btn-accent">Записаться · срочно</button>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-primary-100</VarTag> фон · <VarTag>--c-primary-darker</VarTag> текст</>}>
<button className="btn-s"><I.chat size={14} /> Чат</button>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<button className="btn-g">Отмена</button>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<VarTag>#fff</VarTag> фон · <VarTag>--c-border</VarTag> рамка · <VarTag>--c-fg-2</VarTag> текст
</div>
</div>
</ExampleBlock>
{/* CHIPS */}
<ExampleBlock title="Чипы">
<ExampleRow label={<><VarTag>--c-primary-100</VarTag> / <VarTag>--c-primary-darker</VarTag></>}>
<span className="chip">Обычный</span>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-warm-100</VarTag> / <VarTag>--c-warm-text</VarTag></>}>
<span className="chip chip-warm">К.м.н.</span>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-success-50</VarTag> / <VarTag>--c-success</VarTag></>}>
<span className="chip chip-success">Готово</span>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-accent-50</VarTag> / <VarTag>--c-accent</VarTag></>}>
<span className="chip chip-danger">Пенициллин</span>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<span className="chip chip-soft">+ добавить</span>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<VarTag>--c-bg</VarTag> / <VarTag>--c-fg-3</VarTag> / <VarTag>--c-border</VarTag>
</div>
</div>
</ExampleBlock>
{/* CARDS / SURFACES */}
<ExampleBlock title="Поверхности">
<ExampleRow label={<><VarTag>#fff</VarTag> фон · <VarTag>--sh-sm</VarTag> тень</>}>
<div className="card" style={{ padding: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700 }}>Карточка по умолчанию</div>
<div className="sub" style={{ fontSize: 11 }}>белый фон, лёгкая тень</div>
</div>
</ExampleRow>
<ExampleRow label={<>gradient (<VarTag>--c-primary-100</VarTag> <VarTag>--c-warm-100</VarTag>)</>}>
<div className="card" style={{
padding: 12,
background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))',
border: 0,
}}>
<div style={{ fontSize: 13, fontWeight: 700 }}>Карточка профиля / статистики</div>
</div>
</ExampleRow>
<ExampleRow label={<>solid <VarTag>--c-primary-darker</VarTag> · белый текст</>}>
<div style={{
padding: 14, borderRadius: 14,
background: 'var(--c-primary-darker)', color: '#fff',
}}>
<div style={{ fontSize: 13, fontWeight: 700 }}>CTA-карточка</div>
<div style={{ fontSize: 11, opacity: .85, marginTop: 2 }}>hero записи на главной</div>
</div>
</ExampleRow>
</ExampleBlock>
{/* STATUS */}
<ExampleBlock title="Статусы">
<ExampleRow label={<>success <VarTag>--c-success-50</VarTag> фон</>}>
<div style={{ background: 'var(--c-success-50)', padding: 10, borderRadius: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
<I.check size={16} style={{ color: 'var(--c-success)' }} sw={3} />
<span style={{ fontSize: 13, color: 'var(--c-success)', fontWeight: 700 }}>Успешно</span>
</div>
</ExampleRow>
<ExampleRow label={<>warning <VarTag>--c-warning-50</VarTag> фон</>}>
<div style={{ background: 'var(--c-warning-50)', padding: 10, borderRadius: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
<I.bell size={16} style={{ color: 'var(--c-warning)' }} />
<span style={{ fontSize: 13, color: 'var(--c-warning)', fontWeight: 700 }}>Напоминание</span>
</div>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<div style={{ background: 'var(--c-accent-50)', padding: 10, borderRadius: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
<I.shield size={16} style={{ color: 'var(--c-danger)' }} />
<span style={{ fontSize: 13, color: 'var(--c-danger)', fontWeight: 700 }}>Критично</span>
</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
danger <VarTag>--c-accent-50</VarTag> фон · <VarTag>--c-danger</VarTag> текст
</div>
</div>
</ExampleBlock>
{/* TYPOGRAPHY */}
<ExampleBlock title="Иерархия текста" hint="fg-1 → fg-2 → fg-3 → fg-4 — по убыванию важности. В Лагуне fg-4 переопределён на серый из скрина.">
<ExampleRow label={<><VarTag>--c-fg-1</VarTag> · основной заголовок</>}>
<div className="h-screen" style={{ fontSize: 22 }}>Заголовок экрана</div>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-fg-2</VarTag> · body-текст</>}>
<div style={{ fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>Основной параграф, тело статей и объяснений в карточках.</div>
</ExampleRow>
<ExampleRow label={<><VarTag>--c-fg-3</VarTag> · muted / подписи</>}>
<div className="sub" style={{ fontSize: 13 }}>Подпись, время, sub-текст</div>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<div style={{ fontSize: 13, color: 'var(--c-fg-4)' }}>Плейсхолдер, disabled-состояние</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<VarTag>--c-fg-4</VarTag> · disabled, placeholder
</div>
</div>
</ExampleBlock>
{/* AVATARS & BADGES */}
<ExampleBlock title="Аватары и бейджи">
<ExampleRow label={<><VarTag>--c-primary-100</VarTag> / <VarTag>--c-primary-darker</VarTag> градиент</>}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<Avatar init="АС" size={48} />
<Avatar init="СА" size={40} />
<Avatar init="МЛ" size={32} />
</div>
</ExampleRow>
<ExampleRow label={<>online · <VarTag>--c-success</VarTag> · белая рамка</>}>
<div style={{ position: 'relative', width: 48, height: 48 }}>
<Avatar init="СА" size={48} />
<span style={{
position: 'absolute', bottom: 1, right: 1,
width: 12, height: 12, borderRadius: 999,
background: 'var(--c-success)', border: '2px solid #fff',
}} />
</div>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{
background: 'var(--c-accent)', color: '#fff',
fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 999,
}}>2</span>
<span style={{
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 5, letterSpacing: .5,
}}>AI</span>
</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
unread <VarTag>--c-accent</VarTag> · AI-бейдж <VarTag>--c-primary-100</VarTag> / <VarTag>--c-primary-darker</VarTag>
</div>
</div>
</ExampleBlock>
{/* INPUT */}
<ExampleBlock title="Формы">
<ExampleRow label={<><VarTag>#fff</VarTag> фон · <VarTag>--c-border</VarTag> рамка · placeholder <VarTag>--c-fg-4</VarTag></>}>
<div style={{
background: '#fff', borderRadius: 12, padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
border: '1px solid var(--c-border)',
}}>
<I.search size={16} style={{ color: 'var(--c-fg-4)' }} />
<span style={{ color: 'var(--c-fg-4)', fontSize: 14 }}>Найти врача...</span>
</div>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<div className="seg">
<button className="on">Активно</button>
<button>Неактивно</button>
</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
segmented · <VarTag>#EEF2F5</VarTag> фон · активный <VarTag>#fff</VarTag> / <VarTag>--c-primary-darker</VarTag>
</div>
</div>
</ExampleBlock>
{/* Shadow / radius quick reference */}
<ExampleBlock title="Тени и радиусы">
<ExampleRow label={<><VarTag>--sh-sm</VarTag> · <VarTag>--r-lg</VarTag> = 16px</>}>
<div style={{
padding: 14, borderRadius: 'var(--r-lg)',
background: '#fff', boxShadow: 'var(--sh-sm)',
fontSize: 13, fontWeight: 700,
}}>card (sh-sm)</div>
</ExampleRow>
<ExampleRow label={<><VarTag>--sh-md</VarTag> · <VarTag>--r-xl</VarTag> = 20px</>}>
<div style={{
padding: 14, borderRadius: 'var(--r-xl)',
background: '#fff', boxShadow: 'var(--sh-md)',
fontSize: 13, fontWeight: 700,
}}>modal (sh-md)</div>
</ExampleRow>
<div style={{ padding: '10px 0' }}>
<div style={{
padding: 14, borderRadius: 'var(--r-2xl)',
background: '#fff', boxShadow: 'var(--sh-lg)',
fontSize: 13, fontWeight: 700,
}}>sheet (sh-lg)</div>
<div style={{ fontSize: 11, color: 'var(--c-fg-3)', marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
<VarTag>--sh-lg</VarTag> · <VarTag>--r-2xl</VarTag> = 24px
</div>
</div>
</ExampleBlock>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { I } from '../icons.jsx';
import { ScreenHeader } from '../components.jsx';
import { getAllDocs, resolveRouteForDoc } from '../docs.js';
function DocRow({ doc, route, onOpen }) {
const [open, setOpen] = useState(false);
return (
<div style={{ padding: 0 }}>
<button onClick={() => setOpen(o => !o)} style={{
width: '100%', padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start', textAlign: 'left',
background: 'transparent',
}}>
<div style={{
width: 34, height: 34, borderRadius: 9, background: 'var(--c-primary-100)',
color: 'var(--c-primary-darker)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, flexShrink: 0, marginTop: 1,
}}>i</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 3 }}>{doc.title}</div>
<div className="sub" style={{ fontSize: 12, lineHeight: 1.45 }}>{doc.goal}</div>
</div>
<I.chevD size={16} style={{
color: 'var(--c-fg-4)', flexShrink: 0, marginTop: 8,
transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s',
}} />
</button>
{open && (
<div style={{ padding: '0 16px 14px 62px' }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginTop: 6, marginBottom: 6 }}>Задачи пользователя</div>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{doc.tasks.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 8, padding: '3px 0', fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--c-primary-darker)', marginTop: 8, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', marginTop: 14, marginBottom: 6 }}>Design-решения</div>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{doc.rationale.map((t, i) => (
<li key={i} style={{ display: 'flex', gap: 8, padding: '3px 0', fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<span style={{ width: 5, height: 5, borderRadius: 999, background: 'var(--c-warm-text)', marginTop: 8, flexShrink: 0 }} />
<span>{t}</span>
</li>
))}
</ul>
{doc.variants && (
<div style={{ marginTop: 10, padding: 10, background: 'var(--c-primary-50)', borderRadius: 8, fontSize: 12, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
<strong>Варианты: </strong>{doc.variants}
</div>
)}
<button onClick={() => onOpen(route)} className="btn-s" style={{ marginTop: 12 }}>
Открыть экран <I.arrow size={14} />
</button>
</div>
)}
</div>
);
}
export function DocsScreen({ nav }) {
const groups = getAllDocs();
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Документация" subtitle="Все экраны прототипа" onBack={() => nav.pop()} />
<div style={{ padding: '0 20px 14px' }}>
<div className="card" style={{
padding: 14, background: 'var(--c-primary-50)', border: '1px solid var(--c-primary-200)',
}}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<I.shield size={18} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>
Список всех экранов прототипа с целями и design-решениями. Разверните любой пункт, чтобы прочитать задачи пользователя и обоснование. Кнопка «Открыть экран» ведёт в демо.
</div>
</div>
</div>
</div>
<div style={{ padding: '0 16px' }}>
{groups.map(g => (
<div key={g.category} style={{ marginBottom: 18 }}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--c-fg-3)', padding: '4px 4px 10px', display: 'flex', justifyContent: 'space-between' }}>
<span>{g.category}</span>
<span>{g.items.length}</span>
</div>
<div className="card" style={{ padding: 0 }}>
{g.items.map((item, i) => (
<React.Fragment key={item.key}>
<DocRow doc={item} route={resolveRouteForDoc(item.key)} onOpen={(r) => nav.set(r)} />
{i < g.items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 62 }} />}
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
);
}
+386
View File
@@ -202,6 +202,392 @@ export function HomeListScreen({ nav }) {
); );
} }
export function HomeTimelineXScreen({ nav }) {
const { doctors, appointments, clinic, articles, chronic } = CLINIC_DATA;
const upcoming = appointments.find(a => a.status === 'upcoming');
const upDoc = upcoming && doctors.find(d => d.id === upcoming.doctor);
const myDoctor = doctors.find(d => d.id === chronic.doctorId);
const typeColors = {
diagnosis: { bg: 'var(--c-primary-100)', fg: 'var(--c-primary-darker)' },
procedure: { bg: 'var(--c-primary-100)', fg: 'var(--c-primary-darker)' },
therapy: { bg: 'var(--c-warm-100)', fg: 'var(--c-warm-text)' },
flareup: { bg: 'var(--c-accent-50)', fg: 'var(--c-accent)' },
checkup: { bg: 'var(--c-success-50)', fg: 'var(--c-success)' },
};
return (
<div style={{ paddingBottom: 100 }}>
{/* Greeting header */}
<div style={{ padding: '8px 20px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div className="sub">20 апреля, понедельник</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>Здравствуйте, Анна</div>
</div>
<button onClick={() => nav.push('profile')} className="press">
<Avatar init="АС" size={42} />
</button>
</div>
</div>
{/* Health status hero */}
<div style={{ padding: '0 16px 16px' }}>
<div className="card" style={{
padding: 18,
background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))',
border: '1px solid var(--c-primary-200)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .8, color: 'var(--c-primary-darker)', fontWeight: 700 }}>Ваше состояние</span>
<span style={{
padding: '4px 10px', borderRadius: 999,
background: 'rgba(255,255,255,0.75)', color: 'var(--c-success)',
fontSize: 11, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 5,
}}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-success)' }} />
{chronic.stage}
</span>
</div>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 4 }}>{chronic.condition}</div>
<div style={{ fontSize: 12, color: 'var(--c-fg-3)', marginBottom: 16 }}>
Наблюдение с {chronic.diagnosed} · {myDoctor.name.split(' ').slice(0, 2).join(' ')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.6)' }}>
<div>
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 26, fontWeight: 700, lineHeight: 1, color: 'var(--c-primary-darker)' }}>{chronic.daysSinceLastFlareup}</div>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', marginTop: 3 }}>дней без обострений</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 26, fontWeight: 700, lineHeight: 1, color: 'var(--c-primary-darker)' }}>{chronic.complianceScore}%</div>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', marginTop: 3 }}>комплаенс</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 26, fontWeight: 700, lineHeight: 1, color: 'var(--c-primary-darker)' }}>{chronic.flareupsThisYear}</div>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', marginTop: 3 }}>обострение в году</div>
</div>
</div>
</div>
</div>
{/* Current tasks */}
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="Задачи сегодня" pad="0 0 8px 0" action="История" onAction={() => nav.push('medcard')} />
<div className="card" style={{ padding: 0 }}>
{chronic.currentTasks.map((t, i, a) => {
const isDaily = t.type === 'daily';
return (
<React.Fragment key={t.id}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '12px 14px' }}>
{isDaily ? (
<div style={{
width: 28, height: 28, borderRadius: 999, flexShrink: 0,
background: t.done ? 'var(--c-primary-darker)' : '#fff',
border: t.done ? 0 : '2px solid var(--c-border-strong)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{t.done && <I.check size={16} style={{ color: '#fff' }} sw={3} />}
</div>
) : (
<div style={{
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
background: 'var(--c-primary-100)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<I.calendar size={14} style={{ color: 'var(--c-primary-darker)' }} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 600,
color: isDaily && t.done ? 'var(--c-fg-3)' : 'var(--c-fg-1)',
textDecoration: isDaily && t.done ? 'line-through' : 'none',
}}>{t.text}</div>
<div className="sub" style={{ fontSize: 11 }}>
{isDaily ? (t.streak > 0 ? `Серия: ${t.streak} дней` : 'Ежедневно') : `До ${t.nextDate}`}
</div>
</div>
{isDaily && t.streak > 0 && <span style={{ fontSize: 18 }}>🔥</span>}
{!isDaily && <I.chev size={15} style={{ color: 'var(--c-fg-4)' }} />}
</div>
{i < a.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 54 }} />}
</React.Fragment>
);
})}
</div>
</div>
{/* Promotion: ask AI or doctor */}
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="Есть вопрос?" pad="0 0 8px 0" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<button onClick={() => nav.push('chat:ai')} className="press" style={{
padding: 14, borderRadius: 16,
background: 'linear-gradient(135deg, var(--c-primary-darker), var(--c-primary-dark))',
color: '#fff', textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ fontSize: 26 }}></div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 2 }}>Спросить помощника</div>
<div style={{ fontSize: 11, opacity: .85, lineHeight: 1.4 }}>Быстрый ответ круглосуточно</div>
</div>
</button>
<button onClick={() => nav.push('chat:doctor-syndaev')} className="press card" style={{
padding: 14, textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<Avatar init={myDoctor.init} size={32} />
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 2 }}>Написать врачу</div>
<div className="sub" style={{ fontSize: 11, lineHeight: 1.4 }}>{myDoctor.name.split(' ').slice(0, 2).join(' ')}</div>
</div>
</button>
</div>
</div>
{/* History timeline */}
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="История наблюдения" pad="0 0 8px 0" action="Вся карта" onAction={() => nav.push('medcard')} />
<div className="card" style={{ padding: '8px 0' }}>
{chronic.pastVisits.map((v, i, a) => {
const d = doctors.find(x => x.id === v.doctorId);
const c = typeColors[v.type] || typeColors.checkup;
const isLast = i === a.length - 1;
return (
<div key={v.id} style={{ display: 'flex', gap: 14, padding: '10px 14px', alignItems: 'flex-start', position: 'relative' }}>
{!isLast && (
<div style={{
position: 'absolute', left: 27, top: 32, bottom: -6,
width: 2, background: 'var(--c-divider)',
}} />
)}
<div style={{
width: 28, height: 28, borderRadius: 999, flexShrink: 0,
background: c.bg, color: c.fg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700,
position: 'relative', zIndex: 1,
}}>
{d.init}
</div>
<div style={{ flex: 1, paddingBottom: 4, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 2, lineHeight: 1.35 }}>{v.title}</div>
<div className="sub" style={{ fontSize: 11 }}>{v.date} · {d.name.split(' ').slice(0, 2).join(' ')}</div>
</div>
</div>
);
})}
</div>
</div>
{/* Upcoming appointment */}
{upcoming && upDoc && (
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="Ближайший приём" pad="0 0 8px 0" />
<AppointmentCard appt={upcoming} doctor={upDoc} addr={clinic.addresses.find(a => a.id === upcoming.address)} onClick={() => nav.push('appt:' + upcoming.id)} />
</div>
)}
{/* Recommendations */}
<div style={{ padding: '0 0 18px' }}>
<SectionHeader title="Рекомендации" />
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', padding: '0 20px 8px' }} className="noscroll">
{chronic.recommendations.map((r, i) => (
<div key={i} className="card" style={{
flexShrink: 0, width: 170, padding: 14,
}}>
<div style={{ fontSize: 28, marginBottom: 8, lineHeight: 1 }}>{r.icon}</div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 3, lineHeight: 1.3 }}>{r.title}</div>
<div className="sub" style={{ fontSize: 11, lineHeight: 1.4 }}>{r.sub}</div>
</div>
))}
</div>
</div>
{/* Book CTA */}
<div style={{ padding: '0 20px 18px' }}>
<button onClick={() => nav.push('booking-specs')} className="btn-p block">
<I.plus size={18} /> Записаться на осмотр
</button>
</div>
{/* Articles */}
<div style={{ padding: '0 0 8px' }}>
<SectionHeader title="Полезное чтение" action="Все" onAction={() => nav.push('articles')} />
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', padding: '0 20px 8px' }} className="noscroll">
{articles.slice(0, 3).map(a => (
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{
flexShrink: 0, width: 200, padding: 0, overflow: 'hidden', textAlign: 'left',
}}>
<div style={{
height: 80, background: a.hero,
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', padding: 10,
}}>
<span className="chip chip-warm" style={{ background: 'rgba(255,255,255,0.85)' }}>{a.tag}</span>
<span style={{ fontSize: 24, lineHeight: 1 }}>{a.emoji}</span>
</div>
<div style={{ padding: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.3, marginBottom: 6, minHeight: 34 }}>{a.title}</div>
<div className="sub" style={{ fontSize: 11 }}>{a.mins} мин</div>
</div>
</button>
))}
</div>
</div>
</div>
);
}
export function HomeSplashScreen({ nav }) {
const { patient, doctors, appointments, articles } = CLINIC_DATA;
const firstName = patient.shortName.split(' ')[0];
const upcoming = appointments.find(a => a.status === 'upcoming');
const upDoc = upcoming && doctors.find(d => d.id === upcoming.doctor);
const article = articles[0];
return (
<div style={{ paddingBottom: 100 }}>
<div style={{ padding: '8px 20px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button onClick={() => nav.push('profile')} className="press" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 34, height: 34, borderRadius: 999, background: 'var(--c-primary-200)' }} />
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--c-fg-1)' }}>Главная</span>
</button>
<button onClick={() => nav.push('notifications')} className="press" style={{ width: 40, height: 40, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.bell size={22} style={{ color: 'var(--c-primary-darker)' }} />
</button>
</div>
<div style={{ padding: '4px 20px 14px' }}>
<h1 style={{ fontSize: 28, fontWeight: 800, lineHeight: 1.15, color: 'var(--c-fg-1)', margin: 0 }}>Добрый день, {firstName}!</h1>
</div>
<div style={{ padding: '0 20px 20px' }}>
<button onClick={() => nav.push('search')} className="press" style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', background: '#fff',
border: '1px solid var(--c-border)', borderRadius: 14,
textAlign: 'left',
}}>
<I.search size={18} style={{ color: 'var(--c-fg-3)' }} />
<span style={{ color: 'var(--c-fg-3)', fontSize: 15 }}>Поиск врача...</span>
</button>
</div>
<div style={{ padding: '0 20px 8px' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 10 }}>Записи на прием</div>
{upcoming && upDoc && (
<button onClick={() => nav.push('appt:' + upcoming.id)} className="press" style={{
width: '100%', textAlign: 'left',
background: 'var(--c-primary-50)',
border: '1px solid var(--c-primary-100)',
borderRadius: 16, padding: 16, marginBottom: 10,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 700, letterSpacing: .7, color: 'var(--c-primary-darker)' }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-primary-darker)' }} />
БЛИЖАЙШАЯ ЗАПИСЬ
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--c-primary-darker)', fontWeight: 700 }}>
<I.star size={12} /> Активно
</span>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 10 }}>
<Avatar init={upDoc.init} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)' }}>{upDoc.name}</div>
<div style={{ fontSize: 13, color: 'var(--c-primary-darker)', marginTop: 2 }}>{upDoc.spec.split(' · ')[0]}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, fontSize: 13, fontWeight: 600 }}>
<I.calendar size={14} style={{ color: 'var(--c-primary-darker)' }} /> {upcoming.date}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, fontSize: 13, fontWeight: 600 }}>
<I.clock size={14} style={{ color: 'var(--c-primary-darker)' }} /> {upcoming.time}
</span>
</div>
<I.pin size={16} style={{ color: 'var(--c-primary-darker)', opacity: .6 }} />
</button>
)}
<button onClick={() => nav.set('appts')} className="press" style={{
width: '100%', textAlign: 'left',
background: 'var(--c-primary-50)',
border: '1px solid var(--c-primary-100)',
borderRadius: 16, padding: 16, marginBottom: 10,
display: 'flex', gap: 14, alignItems: 'center',
}}>
<div style={{ width: 54, height: 54, borderRadius: 999, background: 'var(--c-primary-darker)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<I.calendar size={24} style={{ color: '#fff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: .7, color: 'var(--c-primary-darker)', marginBottom: 4 }}>МОИ ПРИЕМЫ</div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 2 }}>Ближайшие приемы</div>
<div style={{ fontSize: 12, color: 'var(--c-fg-3)', lineHeight: 1.35 }}>Просмотрите ваши предстоящие прием</div>
</div>
</button>
<button onClick={() => nav.push('booking-specs')} className="press" style={{
width: '100%', textAlign: 'left', position: 'relative', overflow: 'hidden',
background: 'var(--c-warm-100)',
borderRadius: 16, padding: 16, marginBottom: 18,
}}>
<div style={{ position: 'absolute', top: -20, right: -20, width: 90, height: 90, borderRadius: 999, background: 'rgba(255,255,255,0.35)' }} />
<div style={{ position: 'absolute', top: 30, right: 30, width: 40, height: 40, borderRadius: 999, background: 'rgba(255,255,255,0.25)' }} />
<div style={{ position: 'relative', display: 'flex', gap: 14, alignItems: 'center', marginBottom: 14 }}>
<div style={{ width: 48, height: 48, borderRadius: 999, background: 'var(--c-warm-text)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<I.calendar size={22} style={{ color: '#fff' }} />
</div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--c-fg-1)' }}>Записаться на прием</div>
</div>
<div style={{ position: 'relative', padding: 12, background: '#fff', borderRadius: 12, textAlign: 'center', fontSize: 14, fontWeight: 600, color: 'var(--c-fg-1)' }}>
Выбрать удобное время
</div>
</button>
</div>
<div style={{ padding: '0 20px 8px' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 10 }}>Услуги и консультации</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10, marginBottom: 18 }}>
<SplashTile icon={I.calendar} sub="Записаться на прием" main="К врачу" onClick={() => nav.push('booking-specs')} />
<SplashTile icon={I.chat} sub="Связаться с врачом в" main="Чате" onClick={() => nav.set('chat')} />
</div>
</div>
<div style={{ padding: '0 20px 20px' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 10 }}>Полезная информация</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10 }}>
<SplashTile icon={I.doc} sub="Статья" main={article.title.split(' ').slice(0,2).join(' ')} onClick={() => nav.push('article:' + article.id)} />
<SplashTile icon={I.file} sub="Все" main="Статьи" onClick={() => nav.push('articles')} />
<SplashTile icon={I.shield} sub="Информация" main="Цены" onClick={() => nav.push('prices')} />
<SplashTile icon={I.phone} sub="Информация" main="Контакты" onClick={() => nav.push('contacts')} />
</div>
</div>
</div>
);
}
function SplashTile({ icon: Ic, sub, main, onClick }) {
return (
<button onClick={onClick} className="press" style={{
background: 'var(--c-primary-50)',
border: '1px solid var(--c-primary-100)',
borderRadius: 16, padding: 16,
textAlign: 'left', minHeight: 120,
display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ width: 40, height: 40, borderRadius: 999, background: 'var(--c-primary-darker)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Ic size={20} style={{ color: '#fff' }} />
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--c-primary-darker)', marginBottom: 2 }}>{sub}</div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)' }}>{main}</div>
</div>
</button>
);
}
export function HomeFeedScreen({ nav }) { export function HomeFeedScreen({ nav }) {
const { doctors, appointments, clinic, articles, recovery } = CLINIC_DATA; const { doctors, appointments, clinic, articles, recovery } = CLINIC_DATA;
const upcoming = appointments.find(a => a.status === 'upcoming'); const upcoming = appointments.find(a => a.status === 'upcoming');
+348 -111
View File
@@ -29,10 +29,17 @@ export function ApptsTabScreen({ nav }) {
</div> </div>
)} )}
</div> </div>
<div style={{ padding: 16, marginTop: 12 }}> <div style={{ padding: 16, marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{tab === 'upcoming' && (
<button onClick={() => nav.push('booking-specs')} className="btn-p block"> <button onClick={() => nav.push('booking-specs')} className="btn-p block">
<I.plus size={18} /> Записаться на приём <I.plus size={18} /> Записаться на приём
</button> </button>
)}
{tab === 'past' && (
<button onClick={() => nav.push('medcard')} className="btn-p block">
<I.file size={18} /> Электронная карта
</button>
)}
</div> </div>
</div> </div>
); );
@@ -92,16 +99,43 @@ export function ApptDetailScreen({ nav, apptId }) {
{a.hasReport && ( {a.hasReport && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<div className="h-sec" style={{ marginBottom: 10 }}>Заключение врача</div> <div className="h-sec" style={{ marginBottom: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div className="card"> <span>Заключение врача</span>
<div style={{ fontSize: 14, lineHeight: 1.55, color: 'var(--c-fg-2)' }}> {a.diagnosisCode && <span className="sub" style={{ fontSize: 11, fontFamily: 'var(--font-narrow)' }}>{a.diagnosisCode}</span>}
Диагноз: хронический риносинусит, обострение. Назначено: Аква Марис, Назонекс 14 дней. Контрольный осмотр через 2 недели.
</div> </div>
<button className="btn-s" style={{ marginTop: 12, padding: '8px 14px', fontSize: 13 }}> <div className="card">
{a.diagnosis && <div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>{a.diagnosis}</div>}
<div style={{ fontSize: 14, lineHeight: 1.55, color: 'var(--c-fg-2)' }}>
{a.conclusion || 'Заключение недоступно.'}
</div>
{a.prescriptions && a.prescriptions.length > 0 && (
<>
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: .5, fontWeight: 700, color: 'var(--c-fg-3)', marginTop: 14, marginBottom: 6 }}>Назначения</div>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{a.prescriptions.map((p, i) => (
<li key={i} style={{ display: 'flex', gap: 8, fontSize: 13, color: 'var(--c-fg-2)', padding: '4px 0', lineHeight: 1.5 }}>
<I.pill size={14} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 3 }} />
<span>{p}</span>
</li>
))}
</ul>
</>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button className="btn-s" style={{ padding: '8px 14px', fontSize: 13 }}>
<I.doc size={15} /> Открыть PDF <I.doc size={15} /> Открыть PDF
</button> </button>
<button onClick={() => nav.push('medcard')} className="btn-s" style={{ padding: '8px 14px', fontSize: 13 }}>
<I.file size={15} /> В медкарте
</button>
</div> </div>
</div> </div>
{a.resultIds && a.resultIds.length > 0 && (
<button onClick={() => nav.push('results')} className="btn-g" style={{ width: '100%', padding: 12, fontSize: 13, marginTop: 10 }}>
<I.doc size={15} /> Результаты обследований · {a.resultIds.length}
</button>
)}
</div>
)} )}
</div> </div>
@@ -464,7 +498,7 @@ export function RecoveryScreen({ nav }) {
<div className="sub" style={{ fontSize: 11 }}>Ваш хирург</div> <div className="sub" style={{ fontSize: 11 }}>Ваш хирург</div>
<div style={{ fontSize: 14, fontWeight: 700 }}>{surgeon.name.split(' ').slice(0,2).join(' ')}</div> <div style={{ fontSize: 14, fontWeight: 700 }}>{surgeon.name.split(' ').slice(0,2).join(' ')}</div>
</div> </div>
<button onClick={() => nav.push('chat')} className="btn-s" style={{ padding: '8px 12px' }}> <button onClick={() => nav.push('chat:doctor-syndaev')} className="btn-s" style={{ padding: '8px 12px' }}>
<I.chat size={15} /> Чат <I.chat size={15} /> Чат
</button> </button>
</div> </div>
@@ -606,75 +640,17 @@ export function AudioTestScreen({ nav }) {
); );
} }
export function ChatTabScreen() {
const msgs = [
{ from: 'doc', t: 'Добрый день, Анна! Как самочувствие после операции?', tm: '14:02' },
{ from: 'me', t: 'Здравствуйте! В целом хорошо, немного саднит в носу по утрам.', tm: '14:08' },
{ from: 'doc', t: 'Это нормально на 6-й день. Продолжайте промывания Аква Марис 4 раза в день.', tm: '14:10' },
{ from: 'doc', t: 'Выходите на осмотр сегодня? Я свободен после 15:00.', tm: '14:11' },
{ from: 'me', t: 'Да, буду в 16:00 как запланировано.', tm: '14:14' },
{ from: 'doc', t: 'Отлично, жду. Если что-то изменится — напишите.', tm: '14:15' },
];
const doc = CLINIC_DATA.doctors.find(d => d.id === 'syndaev');
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '12px 20px 12px' }}>
<h1 className="h-screen">Чат</h1>
</div>
<div style={{ padding: '0 16px 12px' }}>
<div className="card" style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 12, background: 'var(--c-primary-50)' }}>
<Avatar init={doc.init} size={44} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{doc.name.split(' ').slice(0,2).join(' ')}</div>
<div className="sub" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-success)' }} />
Онлайн · отвечает 5 мин
</div>
</div>
<button className="btn-s" style={{ padding: 10, borderRadius: 999 }}>
<I.video size={16} />
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{msgs.map((m,i)=>(
<div key={i} style={{ alignSelf: m.from === 'me' ? 'flex-end' : 'flex-start', maxWidth: '78%' }}>
<div style={{
background: m.from === 'me' ? 'var(--c-primary-darker)' : '#fff',
color: m.from === 'me' ? '#fff' : 'var(--c-fg-1)',
padding: '10px 14px', borderRadius: 16,
borderBottomRightRadius: m.from === 'me' ? 4 : 16,
borderBottomLeftRadius: m.from === 'me' ? 16 : 4,
fontSize: 14, lineHeight: 1.45,
boxShadow: m.from === 'me' ? 'none' : 'var(--sh-sm)',
}}>{m.t}</div>
<div className="sub" style={{ fontSize: 11, marginTop: 3, textAlign: m.from === 'me' ? 'right' : 'left', padding: '0 4px' }}>{m.tm}</div>
</div>
))}
</div>
<div style={{ padding: '10px 16px 100px', borderTop: '1px solid var(--c-border)', background: '#fff' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={{ width: 38, height: 38, borderRadius: 999, background: 'var(--c-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.plus size={20} />
</button>
<div style={{ flex: 1, background: 'var(--c-bg)', borderRadius: 999, padding: '10px 16px', fontSize: 14, color: 'var(--c-fg-4)' }}>Сообщение...</div>
<button style={{ width: 38, height: 38, borderRadius: 999, background: 'var(--c-primary-darker)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.mic size={20} />
</button>
</div>
</div>
</div>
);
}
export function ProfileTabScreen({ nav }) { export function ProfileTabScreen({ nav }) {
const { patient, medcard, appointments, results } = CLINIC_DATA;
const pastCount = appointments.filter(a => a.status === 'past').length;
const activeRxCount = medcard.prescriptions.filter(p => p.active).length;
const sections = [ const sections = [
{ {
title: 'Здоровье', title: 'Здоровье',
items: [ items: [
{ i: I.file, t: 'Медицинская карта', s: 'История, диагнозы', go: 'medcard' }, { i: I.file, t: 'Электронная карта', s: `${pastCount} посещений · ${medcard.allergies.length} аллергии · ${medcard.chronicConditions.length} диагноза`, go: 'medcard', featured: true },
{ i: I.doc, t: 'Анализы', s: '5 результатов', go: 'results' }, { i: I.doc, t: 'Анализы', s: `${results.length} результатов`, go: 'results' },
{ i: I.pill, t: 'Лекарства', s: '3 активных курса', go: 'recovery' }, { i: I.pill, t: 'Лекарства', s: `${activeRxCount} активных курса`, go: 'recovery' },
{ i: I.hearing, t: 'История тестов слуха', s: '2 аудиограммы', go: 'results' }, { i: I.hearing, t: 'История тестов слуха', s: '2 аудиограммы', go: 'results' },
] ]
}, },
@@ -707,13 +683,18 @@ export function ProfileTabScreen({ nav }) {
<div style={{ padding: '12px 20px 16px' }}> <div style={{ padding: '12px 20px 16px' }}>
<h1 className="h-screen" style={{ marginBottom: 18 }}>Профиль</h1> <h1 className="h-screen" style={{ marginBottom: 18 }}>Профиль</h1>
<div className="card" style={{ padding: 18, display: 'flex', gap: 14, alignItems: 'center', background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0 }}> <div className="card" style={{ padding: 18, display: 'flex', gap: 14, alignItems: 'center', background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0 }}>
<Avatar init="АС" size={64} style={{ fontSize: 24, boxShadow: 'var(--sh-sm)' }} /> <Avatar init={patient.init} size={64} style={{ fontSize: 24, boxShadow: 'var(--sh-sm)' }} />
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>Анна Сергеевна</div> <div style={{ fontSize: 18, fontWeight: 700 }}>{patient.shortName}</div>
<div className="sub" style={{ fontSize: 13, marginBottom: 6 }}>+7 (912) 485-- · 42 года</div> <div className="sub" style={{ fontSize: 13, marginBottom: 6 }}>{patient.phone} · {patient.age} года</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button onClick={() => nav.push('qr')} className="chip" style={{ fontSize: 12, fontWeight: 700 }}> <button onClick={() => nav.push('qr')} className="chip" style={{ fontSize: 12, fontWeight: 700 }}>
<I.qr size={12} /> QR пациента <I.qr size={12} /> QR пациента
</button> </button>
<button onClick={() => nav.push('medcard')} className="chip" style={{ fontSize: 12, fontWeight: 700 }}>
<I.file size={12} /> Карта {patient.cardNumber.split('-').pop()}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -726,16 +707,16 @@ export function ProfileTabScreen({ nav }) {
const II = it.i; const II = it.i;
return ( return (
<React.Fragment key={i}> <React.Fragment key={i}>
<button onClick={() => it.go && nav.push(it.go)} className="press" style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 14, padding: '13px 16px', textAlign: 'left' }}> <button onClick={() => it.go && nav.push(it.go)} className="press" style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 14, padding: '13px 16px', textAlign: 'left', background: it.featured ? 'var(--c-primary-50)' : 'transparent' }}>
<div style={{ width: 34, height: 34, borderRadius: 9, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 34, height: 34, borderRadius: 9, background: it.featured ? 'var(--c-primary-darker)' : 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<II size={18} style={{ color: 'var(--c-primary-darker)' }} /> <II size={18} style={{ color: it.featured ? '#fff' : 'var(--c-primary-darker)' }} />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{it.t}</div> <div style={{ fontSize: 14, fontWeight: it.featured ? 700 : 600 }}>{it.t}</div>
{it.s && <div className="sub" style={{ fontSize: 12 }}>{it.s}</div>} {it.s && <div className="sub" style={{ fontSize: 12, lineHeight: 1.35 }}>{it.s}</div>}
</div> </div>
{it.badge && <span className="chip chip-warm" style={{ fontSize: 11 }}>{it.badge}</span>} {it.badge && <span className="chip chip-warm" style={{ fontSize: 11 }}>{it.badge}</span>}
<I.chev size={15} style={{ color: 'var(--c-fg-4)' }} /> <I.chev size={15} style={{ color: 'var(--c-fg-4)', flexShrink: 0 }} />
</button> </button>
{i < sec.items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 64 }} />} {i < sec.items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 64 }} />}
</React.Fragment> </React.Fragment>
@@ -843,50 +824,306 @@ export function TelemedScreen({ nav }) {
); );
} }
const MEDCARD_TABS = [
{ id: 'summary', lb: 'Общее' },
{ id: 'visits', lb: 'Посещения' },
{ id: 'rx', lb: 'Назначения' },
{ id: 'shots', lb: 'Прививки' },
{ id: 'ops', lb: 'Операции' },
];
function shortDoctor(d) {
if (!d) return '—';
const parts = d.name.split(' ');
return parts[0] + ' ' + (parts[1] ? parts[1][0] + '.' : '');
}
const SEVERITY_STYLE = {
high: { bg: 'var(--c-accent-50)', c: 'var(--c-accent-dark)', lb: 'Опасная' },
mid: { bg: 'var(--c-warm-100)', c: 'var(--c-warm-text)', lb: 'Средняя' },
low: { bg: 'var(--c-primary-50)',c: 'var(--c-primary-darker)', lb: 'Лёгкая' },
};
export function MedcardScreen({ nav }) { export function MedcardScreen({ nav }) {
const { patient, medcard, appointments, doctors, results } = CLINIC_DATA;
const [tab, setTab] = useState('summary');
const pastVisits = appointments.filter(a => a.status === 'past')
.slice().sort((a, b) => (b.year || 2026) - (a.year || 2026) || 0); // простая сортировка
const activeRx = medcard.prescriptions.filter(p => p.active);
const pastRx = medcard.prescriptions.filter(p => !p.active);
return ( return (
<div style={{ paddingBottom: 40 }}> <div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Медицинская карта" onBack={() => nav.pop()} /> <ScreenHeader title="Электронная карта" onBack={() => nav.pop()} rightIcon={I.search} />
<div style={{ padding: '0 20px' }}>
<div className="card" style={{ marginBottom: 14 }}> {/* Hero — паспорт пациента */}
<div className="h-row" style={{ marginBottom: 10 }}>Основное</div> <div style={{ padding: '0 20px 14px' }}>
{[['Пол','Женский'],['Возраст','42 года'],['Рост / Вес','168 см · 62 кг'],['Группа крови','II (A), Rh+']].map(([k,v])=>( <div className="card" style={{ padding: 18, background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0 }}>
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0' }}> <div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 14 }}>
<span className="sub">{k}</span> <Avatar init={patient.init} size={56} style={{ fontSize: 22, boxShadow: 'var(--sh-sm)' }} />
<span style={{ fontSize: 14, fontWeight: 700 }}>{v}</span> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 700, lineHeight: 1.25 }}>{patient.fullName}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 3 }}>{patient.birthDate} · {patient.age} года · {patient.sex}</div>
</div> </div>
<button onClick={() => nav.push('qr')} className="press" style={{ width: 38, height: 38, borderRadius: 10, background: 'rgba(255,255,255,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} title="QR пациента">
<I.qr size={18} style={{ color: 'var(--c-primary-darker)' }} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12 }}>
<div>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .5, fontWeight: 700, marginBottom: 2 }}> карты</div>
<div style={{ fontWeight: 700, fontFamily: 'var(--font-narrow)' }}>{patient.cardNumber}</div>
</div>
<div>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .5, fontWeight: 700, marginBottom: 2 }}>Полис</div>
<div style={{ fontWeight: 700, fontFamily: 'var(--font-narrow)' }}>{patient.policy}</div>
</div>
</div>
</div>
</div>
{/* Табы-сегмент */}
<div style={{ padding: '0 16px 12px', overflowX: 'auto' }}>
<div className="seg" style={{ display: 'inline-flex', gap: 0, minWidth: '100%' }}>
{MEDCARD_TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className={tab === t.id ? 'on' : ''} style={{ flex: 1, whiteSpace: 'nowrap', padding: '8px 12px' }}>{t.lb}</button>
))} ))}
</div> </div>
<div className="card" style={{ marginBottom: 14 }}>
<div className="h-row" style={{ marginBottom: 10 }}>Аллергии</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="chip chip-danger">Пенициллин</span>
<span className="chip chip-danger">Пыльца берёзы</span>
<span className="chip chip-soft">+ добавить</span>
</div>
</div> </div>
<div className="h-sec" style={{ padding: '4px 4px 10px' }}>История диагнозов</div> <div style={{ padding: '0 20px' }}>
<div className="card" style={{ padding: 0 }}> {tab === 'summary' && (
{[ <>
{ d: '12 апр 2026', t: 'Искривление носовой перегородки', doc: 'Синдяев А.В.', tag: 'Операция' }, {/* Аллергии */}
{ d: '8 апр 2026', t: 'Хронический риносинусит', doc: 'Макарова Л.Г.', tag: 'Приём' }, <div className="h-sec" style={{ padding: '4px 4px 8px' }}>Аллергии · {medcard.allergies.length}</div>
{ d: '15 ноя 2025', t: 'ОРВИ', doc: 'Суворова С.В.', tag: 'Приём' }, <div className="card" style={{ padding: 14, marginBottom: 14 }}>
].map((r,i,a)=>( {medcard.allergies.map((a, i) => {
<div key={i}> const s = SEVERITY_STYLE[a.severity] || SEVERITY_STYLE.low;
return (
<div key={a.id} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: i === 0 ? '0 0 10px' : '10px 0', borderTop: i === 0 ? 0 : '1px solid var(--c-divider)' }}>
<div style={{ padding: '4px 10px', borderRadius: 999, background: s.bg, color: s.c, fontSize: 11, fontWeight: 700, flexShrink: 0 }}>{s.lb}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{a.name}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{a.reaction} · с {a.noted}</div>
</div>
</div>
);
})}
<button className="btn-g" style={{ width: '100%', padding: 10, fontSize: 13, marginTop: 10 }}>
<I.plus size={14} /> Добавить аллергию
</button>
</div>
{/* Хронические */}
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Хронические диагнозы</div>
<div className="card" style={{ padding: 0, marginBottom: 14 }}>
{medcard.chronicConditions.map((c, i, a) => {
const doc = doctors.find(d => d.id === c.doctorId);
return (
<React.Fragment key={c.id}>
<div style={{ padding: '14px 16px' }}> <div style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
<span className="sub" style={{ fontSize: 12 }}>{r.d}</span> <span style={{ fontSize: 14, fontWeight: 700 }}>{c.name}</span>
<span className="chip chip-soft" style={{ fontSize: 10 }}>{r.tag}</span> <span className="sub" style={{ fontSize: 11, fontFamily: 'var(--font-narrow)' }}>{c.code}</span>
</div> </div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.t}</div> <div className="sub" style={{ fontSize: 12 }}>{c.stage} · с {c.diagnosed}</div>
<div className="sub" style={{ fontSize: 12 }}>{r.doc}</div> <div className="sub" style={{ fontSize: 12, marginTop: 2 }}>Наблюдает: {shortDoctor(doc)}</div>
</div> </div>
{i < a.length - 1 && <div className="divider" />} {i < a.length - 1 && <div className="divider" />}
</React.Fragment>
);
})}
</div>
{/* Антропометрия и кровь */}
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Основное</div>
<div className="card" style={{ marginBottom: 14 }}>
{[
['Рост / Вес', patient.height + ' см · ' + patient.weight + ' кг'],
['Группа крови', patient.bloodType],
['СНИЛС', patient.snils],
['Первое обращение', patient.firstVisit],
['Лечащий врач', shortDoctor(doctors.find(d => d.id === patient.primaryDoctorId))],
].map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', fontSize: 13 }}>
<span className="sub">{k}</span>
<span style={{ fontWeight: 700, textAlign: 'right' }}>{v}</span>
</div> </div>
))} ))}
</div> </div>
<button onClick={() => nav.push('results')} className="btn-g" style={{ width: '100%', padding: 12, fontSize: 13, marginBottom: 10 }}>
<I.doc size={15} /> Анализы и обследования · {results.length}
</button>
</>
)}
{tab === 'visits' && (
<>
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Посещения · {pastVisits.length}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pastVisits.map(v => {
const doc = doctors.find(d => d.id === v.doctor);
return (
<button key={v.id} onClick={() => nav.push('appt:' + v.id)} className="card press" style={{ padding: 14, textAlign: 'left', display: 'block' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span className="sub" style={{ fontSize: 12, fontWeight: 700 }}>{v.date} {v.year} · {v.time}</span>
<span className="chip chip-soft" style={{ fontSize: 10 }}>{v.type}</span>
</div>
{v.diagnosis && (
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 4, display: 'flex', gap: 6, alignItems: 'baseline' }}>
<span>{v.diagnosis}</span>
{v.diagnosisCode && <span className="sub" style={{ fontSize: 10, fontFamily: 'var(--font-narrow)' }}>{v.diagnosisCode}</span>}
</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: v.conclusion ? 8 : 0 }}>
<Avatar init={doc.init} size={26} style={{ fontSize: 12 }} />
<div className="sub" style={{ fontSize: 12 }}>{shortDoctor(doc)} · {doc.spec.split(' · ')[0]}</div>
</div>
{v.conclusion && (
<div style={{ fontSize: 12, color: 'var(--c-fg-2)', lineHeight: 1.45, padding: '8px 10px', background: 'var(--c-primary-50)', borderRadius: 8 }}>
{v.conclusion}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
{v.resultIds && v.resultIds.length > 0 && (
<span className="chip" style={{ fontSize: 11 }}><I.doc size={11} /> {v.resultIds.length} результат(а)</span>
)}
{v.prescriptions && v.prescriptions.length > 0 && (
<span className="chip" style={{ fontSize: 11 }}><I.pill size={11} /> Назначения · {v.prescriptions.length}</span>
)}
<span className="chip chip-soft" style={{ fontSize: 11, marginLeft: 'auto' }}>Открыть <I.chev size={11} /></span>
</div>
</button>
);
})}
</div>
</>
)}
{tab === 'rx' && (
<>
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Активный курс · {activeRx.length}</div>
<div className="card" style={{ padding: 0, marginBottom: 14 }}>
{activeRx.map((p, i, a) => {
const doc = doctors.find(d => d.id === p.prescribedBy);
return (
<React.Fragment key={p.id}>
<div style={{ padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 34, height: 34, borderRadius: 9, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<I.pill size={16} style={{ color: 'var(--c-primary-darker)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{p.name}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{p.dose} · {p.course}</div>
<div className="sub" style={{ fontSize: 11, marginTop: 4 }}>Назначил: {shortDoctor(doc)}</div>
</div>
</div>
{i < a.length - 1 && <div className="divider" />}
</React.Fragment>
);
})}
{activeRx.length === 0 && <div style={{ padding: 20, textAlign: 'center' }} className="sub">Активных назначений нет</div>}
</div>
<button onClick={() => nav.push('recovery')} className="btn-g" style={{ width: '100%', padding: 12, fontSize: 13, marginBottom: 14 }}>
<I.clock size={15} /> Расписание приёма
</button>
{pastRx.length > 0 && (
<>
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Завершённые</div>
<div className="card" style={{ padding: 0, marginBottom: 14 }}>
{pastRx.map((p, i, a) => {
const doc = doctors.find(d => d.id === p.prescribedBy);
const appt = p.forApptId ? appointments.find(ap => ap.id === p.forApptId) : null;
return (
<React.Fragment key={p.id}>
<div style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{p.name}</span>
<span className="sub" style={{ fontSize: 11 }}>{p.course}</span>
</div>
<div className="sub" style={{ fontSize: 11, marginTop: 2 }}>{shortDoctor(doc)}</div>
{appt && (
<button onClick={() => nav.push('appt:' + appt.id)} className="chip chip-soft" style={{ marginTop: 6, fontSize: 11 }}>
Приём {appt.date} <I.chev size={10} />
</button>
)}
</div>
{i < a.length - 1 && <div className="divider" />}
</React.Fragment>
);
})}
</div>
</>
)}
</>
)}
{tab === 'shots' && (
<>
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Прививки · {medcard.vaccinations.length}</div>
<div className="card" style={{ padding: 0, marginBottom: 14 }}>
{medcard.vaccinations.map((v, i, a) => (
<React.Fragment key={v.id}>
<div style={{ padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 34, height: 34, borderRadius: 9, background: 'var(--c-success-50)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: 'var(--c-success)' }}>
<I.check size={18} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{v.name}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{v.date} · партия {v.lot}</div>
</div>
</div>
{i < a.length - 1 && <div className="divider" />}
</React.Fragment>
))}
</div>
<button className="btn-g" style={{ width: '100%', padding: 12, fontSize: 13 }}>
<I.plus size={14} /> Добавить прививку
</button>
</>
)}
{tab === 'ops' && (
<>
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Операции · {medcard.surgeries.length}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{medcard.surgeries.map(s => {
const doc = doctors.find(d => d.id === s.doctorId);
const relatedAppt = appointments.find(ap => ap.doctor === s.doctorId && ap.date === s.date.split(' ').slice(0,2).join(' '));
return (
<div key={s.id} className="card" style={{ padding: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.name}</span>
<span className="sub" style={{ fontSize: 12 }}>{s.date}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 12, marginBottom: 8 }}>
<div>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .4, fontWeight: 700 }}>Хирург</div>
<div style={{ fontWeight: 700 }}>{shortDoctor(doc)}</div>
</div>
<div>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .4, fontWeight: 700 }}>Анестезия</div>
<div style={{ fontWeight: 700 }}>{s.anesthesia}</div>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--c-fg-2)', padding: '8px 10px', background: 'var(--c-success-50)', borderRadius: 8 }}>
{s.outcome}
</div>
{relatedAppt && (
<button onClick={() => nav.push('appt:' + relatedAppt.id)} className="chip chip-soft" style={{ marginTop: 10, fontSize: 11 }}>
Карточка приёма <I.chev size={10} />
</button>
)}
</div>
);
})}
</div>
</>
)}
</div> </div>
</div> </div>
); );
+619
View File
@@ -0,0 +1,619 @@
import React, { useState } from 'react';
import { I } from '../icons.jsx';
import { CLINIC_DATA } from '../data.js';
import { Avatar } from '../components.jsx';
// ---------- Общие plate-компоненты ----------
function PlateHeader({ title, onBack, right }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 20px 8px' }}>
{onBack ? (
<button onClick={onBack} className="press" style={{ width: 40, height: 40, borderRadius: 999, background: 'var(--c-primary-50)', border: '1px solid var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.chevL size={20} style={{ color: 'var(--c-primary-darker)' }} />
</button>
) : <div style={{ width: 40 }} />}
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)' }}>{title}</div>
<div style={{ width: 40, display: 'flex', justifyContent: 'flex-end' }}>{right || null}</div>
</div>
);
}
function PlateCard({ children, onClick, pad = 16, tint = 'soft', style = {} }) {
const bg = tint === 'warm' ? 'var(--c-warm-100)' : 'var(--c-primary-50)';
const br = tint === 'warm' ? 'transparent' : 'var(--c-primary-100)';
const base = {
width: '100%', textAlign: 'left',
background: bg, border: '1px solid ' + br,
borderRadius: 16, padding: pad,
display: 'block',
...style,
};
return onClick ? (
<button onClick={onClick} className="press" style={base}>{children}</button>
) : (
<div style={base}>{children}</div>
);
}
function PlateIcon({ icon: Ic, size = 40, bg = 'var(--c-primary-darker)', color = '#fff' }) {
return (
<div style={{ width: size, height: size, borderRadius: 999, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Ic size={Math.round(size * 0.5)} style={{ color }} />
</div>
);
}
function PlateSection({ title, children, action, onAction }) {
return (
<div style={{ padding: '0 20px 8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)' }}>{title}</div>
{action && <button onClick={onAction} style={{ fontSize: 12, fontWeight: 700, color: 'var(--c-primary-darker)' }}>{action}</button>}
</div>
{children}
</div>
);
}
function PlateH1({ children }) {
return <h1 style={{ fontSize: 28, fontWeight: 800, lineHeight: 1.15, color: 'var(--c-fg-1)', margin: 0, padding: '4px 20px 14px' }}>{children}</h1>;
}
const SEVERITY_TINT = {
high: { bg: 'var(--c-accent-50)', c: 'var(--c-accent-dark)', lb: 'Опасная' },
mid: { bg: 'var(--c-warm-100)', c: 'var(--c-warm-text)', lb: 'Средняя' },
low: { bg: 'var(--c-primary-50)', c: 'var(--c-primary-darker)', lb: 'Лёгкая' },
};
function shortDoc(d) {
if (!d) return '—';
const parts = d.name.split(' ');
return parts[0] + ' ' + (parts[1] ? parts[1][0] + '.' : '');
}
// ---------- Профиль ----------
export function ProfilePlateScreen({ nav }) {
const { patient, medcard, appointments, results } = CLINIC_DATA;
const pastCount = appointments.filter(a => a.status === 'past').length;
const activeRxCount = medcard.prescriptions.filter(p => p.active).length;
const sections = [
{
title: 'Здоровье',
items: [
{ i: I.file, t: 'Электронная карта', s: `${pastCount} посещений · ${medcard.allergies.length} аллергии · ${medcard.chronicConditions.length} диагноза`, go: 'medcard', featured: true },
{ i: I.doc, t: 'Анализы', s: `${results.length} результатов`, go: 'results' },
{ i: I.pill, t: 'Лекарства', s: `${activeRxCount} активных курса`, go: 'recovery' },
{ i: I.hearing, t: 'История тестов слуха', s: '2 аудиограммы', go: 'results' },
],
},
{
title: 'Оплата и бонусы',
items: [
{ i: I.card, t: 'Способы оплаты', s: 'Mir •••• 4821' },
{ i: I.gift, t: 'Бонусы', s: '2 480 баллов · 5%', badge: 'Серебро' },
{ i: I.file, t: 'История платежей', s: '12 операций' },
],
},
{
title: 'Клиника',
items: [
{ i: I.pin, t: 'Адреса и часы работы', s: '3 клиники', go: 'contacts' },
{ i: I.phone, t: '(342) 207-03-03', s: 'Ежедневно 9:0021:00' },
],
},
{
title: 'Настройки',
items: [
{ i: I.bell, t: 'Уведомления', go: 'notifications' },
{ i: I.shield, t: 'Конфиденциальность' },
{ i: I.user, t: 'Члены семьи', s: '+ 2 профиля' },
],
},
];
return (
<div style={{ paddingBottom: 100 }}>
<PlateHeader title="Профиль" right={
<button onClick={() => nav.push('notifications')} className="press" style={{ width: 40, height: 40, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.bell size={20} style={{ color: 'var(--c-primary-darker)' }} />
</button>
} />
<PlateH1>{patient.shortName}</PlateH1>
{/* Паспорт */}
<div style={{ padding: '0 20px 16px' }}>
<PlateCard tint="soft">
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 14 }}>
<Avatar init={patient.init} size={56} style={{ fontSize: 22 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: 'var(--c-primary-darker)', fontWeight: 700 }}>{patient.phone}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{patient.birthDate} · {patient.age} года</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => nav.push('qr')} className="press" style={{ flex: 1, padding: '10px 12px', background: '#fff', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, fontWeight: 600 }}>
<I.qr size={14} style={{ color: 'var(--c-primary-darker)' }} /> QR пациента
</button>
<button onClick={() => nav.push('medcard')} className="press" style={{ flex: 1, padding: '10px 12px', background: '#fff', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, fontWeight: 600 }}>
<I.file size={14} style={{ color: 'var(--c-primary-darker)' }} /> Карта {patient.cardNumber.split('-').pop()}
</button>
</div>
</PlateCard>
</div>
{sections.map((sec, si) => (
<PlateSection key={si} title={sec.title}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 18 }}>
{sec.items.map((it, i) => {
const II = it.i;
return (
<PlateCard key={i} pad={14} onClick={() => it.go && nav.push(it.go)} style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<PlateIcon icon={II} size={it.featured ? 44 : 36} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: it.featured ? 700 : 600 }}>{it.t}</div>
{it.s && <div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{it.s}</div>}
</div>
{it.badge && <span className="chip chip-warm" style={{ fontSize: 11 }}>{it.badge}</span>}
<I.chev size={14} style={{ color: 'var(--c-primary-darker)', opacity: .5, flexShrink: 0 }} />
</PlateCard>
);
})}
</div>
</PlateSection>
))}
</div>
);
}
// ---------- Приёмы ----------
export function ApptsPlateScreen({ nav }) {
const { appointments, doctors, clinic } = CLINIC_DATA;
const [tab, setTab] = useState('upcoming');
const items = appointments.filter(a => a.status === tab);
return (
<div style={{ paddingBottom: 100 }}>
<PlateHeader title="Приёмы" right={
<button onClick={() => nav.push('notifications')} className="press" style={{ width: 40, height: 40, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.bell size={20} style={{ color: 'var(--c-primary-darker)' }} />
</button>
} />
<PlateH1>Мои приёмы</PlateH1>
<div style={{ padding: '0 20px 16px' }}>
<div style={{ display: 'flex', gap: 8, background: 'var(--c-primary-50)', border: '1px solid var(--c-primary-100)', borderRadius: 14, padding: 4 }}>
<button onClick={() => setTab('upcoming')} style={{
flex: 1, padding: 10, borderRadius: 10, fontSize: 13, fontWeight: 700,
background: tab === 'upcoming' ? '#fff' : 'transparent',
color: tab === 'upcoming' ? 'var(--c-fg-1)' : 'var(--c-fg-3)',
}}>Предстоящие · {appointments.filter(a => a.status === 'upcoming').length}</button>
<button onClick={() => setTab('past')} style={{
flex: 1, padding: 10, borderRadius: 10, fontSize: 13, fontWeight: 700,
background: tab === 'past' ? '#fff' : 'transparent',
color: tab === 'past' ? 'var(--c-fg-1)' : 'var(--c-fg-3)',
}}>Прошедшие · {appointments.filter(a => a.status === 'past').length}</button>
</div>
</div>
<div style={{ padding: '0 20px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map(a => {
const d = doctors.find(x => x.id === a.doctor);
const ad = clinic.addresses.find(x => x.id === a.address);
const isUp = a.status === 'upcoming';
return (
<PlateCard key={a.id} onClick={() => nav.push('appt:' + a.id)} tint={isUp ? 'soft' : 'soft'}>
{isUp && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 700, letterSpacing: .7, color: 'var(--c-primary-darker)' }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-primary-darker)' }} />
{a.date.toUpperCase()} · {a.time}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--c-primary-darker)', fontWeight: 700 }}>
<I.star size={12} /> Активно
</span>
</div>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 10 }}>
<Avatar init={d.init} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)' }}>{d.name.split(' ').slice(0, 2).join(' ')}</div>
<div style={{ fontSize: 13, color: 'var(--c-primary-darker)', marginTop: 2 }}>{d.spec.split(' · ')[0]}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, fontSize: 13, fontWeight: 600 }}>
<I.calendar size={14} style={{ color: 'var(--c-primary-darker)' }} /> {a.date}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, fontSize: 13, fontWeight: 600 }}>
<I.clock size={14} style={{ color: 'var(--c-primary-darker)' }} /> {a.time}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, fontSize: 13, fontWeight: 600 }}>
<I.pin size={14} style={{ color: 'var(--c-primary-darker)' }} /> {ad.short}
</span>
{!isUp && a.hasReport && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, fontSize: 13, fontWeight: 600 }}>
<I.doc size={14} style={{ color: 'var(--c-primary-darker)' }} /> Заключение
</span>
)}
</div>
</PlateCard>
);
})}
{items.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 20px' }} className="sub">Нет приёмов в этой категории</div>
)}
</div>
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 10 }}>
{tab === 'upcoming' && (
<PlateCard tint="warm" onClick={() => nav.push('booking-specs')} style={{ position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: -20, right: -20, width: 90, height: 90, borderRadius: 999, background: 'rgba(255,255,255,0.35)' }} />
<div style={{ position: 'relative', display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
<PlateIcon icon={I.plus} size={48} bg="var(--c-warm-text)" />
<div style={{ fontSize: 16, fontWeight: 700 }}>Записаться на приём</div>
</div>
<div style={{ position: 'relative', padding: 12, background: '#fff', borderRadius: 12, textAlign: 'center', fontSize: 14, fontWeight: 600 }}>Выбрать удобное время</div>
</PlateCard>
)}
{tab === 'past' && (
<PlateCard onClick={() => nav.push('medcard')} style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
<PlateIcon icon={I.file} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>Электронная карта</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>Все посещения, диагнозы, назначения</div>
</div>
<I.chev size={14} style={{ color: 'var(--c-primary-darker)', opacity: .5 }} />
</PlateCard>
)}
</div>
</div>
);
}
// ---------- Детали приёма ----------
export function ApptDetailPlateScreen({ nav, apptId }) {
const a = CLINIC_DATA.appointments.find(x => x.id === apptId);
const d = CLINIC_DATA.doctors.find(x => x.id === a.doctor);
const ad = CLINIC_DATA.clinic.addresses.find(x => x.id === a.address);
const isUp = a.status === 'upcoming';
return (
<div style={{ paddingBottom: 120 }}>
<PlateHeader title="Приём" onBack={() => nav.pop()} />
<div style={{ padding: '0 20px' }}>
<PlateCard style={{ textAlign: 'center', marginBottom: 12 }}>
<div style={{ fontSize: 13, color: 'var(--c-primary-darker)', fontWeight: 700, marginBottom: 4 }}>{a.weekday}, {a.date}</div>
<div style={{ fontSize: 42, fontFamily: 'var(--font-narrow)', fontWeight: 700, lineHeight: 1, marginBottom: 6, color: 'var(--c-fg-1)' }}>{a.time}</div>
<div className="sub" style={{ fontSize: 13 }}>{a.type}</div>
</PlateCard>
<PlateCard pad={0} onClick={() => nav.push('doctor:' + d.id)} style={{ marginBottom: 10, display: 'flex', gap: 12, alignItems: 'center', padding: 14 }}>
<Avatar init={d.init} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{d.name.split(' ').slice(0, 2).join(' ')}</div>
<div style={{ fontSize: 12, color: 'var(--c-primary-darker)', marginTop: 2 }}>{d.spec}</div>
</div>
<I.chev size={14} style={{ color: 'var(--c-primary-darker)', opacity: .5 }} />
</PlateCard>
<PlateCard style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
<PlateIcon icon={I.pin} size={36} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{ad.full}</div>
<div className="sub" style={{ fontSize: 12 }}>{a.room}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<PlateIcon icon={I.phone} size={36} />
<div style={{ flex: 1, fontSize: 14 }}>(342) 207-03-03</div>
</div>
</PlateCard>
{isUp && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
<button className="press" style={{ width: '100%', padding: 14, background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, fontSize: 14, fontWeight: 600 }}>
<I.calendar size={18} style={{ color: 'var(--c-primary-darker)' }} /> Добавить в календарь
</button>
<button className="press" style={{ width: '100%', padding: 14, background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, fontSize: 14, fontWeight: 600 }}>
<I.bell size={18} style={{ color: 'var(--c-primary-darker)' }} /> Напомнить позже
</button>
</div>
)}
{a.hasReport && (
<>
<div style={{ fontSize: 14, fontWeight: 700, padding: '4px 4px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span>Заключение врача</span>
{a.diagnosisCode && <span className="sub" style={{ fontSize: 11, fontFamily: 'var(--font-narrow)' }}>{a.diagnosisCode}</span>}
</div>
<PlateCard>
{a.diagnosis && <div style={{ fontSize: 15, fontWeight: 700, marginBottom: 6 }}>{a.diagnosis}</div>}
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--c-fg-2)', padding: '10px 12px', background: '#fff', borderRadius: 10 }}>
{a.conclusion || 'Заключение недоступно.'}
</div>
{a.prescriptions && a.prescriptions.length > 0 && (
<>
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: .5, fontWeight: 700, color: 'var(--c-primary-darker)', marginTop: 12, marginBottom: 6 }}>Назначения</div>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{a.prescriptions.map((p, i) => (
<li key={i} style={{ display: 'flex', gap: 8, fontSize: 13, padding: '4px 0', lineHeight: 1.5 }}>
<I.pill size={14} style={{ color: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 3 }} />
<span>{p}</span>
</li>
))}
</ul>
</>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button className="press" style={{ padding: '8px 14px', fontSize: 13, background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600 }}>
<I.doc size={15} style={{ color: 'var(--c-primary-darker)' }} /> PDF
</button>
<button onClick={() => nav.push('medcard')} className="press" style={{ padding: '8px 14px', fontSize: 13, background: '#fff', border: '1px solid var(--c-primary-100)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600 }}>
<I.file size={15} style={{ color: 'var(--c-primary-darker)' }} /> В медкарте
</button>
</div>
</PlateCard>
</>
)}
</div>
{isUp && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '14px 20px 34px', background: '#fff', borderTop: '1px solid var(--c-border)', display: 'flex', gap: 10 }}>
<button className="press" style={{ flex: 1, padding: 14, border: '1px solid var(--c-accent-50)', borderRadius: 12, color: 'var(--c-danger)', background: '#fff', fontWeight: 700, fontSize: 14 }}>Отменить</button>
<button className="press" style={{ flex: 2, padding: 14, background: 'var(--c-primary-darker)', color: '#fff', borderRadius: 12, fontWeight: 700, fontSize: 14 }}>Перенести</button>
</div>
)}
</div>
);
}
// ---------- Медкарта ----------
const PLATE_MEDCARD_TABS = [
{ id: 'summary', lb: 'Общее' },
{ id: 'visits', lb: 'Посещения' },
{ id: 'rx', lb: 'Назначения' },
{ id: 'shots', lb: 'Прививки' },
{ id: 'ops', lb: 'Операции' },
];
export function MedcardPlateScreen({ nav }) {
const { patient, medcard, appointments, doctors, results } = CLINIC_DATA;
const [tab, setTab] = useState('summary');
const pastVisits = appointments.filter(a => a.status === 'past').slice().sort((a, b) => (b.year || 2026) - (a.year || 2026));
const activeRx = medcard.prescriptions.filter(p => p.active);
const pastRx = medcard.prescriptions.filter(p => !p.active);
return (
<div style={{ paddingBottom: 40 }}>
<PlateHeader title="Электронная карта" onBack={() => nav.pop()} right={
<button className="press" style={{ width: 40, height: 40, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.search size={20} style={{ color: 'var(--c-primary-darker)' }} />
</button>
} />
<PlateH1>{patient.shortName}</PlateH1>
<div style={{ padding: '0 20px 14px' }}>
<PlateCard>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12 }}>
<Avatar init={patient.init} size={52} style={{ fontSize: 20 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: 'var(--c-primary-darker)', fontWeight: 700 }}>{patient.birthDate}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{patient.age} года · {patient.sex}</div>
</div>
<button onClick={() => nav.push('qr')} className="press" style={{ width: 40, height: 40, borderRadius: 10, background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<I.qr size={18} style={{ color: 'var(--c-primary-darker)' }} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div style={{ padding: '8px 10px', background: '#fff', borderRadius: 10 }}>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .5, fontWeight: 700 }}> карты</div>
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--font-narrow)', marginTop: 2 }}>{patient.cardNumber}</div>
</div>
<div style={{ padding: '8px 10px', background: '#fff', borderRadius: 10 }}>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .5, fontWeight: 700 }}>Полис</div>
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--font-narrow)', marginTop: 2 }}>{patient.policy}</div>
</div>
</div>
</PlateCard>
</div>
<div style={{ padding: '0 20px 14px', overflowX: 'auto' }}>
<div style={{ display: 'flex', gap: 6, padding: 4, background: 'var(--c-primary-50)', border: '1px solid var(--c-primary-100)', borderRadius: 14, minWidth: '100%' }}>
{PLATE_MEDCARD_TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
flex: 1, whiteSpace: 'nowrap', padding: '8px 10px', borderRadius: 10, fontSize: 12, fontWeight: 700,
background: tab === t.id ? '#fff' : 'transparent',
color: tab === t.id ? 'var(--c-fg-1)' : 'var(--c-fg-3)',
}}>{t.lb}</button>
))}
</div>
</div>
<div style={{ padding: '0 20px' }}>
{tab === 'summary' && (
<>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>Аллергии · {medcard.allergies.length}</div>
<PlateCard style={{ marginBottom: 14 }}>
{medcard.allergies.map((a, i) => {
const s = SEVERITY_TINT[a.severity] || SEVERITY_TINT.low;
return (
<div key={a.id} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: i === 0 ? 0 : '10px 0 0', marginTop: i === 0 ? 0 : 10, borderTop: i === 0 ? 0 : '1px solid var(--c-primary-100)' }}>
<div style={{ padding: '4px 10px', borderRadius: 999, background: s.bg, color: s.c, fontSize: 11, fontWeight: 700, flexShrink: 0 }}>{s.lb}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{a.name}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{a.reaction} · с {a.noted}</div>
</div>
</div>
);
})}
</PlateCard>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>Хронические диагнозы</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{medcard.chronicConditions.map(c => {
const doc = doctors.find(d => d.id === c.doctorId);
return (
<PlateCard key={c.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{c.name}</span>
<span className="sub" style={{ fontSize: 11, fontFamily: 'var(--font-narrow)' }}>{c.code}</span>
</div>
<div className="sub" style={{ fontSize: 12 }}>{c.stage} · с {c.diagnosed}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>Наблюдает: {shortDoc(doc)}</div>
</PlateCard>
);
})}
</div>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>Основное</div>
<PlateCard style={{ marginBottom: 14 }}>
{[
['Рост / Вес', patient.height + ' см · ' + patient.weight + ' кг'],
['Группа крови', patient.bloodType],
['СНИЛС', patient.snils],
['Первое обращение', patient.firstVisit],
['Лечащий врач', shortDoc(doctors.find(d => d.id === patient.primaryDoctorId))],
].map(([k, v], i, arr) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: i === 0 ? '0 0 8px' : '8px 0', fontSize: 13, borderTop: i === 0 ? 0 : '1px solid var(--c-primary-100)' }}>
<span className="sub">{k}</span>
<span style={{ fontWeight: 700 }}>{v}</span>
</div>
))}
</PlateCard>
<PlateCard onClick={() => nav.push('results')} style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<PlateIcon icon={I.doc} size={40} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>Анализы и обследования</div>
<div className="sub" style={{ fontSize: 12 }}>{results.length} результатов</div>
</div>
<I.chev size={14} style={{ color: 'var(--c-primary-darker)', opacity: .5 }} />
</PlateCard>
</>
)}
{tab === 'visits' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pastVisits.map(v => {
const doc = doctors.find(d => d.id === v.doctor);
return (
<PlateCard key={v.id} onClick={() => nav.push('appt:' + v.id)}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--c-primary-darker)' }}>{v.date} {v.year} · {v.time}</span>
<span style={{ padding: '3px 10px', borderRadius: 999, background: '#fff', fontSize: 11, fontWeight: 600 }}>{v.type}</span>
</div>
{v.diagnosis && (
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{v.diagnosis}</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: v.conclusion ? 10 : 0 }}>
<Avatar init={doc.init} size={28} style={{ fontSize: 12 }} />
<div className="sub" style={{ fontSize: 12 }}>{shortDoc(doc)}</div>
</div>
{v.conclusion && (
<div style={{ fontSize: 12, padding: '10px 12px', background: '#fff', borderRadius: 10, lineHeight: 1.5, color: 'var(--c-fg-2)' }}>
{v.conclusion}
</div>
)}
</PlateCard>
);
})}
</div>
)}
{tab === 'rx' && (
<>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>Активный курс · {activeRx.length}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{activeRx.map(p => {
const doc = doctors.find(d => d.id === p.prescribedBy);
return (
<PlateCard key={p.id} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<PlateIcon icon={I.pill} size={36} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{p.name}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{p.dose} · {p.course}</div>
<div className="sub" style={{ fontSize: 11, marginTop: 4 }}>Назначил: {shortDoc(doc)}</div>
</div>
</PlateCard>
);
})}
</div>
{pastRx.length > 0 && (
<>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10 }}>Завершённые</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{pastRx.map(p => {
const doc = doctors.find(d => d.id === p.prescribedBy);
return (
<PlateCard key={p.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{p.name}</span>
<span className="sub" style={{ fontSize: 11 }}>{p.course}</span>
</div>
<div className="sub" style={{ fontSize: 11, marginTop: 2 }}>{shortDoc(doc)}</div>
</PlateCard>
);
})}
</div>
</>
)}
</>
)}
{tab === 'shots' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{medcard.vaccinations.map(v => (
<PlateCard key={v.id} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<PlateIcon icon={I.check} size={36} bg="var(--c-success)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{v.name}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>{v.date} · партия {v.lot}</div>
</div>
</PlateCard>
))}
</div>
)}
{tab === 'ops' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{medcard.surgeries.map(s => {
const doc = doctors.find(d => d.id === s.doctorId);
return (
<PlateCard key={s.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.name}</span>
<span className="sub" style={{ fontSize: 12 }}>{s.date}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 10 }}>
<div style={{ padding: '8px 10px', background: '#fff', borderRadius: 10 }}>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .4, fontWeight: 700 }}>Хирург</div>
<div style={{ fontSize: 12, fontWeight: 700, marginTop: 2 }}>{shortDoc(doc)}</div>
</div>
<div style={{ padding: '8px 10px', background: '#fff', borderRadius: 10 }}>
<div className="sub" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .4, fontWeight: 700 }}>Анестезия</div>
<div style={{ fontSize: 12, fontWeight: 700, marginTop: 2 }}>{s.anesthesia}</div>
</div>
</div>
<div style={{ fontSize: 12, padding: '8px 12px', background: 'var(--c-success-50)', borderRadius: 10, color: 'var(--c-fg-2)' }}>
{s.outcome}
</div>
</PlateCard>
);
})}
</div>
)}
</div>
</div>
);
}
+2 -1
View File
@@ -142,7 +142,8 @@ export function HomeV2Screen({ nav }) {
{/* Clinic stats */} {/* Clinic stats */}
<div style={{ padding: '0 20px 16px' }}> <div style={{ padding: '0 20px 16px' }}>
<div className="card" style={{ <div className="card" style={{
padding: 18, background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0, padding: 18, background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))',
border: '1.5px solid var(--c-primary-300)',
}}> }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>Клиника УГН</div> <div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>Клиника УГН</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>