feat: redesign appointments (نوبتها) admin UI to match tauri turns + expandable sidebar
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>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* تب دکترها با نشانگر underline — بازسازی تبهای بالای صفحهٔ نوبتهای طرح tauri
|
||||
* (`index.jsx`): فعال #5559ce با خط زیرین، بقیه خاکستری.
|
||||
*/
|
||||
export interface DoctorTab {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function DoctorTabs({
|
||||
doctors, selected, onSelect, showAll = true,
|
||||
}: {
|
||||
doctors: DoctorTab[];
|
||||
/** '' یعنی «همه». */
|
||||
selected: string;
|
||||
onSelect: (uuid: string) => void;
|
||||
showAll?: boolean;
|
||||
}) {
|
||||
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,
|
||||
}}>
|
||||
{tabs.map((d) => {
|
||||
const active = selected === d.uuid;
|
||||
return (
|
||||
<button
|
||||
key={d.uuid || 'all'}
|
||||
type="button"
|
||||
onClick={() => onSelect(d.uuid)}
|
||||
style={{
|
||||
position: 'relative', background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px',
|
||||
color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{d.name}
|
||||
{active && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: -1, left: 0, right: 0, height: 3,
|
||||
background: 'var(--primary)', borderRadius: '3px 3px 0 0',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TurnsStatInfo from './TurnsStatInfo';
|
||||
|
||||
describe('TurnsStatInfo', () => {
|
||||
it('renders the four stat labels with Persian-digit values', () => {
|
||||
render(<TurnsStatInfo stats={{ total: 236, completed: 200, waiting: 12, cancelled: 5 }} />);
|
||||
expect(screen.getByText('کل نوبت های امروز')).toBeInTheDocument();
|
||||
expect(screen.getByText('نوبت های انجام شده')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیماران در انتظار')).toBeInTheDocument();
|
||||
expect(screen.getByText('نوبت های لغو شده')).toBeInTheDocument();
|
||||
expect(screen.getByText('۲۳۶')).toBeInTheDocument();
|
||||
expect(screen.getByText('۲۰۰')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders zeros when there is no data (empty state)', () => {
|
||||
render(<TurnsStatInfo stats={{ total: 0, completed: 0, waiting: 0, cancelled: 0 }} />);
|
||||
// چهار مقدار صفر فارسی
|
||||
expect(screen.getAllByText('۰')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { UsersIcon, UserPlusIcon, ClockIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
/**
|
||||
* نوار آمار نوبتها — بازسازیِ `TurnsStatInfo.jsx` طرح tauri (کارت سفید با
|
||||
* جداکنندههای عمودی و چهار آمار). رنگها از `data/turnsHeaderStats.json` مبدأ.
|
||||
*/
|
||||
export interface TurnsStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
waiting: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
interface StatDef {
|
||||
title: string;
|
||||
value: number;
|
||||
Icon: React.ElementType;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
function BadgeIcon({ Icon, color, bg }: { Icon: React.ElementType; color: string; bg: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10, background: bg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<Icon style={{ width: 22, height: 22, color }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TurnsStatInfo({ stats }: { stats: TurnsStats }) {
|
||||
const items: StatDef[] = [
|
||||
{ title: 'کل نوبت های امروز', value: stats.total, Icon: UsersIcon, iconColor: '#494cb3', iconBg: '#e9ebfb' },
|
||||
{ title: 'نوبت های انجام شده', value: stats.completed, Icon: UserPlusIcon, iconColor: '#0d9f43', iconBg: '#e7f6ed' },
|
||||
{ title: 'بیماران در انتظار', value: stats.waiting, Icon: ClockIcon, iconColor: '#e0a838', iconBg: '#fff2dc' },
|
||||
{ title: 'نوبت های لغو شده', value: stats.cancelled, Icon: XCircleIcon, iconColor: '#ee5c6d', iconBg: '#fad6da' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', background: 'var(--surface)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
||||
padding: '14px 20px', marginBottom: 16, gap: 4,
|
||||
}}>
|
||||
{items.map((it, i) => (
|
||||
<div key={it.title} style={{ display: 'flex', alignItems: 'center', flex: 1, gap: 10 }}>
|
||||
{i > 0 && <div style={{ width: 1, height: 48, background: 'var(--border)', marginInlineEnd: 8 }} />}
|
||||
<BadgeIcon Icon={it.Icon} color={it.iconColor} bg={it.iconBg} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{it.title}</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)' }}>
|
||||
{it.value.toLocaleString('fa-IR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { UserCircleIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
||||
import type { Appointment } from '../../types';
|
||||
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
|
||||
import AppointmentActionsMenu from '../AppointmentActions';
|
||||
|
||||
/**
|
||||
* نمای جدولی (نمایش جدولی) — بازسازیِ جدول نوبتهای طرح tauri (`List.jsx`):
|
||||
* ستونهای ردیف/نام بیمار/شماره تماس/[پزشک]/شروع/پایان/سرویس/پرسنل/وضعیت/عملیات.
|
||||
*/
|
||||
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap', fontSize: 12.5 };
|
||||
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle', fontSize: 13 };
|
||||
|
||||
export default function TurnsTable({
|
||||
items, loading, queryKey, showDoctor,
|
||||
}: {
|
||||
items: Appointment[];
|
||||
loading: boolean;
|
||||
queryKey: unknown[];
|
||||
showDoctor: boolean;
|
||||
}) {
|
||||
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
if (!items.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>نوبتی برای این روز ثبت نشده است</div>;
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<th style={th}>ردیف</th>
|
||||
<th style={th}>نام بیمار</th>
|
||||
<th style={th}>شماره تماس</th>
|
||||
{showDoctor && <th style={th}>پزشک</th>}
|
||||
<th style={th}>شروع</th>
|
||||
<th style={th}>پایان</th>
|
||||
<th style={th}>سرویس</th>
|
||||
<th style={th}>پرسنل</th>
|
||||
<th style={th}>وضعیت</th>
|
||||
<th style={th}>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((a, i) => (
|
||||
<tr key={a.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={td}>{(i + 1).toLocaleString('fa-IR')}</td>
|
||||
<td style={td}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<UserCircleIcon style={{ width: 18, height: 18, color: 'var(--text-3)' }} />
|
||||
{a.patient_name || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ ...td, direction: 'ltr', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)' }} />
|
||||
{a.patient_mobile}
|
||||
</div>
|
||||
</td>
|
||||
{showDoctor && <td style={td}>{a.doctor_name}</td>}
|
||||
<td style={{ ...td, fontWeight: 600 }}>{a.appointment_time}</td>
|
||||
<td style={td}>{a.end_time}</td>
|
||||
<td style={td}>{a.service_item?.name || '—'}</td>
|
||||
<td style={td}>{a.staff?.full_name || '—'}</td>
|
||||
<td style={td}>
|
||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||
</td>
|
||||
<td style={td}>
|
||||
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('../../lib/api', () => ({
|
||||
api: { get: vi.fn(() => Promise.resolve({ success: true, data: [] })), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import TurnsTimeline from './TurnsTimeline';
|
||||
import type { TimelineSlot } from './types';
|
||||
import type { Appointment } from '../../types';
|
||||
|
||||
const appt = (over: Partial<Appointment> = {}): Appointment => ({
|
||||
uuid: 'ap1', patient_name: 'ساغر صابری', patient_mobile: '09356619438',
|
||||
doctor_uuid: 'doc1', doctor_name: 'دکتر محمدی',
|
||||
slot_start: 1000, slot_end: 2000, appointment_date: '2024-12-31',
|
||||
appointment_time: '08:00', end_time: '08:35', status: 'completed',
|
||||
version: 1, created_at: '', service_item: { uuid: 's1', name: 'ویزیت عمومی' },
|
||||
} as unknown as Appointment);
|
||||
|
||||
const occupiedSlot: TimelineSlot = {
|
||||
start: 1000, end: 2000, start_time: '08:00', end_time: '08:35',
|
||||
is_available: false, appointment: appt(), cancelled_appointment: null,
|
||||
};
|
||||
const emptySlot: TimelineSlot = {
|
||||
start: Math.floor(Date.now() / 1000) + 3600, end: Math.floor(Date.now() / 1000) + 5400,
|
||||
start_time: '09:10', end_time: '10:30', is_available: true, appointment: null, cancelled_appointment: null,
|
||||
};
|
||||
|
||||
describe('TurnsTimeline', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders an occupied slot card with patient name + service', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[occupiedSlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.getByText('ساغر صابری')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ویزیت عمومی/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty slot as «افزودن نوبت» and fires onBook on click', () => {
|
||||
const onBook = vi.fn();
|
||||
renderWithProviders(<TurnsTimeline slots={[emptySlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={onBook} />);
|
||||
const add = screen.getByText('افزودن نوبت');
|
||||
fireEvent.click(add);
|
||||
expect(onBook).toHaveBeenCalledWith(emptySlot);
|
||||
});
|
||||
|
||||
it('shows the holiday/empty message when there are no slots', () => {
|
||||
renderWithProviders(<TurnsTimeline slots={[]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
|
||||
expect(screen.getByText('این روز تعطیل است')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* سوییچ کشویی «نمایش جدولی / زمانبندی» — بازسازی `TurnsViewModeToggle.jsx` طرح
|
||||
* tauri (کادر ۱۹۳×۴۸، پسزمینهٔ لغزنده، متن فعال #5559ce).
|
||||
*/
|
||||
export type TurnsViewMode = 'table' | 'timeline';
|
||||
|
||||
export default function TurnsViewToggle({
|
||||
viewMode, onChange,
|
||||
}: {
|
||||
viewMode: TurnsViewMode;
|
||||
onChange: (m: TurnsViewMode) => void;
|
||||
}) {
|
||||
const activeText = 'var(--primary)';
|
||||
const idleText = 'var(--text-3)';
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
|
||||
}}>
|
||||
{/* پسزمینهٔ لغزنده */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0, height: '100%', width: '50%',
|
||||
background: 'var(--primary-soft)', transition: 'transform .3s var(--ease)',
|
||||
transform: viewMode === 'table' ? 'translateX(100%)' : 'translateX(0%)',
|
||||
}} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('table')}
|
||||
style={{
|
||||
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 500, color: viewMode === 'table' ? activeText : idleText,
|
||||
}}
|
||||
>
|
||||
نمایش جدولی
|
||||
</button>
|
||||
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('timeline')}
|
||||
style={{
|
||||
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 500, color: viewMode === 'timeline' ? activeText : idleText,
|
||||
}}
|
||||
>
|
||||
زمانبندی
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* پیکربندی وضعیت نوبتها — رنگها عیناً از طرح tauri (`clinic-pro-tauri`
|
||||
* `src/components/turns/Timeline.jsx` → `statusConfig`) برداشته شده و به وضعیتهای
|
||||
* واقعی بکاند (Appointment::STATUS_*) نگاشت شدهاند. لیبلها فارسی مطابق همان طرح.
|
||||
*/
|
||||
export interface TurnStatusConfig {
|
||||
label: string;
|
||||
bgColor: string;
|
||||
darkBgColor: string;
|
||||
borderColor: string;
|
||||
darkBorderColor: string;
|
||||
dotColor: string;
|
||||
textColor: string;
|
||||
darkTextColor: string;
|
||||
}
|
||||
|
||||
// وضعیتهای بکاند → رنگ طرح tauri.
|
||||
const STATUS: Record<string, TurnStatusConfig> = {
|
||||
// pending = «ثبت شده» (tauri: registered)
|
||||
pending: {
|
||||
label: 'ثبت شده',
|
||||
bgColor: '#E3F2FD', darkBgColor: 'rgba(85, 89, 206, 0.18)',
|
||||
borderColor: '#2196F3', darkBorderColor: 'rgba(199, 206, 244, 0.42)',
|
||||
dotColor: '#2196F3', textColor: '#2196F3', darkTextColor: '#C7CEF4',
|
||||
},
|
||||
// confirmed = «قطعی شده» (tauri: finalized)
|
||||
confirmed: {
|
||||
label: 'قطعی شده',
|
||||
bgColor: '#E0F2F1', darkBgColor: 'rgba(25, 191, 211, 0.14)',
|
||||
borderColor: '#009688', darkBorderColor: 'rgba(25, 191, 211, 0.44)',
|
||||
dotColor: '#009688', textColor: '#009688', darkTextColor: '#19BFD3',
|
||||
},
|
||||
// following_up = «در حال پیگیری» (tauri: in_progress)
|
||||
following_up: {
|
||||
label: 'در حال پیگیری',
|
||||
bgColor: '#FFF3E0', darkBgColor: 'rgba(241, 119, 50, 0.16)',
|
||||
borderColor: '#FF9800', darkBorderColor: 'rgba(241, 119, 50, 0.46)',
|
||||
dotColor: '#FF9800', textColor: '#d87f00', darkTextColor: '#F17732',
|
||||
},
|
||||
// salon = «سالن»
|
||||
salon: {
|
||||
label: 'سالن',
|
||||
bgColor: '#F3E5F5', darkBgColor: 'rgba(199, 206, 244, 0.16)',
|
||||
borderColor: '#9C27B0', darkBorderColor: 'rgba(199, 206, 244, 0.42)',
|
||||
dotColor: '#9C27B0', textColor: '#9C27B0', darkTextColor: '#C7CEF4',
|
||||
},
|
||||
// completed = «ویزیت شده» (tauri: success)
|
||||
completed: {
|
||||
label: 'ویزیت شده',
|
||||
bgColor: '#E8F5E9', darkBgColor: 'rgba(34, 197, 94, 0.14)',
|
||||
borderColor: '#4CAF50', darkBorderColor: 'rgba(34, 197, 94, 0.42)',
|
||||
dotColor: '#4CAF50', textColor: '#0d9f43', darkTextColor: '#4ADE80',
|
||||
},
|
||||
// cancel variants = «لغو شده» (tauri: failed)
|
||||
cancelled_by_doctor: {
|
||||
label: 'لغو شده',
|
||||
bgColor: '#fde7e9', darkBgColor: 'rgba(255, 84, 80, 0.16)',
|
||||
borderColor: '#ee5c6d', darkBorderColor: 'rgba(255, 84, 80, 0.44)',
|
||||
dotColor: '#ee5c6d', textColor: '#ee5c6d', darkTextColor: '#FF7A76',
|
||||
},
|
||||
cancelled_by_user: {
|
||||
label: 'لغو توسط بیمار',
|
||||
bgColor: '#fde7e9', darkBgColor: 'rgba(255, 84, 80, 0.16)',
|
||||
borderColor: '#ee5c6d', darkBorderColor: 'rgba(255, 84, 80, 0.44)',
|
||||
dotColor: '#ee5c6d', textColor: '#ee5c6d', darkTextColor: '#FF7A76',
|
||||
},
|
||||
// no_show / expired = خنثی (tauri: pending amber-ish → اینجا خاکستری)
|
||||
no_show: {
|
||||
label: 'غیبت',
|
||||
bgColor: '#f4f4f5', darkBgColor: 'rgba(160, 160, 160, 0.14)',
|
||||
borderColor: '#9E9E9E', darkBorderColor: 'rgba(160, 160, 160, 0.4)',
|
||||
dotColor: '#9E9E9E', textColor: '#757575', darkTextColor: '#A1A1A1',
|
||||
},
|
||||
expired: {
|
||||
label: 'منقضی',
|
||||
bgColor: '#f4f4f5', darkBgColor: 'rgba(160, 160, 160, 0.14)',
|
||||
borderColor: '#9E9E9E', darkBorderColor: 'rgba(160, 160, 160, 0.4)',
|
||||
dotColor: '#9E9E9E', textColor: '#757575', darkTextColor: '#A1A1A1',
|
||||
},
|
||||
};
|
||||
|
||||
// اسلات خالی (نوبت جدید) — tauri: empty.
|
||||
export const EMPTY_SLOT_CONFIG: TurnStatusConfig = {
|
||||
label: 'نوبت جدید',
|
||||
bgColor: '#E3F2FD', darkBgColor: 'rgba(85, 89, 206, 0.16)',
|
||||
borderColor: '#2196F3', darkBorderColor: 'rgba(199, 206, 244, 0.38)',
|
||||
dotColor: '#2196F3', textColor: '#5559ce', darkTextColor: '#C7CEF4',
|
||||
};
|
||||
|
||||
export function turnStatusConfig(status: string): TurnStatusConfig {
|
||||
return STATUS[status] ?? EMPTY_SLOT_CONFIG;
|
||||
}
|
||||
|
||||
/** وضعیتهایی که نوبت را «لغوشده» میکنند (برای منطق تایملاین/آمار). */
|
||||
export const CANCELLED_STATUSES = new Set([
|
||||
'cancelled_by_doctor', 'cancelled_by_user', 'cancelled_by_admin',
|
||||
'auto_cancel_unpaid', 'no_show', 'expired',
|
||||
]);
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Appointment } from '../../types';
|
||||
|
||||
/** یک اسلات زمانی در نمای زمانبندی — همان مدلِ mergeشدهٔ اسلات + نوبت. */
|
||||
export interface TimelineSlot {
|
||||
start: number;
|
||||
end: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_available: boolean;
|
||||
appointment: Appointment | null;
|
||||
cancelled_appointment: Appointment | null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('../../hooks/useSubscription', () => ({
|
||||
useSubscription: () => ({ hasFeature: () => true }),
|
||||
}));
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
describe('Sidebar — expandable نوبتها menu', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
primaryRole: 'admin', dbUuid: 'c1', userName: 'ادمین',
|
||||
availableContexts: [], context: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('renders نوبتها as a collapsible parent and reveals sub-items on click', () => {
|
||||
renderWithProviders(<Sidebar />, { route: '/admin/dashboard' });
|
||||
|
||||
// منوی والد به شکل دکمه
|
||||
const parent = screen.getByRole('button', { name: /نوبتها/ });
|
||||
expect(parent).toBeInTheDocument();
|
||||
|
||||
// در ابتدا (مسیر داشبورد) بسته است → زیرمنوها نیستند
|
||||
expect(screen.queryByText('نوبت های تایید شده')).toBeNull();
|
||||
|
||||
fireEvent.click(parent);
|
||||
|
||||
// پس از باز شدن، دو زیرمنو دیده میشوند
|
||||
expect(screen.getByText('نوبت های تایید شده')).toBeInTheDocument();
|
||||
expect(screen.getByText('افزودن نوبت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-expands when a child route is active', () => {
|
||||
renderWithProviders(<Sidebar />, { route: '/admin/appointments/new' });
|
||||
// چون «افزودن نوبت» فعال است، منو باید خودکار باز باشد
|
||||
expect(screen.getByText('افزودن نوبت')).toBeInTheDocument();
|
||||
expect(screen.getByText('نوبت های تایید شده')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ChevronDownIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
Cog6ToothIcon,
|
||||
CreditCardIcon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
HeartIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
@@ -23,19 +25,29 @@ import {
|
||||
UserGroupIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useSubscription } from "../../hooks/useSubscription";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
type SubItem = { to: string; label: string; icon?: React.ElementType };
|
||||
type SectionItem = {
|
||||
to: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
feature?: string;
|
||||
/** وقتی مقدار داشته باشد، آیتم به یک منوی بازشونده تبدیل میشود. */
|
||||
children?: SubItem[];
|
||||
};
|
||||
type Section = { label: string; items: SectionItem[] };
|
||||
|
||||
/** زیرمنوهای مشترکِ «نوبتها» (نوبتهای تأییدشده + افزودن نوبت). */
|
||||
const APPOINTMENTS_CHILDREN: SubItem[] = [
|
||||
{ to: "/admin/appointments", label: "نوبت های تایید شده", icon: CalendarDaysIcon },
|
||||
{ to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon },
|
||||
];
|
||||
|
||||
function buildSections(
|
||||
primaryRole: string | null,
|
||||
dbUuid: string | null,
|
||||
@@ -49,7 +61,7 @@ function buildSections(
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتهای من" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتهای من", children: APPOINTMENTS_CHILDREN },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -86,6 +98,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
@@ -200,6 +213,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
@@ -274,6 +288,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای من",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/patients",
|
||||
@@ -333,6 +348,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
@@ -433,6 +449,125 @@ interface Props {
|
||||
onMobileClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* یک آیتم سایدبار: منوی ساده (NavLink) یا منوی بازشونده با زیرمنو.
|
||||
* منوی بازشونده وقتی یکی از زیرمنوهایش فعال است بهصورت خودکار باز میشود.
|
||||
*/
|
||||
function NavItem({
|
||||
item,
|
||||
sidebarOpen,
|
||||
isLocked,
|
||||
}: {
|
||||
item: SectionItem;
|
||||
sidebarOpen: boolean;
|
||||
isLocked: boolean;
|
||||
}) {
|
||||
const { icon: Icon, label, to, children } = item;
|
||||
const location = useLocation();
|
||||
const childActive = !!children?.some((c) => location.pathname === c.to);
|
||||
const [open, setOpen] = useState(childActive);
|
||||
|
||||
// منوی ساده — رفتار قبلی بدون تغییر.
|
||||
if (!children || children.length === 0) {
|
||||
const dest = isLocked ? "/admin/subscription" : to;
|
||||
return (
|
||||
<NavLink
|
||||
to={dest}
|
||||
title={
|
||||
isLocked
|
||||
? "نیاز به ارتقاء پنل"
|
||||
: !sidebarOpen
|
||||
? label
|
||||
: undefined
|
||||
}
|
||||
className={({ isActive }) =>
|
||||
`nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`
|
||||
}
|
||||
style={isLocked ? { opacity: 0.55 } : undefined}
|
||||
>
|
||||
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
||||
<span>{label}</span>
|
||||
{isLocked && (
|
||||
<LockClosedIcon
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
marginRight: "auto",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// منوی بازشونده.
|
||||
return (
|
||||
<div className="nav-parent">
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-item${childActive ? " active" : ""}`}
|
||||
aria-expanded={open}
|
||||
title={!sidebarOpen ? label : undefined}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
}}
|
||||
>
|
||||
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
||||
<span>{label}</span>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 15,
|
||||
height: 15,
|
||||
marginRight: "auto",
|
||||
flexShrink: 0,
|
||||
transition: "transform .2s var(--ease)",
|
||||
transform: open ? "rotate(180deg)" : "none",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ paddingInlineStart: 14 }}>
|
||||
{children.map((c) => {
|
||||
const CIcon = c.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={c.to + c.label}
|
||||
to={c.to}
|
||||
end
|
||||
title={!sidebarOpen ? c.label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`nav-item nav-subitem${isActive ? " active" : ""}`
|
||||
}
|
||||
>
|
||||
{CIcon ? (
|
||||
<CIcon
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ width: 16, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<span>{c.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
||||
const { logout, primaryRole, userName, availableContexts, dbUuid, context } =
|
||||
@@ -461,56 +596,18 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
{sections.map((section) => (
|
||||
<div className="nav-group" key={section.label}>
|
||||
<span className="nav-label">{section.label}</span>
|
||||
{section.items.map(
|
||||
({ to, icon: Icon, label, feature }) => {
|
||||
const isLocked = feature
|
||||
? !hasFeature(feature)
|
||||
: false;
|
||||
const dest = isLocked
|
||||
? "/admin/subscription"
|
||||
: to;
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={dest}
|
||||
title={
|
||||
isLocked
|
||||
? "نیاز به ارتقاء پنل"
|
||||
: !sidebarOpen
|
||||
? label
|
||||
: undefined
|
||||
}
|
||||
className={({ isActive }) =>
|
||||
`nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`
|
||||
}
|
||||
style={
|
||||
isLocked
|
||||
? { opacity: 0.55 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
style={{
|
||||
width: 19,
|
||||
height: 19,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{isLocked && (
|
||||
<LockClosedIcon
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
marginRight: "auto",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{section.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.to + item.label}
|
||||
item={item}
|
||||
sidebarOpen={sidebarOpen}
|
||||
isLocked={
|
||||
item.feature
|
||||
? !hasFeature(item.feature)
|
||||
: false
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user