Compare commits
5 Commits
fa2d7f6b42
...
e9a8171252
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a8171252 | |||
| bf1c78ff14 | |||
| a9d669e397 | |||
| 61f7e5776d | |||
| 6700e96476 |
+148
-4
@@ -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 экранов прототипа
|
||||
|
||||
### Итоги
|
||||
_заполнить в конце спринта_
|
||||
|
||||
+154
-15
@@ -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%' },
|
||||
@@ -24,9 +32,11 @@ const SCALE_OPTIONS = [
|
||||
];
|
||||
|
||||
const HOME_OPTIONS = [
|
||||
{ id: 'cards', lb: 'Карточки' },
|
||||
{ id: 'list', lb: 'Лента' },
|
||||
{ id: 'feed', lb: 'Таймлайн' },
|
||||
{ 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 (
|
||||
<FitWrap userScale={tw.scale}>
|
||||
<Phone device={tw.device} screen={tw.screen} ctx={ctx} />
|
||||
</FitWrap>
|
||||
<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} 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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
+363
-126
@@ -29,10 +29,17 @@ export function ApptsTabScreen({ nav }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 16, marginTop: 12 }}>
|
||||
<button onClick={() => nav.push('booking-specs')} className="btn-p block">
|
||||
<I.plus size={18} /> Записаться на приём
|
||||
</button>
|
||||
<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,15 +99,42 @@ 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>
|
||||
<button className="btn-s" style={{ marginTop: 12, padding: '8px 14px', fontSize: 13 }}>
|
||||
<I.doc size={15} /> Открыть PDF
|
||||
</button>
|
||||
<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>
|
||||
<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>
|
||||
<button onClick={() => nav.push('qr')} className="chip" style={{ fontSize: 12, fontWeight: 700 }}>
|
||||
<I.qr size={12} /> QR пациента
|
||||
</button>
|
||||
<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,51 +824,307 @@ 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScreenHeader title="Электронная карта" onBack={() => nav.pop()} rightIcon={I.search} />
|
||||
|
||||
<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>
|
||||
{/* 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 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: '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>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.t}</div>
|
||||
<div className="sub" style={{ fontSize: 12 }}>{r.doc}</div>
|
||||
</div>
|
||||
{i < a.length - 1 && <div className="divider" />}
|
||||
</div>
|
||||
{/* Табы-сегмент */}
|
||||
<div style={{ padding: '0 16px 12px', overflowX: 'auto' }}>
|
||||
<div className="seg" style={{ display: 'inline-flex', gap: 0, minWidth: '100%' }}>
|
||||
{MEDCARD_TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)} className={tab === t.id ? 'on' : ''} style={{ flex: 1, whiteSpace: 'nowrap', padding: '8px 12px' }}>{t.lb}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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', 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 }}>Наблюдает: {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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:00–21: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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user