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:
@@ -13,6 +13,7 @@ import DoctorFormPage from './pages/DoctorFormPage';
|
||||
import ClinicsPage from './pages/ClinicsPage';
|
||||
import ClinicDetailPage from './pages/ClinicDetailPage';
|
||||
import AppointmentsPage from './pages/AppointmentsPage';
|
||||
import AppointmentCreatePage from './pages/AppointmentCreatePage';
|
||||
import AppointmentDetailPage from './pages/AppointmentDetailPage';
|
||||
import AppointmentEditPage from './pages/AppointmentEditPage';
|
||||
import ReserveAppointmentsPage from './pages/ReserveAppointmentsPage';
|
||||
@@ -157,6 +158,7 @@ export default function App() {
|
||||
{/* نوبتها — همه نقشها بهجز نماینده */}
|
||||
<Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
|
||||
<Route path="appointments/reserve" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><ReserveAppointmentsPage /></RoleRoute>} />
|
||||
<Route path="appointments/new" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentCreatePage /></RoleRoute>} />
|
||||
<Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></RoleRoute>} />
|
||||
<Route path="appointments/:uuid/edit" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentEditPage /></RoleRoute>} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentCreatePage from './AppointmentCreatePage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
|
||||
get.mockResolvedValue({ success: true, data: [] });
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
|
||||
});
|
||||
|
||||
describe('AppointmentCreatePage — افزودن نوبت', () => {
|
||||
it('posts a new appointment with the entered patient (happy path)', async () => {
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } });
|
||||
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات'));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
const [url, body] = post.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/my/appointment');
|
||||
expect(body).toMatchObject({ doctor_uuid: 'doc1', patient_name: 'علی محمدی', patient_mobile: '09121234567' });
|
||||
});
|
||||
|
||||
it('keeps the submit button disabled until a valid patient is entered (boundary)', () => {
|
||||
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
|
||||
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
|
||||
expect(btn).toBeDisabled();
|
||||
|
||||
// شماره ناقص → همچنان غیرفعال
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'ب' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '0912' } });
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { PlusIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import PriceInput from '../components/ui/PriceInput';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
||||
|
||||
/**
|
||||
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
|
||||
* موجود استفاده میکند: `POST /api/v1/my/appointment` (یا admin) + در صورت نیاز
|
||||
* `PATCH .../status`. هیچ endpoint جدیدی ساخته نشده است.
|
||||
*/
|
||||
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string }
|
||||
|
||||
const toEpoch = (isoDate: string, time: string) =>
|
||||
Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000);
|
||||
const addMinutes = (time: string, min: number) => {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const t = h * 60 + m + min;
|
||||
return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
||||
const sel: React.CSSProperties = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' };
|
||||
const sectionTitle: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text)', margin: '18px 0 12px' };
|
||||
|
||||
export default function AppointmentCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const isDoctor = primaryRole === 'doctor';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ── پزشک
|
||||
const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? ''));
|
||||
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||||
queryKey: ['clinic-doctors', dbUuid],
|
||||
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
||||
enabled: !isDoctor && !!dbUuid,
|
||||
});
|
||||
const doctorOptions = (clinicDoctorsQuery.data?.data?.data ?? []).map(d => ({ value: d.uuid, label: d.name }));
|
||||
|
||||
// ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
const [picked, setPicked] = useState<PatientRow | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [mobile, setMobile] = useState('');
|
||||
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
|
||||
queryKey: ['create-patients', patientSearch],
|
||||
queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`),
|
||||
enabled: patientSearch.trim().length >= 2,
|
||||
});
|
||||
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
|
||||
|
||||
// ── مشخصات سرویس
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [itemUuid, setItemUuid] = useState('');
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
||||
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
||||
queryKey: ['service-items', sectionUuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||||
enabled: !!sectionUuid,
|
||||
});
|
||||
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
||||
|
||||
// ── زمان نوبت
|
||||
const [date, setDate] = useState(params.get('date') || today);
|
||||
const [duration, setDuration] = useState(40);
|
||||
const [start, setStart] = useState('15:00');
|
||||
const [end, setEnd] = useState(addMinutes('15:00', 40));
|
||||
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
|
||||
|
||||
// ── بیعانه / وضعیت / توضیحات
|
||||
const [depositRequired, setDepositRequired] = useState(false);
|
||||
const [depositRials, setDepositRials] = useState(0);
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const effectiveName = picked?.user_name || name.trim();
|
||||
const effectiveMobile = picked?.user_mobile || mobile.trim();
|
||||
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && !!start && !!end;
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
||||
const payload: Record<string, unknown> = {
|
||||
doctor_uuid: doctorUuid,
|
||||
slot_start: toEpoch(date, start),
|
||||
slot_end: toEpoch(date, end),
|
||||
patient_name: effectiveName,
|
||||
patient_mobile: effectiveMobile,
|
||||
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
|
||||
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
|
||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||||
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
};
|
||||
const res: any = await api.post(createEndpoint, payload);
|
||||
if (status !== 'pending' && res?.data?.uuid) {
|
||||
await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['appointments'] });
|
||||
toast.success('نوبت با موفقیت ثبت شد');
|
||||
navigate('/admin/appointments');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 24px', maxWidth: 720, margin: '0 auto' }}>
|
||||
{/* بردکرامب */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18 }}>
|
||||
<button onClick={() => navigate(-1)} className="btn sm" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<ChevronRightIcon style={{ width: 15, height: 15 }} /> بازگشت
|
||||
</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>نوبت ها</span>
|
||||
<span style={{ color: 'var(--text-3)' }}>›</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: 20 }}>
|
||||
{/* پزشک — فقط برای admin/clinic */}
|
||||
{!isDoctor && (
|
||||
<>
|
||||
<div style={{ ...sectionTitle, marginTop: 0 }}>پزشک:</div>
|
||||
<label style={label}>انتخاب پزشک</label>
|
||||
<div style={{ margin: '6px 0 4px' }}>
|
||||
<SearchableSelect
|
||||
options={doctorOptions}
|
||||
value={doctorUuid || null}
|
||||
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب پزشک..."
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* مراجعه کننده */}
|
||||
<div style={{ ...sectionTitle, marginTop: isDoctor ? 0 : 18 }}>اطلاعات مراجعه کننده:</div>
|
||||
<label style={label}>انتخاب مراجعه کننده</label>
|
||||
<div className="field" style={{ margin: '6px 0 8px' }}>
|
||||
<input value={picked ? `${picked.user_name ?? ''} — ${picked.user_mobile ?? ''}` : patientSearch}
|
||||
onChange={e => { setPicked(null); setPatientSearch(e.target.value); }}
|
||||
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
|
||||
</div>
|
||||
{!picked && patients.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden' }}>
|
||||
{patients.map(p => (
|
||||
<button key={p.uuid} onClick={() => setPicked(p)} style={{
|
||||
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{picked === null && (
|
||||
<>
|
||||
<div style={{ margin: '4px 0 10px' }}>
|
||||
<span style={{ color: 'var(--primary)', border: '1px solid var(--primary)', borderRadius: 'var(--r-sm)', padding: '5px 10px', fontSize: 12, display: 'inline-flex', gap: 5, alignItems: 'center' }}>
|
||||
<PlusIcon style={{ width: 13 }} /> مراجعه کننده جدید
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<label style={label}>نام و نام خانوادگی</label>
|
||||
<div className="field" style={{ marginTop: 6 }}>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>شماره تماس</label>
|
||||
<div className="field" style={{ marginTop: 6 }}>
|
||||
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* مشخصات سرویس */}
|
||||
<div style={sectionTitle}>مشخصات سرویس:</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label style={label}>انتخاب پرسنل</label>
|
||||
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 4px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب...</option>
|
||||
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* زمان نوبت */}
|
||||
<div style={sectionTitle}>زمان نوبت:</div>
|
||||
<label style={label}>انتخاب تاریخ</label>
|
||||
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||||
<div className="field" style={{ marginTop: 6 }}>
|
||||
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت شروع</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>ساعت پایان</label>
|
||||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بیعانه */}
|
||||
<div style={sectionTitle}>بیعانه:</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
|
||||
بیعانه مورد نیاز است.
|
||||
</label>
|
||||
{depositRequired && <WalletChargeLink mobile={effectiveMobile} />}
|
||||
</div>
|
||||
{depositRequired && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||||
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={label}>انتخاب وضعیت</label>
|
||||
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<option value="pending">ثبت شده</option>
|
||||
<option value="confirmed">قطعی شده</option>
|
||||
</select>
|
||||
|
||||
<label style={label}>توضیحات</label>
|
||||
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
|
||||
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
|
||||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||
{create.isPending ? '...' : 'ثبت اطلاعات'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentsPage from './AppointmentsPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 7, completed: 3, waiting: 2, cancelled: 1 } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [] } });
|
||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppointmentsPage — طرح نوبتها', () => {
|
||||
it('renders the title, the stats bar and the افزودن نوبت action', async () => {
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
expect(screen.getByText('نوبت ها')).toBeInTheDocument();
|
||||
expect(screen.getByText('کل نوبت های امروز')).toBeInTheDocument();
|
||||
expect(await screen.findByText('۷')).toBeInTheDocument(); // total from stats
|
||||
expect(screen.getByText('افزودن نوبت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('timeline view shows the holiday message when the doctor has no slots', async () => {
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
// نمای پیشفرض زمانبندی است و پزشک (نقش doctor) از قبل انتخاب شده
|
||||
expect(await screen.findByText('این روز تعطیل است')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon,
|
||||
TableCellsIcon, ClockIcon, UserCircleIcon, PhoneIcon,
|
||||
AdjustmentsHorizontalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
@@ -10,131 +11,25 @@ import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
||||
import type { Appointment } from '../types';
|
||||
import { formatDate, toGregorianDate } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import AppointmentActionsMenu from '../components/AppointmentActions';
|
||||
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
|
||||
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
||||
import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline';
|
||||
import PersianCalendar from '../components/ui/PersianCalendar';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
// اجزای طرح نوبتهای tauri
|
||||
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
||||
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
||||
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
|
||||
import DoctorTabs from '../components/appointments/DoctorTabs';
|
||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||
import TurnsTable from '../components/appointments/TurnsTable';
|
||||
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
|
||||
import type { TimelineSlot } from '../components/appointments/types';
|
||||
|
||||
const EMPTY_ARR: Appointment[] = [];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status config
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Labels follow the Figma نوبتها design; keep in sync with AppointmentStatusDropdown.
|
||||
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
|
||||
pending: { label: 'ثبت شده', color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
confirmed: { label: 'قطعی شده', color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
following_up: { label: 'در حال پیگیری', color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
salon: { label: 'سالن', color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||||
completed: { label: 'ویزیت شده', color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
cancelled_by_doctor: { label: 'لغو شده', color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
||||
cancelled_by_user: { label: 'لغو توسط بیمار', color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
||||
no_show: { label: 'غیبت', color: 'var(--text-3)', bg: 'var(--surface-2)' },
|
||||
expired: { label: 'منقضی', color: 'var(--text-3)', bg: 'var(--surface-2)' },
|
||||
};
|
||||
|
||||
function statusMeta(s: string) {
|
||||
return STATUS_META[s] ?? { label: s, color: 'var(--text-3)', bg: 'var(--surface-2)' };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Stats icons (inline SVG)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function IconTotal() {
|
||||
const dots: [number, number][] = [];
|
||||
for (const x of [10, 16, 22, 28, 34]) for (const y of [10, 16, 22, 28, 34]) dots.push([x, y]);
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<circle cx="22" cy="22" r="22" fill="#ede9fe" />
|
||||
{dots.map(([x, y]) => <circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" fill="#7c3aed" opacity="0.6" />)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCompleted() {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<circle cx="22" cy="22" r="22" fill="#dcfce7" />
|
||||
<ellipse cx="22" cy="18" rx="6" ry="7" fill="#16a34a" opacity="0.8" />
|
||||
<ellipse cx="22" cy="32" rx="10" ry="6" fill="#16a34a" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconWaiting() {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<circle cx="22" cy="22" r="22" fill="#fff7ed" />
|
||||
<path d="M22 14v8l5 3" stroke="#ea580c" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx="22" cy="22" r="9" stroke="#ea580c" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconCancelled() {
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<circle cx="22" cy="22" r="22" fill="#fef2f2" />
|
||||
<circle cx="22" cy="22" r="9" stroke="#ef4444" strokeWidth="2" />
|
||||
<path d="M17 17l10 10M27 17l-10 10" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Stats Bar
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; }
|
||||
|
||||
function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) {
|
||||
const { data } = useQuery<ApiResponse<TodayStats>>({
|
||||
queryKey: ['appt-today-stats', date, isAdmin],
|
||||
queryFn: () => api.get(
|
||||
isAdmin
|
||||
? `/api/v1/admin/appointments/today-stats?date=${date}`
|
||||
: `/api/v1/my/appointments/today-stats?date=${date}`
|
||||
),
|
||||
});
|
||||
const s = data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 };
|
||||
const stats = [
|
||||
{ label: 'کل نوبتهای امروز', value: s.total, icon: <IconTotal /> },
|
||||
{ label: 'نوبتهای انجام شده', value: s.completed, icon: <IconCompleted /> },
|
||||
{ label: 'مراجعین در انتظار', value: s.waiting, icon: <IconWaiting /> },
|
||||
{ label: 'نوبتهای لغو شده', value: s.cancelled, icon: <IconCancelled /> },
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', overflow: 'hidden', marginBottom: 16,
|
||||
}}>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} style={{
|
||||
flex: 1, padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 14,
|
||||
borderRight: i < 3 ? '1px solid var(--border)' : 'none',
|
||||
}}>
|
||||
<div style={{ flexShrink: 0 }}>{s.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 4 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 800, color: 'var(--text)' }}>
|
||||
{s.value.toLocaleString('fa-IR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Date Navigator
|
||||
// Date Navigator (طرح tauri — ناوبری روزانه)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const WEEK_DAYS_FA = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
||||
@@ -143,11 +38,18 @@ function getPersianWeekDay(gregorianDate: string): string {
|
||||
return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()];
|
||||
}
|
||||
|
||||
const navBtnSx: React.CSSProperties = {
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)', height: 36, width: 36,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--text-2)',
|
||||
};
|
||||
|
||||
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
|
||||
const [showCal, setShowCal] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const weekDay = getPersianWeekDay(date);
|
||||
const weekDay = getPersianWeekDay(date);
|
||||
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
||||
|
||||
function addDays(n: number) {
|
||||
@@ -187,261 +89,8 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
||||
);
|
||||
}
|
||||
|
||||
const navBtnSx: React.CSSProperties = {
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)', height: 36, width: 36,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--text-2)',
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Table View
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableView({
|
||||
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' }}>
|
||||
<div className="table-wrap"><table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' };
|
||||
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Schedule View — Slot Card
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SlotItem {
|
||||
start: number;
|
||||
end: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_available: boolean;
|
||||
appointment: Appointment | null;
|
||||
cancelled_appointment: Appointment | null;
|
||||
}
|
||||
|
||||
interface SessionGroup {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
slots: SlotItem[];
|
||||
}
|
||||
|
||||
function CancelledBadge({ appt }: { appt: Appointment }) {
|
||||
const sm = statusMeta(appt.status);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
||||
borderRadius: 'var(--r-sm)', background: '#f9fafb',
|
||||
border: '1px dashed var(--border-2)', fontSize: 12, color: 'var(--text-3)',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
<span style={{ fontSize: 14, lineHeight: 1 }}>⊘</span>
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 99, fontSize: 11, fontWeight: 700,
|
||||
background: `${sm.color}15`, color: sm.color, whiteSpace: 'nowrap',
|
||||
}}>{sm.label}</span>
|
||||
<span style={{ direction: 'ltr', color: 'var(--text)', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||
{appt.appointment_time} — {appt.end_time}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{appt.patient_name || '—'}</span>
|
||||
<span style={{ direction: 'ltr', color: 'var(--text-3)' }}>{appt.patient_mobile}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) {
|
||||
const isPast = slot.start < Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!slot.is_available && slot.appointment) {
|
||||
const a = slot.appointment;
|
||||
const sm = statusMeta(a.status);
|
||||
return (
|
||||
<div style={{
|
||||
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
|
||||
background: sm.bg, overflow: 'hidden',
|
||||
opacity: isPast ? 0.6 : 1,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${sm.color}20`,
|
||||
}}>
|
||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
|
||||
{slot.start_time} — {slot.end_time}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontWeight: 600 }}>{a.patient_name || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
||||
<PhoneIcon style={{ width: 14, height: 14 }} />
|
||||
<span style={{ direction: 'ltr' }}>{a.patient_mobile}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Available slot — possibly with cancelled history
|
||||
if (isPast) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{slot.cancelled_appointment && <CancelledBadge appt={slot.cancelled_appointment} />}
|
||||
<div style={{
|
||||
border: '1.5px dashed var(--border-2)', borderRadius: 'var(--r)', padding: '10px 16px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'not-allowed', color: 'var(--text-3)', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--surface-2)', gap: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 15 }}>⊘</span> گذشته
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{slot.cancelled_appointment && <CancelledBadge appt={slot.cancelled_appointment} />}
|
||||
<div
|
||||
onClick={() => onBook(slot)}
|
||||
style={{
|
||||
border: '1.5px dashed color-mix(in oklch, var(--info) 50%, transparent)', borderRadius: 'var(--r)', padding: '10px 16px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--info)', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--info-bg)', transition: 'all 0.15s', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'color-mix(in oklch, var(--info) 20%, transparent)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'var(--info-bg)')}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>⊕</span> نوبت جدید
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDivider({ startTime, endTime, count }: { startTime: string; endTime: string; count: number }) {
|
||||
const hour = parseInt(startTime.split(':')[0], 10);
|
||||
const isAm = hour < 12;
|
||||
const icon = isAm ? '🌅' : '🌆';
|
||||
const label = isAm ? 'صبح' : 'عصر';
|
||||
const color = isAm ? '#f59e0b' : '#6366f1';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 2px' }}>
|
||||
<span style={{ fontSize: 15 }}>{icon}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, color }}>{label}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 500 }}>
|
||||
{startTime} — {endTime}
|
||||
</span>
|
||||
<span style={{
|
||||
background: `${color}18`, borderRadius: 99, padding: '1px 8px',
|
||||
fontSize: 11, color, fontWeight: 600,
|
||||
}}>{count} نوبت</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleView({
|
||||
sessions, loading, queryKey, onBook,
|
||||
}: {
|
||||
sessions: SessionGroup[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
|
||||
}) {
|
||||
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
if (!sessions.length) return (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
||||
<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: 8, padding: '4px 0' }}>
|
||||
{sessions.map((session, si) => (
|
||||
<div key={si} style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: si > 0 ? 16 : 0 }}>
|
||||
<SessionDivider startTime={session.start_time} endTime={session.end_time} count={session.slots.length} />
|
||||
{session.slots.map((slot, i) => (
|
||||
<div key={`${slot.start}-${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
|
||||
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
|
||||
{slot.start_time}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// New Appointment Modal
|
||||
// Quick booking modal (کلیک روی اسلات خالیِ زمانبندی)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; }
|
||||
@@ -449,7 +98,7 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
|
||||
function NewAppointmentModal({
|
||||
slot, onClose, onSuccess,
|
||||
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [patientName, setPatientName] = useState('');
|
||||
|
||||
const role = useAuthStore(s => s.primaryRole);
|
||||
@@ -459,11 +108,11 @@ function NewAppointmentModal({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.post(createEndpoint, {
|
||||
doctor_uuid: slot.doctor_uuid,
|
||||
slot_start: slot.start,
|
||||
slot_end: slot.end,
|
||||
doctor_uuid: slot.doctor_uuid,
|
||||
slot_start: slot.start,
|
||||
slot_end: slot.end,
|
||||
patient_mobile: mobile,
|
||||
patient_name: patientName.trim(),
|
||||
patient_name: patientName.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوبت با موفقیت ثبت شد');
|
||||
@@ -492,7 +141,7 @@ function NewAppointmentModal({
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--surface)', borderRadius: 'var(--r)', padding: 24,
|
||||
minWidth: 320, maxWidth: 400, width: '90vw', boxShadow: 'var(--shadow-xl)',
|
||||
minWidth: 320, maxWidth: 400, width: '90vw', boxShadow: 'var(--shadow-lg)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
|
||||
@@ -538,10 +187,13 @@ function NewAppointmentModal({
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main Page
|
||||
// Main Page — «نوبت ها» (طرح tauri)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; }
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
@@ -551,15 +203,24 @@ export default function AppointmentsPage() {
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('schedule');
|
||||
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||
const [bookingHint, setBookingHint] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Today stats
|
||||
const statsQuery = useQuery<ApiResponse<TodayStats>>({
|
||||
queryKey: ['appt-today-stats', selectedDate, isAdmin],
|
||||
queryFn: () => api.get(
|
||||
isAdmin
|
||||
? `/api/v1/admin/appointments/today-stats?date=${selectedDate}`
|
||||
: `/api/v1/my/appointments/today-stats?date=${selectedDate}`
|
||||
),
|
||||
});
|
||||
const stats = statsQuery.data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 };
|
||||
|
||||
// ── Appointments query
|
||||
const apptEndpoint = isAdmin
|
||||
? '/api/v1/admin/appointments'
|
||||
@@ -578,14 +239,13 @@ export default function AppointmentsPage() {
|
||||
const filteredAppointments = applyAppointmentFilters(appointments, filters);
|
||||
const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS);
|
||||
|
||||
// Table pagination is client-side: the day's full list stays loaded because
|
||||
// the schedule view and doctor-tab derivation need every row.
|
||||
// Table pagination — client-side (full day stays loaded for timeline + doctor tabs).
|
||||
const TABLE_PAGE_SIZE = 20;
|
||||
const [tablePage, setTablePage] = useState(1);
|
||||
useEffect(() => { setTablePage(1); }, [selectedDate, selectedDoctorUuid, filters]);
|
||||
const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE);
|
||||
|
||||
// ── Clinic: load doctors from clinic profile (not derived from appointments)
|
||||
// ── Clinic doctors (authoritative list for tabs)
|
||||
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||||
queryKey: ['clinic-doctors', dbUuid],
|
||||
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
||||
@@ -593,12 +253,9 @@ export default function AppointmentsPage() {
|
||||
});
|
||||
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
||||
|
||||
// ── Unique doctors from results (for clinic tabs) + merge with clinic list
|
||||
const doctors = React.useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
// first from clinic API (authoritative list)
|
||||
clinicDoctorsList.forEach(d => map.set(d.uuid, d.name));
|
||||
// then supplement with appointment data (for admin view)
|
||||
appointments.forEach(a => {
|
||||
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
|
||||
map.set(a.doctor_uuid, a.doctor_name);
|
||||
@@ -607,26 +264,24 @@ export default function AppointmentsPage() {
|
||||
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
||||
}, [appointments, clinicDoctorsList]);
|
||||
|
||||
const showDoctorTabs = isClinic && doctors.length >= 2;
|
||||
const showDoctorTabs = (isClinic || isAdmin) && doctors.length >= 2;
|
||||
const showDoctorCol = isAdmin || (isClinic && !selectedDoctorUuid);
|
||||
|
||||
// ── Slots query (schedule view)
|
||||
// ── Slots query (timeline)
|
||||
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
|
||||
const slotsQuery = useQuery<ApiResponse<any>>({
|
||||
queryKey: slotsQueryKey,
|
||||
queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`),
|
||||
enabled: viewMode === 'schedule' && !!selectedDoctorUuid,
|
||||
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
|
||||
});
|
||||
|
||||
// ── Merge sessions + appointments for schedule view
|
||||
const mergedSessions: SessionGroup[] = React.useMemo(() => {
|
||||
if (viewMode !== 'schedule') return [];
|
||||
// ── Merge sessions + appointments → flat timeline slots
|
||||
const timelineSlots: TimelineSlot[] = React.useMemo(() => {
|
||||
if (viewMode !== 'timeline') return [];
|
||||
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||
|
||||
const CANCELLED_STATUSES = new Set(['cancelled_by_doctor', 'cancelled_by_user', 'cancelled_by_admin', 'auto_cancel_unpaid', 'no_show', 'expired']);
|
||||
const activeByStart = new Map<number, Appointment>();
|
||||
const activeByStart = new Map<number, Appointment>();
|
||||
const cancelledByStart = new Map<number, Appointment>();
|
||||
|
||||
appointments.forEach(a => {
|
||||
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
||||
if (CANCELLED_STATUSES.has(a.status)) {
|
||||
@@ -637,31 +292,29 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
});
|
||||
|
||||
return rawSessions.map((session: any) => ({
|
||||
start_time: session.start_time as string,
|
||||
end_time: session.end_time as string,
|
||||
slots: (session.slots as any[]).map((s: any) => {
|
||||
const out: TimelineSlot[] = [];
|
||||
rawSessions.forEach((session: any) => {
|
||||
(session.slots as any[]).forEach((s: any) => {
|
||||
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
||||
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
||||
return {
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
||||
out.push({
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
is_available: s.is_available as boolean,
|
||||
appointment: activeByStart.get(slotStart) ?? null,
|
||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
is_available: s.is_available as boolean,
|
||||
appointment: activeByStart.get(slotStart) ?? null,
|
||||
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}, [viewMode, slotsQuery.data, appointments]);
|
||||
|
||||
// ── Handle slot click → open booking modal
|
||||
function handleSlotClick(slot: SlotItem) {
|
||||
// نماینده اجازهی ثبت نوبت ندارد؛ صفحه برای او فقط مشاهده است.
|
||||
// ── Slot click → quick booking modal
|
||||
function handleSlotClick(slot: TimelineSlot) {
|
||||
if (isRepresentation) return;
|
||||
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
||||
setBookingHint(false);
|
||||
setBookingSlot({
|
||||
start: slot.start,
|
||||
end: slot.end,
|
||||
@@ -672,61 +325,50 @@ export default function AppointmentsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function openDetail(a: Appointment) {
|
||||
navigate(`/admin/appointments/${a.uuid}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
{/* Stats Bar */}
|
||||
<StatsBar date={selectedDate} isAdmin={isAdmin} />
|
||||
{/* عنوان */}
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', marginBottom: 16 }}>نوبت ها</h1>
|
||||
|
||||
{/* Doctor tabs (clinic with ≥2 doctors) */}
|
||||
{/* نوار آمار */}
|
||||
<TurnsStatInfo stats={stats} />
|
||||
|
||||
{/* تب دکترها */}
|
||||
{showDoctorTabs && (
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => setSelectedDoctorUuid('')}
|
||||
style={tabStyle(selectedDoctorUuid === '')}
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
{doctors.map(d => (
|
||||
<button
|
||||
key={d.uuid}
|
||||
onClick={() => setSelectedDoctorUuid(d.uuid)}
|
||||
style={tabStyle(selectedDoctorUuid === d.uuid)}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} />
|
||||
)}
|
||||
|
||||
{/* Main card */}
|
||||
{/* کارت اصلی */}
|
||||
<div style={{
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', overflow: 'hidden',
|
||||
}}>
|
||||
{/* Toolbar */}
|
||||
{/* نوار ابزار */}
|
||||
<div style={{
|
||||
padding: '12px 16px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* New appointment button — نماینده اجازهی ثبت ندارد */}
|
||||
{/* افزودن نوبت → صفحهٔ کامل */}
|
||||
{!isRepresentation && (
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => {
|
||||
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
||||
setDrawerOpen(true);
|
||||
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
|
||||
navigate(`/admin/appointments/new${q}`);
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||
>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
نوبت جدید
|
||||
افزودن نوبت
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Personnel filter (Figma «پرسنل را انتخاب کنید...») */}
|
||||
<StaffFilterSelect value={filters.staffUuid} onChange={(v) => setFilters(f => ({ ...f, staffUuid: v }))} />
|
||||
|
||||
{/* Filters (Figma فیلترها) */}
|
||||
<button
|
||||
aria-label="فیلترها"
|
||||
className="btn sm"
|
||||
@@ -740,37 +382,9 @@ export default function AppointmentsPage() {
|
||||
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
|
||||
{/* View toggle */}
|
||||
<div style={{
|
||||
display: 'flex', background: 'var(--surface-2)', borderRadius: 'var(--r-sm)',
|
||||
padding: 2, gap: 1,
|
||||
}}>
|
||||
{(['table', 'schedule'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => { setViewMode(mode); if (mode === 'table') setBookingHint(false); }}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 600,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: viewMode === mode ? 'var(--surface)' : 'transparent',
|
||||
color: viewMode === mode ? 'var(--primary)' : 'var(--text-3)',
|
||||
boxShadow: viewMode === mode ? 'var(--shadow-sm)' : 'none',
|
||||
}}
|
||||
>
|
||||
{mode === 'table' ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<TableCellsIcon style={{ width: 14, height: 14 }} />نمایش جدولی
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<ClockIcon style={{ width: 14, height: 14 }} />زمانبندی
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||
|
||||
{/* Doctor selector (admin / clinic) */}
|
||||
{/* انتخاب پزشک (admin / clinic) */}
|
||||
{!isDoctor && (
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<SearchableSelect
|
||||
@@ -786,15 +400,14 @@ export default function AppointmentsPage() {
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Date navigator */}
|
||||
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{/* محتوا */}
|
||||
<div style={{ padding: 16 }}>
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
<TableView
|
||||
<TurnsTable
|
||||
items={pagedAppointments}
|
||||
loading={apptQuery.isLoading}
|
||||
queryKey={apptQueryKey}
|
||||
@@ -808,35 +421,25 @@ export default function AppointmentsPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{bookingHint && !isRepresentation && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
||||
padding: '10px 16px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--info-bg)', border: '1px solid color-mix(in oklch, var(--info) 40%, transparent)',
|
||||
fontSize: 13, color: 'var(--info)',
|
||||
}}>
|
||||
<span style={{ fontSize: 18 }}>👆</span>
|
||||
<span>روی یک زمان خالی <strong>کلیک کنید</strong> تا نوبت جدید ثبت شود</span>
|
||||
<button
|
||||
onClick={() => setBookingHint(false)}
|
||||
style={{ marginRight: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--info)', fontSize: 16 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{!selectedDoctorUuid ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
|
||||
</div>
|
||||
) : (
|
||||
<TurnsTimeline
|
||||
slots={timelineSlots}
|
||||
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
||||
queryKey={apptQueryKey}
|
||||
onView={openDetail}
|
||||
onBook={handleSlotClick}
|
||||
/>
|
||||
)}
|
||||
<ScheduleView
|
||||
sessions={mergedSessions}
|
||||
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
||||
queryKey={apptQueryKey}
|
||||
onBook={handleSlotClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New appointment modal */}
|
||||
{/* مودال ثبت سریع نوبت */}
|
||||
{bookingSlot && (
|
||||
<NewAppointmentModal
|
||||
slot={bookingSlot}
|
||||
@@ -849,44 +452,24 @@ export default function AppointmentsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters modal */}
|
||||
{/* مودال فیلترها */}
|
||||
{filtersOpen && (
|
||||
<AppointmentFiltersModal value={filters} onApply={setFilters} onClose={() => setFiltersOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Rich create drawer (اضافه کردن نوبت جدید) */}
|
||||
{drawerOpen && (
|
||||
<NewAppointmentDrawer
|
||||
doctorUuid={selectedDoctorUuid}
|
||||
defaultDate={selectedDate}
|
||||
queryKey={apptQueryKey}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toolbar staff filter — filters the loaded day's table rows by پرسنل. */
|
||||
/** فیلتر پرسنل نوار ابزار — ردیفهای بارگذاریشدهٔ روز را بر اساس پرسنل فیلتر میکند. */
|
||||
function StaffFilterSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
|
||||
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
|
||||
});
|
||||
return (
|
||||
<select aria-label="پرسنل" value={value} onChange={e => onChange(e.target.value)}
|
||||
style={{ height: 32, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 12.5, fontFamily: 'inherit', padding: '0 10px', minWidth: 170 }}>
|
||||
style={{ height: 36, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 12.5, fontFamily: 'inherit', padding: '0 10px', minWidth: 170 }}>
|
||||
<option value="">پرسنل را انتخاب کنید...</option>
|
||||
{(staffQ.data?.data ?? []).map(s => <option key={s.uuid} value={s.uuid}>{s.full_name}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function tabStyle(active: boolean): React.CSSProperties {
|
||||
return {
|
||||
padding: '6px 14px', borderRadius: 'var(--r-sm)', fontSize: 13, fontWeight: 600,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? '#eff6ff' : 'var(--surface)',
|
||||
color: active ? 'var(--primary)' : 'var(--text-2)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -382,6 +382,8 @@ body {
|
||||
font-size: 11px; font-weight: 700; min-width: 20px; height: 20px; padding: 0 6px;
|
||||
border-radius: 99px; display: grid; place-items: center;
|
||||
}
|
||||
.nav-subitem { font-size: 13px; padding: 8px 12px; gap: 10px; color: var(--text-3); }
|
||||
.nav-subitem svg { color: var(--text-3); }
|
||||
.app[data-collapsed="true"] .nav-item { justify-content: center; padding: 10px; }
|
||||
.app[data-collapsed="true"] .nav-item > span,
|
||||
.app[data-collapsed="true"] .nav-item .badge-count { display: none; }
|
||||
|
||||
Reference in New Issue
Block a user