diff --git a/assets/admin/pages/UsersPage.tsx b/assets/admin/pages/UsersPage.tsx
index 1187ebb8..cdeb513a 100644
--- a/assets/admin/pages/UsersPage.tsx
+++ b/assets/admin/pages/UsersPage.tsx
@@ -306,7 +306,7 @@ export default function UsersPage() {
setSearchInput(e.target.value)}
- placeholder="نام، موبایل یا ایمیل..."
+ placeholder="شماره کاربری، نام، موبایل یا ایمیل..."
/>
diff --git a/docs/architecture/online-resource-booking.md b/docs/architecture/online-resource-booking.md
new file mode 100644
index 00000000..16b3a9ca
--- /dev/null
+++ b/docs/architecture/online-resource-booking.md
@@ -0,0 +1,250 @@
+# نوبتدهی آنلاین منبعمحور در nobat724
+
+> وضعیت: **طرح پیشنهادی** — هنوز پیاده نشده. مرجعِ تصمیمگیری پیش از شروع کار.
+> دامنه: `clinicpro` (بکاند) + `nobat724_front` (سایت عمومی).
+> پیشنیاز خواندن: [resource-first-model.md](resource-first-model.md) · [docs/api/appointment.md](../api/appointment.md) · [docs/api/resource.md](../api/resource.md)
+
+---
+
+## ۱. صورت مسئله
+
+نوبتدهی منبعمحور امروز فقط از **پنل** کار میکند. بیماری که وارد سایت میشود، برای
+کلینیکی که در حالت `resource` است یا چیزی نمیبیند، یا یک تقویمِ اسلاتیِ بیربط.
+
+سه حالت نوبتدهی در سیستم هست ([WeeklySchedule.php](../../src/Appointment/Entity/WeeklySchedule.php)):
+
+| حالت | یعنی | سایت امروز؟ |
+|---|---|---|
+| `slot` | شبکهٔ اسلات ثابت پزشک | ✅ تاریخ → ساعت |
+| `service` | طول نوبت از مدت سرویسها | ✅ سرویس → تاریخ → ساعت |
+| `resource` | برنامهٔ چندبخشی روی **تقویم منابع** | ❌ هیچ |
+
+`booking_mode` برای هر محل جدا میآید (`GET /api/v1/appointment-booking-locations/{doctorUuid}`)
+و سایت آن را از **محل انتخابشده** میخواند، نه از پزشک — این قرارداد سرِ جایش میماند و
+`resource` فقط حالت سوم همان سوییچ است.
+
+### چرا نمیشود همین موتور را به سایت وصل کرد
+
+موتور منبعمحور (`appointment-availability` → `appointment-hold` → `appointment-confirm`)
+**ذاتاً پنلی** است، نه فقط «احراز هویت لازم دارد»:
+
+```php
+// AvailabilityController::search() و BookingController::create()
+$address = $this->branches->resolve($user, $data['branch_uuid']); // ← محیطِ خودِ کاربر
+[$entityType, $entityId] = $this->branches->pair($user); // ← محیطِ خودِ کاربر
+```
+
+`AddressResolver::pair()` محیط را از **کاربرِ درخواستدهنده** میگیرد. بیمار هیچ محیطی
+ندارد، پس همین حالا هم اگر توکنِ بیمار بفرستیم، آدرس کلینیک «یافت نشد» میشود. یعنی
+مسئله یک خط `security.yaml` نیست؛ **جهتِ resolve** باید برعکس شود: محیط باید از
+**مقصدِ رزرو** (پزشک/کلینیک) بیاید، نه از فرستنده.
+
+سه گاردِ دیگر هم که مسیرهای عمومی از قبل دارند، در این موتور **اصلاً وجود ندارند** —
+چون تا امروز فقط کارمند صدایش میزده:
+
+| گارد | مسیر عمومیِ اسلاتی/سرویسی | موتور منبعمحور |
+|---|---|---|
+| `online_booking_enabled` | ✅ [SlotCalculatorService.php:197](../../src/Appointment/Service/SlotCalculatorService.php#L197) | ❌ |
+| پنجرهٔ رزرو (`booking_window_*`) | ✅ | ❌ |
+| «فقط سرویسهای bookable» | ✅ | جزئی |
+| عدم افشای منابع | — | ❌ پاسخ، نام دستگاه و اپراتور را میدهد |
+
+---
+
+## ۲. تصمیم معماری
+
+سه راه روی میز بود:
+
+| گزینه | کار | چرا نه / چرا آری |
+|---|---|---|
+| **الف. مسیر عمومیِ موازی روی همان موتور** | سه اندپوینت عمومی که محیط را از مقصد resolve میکنند و گاردهای عمومی را دارند | ✅ **پیشنهادی.** موتور تخصیص، ظرفیت و اتمیکبودن یکی میماند؛ فقط لایهٔ ورودی/گارد جدا میشود |
+| ب. بیمار خودش منبع را انتخاب کند (مثل مودال پنل) | استفادهٔ مستقیم از `resource/{uuid}/service-slots` | ❌ بیمار نباید بین «لیزر CO2 شمارهٔ ۲» و «۳» انتخاب کند؛ این تصمیمِ کلینیک است و افشای ظرفیت داخلی هم هست |
+| ج. تنزل `resource` به `service` برای سایت | نادیده گرفتن منابع در مسیر عمومی | ❌ نوبتِ ثبتشده منبع نمیگیرد، پس پنل و سایت دو حقیقت متفاوت از ظرفیت میسازند و دابلبوکینگ قطعی است |
+
+**تصمیم: گزینهٔ الف.** هستهٔ `AvailabilityEngine` / `HoldService` / `BookingService` دست
+نمیخورد؛ فقط سه کنترلر نازکِ عمومی روی آن مینشیند.
+
+---
+
+## ۳. قرارداد API پیشنهادی
+
+همه زیر `/api/v1/public/...` تا از مسیر پنلی جدا بماند و در `security.yaml` یکجا
+whitelist شود. احراز هویتِ بیمار برای دو مرحلهٔ آخر لازم است (مثل `POST /api/v1/appointment`).
+
+### ۳.۱ `GET /api/v1/appointment-booking-services/{doctorUuid}` — بدون تغییر
+
+از قبل عمومی است و `booking_mode` را میدهد. سایت با دیدن `"resource"` جریان تازه را
+شروع میکند. تنها افزودهٔ لازم: در این حالت هم `services[]` باید سرویسهای **قابل رزرو
+آنلاین** باشند (`ServiceItem.bookable`).
+
+### ۳.۲ `GET /api/v1/public/resource-availability/month` — تقویم ماه
+
+```
+?doctor_uuid=…&clinic_uuid=…&service_uuid=…&item_uuids[]=…&year=1405&month=5
+→ { enabled_dates: [...], disabled_dates: [...], online_booking_enabled: true,
+ booking_window: { value: 3, unit: "month" } }
+```
+
+عمداً سبک: فقط «این روز ظرفیت دارد یا نه»، بدون ساختِ تخصیص. معادلِ موجودِ پنلیاش
+`appointment-availability/month` است.
+
+### ۳.۳ `POST /api/v1/public/resource-availability` — زمانهای یک روز
+
+```json
+{ "doctor_uuid": "…", "clinic_uuid": "…", "service_uuid": "…",
+ "item_uuids": ["…"], "date": "2026-08-04", "patient_gender": "woman" }
+```
+
+پاسخ **بدون** `assignment`:
+
+```json
+{ "plan": { "total_minutes": 75, "segments": [ { "name": "لیزر", "duration_minutes": 50 } ] },
+ "slots": [ { "start": 1785220200, "end": 1785224700 } ],
+ "reason": null }
+```
+
+> **افشای منابع ممنوع.** پاسخ پنلی `assignment` (نام دستگاه و اپراتور) دارد؛ نسخهٔ
+> عمومی فقط زمان میدهد. تخصیص سمت سرور در `hold` نگه داشته میشود.
+
+### ۳.۴ `POST /api/v1/public/resource-hold` — نگهداشتن موقت (احراز شده)
+
+ورودی: همان کلیدها + `start`. خروجی: `{ hold_uuid, starts_at, ends_at, expires_at }`.
+
+تخصیص منبع را **سرور** انتخاب میکند (`resource_strategy` محیط: `first_available` /
+`least_loaded` / …) و بیمار در آن نقشی ندارد. hold اجباری است: بین دیدن وقت و پرداخت،
+صندلی باید قفل شود وگرنه دو بیمار همزمان یک دستگاه را میخرند.
+
+### ۳.۵ `POST /api/v1/public/resource-confirm` — ثبت نهایی (احراز شده)
+
+ورودی `{ hold_uuid, patient_national_code, patient_gender, … }`. خروجی همان
+`appointment_uuid` + `price_snapshot`. نوبت `pending` متولد میشود و مسیر پرداخت/بیعانهٔ
+موجود دستنخورده میماند.
+
+### ۳.۶ گاردهای مشترکِ هر چهار اندپوینت
+
+1. `online_booking_enabled` برای همان (پزشک، محیط) — خاموش ⇒ `403` صریح، نه فهرست خالی.
+2. پنجرهٔ رزرو `booking_window_value/unit` — تاریخ خارج از پنجره ⇒ `422`.
+3. `booking_mode === 'resource'` — در غیر اینصورت `422` با اشاره به مسیر درست.
+4. سرویس باید `bookable` و مالِ همان محیط باشد.
+5. محیط از **مقصد** resolve میشود: `EntityContext::forBooking($doctor, $clinic)` — نه از کاربر.
+6. Rate limit روی `hold` (بیمار میتواند با چند hold ظرفیت را قفل کند).
+
+---
+
+## ۴. کلید روشن/خاموش برای مدیر کلینیک
+
+خواستهٔ صریح: **مدیر کلینیک باید بتواند نوبتدهی آنلاین را داشته باشد یا نه.**
+این کلید از قبل وجود دارد و لازم نیست چیز تازهای اختراع شود — فقط باید در مسیر
+منبعمحور هم **خوانده** شود.
+
+### وضع موجود
+
+| لایه | کجا |
+|---|---|
+| ذخیره | `WeeklySchedule.meta.online_booking_enabled` per (پزشک، محیط) — [WeeklySchedule.php:50](../../src/Appointment/Entity/WeeklySchedule.php#L50) |
+| کنترل در پنل | سوییچ «نوبتدهی آنلاین» در [ScheduleSection.tsx:886](../../assets/admin/components/schedule/ScheduleSection.tsx#L886) |
+| اعمال در مسیر اسلاتی/سرویسی | [SlotCalculatorService.php:197](../../src/Appointment/Service/SlotCalculatorService.php#L197) و `:315` (فقط وقتی `forManagement` نباشد) |
+| اثر روی فهرستها | پزشکِ همهخاموش از فهرست عمومی حذف میشود — [DoctorRepository.php:98](../../src/Doctor/Repository/DoctorRepository.php#L98) |
+
+نکتهٔ مهم: خاموشبودن آنلاین **هرگز** جلوی ثبت نوبت از پنل را نمیگیرد — منشی همچنان
+نوبت میدهد. همین رفتار باید در حالت منبعمحور هم عیناً حفظ شود.
+
+### آنچه باید اضافه شود
+
+1. **خواندن همان کلید در موتور منبعمحور.** هر چهار اندپوینت عمومی بند ۳ اول این را
+ بسنجند. بدون این، عمومیکردن موتور یعنی کلیدِ خاموشِ کلینیک بیاثر میشود — یعنی
+ نشت رفتار، نه یک نقص کوچک.
+2. **سه سطحِ کنترل، از درشت به ریز:**
+
+ | سطح | کلید | وضعیت |
+ |---|---|---|
+ | کل محیط/پزشک | `online_booking_enabled` | ✅ هست |
+ | هر سرویس | `ServiceItem.bookable` («نمایش در نوبتدهی») | ✅ هست |
+ | هر منبع | `ClinicResource.online_bookable` | ⛔ **پیشنهاد فاز ۲** |
+
+ سطح سوم برای دستگاهی است که باید در گردش کار داخلی بماند ولی مستقیم آنلاین فروخته
+ نشود. تا وقتی نیست، همان `active` تنها اهرم است — و خاموشکردنش نوبتدهی پنلی را هم
+ میکُشد، که همان چیزی نیست که مدیر میخواهد.
+3. **پاسخِ «خاموش است» باید صریح باشد.** فهرست خالی، هم بیمار را گیج میکند هم
+ پشتیبانی را. یک `403` با پیام «نوبتدهی آنلاین این کلینیک فعال نیست» + پنهانکردن
+ دکمهٔ رزرو در سایت.
+
+---
+
+## ۵. جریان کاربر در سایت
+
+```
+انتخاب محل (booking_mode از همان محل)
+ └─ resource ─→ ۱. انتخاب سرویس (یک یا چند) ← appointment-booking-services
+ ۲. تقویم ماه ← public/resource-availability/month
+ ۳. زمانهای روز ← public/resource-availability
+ ۴. نگهداشتن + شمارش معکوس ← public/resource-hold
+ ۵. مشخصات بیمار و پرداخت ← public/resource-confirm
+```
+
+نگاشت به کد موجود `nobat724_front`:
+
+| گام | فایل | کار |
+|---|---|---|
+| ارکستراسیون | `components/appointment/index.js` | شاخهٔ سوم برای `booking_mode === 'resource'` |
+| انتخاب سرویس | `components/appointment/service/` | تقریباً بدون تغییر؛ مدت کل از سرور میآید |
+| تقویم/ساعت | `components/appointment/date/`, `lib/appointmentSlots.js` | آداپتور سوم `adaptResourceSlots` با همان خروجیِ `{ start_time, end_time, label, slots }` |
+| تماسها | `services/response.js` | چهار متد تازه |
+| نگهداشتن | جدید | شمارش معکوس تا `expires_at`؛ معادلِ `HoldCountdown` پنل |
+
+قواعدی که همین حالا در `nobat724_front/CLAUDE.md` هست و اینجا هم برقرارند: مدت **دادهٔ
+سرور** است و در فرانت جمع زده نمیشود؛ مرزهای شیفت در فرانت استنتاج نمیشوند.
+
+رفتارهای لبهای که باید در UI دیده شوند:
+
+- `409` روی hold ⇒ «این زمان همین لحظه گرفته شد» + رفرش خودکار زمانها.
+- انقضای hold پیش از پرداخت ⇒ برگشت به گام ۳ با پیام روشن، نه خطای خام.
+- ترک صفحه ⇒ آزادسازی hold (`beforeunload` + TTL سمت سرور بهعنوان تور ایمنی).
+
+---
+
+## ۶. داده و مهاجرت
+
+فاز ۱ **هیچ مهاجرتی ندارد**: `resource_occupancy`، `appointment_holds` و
+`price_snapshots` از قبل هستند. تنها مهاجرتِ احتمالی، فیلد `online_bookable` روی
+`clinic_resources` در فاز ۲ است (پیشفرض `true`، عقبرو-سازگار).
+
+یک بدهیِ شناختهشده که این کار آن را برجسته میکند: نوبتهای مسیر پنلیِ منبع
+(`appointments.resource_id`) ردیف `resource_occupancy` نمیسازند، پس موتور آنها را
+نمیبیند ([appointment.md](../api/appointment.md)). تا وقتی رزرو آنلاین منبعمحور روشن
+نشده این فقط یک ناهماهنگی است؛ **بعد از آن، منبعِ دابلبوکینگ میشود.** بستنش
+پیشنیازِ فاز ۱ است، نه کارِ بعدی.
+
+---
+
+## ۷. فازبندی
+
+| فاز | کار | خروجی |
+|---|---|---|
+| ۰ | نوشتن occupancy برای نوبتهای منبعِ پنلی + آزادسازی روی لغو | یک منبعِ حقیقت برای اشغال |
+| ۱ | چهار اندپوینت عمومی + گاردها + تست (موفق/خطا/مرزی) + `docs/api/*` | API آمادهٔ مصرف |
+| ۲ | سایت: آداپتور، مراحل، شمارش معکوس، حالتهای لبه | رزرو آنلاین قابل استفاده |
+| ۳ | `ClinicResource.online_bookable` + نمایش «اتاق/پزشک» در تأیید نهایی | کنترل ریزتر |
+
+---
+
+## ۸. تصمیمهای باز
+
+1. **بیعانه/پرداخت آنلاین:** آیا رزرو منبعمحور مثل بقیه بیعانه میگیرد؟ اگر بله، مهلت
+ hold باید از مهلت درگاه بیشتر باشد وگرنه بیمار پول میدهد و وقت را از دست میدهد.
+2. **مهلت hold:** مقدار فعلی پنلی برای بیمارِ در حال تایپ کوتاه است. عدد جدا برای مسیر
+ عمومی؟
+3. **بیمه:** مسیر عمومی امروز بیمه نمیگیرد؛ نوبت منبعمحور فاکتور لحظهای دارد
+ (`PriceSnapshot`). تکلیف سهم بیمار در سایت باید روشن شود.
+4. **چند سرویس در یک نوبت آنلاین:** پنل اجازه میدهد؛ سایت هم؟ اگر بله، سقفِ مدت لازم است.
+
+---
+
+## ۹. چکلیست پذیرش
+
+- [ ] کلینیک با `booking_mode = resource` و `online_booking_enabled = true` در سایت قابل رزرو است.
+- [ ] خاموشکردن سوییچ، بلافاصله رزرو آنلاین را میبندد و **پنل دستنخورده** کار میکند.
+- [ ] پاسخ عمومی هیچ نام منبعی افشا نمیکند.
+- [ ] دو رزرو همزمان روی آخرین ظرفیت ⇒ یکی `409` میگیرد، نه هر دو موفق.
+- [ ] نوبتِ ثبتشده از سایت، در تایملاین منبعِ پنل دیده میشود و بالعکس.
+- [ ] تاریخ خارج از پنجرهٔ رزرو، در تقویم غیرفعال است.
diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php
index 4afef556..8019cacc 100644
--- a/src/Admin/Controller/AdminApiController.php
+++ b/src/Admin/Controller/AdminApiController.php
@@ -226,8 +226,20 @@ class AdminApiController extends BaseController
->from(User::class, 'u');
if ($search !== '') {
- $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s')
- ->setParameter('s', '%' . $search . '%');
+ // Persian/Arabic digits are what an admin actually types into the box; normalise
+ // them so both the mobile LIKE and the numeric user-id match behave.
+ $search = InputValidator::toEnglishDigits($search);
+
+ // A digits-only term is ambiguous: it can be a user id (#123) or part of a
+ // mobile number, so match either.
+ if (ctype_digit($search)) {
+ $qb->andWhere('u.id = :sid OR u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s')
+ ->setParameter('sid', (int) $search);
+ } else {
+ $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s');
+ }
+
+ $qb->setParameter('s', '%' . $search . '%');
}
if ($role !== '') {
diff --git a/tests/Admin/AdminUsersSearchTest.php b/tests/Admin/AdminUsersSearchTest.php
new file mode 100644
index 00000000..dd5f036e
--- /dev/null
+++ b/tests/Admin/AdminUsersSearchTest.php
@@ -0,0 +1,76 @@
+ $u['id'], $body['data']);
+ }
+
+ public function testSearchByUserIdReturnsThatUserOnly(): void
+ {
+ $target = $this->createUser(['ROLE_USER']);
+ $other = $this->createUser(['ROLE_USER']);
+ $admin = $this->createUser(['ROLE_ADMIN']);
+
+ $body = $this->authJson('GET', '/api/v1/admin/users?search=' . $target->getId(), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ self::assertTrue($body['success']);
+ self::assertContains($target->getId(), $this->idsOf($body));
+ self::assertNotContains($other->getId(), $this->idsOf($body));
+ }
+
+ public function testSearchByPersianDigitsMatchesUserId(): void
+ {
+ $target = $this->createUser(['ROLE_USER']);
+ $admin = $this->createUser(['ROLE_ADMIN']);
+
+ $persianId = strtr((string) $target->getId(), [
+ '0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴',
+ '5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹',
+ ]);
+
+ $body = $this->authJson('GET', '/api/v1/admin/users?search=' . urlencode($persianId), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ self::assertContains($target->getId(), $this->idsOf($body));
+ }
+
+ public function testSearchByMobileStillWorks(): void
+ {
+ $target = $this->createUser(['ROLE_USER']);
+ $admin = $this->createUser(['ROLE_ADMIN']);
+
+ $body = $this->authJson('GET', '/api/v1/admin/users?search=' . $target->getMobileNumber(), $admin);
+
+ self::assertSame(200, $this->responseCode());
+ self::assertContains($target->getId(), $this->idsOf($body));
+ }
+
+ public function testSearchByUnknownIdReturnsEmptyList(): void
+ {
+ $admin = $this->createUser(['ROLE_ADMIN']);
+
+ $body = $this->authJson('GET', '/api/v1/admin/users?search=999999999', $admin);
+
+ self::assertSame(200, $this->responseCode());
+ self::assertSame([], $body['data']);
+ self::assertSame(0, $body['meta']['totalRecords']);
+ }
+
+ public function testNonAdminForbidden(): void
+ {
+ $user = $this->createUser(['ROLE_USER']);
+ $this->authJson('GET', '/api/v1/admin/users?search=1', $user);
+ self::assertSame(403, $this->responseCode());
+ }
+}