feat(appointments): rich create drawer (اضافه کردن نوبت جدید) — phase C1

NewAppointmentDrawer implements add.pdf: patient search-or-new (patient-list
search), بخش/سرویس/پرسنل selects (service-sections, service-items, staff),
Jalali date + default-duration with auto end-time, deposit toggle + PriceInput,
status pick (applied via the status endpoint after create) and notes. The
toolbar «نوبت جدید» button now opens it instead of only hinting at slot click;
the slot-click quick modal stays. isReserve mode (day-level, no time fields)
is reused by the upcoming reserve list page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 23:55:13 +03:30
co-authored by Claude Fable 5
parent 27765b33d2
commit 91ea1b02aa
3 changed files with 351 additions and 2 deletions
@@ -0,0 +1,100 @@
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 NewAppointmentDrawer from './NewAppointmentDrawer';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset(); post.mockReset();
get.mockImplementation((url: string) => {
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
if (url.startsWith('/api/v1/service-items/sec1')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر توتال' }] });
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] });
if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'ساغر صابری', user_mobile: '09356619438' }] });
return Promise.resolve({ success: true, data: [] });
});
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
});
function renderDrawer() {
return renderWithProviders(
<NewAppointmentDrawer doctorUuid="d1" defaultDate="2026-08-01" queryKey={['appts']} onClose={() => {}} />,
);
}
describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => {
it('renders the Figma sections and disables submit until valid', async () => {
renderDrawer();
expect(screen.getByText('اطلاعات مراجعه کننده:')).toBeInTheDocument();
expect(screen.getByText('مشخصات سرویس:')).toBeInTheDocument();
expect(screen.getByText('زمان نوبت:')).toBeInTheDocument();
expect(screen.getByText('بیعانه:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
});
it('end time follows start + default duration', () => {
renderDrawer();
fireEvent.change(screen.getByLabelText('ساعت شروع'), { target: { value: '09:00' } });
fireEvent.change(screen.getByLabelText('زمان پیش فرض'), { target: { value: '45' } });
expect((screen.getByLabelText('ساعت پایان') as HTMLInputElement).value).toBe('09:45');
});
it('posts the extended payload for a new patient with service specs', async () => {
renderDrawer();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } });
await screen.findByRole('option', { name: 'زیبایی' });
fireEvent.change(screen.getByLabelText('بخش'), { target: { value: 'sec1' } });
await screen.findByRole('option', { name: 'لیزر توتال' });
fireEvent.change(screen.getByLabelText('سرویس'), { target: { value: 'it1' } });
await screen.findByRole('option', { name: 'سحر ایمانی' });
fireEvent.change(screen.getByLabelText('پرسنل'), { target: { value: 'st1' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
doctor_uuid: 'd1',
patient_name: 'مریم خلیلی',
patient_mobile: '09136549874',
service_section_uuid: 'sec1',
service_item_uuid: 'it1',
staff_uuid: 'st1',
is_reserve: false,
})));
});
it('picks an existing patient from the search results', async () => {
renderDrawer();
fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'ساغر' } });
fireEvent.click(await screen.findByText('ساغر صابری'));
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
patient_name: 'ساغر صابری', patient_mobile: '09356619438',
})));
});
it('reserve mode hides time fields and posts a day-level entry', async () => {
renderWithProviders(
<NewAppointmentDrawer doctorUuid="d1" defaultDate="2026-08-01" queryKey={['r']} onClose={() => {}} isReserve />,
);
expect(screen.getByText('اضافه کردن نوبت رزرو')).toBeInTheDocument();
expect(screen.queryByLabelText('ساعت شروع')).toBeNull();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
const day = Math.floor(new Date('2026-08-01T00:00').getTime() / 1000);
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
is_reserve: true, slot_start: day, slot_end: day,
})));
});
});
@@ -0,0 +1,238 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
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')}`;
};
/**
* اضافه کردن نوبت جدید (Figma add.pdf) — rich create form: patient
* search-or-new, بخش/سرویس/پرسنل, date + default-duration + start/end time,
* deposit toggle, status and notes. POSTs the extended /my/appointment.
*/
export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey, onClose, isReserve = false }: {
doctorUuid: string;
/** ISO Y-m-d — the currently viewed day. */
defaultDate: string;
queryKey: unknown[];
onClose: () => void;
/** true → «اضافه کردن نوبت رزرو» (day-level entry, no time fields). */
isReserve?: boolean;
}) {
const qc = useQueryClient();
// ── patient: pick an existing record or enter a new person ────────────────
const [patientSearch, setPatientSearch] = useState('');
const [pickedPatient, setPickedPatient] = useState<PatientRow | null>(null);
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
queryKey: ['drawer-patients', patientSearch],
queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`),
enabled: patientSearch.trim().length >= 2,
});
// ── service specs ──────────────────────────────────────────────────────────
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [staffUuid, setStaffUuid] = useState('');
const sectionsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
});
const itemsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-items', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const staffQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
});
// ── timing ─────────────────────────────────────────────────────────────────
const [date, setDate] = useState(defaultDate);
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]);
// ── deposit / status / notes ───────────────────────────────────────────────
const [depositRequired, setDepositRequired] = useState(false);
const [depositRials, setDepositRials] = useState(0);
const [status, setStatus] = useState('pending');
const [note, setNote] = useState('');
const effectiveName = pickedPatient?.user_name || name.trim();
const effectiveMobile = pickedPatient?.user_mobile || mobile.trim();
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && (isReserve || (!!start && !!end));
const create = useMutation({
mutationFn: async () => {
const payload: Record<string, unknown> = {
doctor_uuid: doctorUuid,
slot_start: isReserve ? toEpoch(date, '00:00') : toEpoch(date, start),
slot_end: isReserve ? toEpoch(date, '00:00') : toEpoch(date, end),
patient_name: effectiveName,
patient_mobile: effectiveMobile,
is_reserve: isReserve,
...(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('/api/v1/my/appointment', payload);
// POST creates a pending booking; apply the picked status afterwards.
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 });
toast.success(isReserve ? 'نوبت رزرو ثبت شد' : 'نوبت با موفقیت ثبت شد');
onClose();
},
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
});
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const;
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
return (
<Modal open title={isReserve ? 'اضافه کردن نوبت رزرو' : 'اضافه کردن نوبت جدید'} onClose={onClose}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 10 }}>اطلاعات مراجعه کننده:</div>
<label style={label}>انتخاب مراجعه کننده</label>
<div className="field" style={{ margin: '6px 0 8px' }}>
<input value={pickedPatient ? `${pickedPatient.user_name ?? ''}${pickedPatient.user_mobile ?? ''}` : patientSearch}
onChange={e => { setPickedPatient(null); setPatientSearch(e.target.value); }}
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
</div>
{!pickedPatient && patients.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden' }}>
{patients.map(p => (
<button key={p.uuid} onClick={() => setPickedPatient(p)} style={{
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
</button>
))}
</div>
)}
{pickedPatient === null && (
<>
<div style={{ margin: '4px 0 10px' }}>
<span className="badge" style={{ color: 'var(--primary)', border: '1px solid var(--primary)', borderRadius: 'var(--r-sm)', padding: '5px 10px', fontSize: 12, display: 'inline-flex', gap: 5, alignItems: 'center' }}>
<PlusIcon style={{ width: 13 }} /> مراجعه کننده جدید
</span>
</div>
<label style={label}>نام و نام خانوادگی مراجعه کننده</label>
<div className="field" style={{ margin: '6px 0 10px' }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده را وارد نمایید" />
</div>
<label style={label}>شماره تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" />
</div>
</>
)}
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>مشخصات سرویس:</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
<option value="">انتخاب بخش</option>
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
<div>
<label style={label}>سرویس</label>
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
</div>
<label style={label}>انتخاب پرسنل</label>
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 12px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
<option value="">انتخاب...</option>
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
</select>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>زمان نوبت:</div>
<label style={label}>انتخاب تاریخ</label>
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ margin: '6px 0 10px' }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
</div>
{!isReserve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
)}
{!isReserve && (
<>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
</div>
{depositRequired && (
<div style={{ marginBottom: 12 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput value={depositRials} onChange={setDepositRials} />
</div>
</div>
)}
</>
)}
<label style={label}>انتخاب وضعیت</label>
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value)}>
<option value="pending">ثبت شده</option>
<option value="confirmed">قطعی شده</option>
</select>
<div className="field" style={{ height: 'auto', marginBottom: 16 }}>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
</div>
<button className="btn primary" style={{ width: '100%' }} disabled={!valid || create.isPending} onClick={() => create.mutate()}>
ثبت نوبت
</button>
</div>
</Modal>
);
}
+13 -2
View File
@@ -12,6 +12,7 @@ import { formatDate, toGregorianDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
import AppointmentActionsMenu from '../components/AppointmentActions';
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
import PersianCalendar from '../components/ui/PersianCalendar';
import SearchableSelect from '../components/ui/SearchableSelect';
@@ -547,6 +548,7 @@ export default function AppointmentsPage() {
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
const [bookingHint, setBookingHint] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const qc = useQueryClient();
// ── Appointments query
@@ -694,8 +696,7 @@ export default function AppointmentsPage() {
className="btn primary sm"
onClick={() => {
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
setViewMode('schedule');
setBookingHint(true);
setDrawerOpen(true);
}}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
@@ -805,6 +806,16 @@ export default function AppointmentsPage() {
}}
/>
)}
{/* Rich create drawer (اضافه کردن نوبت جدید) */}
{drawerOpen && (
<NewAppointmentDrawer
doctorUuid={selectedDoctorUuid}
defaultDate={selectedDate}
queryKey={apptQueryKey}
onClose={() => setDrawerOpen(false)}
/>
)}
</div>
);
}