The tab stacked every course's full session list on one page. A protocol allows sixty steps, so one open course was enough to bury the others. Courses are cards now — service, progress, supervising doctor, staff — and opening one replaces the list with its detail: a back button, two tabs (the whole course, or only what is still to come), search and paging inside each. The choice lives in the URL so browser-back returns to the same course. Booking stays where the work is: the button sits on the session card inside the upcoming tab, not on a separate page. The patient banner's 'نوبت بعدی' read '—' for anyone mid-course, because it only looked at booked appointments and a course's later sessions have none yet. It now falls back to the next session of the active course and relabels itself 'جلسهٔ بعدی' when it does — a planned session is not a booking, and the banner should not call it one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
6.4 KiB
TypeScript
135 lines
6.4 KiB
TypeScript
import { formatDate, formatNumber } from '../lib/utils';
|
|
import BackButton from './ui/BackButton';
|
|
import {
|
|
ArrowLeftD, FilesServicePhone, FilesServiceCalendar,
|
|
FilesServiceNotification, FilesServiceMessage,
|
|
} from './icons/FilesServiceIcons';
|
|
|
|
interface Tag { uuid: string; name: string; color: string }
|
|
|
|
/** پرونده > {name} breadcrumb, ported from tauri BreadcrumbHeader. */
|
|
export function Breadcrumb({ name, backTo }: { name: string; backTo: string }) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-2)', fontSize: 12, marginBottom: 14 }}>
|
|
<BackButton fallback={backTo} />
|
|
<ArrowLeftD />
|
|
<span>پرونده</span>
|
|
<ArrowLeftD />
|
|
<span style={{ color: 'var(--text)' }}>{name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TagDots({ tags }: { tags?: Tag[] }) {
|
|
if (!tags || tags.length === 0) return <span style={{ fontSize: 13, color: 'var(--text-3)' }}>—</span>;
|
|
return (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
{tags.slice(0, 4).map((t, i) => (
|
|
<span key={t.uuid} title={t.name} style={{ width: 16, height: 16, borderRadius: '50%', background: t.color, border: '2px solid var(--surface)', marginInlineStart: i === 0 ? 0 : -6 }} />
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const InfoLine = ({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) => (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{icon}
|
|
<span style={{ fontSize: 12, color: 'var(--text)' }}>{label}</span>
|
|
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }} dir="ltr">{value}</span>
|
|
</div>
|
|
);
|
|
|
|
/**
|
|
* The patient case-file banner — ported pixel-for-pixel from tauri
|
|
* FileServicesHeader (name + status chip, file number, tags, contact/date,
|
|
* next appointment, یادداشت button).
|
|
*/
|
|
export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, nextSession, hasDebt, noShows, onAddNote }: {
|
|
name: string;
|
|
recordNumber?: string | null;
|
|
mobile?: string | null;
|
|
createdAt?: number;
|
|
tags?: Tag[];
|
|
nextAppointment?: number | null;
|
|
/**
|
|
* جلسهٔ بعدیِ دوره وقتی نوبتی رزرو نشده.
|
|
*
|
|
* جدا از `nextAppointment` است و باید هم باشد: این هنوز نوبت نیست، برنامه است.
|
|
* یکی کردنشان یعنی بنر چیزی را «نوبت» بنامد که کسی رزروش نکرده.
|
|
*/
|
|
nextSession?: number | null;
|
|
hasDebt?: boolean;
|
|
/** خلاصهٔ عدم حضور در پنجرهٔ سیاست — `null` یعنی هنوز نیامده. */
|
|
noShows?: { count: number; threshold: number; window_days: number; at_risk: boolean } | null;
|
|
onAddNote: () => void;
|
|
}) {
|
|
const complete = !hasDebt;
|
|
return (
|
|
<div
|
|
className="bg-[var(--surface)] border border-[var(--border)]"
|
|
style={{ borderRadius: 16, padding: '14px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, marginBottom: 16, minHeight: 140, flexWrap: 'wrap' }}
|
|
>
|
|
{/* right — name + tags */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
<span style={{ fontWeight: 700, fontSize: 20, color: 'var(--text)' }}>{name}</span>
|
|
<span style={{
|
|
height: 29, minWidth: 95, borderRadius: 8, padding: '0 10px', fontSize: 13, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
background: complete ? 'var(--success-bg)' : 'rgba(255,192,81,0.15)', color: complete ? 'var(--success)' : 'var(--warning)',
|
|
}}>{complete ? 'تکمیل شده' : 'تکمیل نشده'}</span>
|
|
</div>
|
|
<span style={{ fontSize: 16, color: 'var(--text)' }}>شماره پرونده: {recordNumber || '—'}</span>
|
|
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
|
<span style={{ fontSize: 16, color: 'var(--text-2)' }}>برچسب ها:</span>
|
|
<TagDots tags={tags} />
|
|
</div>
|
|
|
|
{/* شمار عدم حضور فقط وقتی میآید که واقعاً اتفاقی افتاده باشد. «۰ غیبت» روی
|
|
پروندهٔ هر بیمار سالم، اتهام بیجاست. عبور از آستانه فقط رنگش را عوض میکند —
|
|
مسدودسازی کارِ قانون `eligibility` است، نه این نشان. */}
|
|
{noShows && noShows.count > 0 && (
|
|
<span
|
|
title={`در ${formatNumber(noShows.window_days)} روز گذشته · آستانهٔ سیاست: ${formatNumber(noShows.threshold)}`}
|
|
style={{
|
|
alignSelf: 'flex-start',
|
|
fontSize: 13,
|
|
borderRadius: 8,
|
|
padding: '4px 10px',
|
|
background: noShows.at_risk ? 'var(--danger-bg)' : 'var(--warning-bg)',
|
|
color: noShows.at_risk ? 'var(--danger)' : 'var(--warning)',
|
|
}}
|
|
>
|
|
{formatNumber(noShows.count)} بار عدم حضور
|
|
{noShows.at_risk && ' — پرریسک'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* middle — contact + file date */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 28 }}>
|
|
<InfoLine icon={<FilesServicePhone />} label="شماره تماس:" value={mobile || '—'} />
|
|
<InfoLine icon={<FilesServiceCalendar />} label="تاریخ تشکیل پرونده:" value={createdAt ? formatDate(createdAt) : '—'} />
|
|
</div>
|
|
|
|
{/* left — next appointment + note */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 28 }}>
|
|
<InfoLine
|
|
icon={<FilesServiceNotification />}
|
|
label={nextAppointment || !nextSession ? 'نوبت بعدی:' : 'جلسهٔ بعدی:'}
|
|
value={
|
|
nextAppointment ? formatDate(nextAppointment)
|
|
: nextSession ? formatDate(nextSession)
|
|
: '—'
|
|
}
|
|
/>
|
|
<button
|
|
type="button" onClick={onAddNote}
|
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--accent)', color: 'var(--on-primary)', border: 'none', borderRadius: 10, padding: '0 18px', height: 36, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
|
>
|
|
<FilesServiceMessage /> یادداشت
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|