From a6ae6220cbbdb14538d3ea2e5c9b750e8d17d39f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 15:33:03 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20redesign=20appointments=20(=D9=86=D9=88?= =?UTF-8?q?=D8=A8=D8=AA=E2=80=8C=D9=87=D8=A7)=20admin=20UI=20to=20match=20?= =?UTF-8?q?tauri=20turns=20+=20expandable=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/admin/App.tsx | 2 + .../components/appointments/DoctorTabs.tsx | 50 ++ .../appointments/TurnsStatInfo.test.tsx | 21 + .../components/appointments/TurnsStatInfo.tsx | 61 ++ .../components/appointments/TurnsTable.tsx | 74 ++ .../appointments/TurnsTimeline.test.tsx | 52 ++ .../components/appointments/TurnsTimeline.tsx | 151 +++++ .../appointments/TurnsViewToggle.tsx | 51 ++ .../components/appointments/turnStatus.ts | 98 +++ assets/admin/components/appointments/types.ts | 12 + .../admin/components/layout/Sidebar.test.tsx | 43 ++ assets/admin/components/layout/Sidebar.tsx | 201 ++++-- .../pages/AppointmentCreatePage.test.tsx | 50 ++ assets/admin/pages/AppointmentCreatePage.tsx | 278 ++++++++ assets/admin/pages/AppointmentsPage.test.tsx | 41 ++ assets/admin/pages/AppointmentsPage.tsx | 631 +++--------------- assets/admin/styles.css | 2 + 17 files changed, 1242 insertions(+), 576 deletions(-) create mode 100644 assets/admin/components/appointments/DoctorTabs.tsx create mode 100644 assets/admin/components/appointments/TurnsStatInfo.test.tsx create mode 100644 assets/admin/components/appointments/TurnsStatInfo.tsx create mode 100644 assets/admin/components/appointments/TurnsTable.tsx create mode 100644 assets/admin/components/appointments/TurnsTimeline.test.tsx create mode 100644 assets/admin/components/appointments/TurnsTimeline.tsx create mode 100644 assets/admin/components/appointments/TurnsViewToggle.tsx create mode 100644 assets/admin/components/appointments/turnStatus.ts create mode 100644 assets/admin/components/appointments/types.ts create mode 100644 assets/admin/components/layout/Sidebar.test.tsx create mode 100644 assets/admin/pages/AppointmentCreatePage.test.tsx create mode 100644 assets/admin/pages/AppointmentCreatePage.tsx create mode 100644 assets/admin/pages/AppointmentsPage.test.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 4de6d69b..fcf256bb 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { {/* نوبت‌ها — همه نقش‌ها به‌جز نماینده */} } /> } /> + } /> } /> } /> diff --git a/assets/admin/components/appointments/DoctorTabs.tsx b/assets/admin/components/appointments/DoctorTabs.tsx new file mode 100644 index 00000000..4e0e2313 --- /dev/null +++ b/assets/admin/components/appointments/DoctorTabs.tsx @@ -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 ( +
+ {tabs.map((d) => { + const active = selected === d.uuid; + return ( + + ); + })} +
+ ); +} diff --git a/assets/admin/components/appointments/TurnsStatInfo.test.tsx b/assets/admin/components/appointments/TurnsStatInfo.test.tsx new file mode 100644 index 00000000..5717b96c --- /dev/null +++ b/assets/admin/components/appointments/TurnsStatInfo.test.tsx @@ -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(); + 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(); + // چهار مقدار صفر فارسی + expect(screen.getAllByText('۰')).toHaveLength(4); + }); +}); diff --git a/assets/admin/components/appointments/TurnsStatInfo.tsx b/assets/admin/components/appointments/TurnsStatInfo.tsx new file mode 100644 index 00000000..d7045a6f --- /dev/null +++ b/assets/admin/components/appointments/TurnsStatInfo.tsx @@ -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 ( +
+ +
+ ); +} + +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 ( +
+ {items.map((it, i) => ( +
+ {i > 0 &&
} + +
+ {it.title} + + {it.value.toLocaleString('fa-IR')} + +
+
+ ))} +
+ ); +} diff --git a/assets/admin/components/appointments/TurnsTable.tsx b/assets/admin/components/appointments/TurnsTable.tsx new file mode 100644 index 00000000..414029a3 --- /dev/null +++ b/assets/admin/components/appointments/TurnsTable.tsx @@ -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
در حال بارگذاری...
; + if (!items.length) return
نوبتی برای این روز ثبت نشده است
; + + return ( +
+ + + + + + + {showDoctor && } + + + + + + + + + + {items.map((a, i) => ( + + + + + {showDoctor && } + + + + + + + + ))} + +
ردیفنام بیمارشماره تماسپزشکشروعپایانسرویسپرسنلوضعیتعملیات
{(i + 1).toLocaleString('fa-IR')} +
+ + {a.patient_name || '—'} +
+
+
+ + {a.patient_mobile} +
+
{a.doctor_name}{a.appointment_time}{a.end_time}{a.service_item?.name || '—'}{a.staff?.full_name || '—'} + + + +
+
+ ); +} diff --git a/assets/admin/components/appointments/TurnsTimeline.test.tsx b/assets/admin/components/appointments/TurnsTimeline.test.tsx new file mode 100644 index 00000000..0e73bec7 --- /dev/null +++ b/assets/admin/components/appointments/TurnsTimeline.test.tsx @@ -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 => ({ + 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(); + expect(screen.getByText('ساغر صابری')).toBeInTheDocument(); + expect(screen.getByText(/ویزیت عمومی/)).toBeInTheDocument(); + }); + + it('renders an empty slot as «افزودن نوبت» and fires onBook on click', () => { + const onBook = vi.fn(); + renderWithProviders(); + const add = screen.getByText('افزودن نوبت'); + fireEvent.click(add); + expect(onBook).toHaveBeenCalledWith(emptySlot); + }); + + it('shows the holiday/empty message when there are no slots', () => { + renderWithProviders(); + expect(screen.getByText('این روز تعطیل است')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/appointments/TurnsTimeline.tsx b/assets/admin/components/appointments/TurnsTimeline.tsx new file mode 100644 index 00000000..1eeb564b --- /dev/null +++ b/assets/admin/components/appointments/TurnsTimeline.tsx @@ -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 ( +
+ {showLine && ( +
+ )} +
+ + {time} +
+
+ ); +} + +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 ( +
!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, + }} + > + + {isPast ? 'گذشته' : 'افزودن نوبت'} + + {!isPast && } +
+ ); +} + +function OccupiedCard({ + appointment: a, slot, queryKey, onView, +}: { + appointment: Appointment; slot: TimelineSlot; queryKey: unknown[]; onView: (a: Appointment) => void; +}) { + const cfg = turnStatusConfig(a.status); + return ( +
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, + }} + > + {/* اطلاعات بیمار */} +
+
+ + + {a.patient_name || '—'} + +
+
+ + {a.patient_mobile} +
+
+ سرویس: {a.service_item?.name || '—'} +
+
+ + {/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */} +
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8, flexShrink: 0 }}> + + +
+
+ ); +} + +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(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
در حال بارگذاری...
; + if (!slots.length) return ( +
+
این روز تعطیل است
+
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
+
+ ); + + return ( +
+ {slots.map((slot, i) => { + const color = slot.appointment ? turnStatusConfig(slot.appointment.status).dotColor : EMPTY_SLOT_CONFIG.dotColor; + return ( +
+ +
+ {slot.appointment + ? + : } +
+
+ ); + })} +
+ ); +} diff --git a/assets/admin/components/appointments/TurnsViewToggle.tsx b/assets/admin/components/appointments/TurnsViewToggle.tsx new file mode 100644 index 00000000..7ab3942f --- /dev/null +++ b/assets/admin/components/appointments/TurnsViewToggle.tsx @@ -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 ( +
+ {/* پس‌زمینهٔ لغزنده */} +
+ +
+ +
+ ); +} diff --git a/assets/admin/components/appointments/turnStatus.ts b/assets/admin/components/appointments/turnStatus.ts new file mode 100644 index 00000000..44a68775 --- /dev/null +++ b/assets/admin/components/appointments/turnStatus.ts @@ -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 = { + // 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', +]); diff --git a/assets/admin/components/appointments/types.ts b/assets/admin/components/appointments/types.ts new file mode 100644 index 00000000..f7f9a531 --- /dev/null +++ b/assets/admin/components/appointments/types.ts @@ -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; +} diff --git a/assets/admin/components/layout/Sidebar.test.tsx b/assets/admin/components/layout/Sidebar.test.tsx new file mode 100644 index 00000000..a6734add --- /dev/null +++ b/assets/admin/components/layout/Sidebar.test.tsx @@ -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(, { 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(, { route: '/admin/appointments/new' }); + // چون «افزودن نوبت» فعال است، منو باید خودکار باز باشد + expect(screen.getByText('افزودن نوبت')).toBeInTheDocument(); + expect(screen.getByText('نوبت های تایید شده')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 191cc260..76b3e9a5 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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 ( + + `nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}` + } + style={isLocked ? { opacity: 0.55 } : undefined} + > + + {label} + {isLocked && ( + + )} + + ); + } + + // منوی بازشونده. + return ( +
+ + {open && ( +
+ {children.map((c) => { + const CIcon = c.icon; + return ( + + `nav-item nav-subitem${isActive ? " active" : ""}` + } + > + {CIcon ? ( + + ) : ( + + )} + {c.label} + + ); + })} +
+ )} +
+ ); +} + 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) => (
{section.label} - {section.items.map( - ({ to, icon: Icon, label, feature }) => { - const isLocked = feature - ? !hasFeature(feature) - : false; - const dest = isLocked - ? "/admin/subscription" - : to; - return ( - - `nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}` - } - style={ - isLocked - ? { opacity: 0.55 } - : undefined - } - > - - {label} - {isLocked && ( - - )} - - ); - }, - )} + {section.items.map((item) => ( + + ))}
))} diff --git a/assets/admin/pages/AppointmentCreatePage.test.tsx b/assets/admin/pages/AppointmentCreatePage.test.tsx new file mode 100644 index 00000000..2cfd6de2 --- /dev/null +++ b/assets/admin/pages/AppointmentCreatePage.test.tsx @@ -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; +const post = api.post as ReturnType; + +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(, { 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(, { 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(); + }); +}); diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx new file mode 100644 index 00000000..c4f36c76 --- /dev/null +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -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>({ + 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(null); + const [name, setName] = useState(''); + const [mobile, setMobile] = useState(''); + const patientsQ = useQuery>({ + 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>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); + const itemsQ = useQuery>({ + queryKey: ['service-items', sectionUuid], + queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), + enabled: !!sectionUuid, + }); + const staffQ = useQuery>({ 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 = { + 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 ( +
+ {/* بردکرامب */} +
+ + نوبت ها + + ثبت نوبت جدید +
+ +
+ {/* پزشک — فقط برای admin/clinic */} + {!isDoctor && ( + <> +
پزشک:
+ +
+ setDoctorUuid(v ? String(v) : '')} + placeholder="انتخاب پزشک..." + isClearable + height={38} + /> +
+ + )} + + {/* مراجعه کننده */} +
اطلاعات مراجعه کننده:
+ +
+ { setPicked(null); setPatientSearch(e.target.value); }} + placeholder="جستجوی نام، شماره تماس، شماره پرونده..." /> +
+ {!picked && patients.length > 0 && ( +
+ {patients.map(p => ( + + ))} +
+ )} + {picked === null && ( + <> +
+ + مراجعه کننده جدید + +
+
+
+ +
+ setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" /> +
+
+
+ +
+ setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده" dir="ltr" /> +
+
+
+ + )} + + {/* مشخصات سرویس */} +
مشخصات سرویس:
+
+
+ + +
+
+ + +
+
+ + + + {/* زمان نوبت */} +
زمان نوبت:
+ +
+
+
+ +
+ setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> +
+
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
+
+ + {/* بیعانه */} +
بیعانه:
+
+ + {depositRequired && } +
+ {depositRequired && ( +
+ +
+
+ )} + + + + + +
+