Compare commits

..

5 Commits

Author SHA1 Message Date
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
12 changed files with 3321 additions and 155 deletions
+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
@@ -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 экранов прототипа
### Итоги
_заполнить в конце спринта_
+149 -10
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IOSDevice } from './frames/IOSDevice.jsx';
import { AndroidDevice } from './frames/AndroidDevice.jsx';
import { PhoneApp } from './PhoneApp.jsx';
import { getScreenDoc } from './docs.js';
const TWEAKS_DEFAULT = {
homeVariant: 'cards',
@@ -14,8 +15,15 @@ const TWEAKS_DEFAULT = {
device: 'ios',
scale: 'auto',
showIntro: true,
docsEnabled: false,
design: 'claude',
};
const DESIGN_OPTIONS = [
{ id: 'claude', lb: 'Клод' },
{ id: 'plate', lb: 'Прозрачная карточка' },
];
const SCALE_OPTIONS = [
{ id: 'auto', lb: 'Авто' },
{ id: '0.5', lb: '50%' },
@@ -27,6 +35,8 @@ const HOME_OPTIONS = [
{ id: 'cards', lb: 'Карточки' },
{ id: 'list', lb: 'Лента' },
{ id: 'feed', lb: 'Таймлайн' },
{ id: 'timelineX', lb: 'Таймлайн X' },
{ id: 'splash', lb: 'Светлая плитка' },
];
const DOC_OPTIONS = [
{ id: 'rich', lb: 'Карточки+' },
@@ -38,9 +48,11 @@ const DENSITY_OPTIONS = [
{ id: 'compact', lb: 'Плотно' },
];
const ACCENT_OPTIONS = [
{ id: 'teal', lb: 'Тил', primary: '#1F8F85', darker: '#166B63', dark: '#0F4A44', p50: '#E3F4F2', p100: '#C7E8E4', p200: '#9BD6CE', warm50: '#FDF8EE', warm100: '#F5EDDF' },
{ id: 'terra', lb: 'Терра', primary: '#C77A4C', darker: '#A65C33', dark: '#7F4426', p50: '#FBEFE4', p100: '#F5DDC9', p200: '#EBC19F', warm50: '#F4F7F3', warm100: '#E5ECE4' },
{ id: 'marine', lb: 'Марин', primary: '#3C6EA8', darker: '#23538B', dark: '#193C66', p50: '#E4EDF8', p100: '#C8DAEE', p200: '#9DBDDE', warm50: '#FBF6EE', warm100: '#F2E8D5' },
{ 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', 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', 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 = [
{ id: 'manrope', lb: 'Manrope', base: '"Manrope", system-ui, sans-serif', narrow: '"Oswald", sans-serif' },
@@ -64,7 +76,10 @@ const SCREEN_OPTIONS = [
{ id: 'result:r2', lb: 'Эндоскопия носоглотки' },
{ id: 'recovery', 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: 'qr', lb: 'QR' },
{ id: 'telemed', lb: 'Телемед' },
@@ -78,6 +93,9 @@ const SCREEN_OPTIONS = [
{ id: 'search', lb: 'Поиск' },
{ id: 'contacts', lb: 'Контакты' },
{ id: 'prices', lb: 'Цены' },
{ id: 'dev-colors', lb: 'DEV · Палитра' },
{ id: 'dev-examples', lb: 'DEV · Примеры' },
{ id: 'docs', lb: 'Документация' },
];
function applyTheme(tw) {
@@ -92,13 +110,19 @@ function applyTheme(tw) {
r.setProperty('--c-primary-200', a.p200);
r.setProperty('--c-warm-50', a.warm50);
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-narrow', f.narrow);
document.body.style.fontFamily = f.base;
}
function Phone({ device = 'ios', screen, ctx, label, sublabel }) {
const content = <PhoneApp initialScreen={screen} ctx={ctx} />;
function Phone({ device = 'ios', screen, ctx, label, sublabel, onCurrentChange }) {
const content = <PhoneApp initialScreen={screen} ctx={ctx} onCurrentChange={onCurrentChange} />;
const frame = device === 'android'
? <AndroidDevice>{content}</AndroidDevice>
: <IOSDevice>{content}</IOSDevice>;
@@ -138,6 +162,11 @@ function TweaksPanel({ tw, setTw, onClose }) {
</select>
)}
{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'))}
{group('Компоновка', opts([
{ id:'single', lb:'1 телефон' },
@@ -163,13 +192,111 @@ function TweaksPanel({ tw, setTw, onClose }) {
);
}
function DocPlashka({ doc, onOpen }) {
if (!doc) return null;
return (
<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={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--c-primary-100)', color: 'var(--c-primary-darker)',
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>
);
}
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.parentElement;
const stage = outer.closest('.stage') || outer.parentElement;
if (!stage) return;
const measure = () => {
const padding = 48;
@@ -199,10 +326,17 @@ export default function App() {
const [tw, setTw] = useState(TWEAKS_DEFAULT);
const [panelOpen, setPanelOpen] = useState(true);
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]);
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(() => {
if (tw.layout === 'home3') {
@@ -248,11 +382,14 @@ export default function App() {
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
{currentDoc && <DocPlashka doc={currentDoc} onOpen={() => setDocModal(currentDoc)} />}
<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>
</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';
@@ -273,6 +410,8 @@ export default function App() {
{!panelOpen && (
<button className="tweaks-fab" onClick={() => setPanelOpen(true)} title="Tweaks"></button>
)}
{docModal && <DocModal doc={docModal} onClose={() => setDocModal(null)} />}
</div>
);
}
+36 -7
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
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 {
BookingSpecsScreen, BookingDoctorScreen, BookingTimeScreen,
BookingConfirmScreen, BookingSuccessScreen,
@@ -10,16 +10,34 @@ import {
ApptsTabScreen, ApptDetailScreen,
ResultsScreen, ResultAudioScreen, ResultEndoscopyScreen,
RecoveryScreen, AudioTestScreen,
ChatTabScreen, ProfileTabScreen, QRScreen,
ProfileTabScreen, QRScreen,
TelemedScreen, MedcardScreen, NotificationsScreen,
} from './screens/screens-misc.jsx';
import { ChatsListScreen, ChatConversationScreen } from './screens/screens-chats.jsx';
import { ArticlesScreen, ArticleDetailScreen } from './screens/screens-articles.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) {
const parts = screenId.split(':');
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) {
case 'home': return <HOME 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 'recovery': return <RecoveryScreen 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 'qr': return <QRScreen nav={nav} />;
case 'telemed': return <TelemedScreen nav={nav} />;
@@ -48,17 +68,26 @@ function renderScreen(screenId, nav, ctx) {
case 'search': return <SearchScreen nav={nav} />;
case 'contacts': return <ContactsScreen 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>;
}
}
const TAB_IDS = ['home', 'appts', 'doctors', 'chat', 'profile'];
export function PhoneApp({ initialScreen, ctx }) {
export function PhoneApp({ initialScreen, ctx, onCurrentChange }) {
const [stack, setStack] = useState([initialScreen]);
useEffect(() => { setStack([initialScreen]); }, [initialScreen]);
const current = stack[stack.length - 1];
useEffect(() => {
if (onCurrentChange) onCurrentChange(current);
}, [current, onCurrentChange]);
const nav = useMemo(() => ({
push: (id) => setStack(s => [...s, id]),
pop: () => setStack(s => s.length > 1 ? s.slice(0, -1) : s),
@@ -66,9 +95,9 @@ export function PhoneApp({ initialScreen, ctx }) {
reset:() => setStack(['home']),
}), []);
const current = stack[stack.length - 1];
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 modalScreens = ['qr', 'telemed', 'booking-success', 'audiotest'];
+146 -2
View File
@@ -2,6 +2,48 @@
// (oclinica.ru/lor) — врачи, услуги, цены.
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: {
name: 'Клиника УГН',
full: 'Клиника ухо, горло, нос им. проф. Е.Н. Оленевой',
@@ -49,8 +91,24 @@ export const CLINIC_DATA = {
appointments: [
{ 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: 'a3', status: 'past', doctor: 'makarova', date: '8 апр', 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 },
{ id: 'a3', status: 'past', doctor: 'makarova', date: '8 апр', year: 2026, weekday: 'среда', time: '10:30', room: 'Каб. 202', 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: [
{ 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: {
op: 'Септопластика',
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;
}
+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 }) {
const { doctors, appointments, clinic, articles, recovery } = CLINIC_DATA;
const upcoming = appointments.find(a => a.status === 'upcoming');
+348 -111
View File
@@ -29,10 +29,17 @@ export function ApptsTabScreen({ nav }) {
</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">
<I.plus size={18} /> Записаться на приём
</button>
)}
{tab === 'past' && (
<button onClick={() => nav.push('medcard')} className="btn-p block">
<I.file size={18} /> Электронная карта
</button>
)}
</div>
</div>
);
@@ -92,16 +99,43 @@ export function ApptDetailScreen({ nav, apptId }) {
{a.hasReport && (
<div style={{ marginTop: 16 }}>
<div className="h-sec" style={{ marginBottom: 10 }}>Заключение врача</div>
<div className="card">
<div style={{ fontSize: 14, lineHeight: 1.55, color: 'var(--c-fg-2)' }}>
Диагноз: хронический риносинусит, обострение. Назначено: Аква Марис, Назонекс 14 дней. Контрольный осмотр через 2 недели.
<div className="h-sec" style={{ marginBottom: 10, 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>
<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
</button>
<button onClick={() => nav.push('medcard')} className="btn-s" style={{ padding: '8px 14px', fontSize: 13 }}>
<I.file size={15} /> В медкарте
</button>
</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>
@@ -464,7 +498,7 @@ export function RecoveryScreen({ nav }) {
<div className="sub" style={{ fontSize: 11 }}>Ваш хирург</div>
<div style={{ fontSize: 14, fontWeight: 700 }}>{surgeon.name.split(' ').slice(0,2).join(' ')}</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} /> Чат
</button>
</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 }) {
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: 'История, диагнозы', go: 'medcard' },
{ i: I.doc, t: 'Анализы', s: '5 результатов', go: 'results' },
{ i: I.pill, t: 'Лекарства', s: '3 активных курса', go: 'recovery' },
{ 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' },
]
},
@@ -707,13 +683,18 @@ export function ProfileTabScreen({ nav }) {
<div style={{ padding: '12px 20px 16px' }}>
<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 }}>
<Avatar init="АС" size={64} style={{ fontSize: 24, boxShadow: 'var(--sh-sm)' }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>Анна Сергеевна</div>
<div className="sub" style={{ fontSize: 13, marginBottom: 6 }}>+7 (912) 485-- · 42 года</div>
<Avatar init={patient.init} size={64} style={{ fontSize: 24, boxShadow: 'var(--sh-sm)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700 }}>{patient.shortName}</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 }}>
<I.qr size={12} /> QR пациента
</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>
@@ -726,16 +707,16 @@ export function ProfileTabScreen({ nav }) {
const II = it.i;
return (
<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' }}>
<div style={{ width: 34, height: 34, borderRadius: 9, background: 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<II size={18} style={{ color: 'var(--c-primary-darker)' }} />
<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: it.featured ? 'var(--c-primary-darker)' : 'var(--c-primary-100)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<II size={18} style={{ color: it.featured ? '#fff' : 'var(--c-primary-darker)' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{it.t}</div>
{it.s && <div className="sub" style={{ fontSize: 12 }}>{it.s}</div>}
<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, lineHeight: 1.35 }}>{it.s}</div>}
</div>
{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>
{i < sec.items.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 64 }} />}
</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 }) {
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 (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Медицинская карта" onBack={() => nav.pop()} />
<div style={{ padding: '0 20px' }}>
<div className="card" style={{ marginBottom: 14 }}>
<div className="h-row" style={{ marginBottom: 10 }}>Основное</div>
{[['Пол','Женский'],['Возраст','42 года'],['Рост / Вес','168 см · 62 кг'],['Группа крови','II (A), Rh+']].map(([k,v])=>(
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0' }}>
<span className="sub">{k}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{v}</span>
<ScreenHeader title="Электронная карта" onBack={() => nav.pop()} rightIcon={I.search} />
{/* Hero — паспорт пациента */}
<div style={{ padding: '0 20px 14px' }}>
<div className="card" style={{ padding: 18, background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))', border: 0 }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 14 }}>
<Avatar init={patient.init} size={56} style={{ fontSize: 22, boxShadow: 'var(--sh-sm)' }} />
<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>
<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 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 className="h-sec" style={{ padding: '4px 4px 10px' }}>История диагнозов</div>
<div className="card" style={{ padding: 0 }}>
{[
{ d: '12 апр 2026', t: 'Искривление носовой перегородки', doc: 'Синдяев А.В.', tag: 'Операция' },
{ d: '8 апр 2026', t: 'Хронический риносинусит', doc: 'Макарова Л.Г.', tag: 'Приём' },
{ d: '15 ноя 2025', t: 'ОРВИ', doc: 'Суворова С.В.', tag: 'Приём' },
].map((r,i,a)=>(
<div key={i}>
<div style={{ padding: '0 20px' }}>
{tab === 'summary' && (
<>
{/* Аллергии */}
<div className="h-sec" style={{ padding: '4px 4px 8px' }}>Аллергии · {medcard.allergies.length}</div>
<div className="card" style={{ padding: 14, marginBottom: 14 }}>
{medcard.allergies.map((a, 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={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span className="sub" style={{ fontSize: 12 }}>{r.d}</span>
<span className="chip chip-soft" style={{ fontSize: 10 }}>{r.tag}</span>
<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 style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.t}</div>
<div className="sub" style={{ fontSize: 12 }}>{r.doc}</div>
<div className="sub" style={{ fontSize: 12 }}>{c.stage} · с {c.diagnosed}</div>
<div className="sub" style={{ fontSize: 12, marginTop: 2 }}>Наблюдает: {shortDoctor(doc)}</div>
</div>
{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>
<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>
);
+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 */}
<div style={{ padding: '0 20px 16px' }}>
<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={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>