diff --git a/assets/admin/components/AppointmentActions.test.tsx b/assets/admin/components/AppointmentActions.test.tsx new file mode 100644 index 00000000..253ddb50 --- /dev/null +++ b/assets/admin/components/AppointmentActions.test.tsx @@ -0,0 +1,99 @@ +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 AppointmentActionsMenu from './AppointmentActions'; +import type { Appointment } from '../types'; + +const get = api.get as ReturnType; +const patch = api.patch as ReturnType; + +const appt: Appointment = { + uuid: 'ap1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874', + doctor_uuid: 'd1', doctor_name: 'دکتر احمدی', + slot_start: 1735639200, slot_end: 1735641900, // 45 min + appointment_date: '2024-12-31', appointment_time: '09:00', end_time: '09:45', + status: 'confirmed', version: 3, created_at: '', + service_section: { uuid: 's1', name: 'زیبایی' }, + service_item: { uuid: 'i1', name: 'لیزر توتال' }, + staff: { uuid: 'st1', full_name: 'دکتر حمیدی' }, +}; + +beforeEach(() => { + get.mockReset(); patch.mockReset(); + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1' }] }); + if (url === '/api/v1/patient/rec1/wallet') return Promise.resolve({ success: true, data: { balance_rials: 500000, recent_transactions: [] } }); + return Promise.resolve({ success: true, data: [] }); + }); + patch.mockResolvedValue({ success: true, data: {} }); +}); + +function openMenu() { + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: 'عملیات' })); +} + +describe('AppointmentActionsMenu (عملیات نوبت)', () => { + it('lists all six actions from the Figma menu', () => { + openMenu(); + for (const label of ['ویرایش', 'ثبت سرویس', 'مشاهده', 'جا به جایی نوبت', 'انتقال به لیست رزرو', 'جایگزینی نوبت']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('info modal shows appointment details and patient wallet balance', async () => { + openMenu(); + fireEvent.click(screen.getByText('مشاهده')); + expect(await screen.findByText('ساعت شروع:')).toBeInTheDocument(); + expect(screen.getByText('۴۵ دقیقه')).toBeInTheDocument(); + expect(screen.getByText('لیزر توتال')).toBeInTheDocument(); + expect(screen.getByText('دکتر حمیدی')).toBeInTheDocument(); + // wallet resolved through record search → balance shown (rial → toman) + expect(await screen.findByText(/تومان/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'مشاهده پرونده' })).toBeInTheDocument(); + }); + + it('move modal patches new slot times', async () => { + openMenu(); + fireEvent.click(screen.getByText('جا به جایی نوبت')); + expect(await screen.findByText('اعمال تغییرات')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('ساعت شروع'), { target: { value: '15:00' } }); + fireEvent.change(screen.getByLabelText('ساعت پایان'), { target: { value: '16:00' } }); + fireEvent.click(screen.getByText('اعمال تغییرات')); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + slot_start: Math.floor(new Date('2024-12-31T15:00').getTime() / 1000), + slot_end: Math.floor(new Date('2024-12-31T16:00').getTime() / 1000), + version: 3, + }))); + }); + + it('transfer modal flips is_reserve with a day-level slot', async () => { + openMenu(); + fireEvent.click(screen.getByText('انتقال به لیست رزرو')); + expect(await screen.findByText(/به لیست نوبت های رزرو شده منتقل می شود/)).toBeInTheDocument(); + fireEvent.click(screen.getByText('انتقال و حذف از لیست')); + const day = Math.floor(new Date('2024-12-31T00:00').getTime() / 1000); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + is_reserve: true, slot_start: day, slot_end: day, version: 3, + }))); + }); + + it('replace modal swaps the patient on the slot', async () => { + openMenu(); + fireEvent.click(screen.getByText('جایگزینی نوبت')); + expect(await screen.findByPlaceholderText('نام و نام خانوادگی مراجعه کننده')).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'ساغر صابری' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس'), { target: { value: '09356619438' } }); + fireEvent.click(screen.getByText('ثبت نوبت')); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({ + patient_name: 'ساغر صابری', patient_mobile: '09356619438', version: 3, + }))); + }); +}); diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx new file mode 100644 index 00000000..bf803ef3 --- /dev/null +++ b/assets/admin/components/AppointmentActions.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + EllipsisHorizontalIcon, PencilIcon, PlusIcon, EyeIcon, + ArrowsRightLeftIcon, ArrowDownOnSquareIcon, ArrowPathIcon, + ClockIcon, PhoneIcon, TagIcon, UserIcon, WalletIcon, Squares2X2Icon, +} from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { Appointment } from '../types'; +import { formatRial } from '../lib/utils'; +import Modal from './ui/Modal'; +import PersianDateInput from './ui/PersianDateInput'; +import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown'; + +/** Row actions for the appointments table (Figma عملیات menu). */ +type ModalKind = null | 'info' | 'move' | 'transfer' | 'replace'; + +const toEpoch = (isoDate: string, time: string) => + Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); + +/** + * Resolve the patient-record uuid behind an appointment via the patient list + * search (mobile is unique per user). Returns null when no record exists yet. + */ +async function findRecordUuid(mobile: string): Promise { + const res: any = await api.get(`/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`); + return res?.data?.[0]?.uuid ?? null; +} + +export default function AppointmentActionsMenu({ appointment, queryKey }: { + appointment: Appointment; queryKey: unknown[]; +}) { + const [open, setOpen] = useState(false); + const [modal, setModal] = useState(null); + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); + const btnRef = useRef(null); + const menuRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + const t = e.target as Node; + if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const openMenu = () => { + if (!open && btnRef.current) { + const r = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: r.bottom + 4, right: window.innerWidth - r.right }); + } + setOpen(o => !o); + }; + + const goToServiceRegistration = async () => { + setOpen(false); + try { + const recordUuid = await findRecordUuid(appointment.patient_mobile); + if (!recordUuid) { toast.error('پرونده‌ای برای این بیمار یافت نشد'); return; } + navigate(`/admin/patients/${recordUuid}/session/new`); + } catch { toast.error('خطا در یافتن پرونده بیمار'); } + }; + + const items: { label: string; icon: React.ElementType; onClick: () => void }[] = [ + { label: 'ویرایش', icon: PencilIcon, onClick: () => { setOpen(false); navigate(`/admin/appointments/${appointment.uuid}/edit`); } }, + { label: 'ثبت سرویس', icon: PlusIcon, onClick: goToServiceRegistration }, + { label: 'مشاهده', icon: EyeIcon, onClick: () => { setOpen(false); setModal('info'); } }, + { label: 'جا به جایی نوبت', icon: ArrowsRightLeftIcon, onClick: () => { setOpen(false); setModal('move'); } }, + { label: appointment.is_reserve ? 'انتقال به لیست نوبت‌ها' : 'انتقال به لیست رزرو', icon: ArrowDownOnSquareIcon, onClick: () => { setOpen(false); setModal('transfer'); } }, + { label: 'جایگزینی نوبت', icon: ArrowPathIcon, onClick: () => { setOpen(false); setModal('replace'); } }, + ]; + + return ( + <> + + + {open && menuPos && ReactDOM.createPortal( +
+ {items.map(({ label, icon: Icon, onClick }) => ( + + ))} +
, + document.body, + )} + + {modal === 'info' && setModal(null)} />} + {modal === 'move' && setModal(null)} />} + {modal === 'transfer' && setModal(null)} />} + {modal === 'replace' && setModal(null)} />} + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// مشاهده — appointment info + patient wallet balance (Figma appointments-info) +// ───────────────────────────────────────────────────────────────────────────── + +function InfoRow({ icon: Icon, label, value, ltr }: { icon: React.ElementType; label: string; value?: string | null; ltr?: boolean }) { + return ( +
+ + {label} + + {value || '—'} +
+ ); +} + +export function AppointmentInfoModal({ appointment: a, queryKey, onClose }: { + appointment: Appointment; queryKey: unknown[]; onClose: () => void; +}) { + const navigate = useNavigate(); + + // record uuid → wallet balance + «مشاهده پرونده» target (both need the record) + const recordQ = useQuery({ + queryKey: ['appt-record', a.patient_mobile], + queryFn: () => findRecordUuid(a.patient_mobile), + }); + const walletQ = useQuery>({ + queryKey: ['appt-wallet', recordQ.data], + queryFn: () => api.get(`/api/v1/patient/${recordQ.data}/wallet`), + enabled: !!recordQ.data, + }); + + const durationMin = Math.max(0, Math.round((a.slot_end - a.slot_start) / 60)); + + return ( + +
+ + + + + + + + +
+ +
+ + +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// جا به جایی نوبت — pick a new date + start/end time +// ───────────────────────────────────────────────────────────────────────────── + +export function MoveAppointmentModal({ appointment: a, queryKey, onClose }: { + appointment: Appointment; queryKey: unknown[]; onClose: () => void; +}) { + const qc = useQueryClient(); + const [date, setDate] = useState(a.appointment_date); + const [start, setStart] = useState(a.appointment_time); + const [end, setEnd] = useState(a.end_time); + + const move = useMutation({ + mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, { + slot_start: toEpoch(date, start), slot_end: toEpoch(date, end), version: a.version, + }), + onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جا به جا شد'); onClose(); }, + onError: (e: any) => toast.error(e.message || 'خطا در جا به جایی نوبت'), + }); + + return ( + +
+ +
+
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
+
+
+ +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// انتقال به لیست رزرو (و بازگشت) — flips is_reserve for a chosen day +// ───────────────────────────────────────────────────────────────────────────── + +export function TransferReserveModal({ appointment: a, queryKey, onClose }: { + appointment: Appointment; queryKey: unknown[]; onClose: () => void; +}) { + const qc = useQueryClient(); + const [date, setDate] = useState(a.appointment_date); + const toReserve = !a.is_reserve; + + const transfer = useMutation({ + mutationFn: () => { + const day = toEpoch(date, '00:00'); + return api.patch(`/api/v1/appointment/${a.uuid}`, toReserve + // reserve entries are day-level: midnight-to-midnight, no slot occupation + ? { is_reserve: true, slot_start: day, slot_end: day, version: a.version } + : { is_reserve: false, slot_start: toEpoch(date, a.appointment_time), slot_end: toEpoch(date, a.end_time), version: a.version }); + }, + onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success(toReserve ? 'به لیست رزرو منتقل شد' : 'به لیست نوبت‌ها منتقل شد'); onClose(); }, + onError: (e: any) => toast.error(e.message || 'خطا در انتقال'), + }); + + return ( + +
+
+ ! + {toReserve + ? 'نوبت از لیست نوبت های تایید شده حذف شده و به لیست نوبت های رزرو شده منتقل می شود.' + : 'نوبت از لیست رزرو حذف شده و به لیست نوبت های تایید شده منتقل می شود.'} +
+ +
+
+ + +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// جایگزینی نوبت — put a different patient into the same slot +// ───────────────────────────────────────────────────────────────────────────── + +export function ReplaceAppointmentModal({ appointment: a, queryKey, onClose }: { + appointment: Appointment; queryKey: unknown[]; onClose: () => void; +}) { + const qc = useQueryClient(); + const [name, setName] = useState(''); + const [mobile, setMobile] = useState(''); + const [note, setNote] = useState(''); + + const replace = useMutation({ + mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, { + patient_name: name.trim(), patient_mobile: mobile.trim(), + ...(note.trim() ? { note: note.trim() } : {}), + version: a.version, + }), + onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جایگزین شد'); onClose(); }, + onError: (e: any) => toast.error(e.message || 'خطا در جایگزینی نوبت'), + }); + + return ( + +
+ +
+ setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" /> +
+ +
+ setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" /> +
+ +
+