From 7354c92e400809ee39ba0bfe20365b85669a1c2b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 23:47:54 +0330 Subject: [PATCH] =?UTF-8?q?feat(appointments):=20row=20actions=20menu=20+?= =?UTF-8?q?=20info/move/transfer/replace=20modals=20=E2=80=94=20phase=20B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the Figma عملیات menu on the confirmed-appointments table: - AppointmentActions composite: six-item row menu (ویرایش، ثبت سرویس، مشاهده، جا به جایی، انتقال به لیست رزرو، جایگزینی) plus the four modals it opens. ثبت سرویس and the info modal resolve the patient record via the patient-list search (mobile) to reuse the existing wallet endpoint and NewSessionPage. - Info modal mirrors appointments-info.pdf: start time, duration, phone, بخش/سرویس/پرسنل, wallet balance, status dropdown, مشاهده پرونده. - Move/transfer/replace modals PATCH the new general update endpoint with optimistic-lock version; transfer uses day-level midnight slots. - Status labels/transitions updated to the design set (ثبت شده/قطعی شده/ در حال پیگیری/سالن/ویزیت شده/لغو شده) in AppointmentStatusDropdown and StatusBadge; Appointment type gains the new workflow fields; the table gains سرویس/پرسنل/عملیات columns. Co-Authored-By: Claude Fable 5 --- .../components/AppointmentActions.test.tsx | 99 ++++++ .../admin/components/AppointmentActions.tsx | 309 ++++++++++++++++++ .../ui/AppointmentStatusDropdown.test.tsx | 46 +++ .../ui/AppointmentStatusDropdown.tsx | 22 +- assets/admin/components/ui/StatusBadge.tsx | 12 +- assets/admin/pages/AppointmentsPage.tsx | 9 + assets/admin/types/index.ts | 12 +- 7 files changed, 495 insertions(+), 14 deletions(-) create mode 100644 assets/admin/components/AppointmentActions.test.tsx create mode 100644 assets/admin/components/AppointmentActions.tsx create mode 100644 assets/admin/components/ui/AppointmentStatusDropdown.test.tsx 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" /> +
+ +
+