feat(appointments): row actions menu + info/move/transfer/replace modals — phase B
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(<AppointmentActionsMenu appointment={appt} queryKey={['appts']} />);
|
||||
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,
|
||||
})));
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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<ModalKind>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<button ref={btnRef} onClick={openMenu} aria-label="عملیات" className="btn sm ghost" style={{ color: 'var(--primary)', gap: 4 }}>
|
||||
<EllipsisHorizontalIcon style={{ width: 18 }} /> عملیات
|
||||
</button>
|
||||
|
||||
{open && menuPos && ReactDOM.createPortal(
|
||||
<div ref={menuRef} style={{
|
||||
position: 'fixed', top: menuPos.top, right: menuPos.right, zIndex: 9000,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)', minWidth: 190, overflow: 'hidden',
|
||||
}}>
|
||||
{items.map(({ label, icon: Icon, onClick }) => (
|
||||
<button key={label} onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '9px 12px',
|
||||
fontSize: 13, background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text)', fontFamily: 'inherit', textAlign: 'right',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Icon style={{ width: 15, color: 'var(--text-2)' }} /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{modal === 'info' && <AppointmentInfoModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||||
{modal === 'move' && <MoveAppointmentModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||||
{modal === 'transfer' && <TransferReserveModal appointment={appointment} queryKey={queryKey} onClose={() => setModal(null)} />}
|
||||
{modal === 'replace' && <ReplaceAppointmentModal appointment={appointment} queryKey={queryKey} onClose={() => 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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '11px 0', borderBottom: '1px solid var(--border)', gap: 12 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, color: 'var(--text-2)' }}>
|
||||
<Icon style={{ width: 16, color: 'var(--text-3)' }} /> {label}
|
||||
</span>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text)', direction: ltr ? 'ltr' : undefined }}>{value || '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>({
|
||||
queryKey: ['appt-record', a.patient_mobile],
|
||||
queryFn: () => findRecordUuid(a.patient_mobile),
|
||||
});
|
||||
const walletQ = useQuery<ApiResponse<{ balance_rials: number }>>({
|
||||
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 (
|
||||
<Modal open title={a.patient_name || 'نوبت'} onClose={onClose}>
|
||||
<div>
|
||||
<InfoRow icon={ClockIcon} label="ساعت شروع:" value={a.appointment_time} ltr />
|
||||
<InfoRow icon={ClockIcon} label="مدت انجام:" value={`${durationMin.toLocaleString('fa-IR')} دقیقه`} />
|
||||
<InfoRow icon={PhoneIcon} label="تلفن:" value={a.patient_mobile} ltr />
|
||||
<InfoRow icon={Squares2X2Icon} label="بخش:" value={a.service_section?.name} />
|
||||
<InfoRow icon={TagIcon} label="سرویس:" value={a.service_item?.name} />
|
||||
<InfoRow icon={UserIcon} label="پرسنل:" value={a.staff?.full_name} />
|
||||
<InfoRow icon={WalletIcon} label="موجودی کیف پول:"
|
||||
value={walletQ.data?.data ? `${formatRial(walletQ.data.data.balance_rials)}` : undefined} />
|
||||
|
||||
<div style={{ margin: '14px 0' }}>
|
||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||
</div>
|
||||
|
||||
<button className="btn" style={{ width: '100%', color: 'var(--primary)', border: '1px solid var(--primary-soft2, var(--border))', background: 'var(--primary-soft)' }}
|
||||
disabled={!recordQ.data}
|
||||
onClick={() => recordQ.data && navigate(`/admin/patients/${recordQ.data}`)}>
|
||||
مشاهده پرونده
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// جا به جایی نوبت — 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 (
|
||||
<Modal open title="جا به جایی نوبت" onClose={onClose}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>انتخاب تاریخ</label>
|
||||
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت شروع</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 style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت پایان</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>
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={!date || !start || !end || move.isPending} onClick={() => move.mutate()}>
|
||||
اعمال تغییرات
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// انتقال به لیست رزرو (و بازگشت) — 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 (
|
||||
<Modal open title={toReserve ? 'انتقال به لیست رزرو' : 'انتقال به لیست نوبتها'} onClose={onClose}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', background: 'var(--warning-bg)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: '10px 12px', marginBottom: 14, fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span style={{ color: 'var(--warning)', fontWeight: 800 }}>!</span>
|
||||
{toReserve
|
||||
? 'نوبت از لیست نوبت های تایید شده حذف شده و به لیست نوبت های رزرو شده منتقل می شود.'
|
||||
: 'نوبت از لیست رزرو حذف شده و به لیست نوبت های تایید شده منتقل می شود.'}
|
||||
</div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>انتخاب تاریخ</label>
|
||||
<div style={{ margin: '6px 0 16px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn primary" style={{ flex: 1 }} disabled={!date || transfer.isPending} onClick={() => transfer.mutate()}>
|
||||
انتقال و حذف از لیست
|
||||
</button>
|
||||
<button className="btn" style={{ flex: 1 }} onClick={onClose}>انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// جایگزینی نوبت — 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 (
|
||||
<Modal open title="جایگزینی نوبت" onClose={onClose}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>نام و نام خانوادگی</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
|
||||
</div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>شماره تماس</label>
|
||||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||||
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" />
|
||||
</div>
|
||||
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>توضیحات</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={name.trim().length < 2 || mobile.trim().length < 10 || replace.isPending}
|
||||
onClick={() => replace.mutate()}>
|
||||
ثبت نوبت
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 AppointmentStatusDropdown from './AppointmentStatusDropdown';
|
||||
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
patch.mockReset();
|
||||
patch.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('AppointmentStatusDropdown (وضعیت نوبت)', () => {
|
||||
it('shows the design label for the current status', () => {
|
||||
renderWithProviders(<AppointmentStatusDropdown uuid="a1" currentStatus="confirmed" version={1} queryKey={['x']} />);
|
||||
expect(screen.getByText('قطعی شده')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the day-of workflow targets from confirmed', () => {
|
||||
renderWithProviders(<AppointmentStatusDropdown uuid="a1" currentStatus="confirmed" version={1} queryKey={['x']} />);
|
||||
fireEvent.click(screen.getByText('قطعی شده'));
|
||||
expect(screen.getByText('در حال پیگیری')).toBeInTheDocument();
|
||||
expect(screen.getByText('سالن')).toBeInTheDocument();
|
||||
expect(screen.getByText('ویزیت شده')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('patches the status endpoint on selection', async () => {
|
||||
renderWithProviders(<AppointmentStatusDropdown uuid="a1" currentStatus="salon" version={2} queryKey={['x']} />);
|
||||
fireEvent.click(screen.getByText('سالن'));
|
||||
fireEvent.click(screen.getByText('ویزیت شده'));
|
||||
await waitFor(() =>
|
||||
expect(patch).toHaveBeenCalledWith('/api/v1/appointment/a1/status', { status: 'completed', version: 2 }));
|
||||
});
|
||||
|
||||
it('disables terminal statuses', () => {
|
||||
renderWithProviders(<AppointmentStatusDropdown uuid="a1" currentStatus="completed" version={1} queryKey={['x']} />);
|
||||
expect(screen.getByText('ویزیت شده').closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,25 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'رزرو شده', color: '#3b82f6' },
|
||||
confirmed: { label: 'تأیید شده', color: '#22c55e' },
|
||||
completed: { label: 'تکمیل شده', color: '#16a34a' },
|
||||
cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444' },
|
||||
cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444' },
|
||||
// Labels follow the Figma نوبتها design (ثبت شده / قطعی شده / ویزیت شده …).
|
||||
export const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'ثبت شده', color: '#3b82f6' },
|
||||
confirmed: { label: 'قطعی شده', color: '#14b8a6' },
|
||||
following_up: { label: 'در حال پیگیری', color: '#f59e0b' },
|
||||
salon: { label: 'سالن', color: '#8b5cf6' },
|
||||
completed: { label: 'ویزیت شده', color: '#22c55e' },
|
||||
cancelled_by_doctor: { label: 'لغو شده', color: '#ef4444' },
|
||||
cancelled_by_user: { label: 'لغو توسط بیمار', color: '#ef4444' },
|
||||
no_show: { label: 'غیبت', color: '#9ca3af' },
|
||||
expired: { label: 'منقضی شده', color: '#9ca3af' },
|
||||
};
|
||||
|
||||
// Mirrors Appointment::ALLOWED_TRANSITIONS on the backend.
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
pending: ['confirmed', 'cancelled_by_doctor', 'cancelled_by_user', 'expired'],
|
||||
confirmed: ['completed', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
|
||||
pending: ['confirmed', 'following_up', 'cancelled_by_doctor', 'cancelled_by_user'],
|
||||
confirmed: ['completed', 'following_up', 'salon', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
|
||||
following_up: ['confirmed', 'salon', 'completed', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
|
||||
salon: ['completed', 'following_up', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -4,11 +4,13 @@ import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementSta
|
||||
type BadgeColor = 'green' | 'amber' | 'red' | 'blue' | 'violet' | 'gray';
|
||||
|
||||
const appointmentMap: Record<AppointmentStatus, { color: BadgeColor; label: string }> = {
|
||||
pending: { color: 'blue', label: 'رزرو شده' },
|
||||
confirmed: { color: 'green', label: 'تأیید شده' },
|
||||
completed: { color: 'green', label: 'تکمیل شده' },
|
||||
cancelled_by_user: { color: 'red', label: 'لغو بیمار' },
|
||||
cancelled_by_doctor: { color: 'red', label: 'لغو پزشک' },
|
||||
pending: { color: 'blue', label: 'ثبت شده' },
|
||||
confirmed: { color: 'green', label: 'قطعی شده' },
|
||||
following_up: { color: 'amber', label: 'در حال پیگیری' },
|
||||
salon: { color: 'violet', label: 'سالن' },
|
||||
completed: { color: 'green', label: 'ویزیت شده' },
|
||||
cancelled_by_user: { color: 'red', label: 'لغو توسط بیمار' },
|
||||
cancelled_by_doctor: { color: 'red', label: 'لغو شده' },
|
||||
no_show: { color: 'gray', label: 'غیبت' },
|
||||
expired: { color: 'gray', label: 'منقضی' },
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Appointment } from '../types';
|
||||
import { formatDate, toGregorianDate } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
|
||||
import AppointmentActionsMenu from '../components/AppointmentActions';
|
||||
import PersianCalendar from '../components/ui/PersianCalendar';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
@@ -208,7 +209,10 @@ function TableView({
|
||||
{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>
|
||||
@@ -230,6 +234,8 @@ function TableView({
|
||||
{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}
|
||||
@@ -238,6 +244,9 @@ function TableView({
|
||||
queryKey={queryKey}
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -86,7 +86,9 @@ export type AppointmentStatus =
|
||||
| "cancelled_by_doctor"
|
||||
| "cancelled_by_user"
|
||||
| "no_show"
|
||||
| "expired";
|
||||
| "expired"
|
||||
| "following_up"
|
||||
| "salon";
|
||||
|
||||
export interface Appointment {
|
||||
uuid: string;
|
||||
@@ -102,6 +104,14 @@ export interface Appointment {
|
||||
status: AppointmentStatus;
|
||||
version: number;
|
||||
created_at: string;
|
||||
patient_uuid?: string;
|
||||
is_reserve?: boolean;
|
||||
deposit_required?: boolean;
|
||||
deposit_amount_rials?: number | null;
|
||||
note?: string | null;
|
||||
service_section?: { uuid: string; name: string } | null;
|
||||
service_item?: { uuid: string; name: string } | null;
|
||||
staff?: { uuid: string; full_name: string } | null;
|
||||
}
|
||||
|
||||
export type PaymentStatus =
|
||||
|
||||
Reference in New Issue
Block a user