fix(appointments): center timeline card and match tauri turns layout
- Center the whole page in a max-width container; move the toolbar above the card and make the doctor tabs the card header (as in the tauri turns design). - Rebuild the timeline card to match the reference: inner start/end ring-dot time markers, patient name / phone / «سرویس» lines, and status pill + عملیات on the card's left; outer marker rail with time on the right. - Timeline rows are width-capped and centered on the page. - Empty slots render the light-blue «افزودن نوبت +» card with a «نوبت جدید» pill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,8 @@ export default function DoctorTabs({
|
||||
const tabs: DoctorTab[] = showAll ? [{ uuid: '', name: 'همه' }, ...doctors] : doctors;
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 24, padding: '4px 4px 0',
|
||||
borderBottom: '1px solid var(--border)', overflowX: 'auto', marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 24, padding: '8px 16px 0',
|
||||
borderBottom: '1px solid var(--border)', overflowX: 'auto',
|
||||
}}>
|
||||
{tabs.map((d) => {
|
||||
const active = selected === d.uuid;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { UserIcon, PhoneIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import type { Appointment } from '../../types';
|
||||
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
|
||||
import AppointmentActionsMenu from '../AppointmentActions';
|
||||
@@ -7,29 +7,56 @@ import { turnStatusConfig, EMPTY_SLOT_CONFIG } from './turnStatus';
|
||||
import type { TimelineSlot } from './types';
|
||||
|
||||
/**
|
||||
* نمای زمانبندی (زمانبندی) — بازسازیِ `Timeline.jsx` طرح tauri: هر ردیف شامل یک
|
||||
* «ریل مارکر» (نقطهٔ رنگی + ساعت شروع + خطچین اتصال) در راست و یک «کارت نوبت» در
|
||||
* چپ است (RTL، row-reverse). اسلات خالی → «افزودن نوبت». اسکرول خودکار به اولین
|
||||
* نوبتِ فعالِ امروز. رنگها عیناً از طرح مبدأ.
|
||||
* نمای زمانبندی (زمانبندی) — بازسازیِ `Timeline.jsx` طرح tauri:
|
||||
* هر ردیف: «ریل مارکر» بیرونی (نقطهٔ رنگی + ساعت شروع + خطچین) سمت راست + «کارت
|
||||
* نوبت» چپِ آن. داخل کارت: مارکر زمانِ داخلی (نقطهٔ حلقهای شروع/پایان)، اطلاعات
|
||||
* بیمار (نام/تلفن/سرویس) و در سمت چپ وضعیت + عملیات. اسلات خالی → «افزودن نوبت».
|
||||
* ردیفها با max-width وسطچین میشوند. رنگها عیناً از طرح مبدأ.
|
||||
*/
|
||||
|
||||
// نقطهٔ رنگی + خطچین عمودی (ریل مارکر سمت راست).
|
||||
function Marker({ color, time, showLine }: { color: string; time: string; showLine: boolean }) {
|
||||
const ROW_MAX = 760;
|
||||
|
||||
// نقطهٔ حلقهای (حلقهٔ بیرونی + نقطهٔ داخلی) — مثل TimelineElement طرح tauri.
|
||||
function RingDot({ color }: { color: string }) {
|
||||
return (
|
||||
<span style={{ position: 'relative', width: 17, height: 17, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<span style={{ position: 'absolute', width: 16, height: 16, borderRadius: '50%', border: `2px solid ${color}` }} />
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: color }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// مارکر زمانِ داخلِ کارت (ساعت شروع بالا، ساعت پایان پایین + خطچین بین آنها).
|
||||
function InnerTimes({ start, end, color }: { start: string; end: string; color: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 4, minWidth: 62, alignSelf: 'stretch' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<RingDot color={color} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 38, textAlign: 'center' }}>{start}</span>
|
||||
</div>
|
||||
<div style={{ width: 2, height: 14, marginInlineEnd: 6, backgroundImage: `repeating-linear-gradient(to bottom, ${color} 0, ${color} 3px, transparent 3px, transparent 6px)` }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<RingDot color={color} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 38, textAlign: 'center' }}>{end}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ریل مارکر بیرونی (سمت راستِ ردیف): نقطهٔ رنگی + ساعت + خطچین عمودی.
|
||||
function OuterMarker({ color, time, showLine }: { color: string; time: string; showLine: boolean }) {
|
||||
return (
|
||||
<div style={{ width: 55, position: 'relative', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start' }}>
|
||||
{showLine && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 4.5, top: 19, height: 'calc(100% + 16px)', width: 2,
|
||||
position: 'absolute', right: 5, top: 18, height: 'calc(100% + 16px)', width: 2,
|
||||
backgroundImage: 'repeating-linear-gradient(to bottom, var(--border-2) 0, var(--border-2) 4px, transparent 4px, transparent 8px)',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingTop: 1, position: 'relative', zIndex: 1 }}>
|
||||
<span style={{
|
||||
width: 12, height: 12, borderRadius: '50%', background: color,
|
||||
border: '2px solid var(--surface)', boxShadow: '0 0 0 1px var(--border)', flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 45 }}>{time}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, position: 'relative', zIndex: 1 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: color, border: '2px solid var(--surface)', boxShadow: '0 0 0 1px var(--border)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 42 }}>{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -42,25 +69,38 @@ function EmptyCard({ slot, onBook }: { slot: TimelineSlot; onBook: (s: TimelineS
|
||||
<div
|
||||
onClick={() => !isPast && onBook(slot)}
|
||||
style={{
|
||||
minHeight: 64, borderRadius: 8, padding: '12px 10px',
|
||||
border: `1px dashed ${isPast ? 'var(--border-2)' : cfg.borderColor}`,
|
||||
background: isPast ? 'var(--surface-2)' : cfg.bgColor,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
cursor: isPast ? 'not-allowed' : 'pointer', opacity: isPast ? 0.7 : 1,
|
||||
border: `1px dashed ${isPast ? 'var(--border-2)' : cfg.borderColor}`,
|
||||
borderRadius: 8, padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14,
|
||||
cursor: isPast ? 'not-allowed' : 'pointer', opacity: isPast ? 0.7 : 1, minHeight: 76,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: isPast ? 'var(--text-3)' : cfg.textColor, fontWeight: 500 }}>
|
||||
{isPast ? 'گذشته' : 'افزودن نوبت'}
|
||||
</span>
|
||||
{!isPast && <PlusIcon style={{ width: 18, height: 18, color: cfg.textColor }} />}
|
||||
<InnerTimes start={slot.start_time} end={slot.end_time} color={isPast ? '#9E9E9E' : cfg.dotColor} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13.5, color: isPast ? 'var(--text-3)' : cfg.textColor, fontWeight: 500 }}>
|
||||
{isPast ? 'گذشته' : 'افزودن نوبت'}
|
||||
</span>
|
||||
{!isPast && <PlusIcon style={{ width: 18, height: 18, color: cfg.textColor }} />}
|
||||
</div>
|
||||
{/* برچسب «نوبت جدید» سمت چپ (مطابق طرح) */}
|
||||
{!isPast && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px', borderRadius: 99,
|
||||
fontSize: 12, fontWeight: 700, color: cfg.textColor, background: `${cfg.dotColor}15`,
|
||||
border: `1.5px solid ${cfg.dotColor}30`, whiteSpace: 'nowrap', alignSelf: 'flex-start',
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: cfg.dotColor }} />
|
||||
نوبت جدید
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OccupiedCard({
|
||||
appointment: a, slot, queryKey, onView,
|
||||
appointment: a, queryKey, onView,
|
||||
}: {
|
||||
appointment: Appointment; slot: TimelineSlot; queryKey: unknown[]; onView: (a: Appointment) => void;
|
||||
appointment: Appointment; queryKey: unknown[]; onView: (a: Appointment) => void;
|
||||
}) {
|
||||
const cfg = turnStatusConfig(a.status);
|
||||
return (
|
||||
@@ -68,12 +108,13 @@ function OccupiedCard({
|
||||
onClick={() => onView(a)}
|
||||
style={{
|
||||
background: cfg.bgColor, border: `1px solid ${cfg.borderColor}`, borderRadius: 8,
|
||||
padding: '12px 10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 10, cursor: 'pointer', minHeight: 64,
|
||||
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14, cursor: 'pointer', minHeight: 88,
|
||||
}}
|
||||
>
|
||||
<InnerTimes start={a.appointment_time} end={a.end_time} color={cfg.dotColor} />
|
||||
|
||||
{/* اطلاعات بیمار */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<UserIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -84,13 +125,16 @@ function OccupiedCard({
|
||||
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', direction: 'ltr' }}>{a.patient_mobile}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
سرویس: {a.service_item?.name || '—'}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<DocumentTextIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
سرویس: {a.service_item?.name || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, flexShrink: 0 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start', flexShrink: 0 }}>
|
||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
|
||||
</div>
|
||||
@@ -109,7 +153,6 @@ export default function TurnsTimeline({
|
||||
}) {
|
||||
const activeRef = useRef<HTMLDivElement | null>(null);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// اولین نوبتِ فعالِ آینده برای اسکرول خودکار.
|
||||
const activeIndex = slots.findIndex(s => s.appointment && s.end >= now);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,19 +171,19 @@ export default function TurnsTimeline({
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 900 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', padding: '4px 0' }}>
|
||||
{slots.map((slot, i) => {
|
||||
const color = slot.appointment ? turnStatusConfig(slot.appointment.status).dotColor : EMPTY_SLOT_CONFIG.dotColor;
|
||||
return (
|
||||
<div
|
||||
key={`${slot.start}-${i}`}
|
||||
ref={i === activeIndex ? activeRef : null}
|
||||
style={{ display: 'flex', flexDirection: 'row-reverse', gap: 16, position: 'relative' }}
|
||||
style={{ display: 'flex', flexDirection: 'row-reverse', gap: 16, position: 'relative', width: '100%', maxWidth: ROW_MAX }}
|
||||
>
|
||||
<Marker color={color} time={slot.start_time} showLine={i < slots.length - 1} />
|
||||
<OuterMarker color={color} time={slot.start_time} showLine={i < slots.length - 1} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{slot.appointment
|
||||
? <OccupiedCard appointment={slot.appointment} slot={slot} queryKey={queryKey} onView={onView} />
|
||||
? <OccupiedCard appointment={slot.appointment} queryKey={queryKey} onView={onView} />
|
||||
: <EmptyCard slot={slot} onBook={onBook} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user