Rebuild the /admin/appointments page visual layer to match the tauri
clinic-pro-tauri "turns" design while keeping all existing data wiring and
backend endpoints unchanged (add/edit/move/transfer-reserve/replace already
supported via PATCH /api/v1/appointment/{uuid} and POST /api/v1/my/appointment).
Frontend (assets/admin):
- Sidebar: نوبتها becomes an expandable parent with sub-items
«نوبت های تایید شده» (/admin/appointments) and «افزودن نوبت»
(/admin/appointments/new); auto-expands on active child. Applied to
admin/clinic/doctor/secretary roles. Adds nav-subitem styling.
- New presentational components under components/appointments/: tauri status
palette (turnStatus), TurnsStatInfo, TurnsViewToggle (sliding), DoctorTabs
(underline), TurnsTimeline (marker rail + status cards, empty slot → افزودن
نوبت), TurnsTable.
- AppointmentsPage recomposed with the new components (stats bar, doctor tabs,
view toggle, timeline/table), preserving queries, filters, pagination,
quick-book modal and the row actions menu.
- AppointmentCreatePage: full-page create form (CreateTurn layout) at
/admin/appointments/new, reusing POST /api/v1/my|admin/appointment.
Tests: TurnsStatInfo, TurnsTimeline, Sidebar (expandable), AppointmentsPage,
AppointmentCreatePage. Backend move/reserve/replace verified green via existing
tests/Appointment/AppointmentUpdateTest + AppointmentWorkflowFieldsTest.
No API endpoints changed → no docs/api change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
7.0 KiB
TypeScript
152 lines
7.0 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { UserIcon, PhoneIcon, PlusIcon } from '@heroicons/react/24/outline';
|
|
import type { Appointment } from '../../types';
|
|
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
|
|
import AppointmentActionsMenu from '../AppointmentActions';
|
|
import { turnStatusConfig, EMPTY_SLOT_CONFIG } from './turnStatus';
|
|
import type { TimelineSlot } from './types';
|
|
|
|
/**
|
|
* نمای زمانبندی (زمانبندی) — بازسازیِ `Timeline.jsx` طرح tauri: هر ردیف شامل یک
|
|
* «ریل مارکر» (نقطهٔ رنگی + ساعت شروع + خطچین اتصال) در راست و یک «کارت نوبت» در
|
|
* چپ است (RTL، row-reverse). اسلات خالی → «افزودن نوبت». اسکرول خودکار به اولین
|
|
* نوبتِ فعالِ امروز. رنگها عیناً از طرح مبدأ.
|
|
*/
|
|
|
|
// نقطهٔ رنگی + خطچین عمودی (ریل مارکر سمت راست).
|
|
function Marker({ 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,
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyCard({ slot, onBook }: { slot: TimelineSlot; onBook: (s: TimelineSlot) => void }) {
|
|
const cfg = EMPTY_SLOT_CONFIG;
|
|
const isPast = slot.start < Math.floor(Date.now() / 1000);
|
|
return (
|
|
<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,
|
|
}}
|
|
>
|
|
<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 }} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OccupiedCard({
|
|
appointment: a, slot, queryKey, onView,
|
|
}: {
|
|
appointment: Appointment; slot: TimelineSlot; queryKey: unknown[]; onView: (a: Appointment) => void;
|
|
}) {
|
|
const cfg = turnStatusConfig(a.status);
|
|
return (
|
|
<div
|
|
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,
|
|
}}
|
|
>
|
|
{/* اطلاعات بیمار */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: 1 }}>
|
|
<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' }}>
|
|
{a.patient_name || '—'}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
<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>
|
|
</div>
|
|
|
|
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
|
|
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, flexShrink: 0 }}>
|
|
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
|
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TurnsTimeline({
|
|
slots, loading, queryKey, onView, onBook,
|
|
}: {
|
|
slots: TimelineSlot[];
|
|
loading: boolean;
|
|
queryKey: unknown[];
|
|
onView: (a: Appointment) => void;
|
|
onBook: (s: TimelineSlot) => void;
|
|
}) {
|
|
const activeRef = useRef<HTMLDivElement | null>(null);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
// اولین نوبتِ فعالِ آینده برای اسکرول خودکار.
|
|
const activeIndex = slots.findIndex(s => s.appointment && s.end >= now);
|
|
|
|
useEffect(() => {
|
|
if (!activeRef.current) return;
|
|
const el = activeRef.current;
|
|
const top = el.getBoundingClientRect().top + window.scrollY - window.innerHeight * 0.4;
|
|
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
|
}, [activeIndex]);
|
|
|
|
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
|
if (!slots.length) return (
|
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 900 }}>
|
|
{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' }}
|
|
>
|
|
<Marker 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} />
|
|
: <EmptyCard slot={slot} onBook={onBook} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|