Browse Source

Add endoscopy result viewer screen

New screen for viewing endoscopy results: 2×2 snapshot grid with
simulated endoscopic view (radial gradient + specular highlight),
diagnosis chips, conclusion, numbered recommendations, PDF/share
actions, and full-screen image viewer with pagination.

Wired via nav route `result:<id>` from ResultsScreen and exposed in
Tweaks screen selector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
parent
commit
31b861e452
  1. 1
      src/App.jsx
  2. 3
      src/PhoneApp.jsx
  3. 176
      src/screens/screens-misc.jsx

1
src/App.jsx

@ -60,6 +60,7 @@ const SCREEN_OPTIONS = [
{ id: 'appt:a1', lb: 'Детали приёма' }, { id: 'appt:a1', lb: 'Детали приёма' },
{ id: 'results', lb: 'Результаты' }, { id: 'results', lb: 'Результаты' },
{ id: 'result-audio', lb: 'Аудиограмма' }, { id: 'result-audio', lb: 'Аудиограмма' },
{ id: 'result:r2', lb: 'Эндоскопия носоглотки' },
{ id: 'recovery', lb: 'Восстановление' }, { id: 'recovery', lb: 'Восстановление' },
{ id: 'audiotest', lb: 'Тест слуха' }, { id: 'audiotest', lb: 'Тест слуха' },
{ id: 'chat', lb: 'Чат' }, { id: 'chat', lb: 'Чат' },

3
src/PhoneApp.jsx

@ -8,7 +8,7 @@ import {
} from './screens/screens-booking.jsx'; } from './screens/screens-booking.jsx';
import { import {
ApptsTabScreen, ApptDetailScreen, ApptsTabScreen, ApptDetailScreen,
ResultsScreen, ResultAudioScreen, ResultsScreen, ResultAudioScreen, ResultEndoscopyScreen,
RecoveryScreen, AudioTestScreen, RecoveryScreen, AudioTestScreen,
ChatTabScreen, ProfileTabScreen, QRScreen, ChatTabScreen, ProfileTabScreen, QRScreen,
TelemedScreen, MedcardScreen, NotificationsScreen, TelemedScreen, MedcardScreen, NotificationsScreen,
@ -31,6 +31,7 @@ function renderScreen(screenId, nav, ctx) {
case 'appt': return <ApptDetailScreen nav={nav} apptId={parts[1]} />; case 'appt': return <ApptDetailScreen nav={nav} apptId={parts[1]} />;
case 'results': return <ResultsScreen nav={nav} />; case 'results': return <ResultsScreen nav={nav} />;
case 'result-audio': return <ResultAudioScreen nav={nav} />; case 'result-audio': return <ResultAudioScreen nav={nav} />;
case 'result': return <ResultEndoscopyScreen nav={nav} resultId={parts[1]} />;
case 'recovery': return <RecoveryScreen nav={nav} />; case 'recovery': return <RecoveryScreen nav={nav} />;
case 'audiotest': return <AudioTestScreen nav={nav} />; case 'audiotest': return <AudioTestScreen nav={nav} />;
case 'chat': return <ChatTabScreen nav={nav} />; case 'chat': return <ChatTabScreen nav={nav} />;

176
src/screens/screens-misc.jsx

@ -115,6 +115,182 @@ export function ApptDetailScreen({ nav, apptId }) {
); );
} }
function EndoscopyTile({ label, time, seed = 0, big = false }) {
const variants = [
'radial-gradient(circle at 45% 40%, #FCE1DD 0%, #E9A29B 35%, #B36962 65%, #5A2624 100%)',
'radial-gradient(circle at 55% 50%, #FBD5D0 0%, #DC8981 40%, #9B4640 75%, #3F1B19 100%)',
'radial-gradient(circle at 40% 35%, #FFE4DE 0%, #E7988F 40%, #A55651 70%, #4A1E1B 100%)',
'radial-gradient(circle at 50% 55%, #FCDAD4 0%, #E5968C 35%, #A8544E 75%, #442220 100%)',
];
const insetPad = big ? 14 : 8;
return (
<div style={{ position: 'relative', background: '#0B0606', borderRadius: big ? 22 : 14, overflow: 'hidden', aspectRatio: '1', width: '100%' }}>
<div style={{
position: 'absolute', inset: insetPad, borderRadius: '50%',
background: variants[seed % 4],
}} />
<div style={{
position: 'absolute', inset: insetPad, borderRadius: '50%',
boxShadow: 'inset 0 0 30px rgba(0,0,0,0.65), inset 0 0 6px rgba(255,255,255,0.15)',
pointerEvents: 'none',
}} />
{/* subtle highlight spot (specular) */}
<div style={{
position: 'absolute', top: `${22 + (seed % 3) * 6}%`, left: `${30 + (seed % 4) * 5}%`,
width: big ? 26 : 14, height: big ? 26 : 14, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,255,255,0.55) 0%, transparent 70%)',
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute', left: insetPad + 4, right: insetPad + 4, bottom: insetPad + 4,
padding: big ? '8px 12px' : '5px 8px',
background: 'rgba(0,0,0,0.55)', borderRadius: 8,
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
color: '#fff', fontSize: big ? 13 : 11,
}}>
<span style={{ fontWeight: 700 }}>{label}</span>
<span style={{ opacity: .7, fontFamily: 'var(--font-narrow)' }}>{time}</span>
</div>
</div>
);
}
export function ResultEndoscopyScreen({ nav, resultId = 'r2' }) {
const r = CLINIC_DATA.results.find(x => x.id === resultId) || CLINIC_DATA.results.find(x => x.kind === 'image');
const doctor = CLINIC_DATA.doctors.find(d => d.id === r.doctor);
const [selected, setSelected] = useState(null);
const images = [
{ label: 'Носоглотка', time: '10:32' },
{ label: 'Аденоиды', time: '10:33' },
{ label: 'Хоана лев.', time: '10:34' },
{ label: 'Хоана прав.', time: '10:35' },
];
const diagnoses = ['Гипертрофия аденоидов III ст.'];
const conclusion = 'Слизистая носоглотки умеренно гиперемирована, отёчна. Глоточная миндалина (аденоиды) III степени, заполняет просвет хоан на 2/3 с обеих сторон. Отделяемое слизистое. Устья слуховых труб обозримы, без особенностей.';
const recommendations = [
'Консультация ЛОР-хирурга в течение 2 недель',
'Курс промываний «Кукушка» (5 процедур)',
'Контрольная эндоскопия через 1 месяц',
'Обсудить показания к аденотомии',
];
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title={r.name} subtitle={`${r.date} · ${images.length} снимков`} onBack={() => nav.pop()} rightIcon={I.doc} />
<div style={{ padding: '0 20px' }}>
<div className="card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', marginBottom: 14 }}>
<Avatar init={doctor.init} size={44} />
<div style={{ flex: 1 }}>
<div className="sub" style={{ fontSize: 11, marginBottom: 2 }}>Исследование провёл</div>
<div style={{ fontSize: 14, fontWeight: 700 }}>{doctor.name.split(' ').slice(0,2).join(' ')}</div>
</div>
<span className="chip chip-success">Готово</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 16 }}>
{images.map((img, i) => (
<button key={i} onClick={() => setSelected(i)} className="press" style={{ padding: 0, background: 'transparent', borderRadius: 14 }}>
<EndoscopyTile label={img.label} time={img.time} seed={i} />
</button>
))}
</div>
<div className="card" style={{ marginBottom: 12 }}>
<div className="h-row" style={{ marginBottom: 10 }}>Диагноз</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{diagnoses.map(d => <span key={d} className="chip chip-warm" style={{ fontSize: 13, padding: '7px 12px' }}>{d}</span>)}
</div>
</div>
<div className="card" style={{ marginBottom: 12 }}>
<div className="h-row" style={{ marginBottom: 8 }}>Заключение</div>
<p style={{ margin: 0, fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>{conclusion}</p>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<div className="h-row" style={{ marginBottom: 6 }}>Рекомендации</div>
{recommendations.map((rec, i, a) => (
<div key={i}>
<div style={{ display: 'flex', gap: 12, padding: '10px 0', alignItems: 'flex-start' }}>
<div style={{
width: 22, height: 22, borderRadius: 999, background: 'var(--c-primary-100)',
color: 'var(--c-primary-darker)', fontSize: 12, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1,
}}>{i + 1}</div>
<div style={{ fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>{rec}</div>
</div>
{i < a.length - 1 && <div className="divider" />}
</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<button className="btn-p block">
<I.doc size={18} /> Скачать PDF
</button>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={() => nav.push('chat')} className="btn-g" style={{ flex: 1, padding: 14 }}>
<I.chat size={16} /> Обсудить
</button>
<button className="btn-g" style={{ flex: 1, padding: 14 }}>
<I.arrow size={16} /> Поделиться
</button>
</div>
</div>
</div>
{selected !== null && (
<div
onClick={() => setSelected(null)}
style={{
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.95)', zIndex: 200,
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ padding: '50px 20px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#fff' }}>
<button onClick={(e) => { e.stopPropagation(); setSelected(null); }} className="press" style={{
width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<I.close size={18} style={{ color: '#fff' }} />
</button>
<div style={{ fontSize: 15, fontWeight: 700 }}>{images[selected].label}</div>
<button onClick={(e) => e.stopPropagation()} className="press" style={{
width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<I.doc size={18} style={{ color: '#fff' }} />
</button>
</div>
<div
onClick={(e) => e.stopPropagation()}
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 20px' }}
>
<div style={{ width: '100%', maxWidth: 340 }}>
<EndoscopyTile label={images[selected].label} time={images[selected].time} seed={selected} big />
</div>
</div>
<div style={{ padding: '16px 20px 36px', display: 'flex', justifyContent: 'center', gap: 8 }}>
{images.map((_, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setSelected(i); }}
style={{
width: i === selected ? 24 : 8, height: 8, borderRadius: 999,
background: i === selected ? '#fff' : 'rgba(255,255,255,0.35)',
border: 0, padding: 0, cursor: 'pointer', transition: 'width .2s',
}}
/>
))}
</div>
</div>
)}
</div>
);
}
export function ResultsScreen({ nav }) { export function ResultsScreen({ nav }) {
const { results, doctors } = CLINIC_DATA; const { results, doctors } = CLINIC_DATA;
return ( return (

Loading…
Cancel
Save