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:
hamed
2026-07-15 15:33:03 +03:30
co-authored by Claude Opus 4.8
parent 9f75aeedf5
commit a6ae6220cb
17 changed files with 1242 additions and 576 deletions
@@ -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();
});
});
+149 -52
View File
@@ -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>
))}