setPatientName(e.target.value)}
@@ -334,9 +368,10 @@ export default function NewAppointmentModal({
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
{searchBy === 'national' && (
-
+
-
+
)}
+
+ {/* هزینه ویزیت مرحلهٔ شمارهدار نیست: در حالت اختیاری یک کلپسِ بسته است و
+ شماره دادن به آن، کاری اختیاری را اجباری نشان میداد. */}
{requireVisit ? (
+
+ {blockReason && (
+
+
+ برای ثبت نوبت: {blockReason}
+
+ )}
);
}
+
+/** مرحلهٔ شمارهدار فرم — ترتیب تصمیمها را دیدنی میکند، نه فقط ترتیب فیلدها. */
+function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) {
+ return (
+
+
+ {/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */}
+ {n !== null && (
+ {n}
+ )}
+ {title}
+
+ {children}
+
+ );
+}
diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx
index aac03463..4da9e3f9 100644
--- a/assets/admin/components/appointments/ResourceBookingModal.test.tsx
+++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx
@@ -48,7 +48,9 @@ function mockApi() {
async function pickServiceAndTime() {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
- fireEvent.click(await screen.findByRole('button', { name: /کرایوتراپی/ }));
+ // ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
+ // «انتخابشده/نشده» اعلام شود.
+ fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
}
@@ -114,6 +116,49 @@ describe('مودال ثبت نوبتِ منبع', () => {
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
});
+ it('دلیلِ غیرفعال بودنِ ثبت را میگوید و با پیشرفتِ فرم عوض میشود', async () => {
+ renderWithProviders(
+ {}} onSuccess={() => {}} />,
+ );
+
+ expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
+
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
+ fireEvent.click(await screen.findByText('خدمات درمانگاه'));
+ fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
+
+ // سرویس هست، زمان نه — پیام باید مرحلهٔ بعد را نشان دهد نه همان قبلی.
+ expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument();
+
+ fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
+ expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
+ });
+
+ it('تاریخ نوبت بهصورت جلالی بالای فرم میآید', () => {
+ renderWithProviders(
+ {}} onSuccess={() => {}} />,
+ );
+
+ // بدون این، کاربر روزِ در حال رزرو را هیچجای مودال نمیدید.
+ expect(screen.getByText('تاریخ:')).toBeInTheDocument();
+ expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
+ });
+
+ it('معیار جستجو یک seg با حالتِ اعلامشده است', () => {
+ renderWithProviders(
+ {}} onSuccess={() => {}} />,
+ );
+
+ const national = screen.getByRole('button', { name: 'کد ملی' });
+ const mobile = screen.getByRole('button', { name: 'شماره موبایل' });
+ expect(national).toHaveAttribute('aria-pressed', 'true');
+ expect(mobile).toHaveAttribute('aria-pressed', 'false');
+
+ fireEvent.click(mobile);
+ expect(screen.getByRole('button', { name: 'شماره موبایل' })).toHaveAttribute('aria-pressed', 'true');
+ expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
+ });
+
it('منبعِ بدون سرویس، پیام راهنما میدهد نه فهرست خالی', () => {
renderWithProviders(
{}} onSuccess={() => {}} />,
diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx
index 1fbd5e5c..b4115ed1 100644
--- a/assets/admin/components/appointments/ServiceSlotPicker.tsx
+++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx
@@ -4,6 +4,7 @@ import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
import { useClinicContext } from '../../hooks/useClinicContext';
+import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from '../ui/SearchableSelect';
import DigitInput from '../ui/DigitInput';
@@ -118,7 +119,7 @@ export default function ServiceSlotPicker({
return (
+ ) : slotsQ.isError ? (
+ /* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه میشد — یعنی کاربر
+ روز درست را کنار میگذاشت، چون پاسخ دروغ بود. */
+
+ خواندن زمانهای خالی ناموفق بود.
+ slotsQ.refetch()}>تلاش دوباره
+
) : startTimes.length === 0 ? (
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
@@ -233,9 +242,10 @@ export default function ServiceSlotPicker({
const active = pickedSlot?.start === s.start;
return (
setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
style={{
- fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
+ fontSize: 13, minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? 'var(--on-primary)' : 'var(--text)',
}}>
diff --git a/assets/admin/components/ui/Modal.tsx b/assets/admin/components/ui/Modal.tsx
index d0d02a3f..f6b1acb1 100644
--- a/assets/admin/components/ui/Modal.tsx
+++ b/assets/admin/components/ui/Modal.tsx
@@ -39,7 +39,7 @@ export default function Modal({ open, title, size = 'md', onClose, children, foo
>
{title}
-
+
diff --git a/assets/admin/styles.css b/assets/admin/styles.css
index 026b7235..4f4eac2a 100644
--- a/assets/admin/styles.css
+++ b/assets/admin/styles.css
@@ -649,7 +649,10 @@ table.t tbody tr:last-child td { border-bottom: none; }
transition: .15s;
}
.field:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); }
-.field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; color: var(--text); font-family: inherit; font-size: 14px; }
+/* کنترل باید کل ارتفاع ۴۰px قاب را بگیرد: هم هدف لمسی کامل میشود، هم کلیک روی
+ حاشیهٔ داخلیِ قاب فوکوس میدهد. بدون این، خودِ input حدود ۲۱px است. */
+.field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; align-self: stretch; color: var(--text); font-family: inherit; font-size: 14px; }
+.field textarea { align-self: auto; }
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
/* `a` هم پذیرفته میشود: نوار تبهایی که بین صفحهها جابهجا میکنند باید لینک واقعی باشند
(باز کردن در تب جدید، کلیک وسط)، نه دکمهای که navigate صدا میزند. */