Compare commits
287
Commits
v1.0.0
...
7716b40f6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7716b40f6a | ||
|
|
2471c90cbb | ||
|
|
a6a965a2aa | ||
|
|
60ccd5cc1d | ||
|
|
cfeb447645 | ||
|
|
cd793489ef | ||
|
|
3ffab2bbd0 | ||
|
|
948f59827a | ||
|
|
b49abee52f | ||
|
|
e48ab9a974 | ||
|
|
89a1428ca0 | ||
|
|
dcf9285467 | ||
|
|
f6ef086589 | ||
|
|
2da5b5188c | ||
|
|
08a344c99d | ||
|
|
fb1cb20c11 | ||
|
|
d74a351e5a | ||
|
|
35181cd715 | ||
|
|
3862a91fd1 | ||
|
|
47323daa27 | ||
|
|
934405c42d | ||
|
|
c452150a83 | ||
|
|
b699476305 | ||
|
|
da07e3ad9c | ||
|
|
6876135a53 | ||
|
|
a4a24c51af | ||
|
|
9b9a7500e2 | ||
|
|
7c3407b0b3 | ||
|
|
a6eee1ce9d | ||
|
|
d6de746938 | ||
|
|
6a6852379d | ||
|
|
294ca19a46 | ||
|
|
be5cdf8af6 | ||
|
|
073a68bcc2 | ||
|
|
52c45443c5 | ||
|
|
be93e15bad | ||
|
|
1d10f8c907 | ||
|
|
ccd869e442 | ||
|
|
0a22cf98fb | ||
|
|
a0fd85d910 | ||
|
|
ddd5f8f75a | ||
|
|
dc40651308 | ||
|
|
5211b34d0e | ||
|
|
aff7b7fd4a | ||
|
|
1d1efd7a85 | ||
|
|
0360a5e46f | ||
|
|
01100b90ad | ||
|
|
7a6bc313d2 | ||
|
|
bc8003562a | ||
|
|
f61b79b67e | ||
|
|
8672608696 | ||
|
|
e385056f09 | ||
|
|
288919339e | ||
|
|
02fdfd13cb | ||
|
|
c4099f0f63 | ||
|
|
de52d668c0 | ||
|
|
fc50ac4b3b | ||
|
|
50d82279d6 | ||
|
|
250e0b0813 | ||
|
|
fbaf98a1f5 | ||
|
|
c7a3b88b32 | ||
|
|
d857242145 | ||
|
|
d3f7812d15 | ||
|
|
9cabb28355 | ||
|
|
b78f7311cf | ||
|
|
12c0d57e1a | ||
|
|
e43e8ec93e | ||
|
|
93c11139fd | ||
|
|
a182b05e1f | ||
|
|
a6180bc7e7 | ||
|
|
5b3e80f92a | ||
|
|
384ea7b180 | ||
|
|
73963020e2 | ||
|
|
2bb76258f6 | ||
|
|
952e09bd6a | ||
|
|
00349cdb44 | ||
|
|
cc2e630c40 | ||
|
|
072e10d0ba | ||
|
|
8875b8c64a | ||
|
|
1366a7f15c | ||
|
|
251b45d807 | ||
|
|
38ea477429 | ||
|
|
f5902eeeeb | ||
|
|
a331aab2b8 | ||
|
|
b437390e06 | ||
|
|
103d913cd0 | ||
|
|
1f4f7e6927 | ||
|
|
a63de2a52c | ||
|
|
c7fdb92df4 | ||
|
|
d8aabe5d0a | ||
|
|
77eeefd5b4 | ||
|
|
1cdd62979f | ||
|
|
9a95bc59d4 | ||
|
|
12c1d2cbf4 | ||
|
|
252e20bfe9 | ||
|
|
9af763bfbe | ||
|
|
6847a473d4 | ||
|
|
e2e3e6b43b | ||
|
|
85985b04a0 | ||
|
|
1d43475724 | ||
|
|
1a07dad17c | ||
|
|
0e7970d6e0 | ||
|
|
f9bbdf1e7c | ||
|
|
9d767a6c63 | ||
|
|
df8a43f8b2 | ||
|
|
2db4c3c4b0 | ||
|
|
3e7028d77a | ||
|
|
0dca245246 | ||
|
|
4653cd0e4a | ||
|
|
85a27812c7 | ||
|
|
9b8eef9598 | ||
|
|
810e9351a9 | ||
|
|
2c7b86d917 | ||
|
|
429d7ed813 | ||
|
|
1db1ee4cd8 | ||
|
|
41b3109478 | ||
|
|
654c1e8303 | ||
|
|
cf0ca23f82 | ||
|
|
581a553516 | ||
|
|
05bb4a4ade | ||
|
|
4f69bc9044 | ||
|
|
981261ed3a | ||
|
|
28f853400b | ||
|
|
1543eddd9e | ||
|
|
dc728acaaf | ||
|
|
c688f48583 | ||
|
|
8a53299cc9 | ||
|
|
732fbd462f | ||
|
|
202be9a328 | ||
|
|
fd59eb57a3 | ||
|
|
a63abc31be | ||
|
|
cadf18d07a | ||
|
|
4f4c396754 | ||
|
|
ab4974d174 | ||
|
|
b03ae95bf8 | ||
|
|
babfacfc73 | ||
|
|
fd27ceef7d | ||
|
|
993478fcc0 | ||
|
|
3e3a2482fc | ||
|
|
8509a04ae2 | ||
|
|
00c275d618 | ||
|
|
b2b36e3eec | ||
|
|
82abd1bb52 | ||
|
|
17ce271b2c | ||
|
|
348e1cf517 | ||
|
|
c42679d98c | ||
|
|
4fe0c4f9bf | ||
|
|
f06efe26c0 | ||
|
|
bd4347f9c5 | ||
|
|
5e87bbc18b | ||
|
|
b8e8580867 | ||
|
|
9ba4c8d948 | ||
|
|
f144401dd3 | ||
|
|
dd284ec622 | ||
|
|
1c4f2a2451 | ||
|
|
444ebc897a | ||
|
|
eeb9ae851a | ||
|
|
4380e64a5d | ||
|
|
f11514950e | ||
|
|
e08876f7b4 | ||
|
|
03d8d7d68a | ||
|
|
f8e8a63ae8 | ||
|
|
1972fdd20f | ||
|
|
26425bec31 | ||
|
|
a6acf3bfe2 | ||
|
|
0a2ba88808 | ||
|
|
6d7c54508c | ||
|
|
021d9f82a2 | ||
|
|
a70a98769b | ||
|
|
826b940c00 | ||
|
|
8280aa7578 | ||
|
|
c4f1f25c80 | ||
|
|
9486721fa3 | ||
|
|
65d5831c64 | ||
|
|
4711ba0af7 | ||
|
|
fc6b865c15 | ||
|
|
369f3ae710 | ||
|
|
cbff3f3e3e | ||
|
|
03f09637ed | ||
|
|
df07d00adc | ||
|
|
5ae6c040c3 | ||
|
|
e5b74ebab4 | ||
|
|
bab7b57a9d | ||
|
|
4cc894e1f1 | ||
|
|
e530ab5678 | ||
|
|
2db7a500e6 | ||
|
|
f2600f9922 | ||
|
|
60b4fb9b93 | ||
|
|
ca2f9b8652 | ||
|
|
f8d4e97e35 | ||
|
|
beaa11334c | ||
|
|
2baa2ce7ca | ||
|
|
5c754244f2 | ||
|
|
fe48b10fb5 | ||
|
|
496432889d | ||
|
|
d98a0396a4 | ||
|
|
dedae05542 | ||
|
|
b3c331f0cb | ||
|
|
92edd175cc | ||
|
|
27c0b8f4f6 | ||
|
|
824e7f83c6 | ||
|
|
e9e61adfee | ||
|
|
83704f9d30 | ||
|
|
000cf70761 | ||
|
|
ba7cf7edc9 | ||
|
|
47a40e2021 | ||
|
|
c7811120d7 | ||
|
|
4bba322b8e | ||
|
|
913713f632 | ||
|
|
635bf3d2a8 | ||
|
|
29148b6bd3 | ||
|
|
4049daf071 | ||
|
|
b55c33f686 | ||
|
|
a8020e3c20 | ||
|
|
7f057f02ee | ||
|
|
0127b463a6 | ||
|
|
3dcc7c2ec0 | ||
|
|
aa6ea45a57 | ||
|
|
62f18b3c0d | ||
|
|
0074162bb1 | ||
|
|
4bdacdcc7b | ||
|
|
4bca659939 | ||
|
|
1559a60994 | ||
|
|
d56c41c87e | ||
|
|
1d6855a2b0 | ||
|
|
26a8e53b34 | ||
|
|
bd1f79f5e1 | ||
|
|
3c43955800 | ||
|
|
a379111606 | ||
|
|
fba1555f22 | ||
|
|
d831ce2c1c | ||
|
|
fc504f4415 | ||
|
|
edf22e0552 | ||
|
|
ca9648732d | ||
|
|
d6294242b7 | ||
|
|
bcfa87bfad | ||
|
|
56bd1b474a | ||
|
|
584ea4067f | ||
|
|
281420ab4d | ||
|
|
34b07421bd | ||
|
|
cd12fabe14 | ||
|
|
4395eea56e | ||
|
|
5d93208383 | ||
|
|
24534ec483 | ||
|
|
7cca433c56 | ||
|
|
22c89fbae4 | ||
|
|
04526542c9 | ||
|
|
b1b06c1b36 | ||
|
|
0f4db93fb8 | ||
|
|
4d722830e3 | ||
|
|
1fdfdf9e48 | ||
|
|
73456447b2 | ||
|
|
04d3222559 | ||
|
|
964c09cc00 | ||
|
|
92181bacff | ||
|
|
d813843fcd | ||
|
|
eebb363b9f | ||
|
|
a44cf8f9f7 | ||
|
|
043713275c | ||
|
|
118b7f9fba | ||
|
|
9891c2e44a | ||
|
|
56a3c3c0d6 | ||
|
|
7482eb2ba3 | ||
|
|
f1ea7bb161 | ||
|
|
9502ed61f6 | ||
|
|
4fbedecec1 | ||
|
|
96095c05f3 | ||
|
|
50759bf663 | ||
|
|
0051205bf2 | ||
|
|
bfe7f36a45 | ||
|
|
231735162e | ||
|
|
02cceb1c72 | ||
|
|
b589a851d0 | ||
|
|
2dae76cac0 | ||
|
|
b784124b7e | ||
|
|
7eb0b1566f | ||
|
|
6c2e075eea | ||
|
|
aaa864f408 | ||
|
|
6afc5c090e | ||
|
|
113e8d93a0 | ||
|
|
c37c0cee39 | ||
|
|
f2ecaf201a | ||
|
|
70739691d1 | ||
|
|
158dcb58aa | ||
|
|
021d0eb6b2 | ||
|
|
1d338503c8 | ||
|
|
57aeb40934 |
+20
-2
@@ -1,6 +1,6 @@
|
||||
# راهنمای مشترک `prompt-writer` و `run-prompt`
|
||||
# راهنمای مشترک اسکیلهای این پروژه
|
||||
|
||||
این فایل مرجع کیفیت هر دو اسکیل است. هر دو اسکیل باید در ابتدای بدنهٔ خود این خط را داشته باشند:
|
||||
این فایل مرجع کیفیت همهٔ اسکیلهای پروژه است. هر اسکیل باید در ابتدای بدنهٔ خود این خط را داشته باشد:
|
||||
|
||||
> قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن.
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
|
||||
---
|
||||
|
||||
## ۰. Grill — قبل از هر تغییر (اجباری)
|
||||
|
||||
قبل از نوشتن کد، ساختن فایل یا اجرای هر تغییر:
|
||||
|
||||
1. ابزار Skill را با `skill: "mattpocock-skills:grilling"` صدا بزن.
|
||||
2. طبق آن اسکیل، سؤالها را **یکییکی** بپرس و بعد از هر سؤال منتظر جواب بمان.
|
||||
3. هر چیزی که با ابزار قابل کشف است را نپرس. خودت پیدا کن.
|
||||
4. اول `graphify query "<سؤال>"` بزن، بعد فایل بخوان.
|
||||
5. فقط **تصمیمها** را از کاربر بپرس، نه **واقعیتها**.
|
||||
6. برای هر سؤال، پاسخ پیشنهادی خودت را هم بنویس.
|
||||
7. تا وقتی کاربر «تأیید» نداده، هیچ فایلی را تغییر نده.
|
||||
|
||||
خروجی این مرحله یک درک مشترک است. بعد از تأیید، برو سراغ §۱.
|
||||
|
||||
**استثنا:** اگر کاربر گفت «بدون grill»، «مستقیم انجام بده» یا «سؤال نپرس»، این بخش را رد کن.
|
||||
|
||||
---
|
||||
|
||||
## ۱. درک مسئله — قبل از هر خط کد
|
||||
|
||||
هیچ چیز گرانتر از پیادهسازیِ درستِ مسئلهٔ اشتباه نیست. به همین دلیل:
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
# راهبر اجرای تسکهای موتور نوبتدهی — یک تسک در هر اجرا
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (بکاند + پنل ادمین)
|
||||
|
||||
پرامپت همتا برای سایت عمومی: `nobat724_front/.claude/prompt/booking-engine-task-00b-service-mode.md`
|
||||
(همین راهبر وقتی به تسک ۰۰ب برسد، تحویلش میدهد و میایستد.)
|
||||
|
||||
---
|
||||
|
||||
## زمینه
|
||||
|
||||
`docs/new_feture/taskes/` شانزده تسک برای پیادهسازی
|
||||
[مستند موتور نوبتدهی](../../docs/new_feture/clinic-pro-mostanad-sade.md) دارد. هر تسک یک
|
||||
پوشه با شش فایل است و **خودش یک پرامپت کامل است**: `task.md` (دامنه و معیار پذیرش)،
|
||||
`architecture.md` (فایلها و سرویسها)، `database.md` (جداول و migration)،
|
||||
`implementation_notes.md` (نکات و edge case و تست)، `checklist.md` (وضعیت)، و برای
|
||||
تسکهای پیچیده `user_flow.md`.
|
||||
|
||||
جمع زمان تخمینی ۲۱۴ تا ۲۵۶ ساعت است. هیچ اجرایی نمیتواند همه را در یک نشست تمام کند.
|
||||
|
||||
**پس این پرامپت خودش قابلیت نیست — راهبر است.** هر اجرا:
|
||||
|
||||
```
|
||||
وضعیت را از checklist.md ها بخوان
|
||||
→ تسک بعدی واجد شرایط را انتخاب کن
|
||||
→ همان یک تسک را کامل انجام بده (پیادهسازی + تست + مستند + چکلیست)
|
||||
→ commit + graphify update
|
||||
→ بایست و بگو تسک بعدی چیست
|
||||
```
|
||||
|
||||
همین پرامپت را دوباره اجرا کن تا تسک بعدی برود. تا وقتی همهٔ چکلیستها ✅ نشدهاند، کار
|
||||
تمام نیست.
|
||||
|
||||
---
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**هدف:** یک مسیر اجرای تکرارشونده و ایمن که شانزده تسک را به ترتیب وابستگی، یکییکی و
|
||||
بدون از دست دادن وضعیت پیش ببرد — طوری که هر اجرا مستقل باشد و قطع شدن وسط کار،
|
||||
کار انجامشده را از بین نبرد.
|
||||
|
||||
**چرا این شکل و نه یک پرامپت غول:** وضعیت در `checklist.md` روی دیسک زندگی میکند، نه در
|
||||
context. اجرای بعدی همان فایل را میخواند و میفهمد کجا مانده. هزینهٔ این طراحی یک
|
||||
مرحلهٔ «خواندن وضعیت» در ابتدای هر اجراست؛ سودش این است که هیچ اجرایی به context اجرای
|
||||
قبلی وابسته نیست.
|
||||
|
||||
---
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
معیار پذیرش این راهبر، **رفتار خودِ راهبر** است. معیار پذیرش هر تسک در `task.md` همان تسک
|
||||
است و اینجا تکرار نمیشود.
|
||||
|
||||
- ✅ **موفق:** اجرا با شناسایی وضعیت شروع میشود (جدول ۱۶ تسک با ✅/🔄/⏳)، دقیقاً یک تسک
|
||||
انتخاب میشود، همهٔ ردیفهای `checklist.md` آن تسک به ✅ میرسند، `bin/phpunit` و
|
||||
`--group=slot-mode-frozen` سبزند، `docs/api/*` بهروز است، یک commit زده میشود،
|
||||
`graphify update .` اجرا میشود، و اجرا با اعلام تسک بعدی تمام میشود.
|
||||
- ✅ **موفق (ازسرگیری):** اجرای دوم روی همان مخزن، تسک تمامشده را **دوباره انجام نمیدهد**
|
||||
و مستقیم سراغ تسک بعدی میرود.
|
||||
- ✅ **موفق (نیمهکاره):** اگر چکلیستی ردیف 🔄 دارد، همان تسک ادامه داده میشود، نه تسک بعدی.
|
||||
- ❌ **خطا:** اگر تسکی وابستگیاش ✅ نیست → اجرا شروع نمیشود؛ پیام روشن با نام تسک
|
||||
پیشنیاز و توقف.
|
||||
- ❌ **خطا:** اگر `--group=slot-mode-frozen` قرمز شد → **توقف کامل**، rollback تغییرات آن
|
||||
مرحله، گزارش دقیق کدام fixture شکست. fixture هرگز بهروز نمیشود.
|
||||
- ❌ **خطا:** اگر تستی سبز نشد → تسک `completed` نمیشود؛ ردیف چکلیست 🔄 میماند و اجرا
|
||||
با گزارش خطا تمام میشود. تسک بعدی شروع نمیشود.
|
||||
- ⚠️ **مرزی:** همهٔ ۱۶ چکلیست ✅ → پیام «همهٔ تسکها تمام شدهاند» و توقف بدون تغییر.
|
||||
- ⚠️ **مرزی:** تسک انتخابشده ۰۰ب است (پروژهٔ دیگر) → اجرا **کد نمیزند**؛ دستور اجرای
|
||||
پرامپت `nobat724_front` را میدهد و میایستد.
|
||||
- ⚠️ **مرزی:** کاربر شمارهٔ تسک را صریح داد (`/run-prompt … --task=05`) → همان تسک، ولی
|
||||
وابستگیها همچنان بررسی میشوند و اگر ناقص بودند توقف.
|
||||
- ⚠️ **مرزی:** ردیفی از چکلیست به دلیل موجه قابل انجام نیست → ⏳ میماند **با دلیل مکتوب و
|
||||
تسک مقصد** در ستون یادداشت. ⏳ بیدلیل = تسک تمام نشده.
|
||||
|
||||
---
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `docs/new_feture/taskes/README.md` | فهرست ۱۶ تسک، وابستگیها، ترتیب اجرا |
|
||||
| `docs/new_feture/taskes/00-current-state-report.md` | تحلیل شکاف وضعیت فعلی در برابر مستند |
|
||||
| `docs/new_feture/taskes/_shared/red-lines.md` | ⛔ قواعد قفلشده — منطق اسلاتی دستکاری نمیشود |
|
||||
| `docs/new_feture/taskes/_shared/ui-conventions.md` | 🎨 توکنها و کامپوننتهای اجباری |
|
||||
| `docs/new_feture/taskes/_shared/definition-of-done.md` | ☑️ نمادها و بازبینی پایانی |
|
||||
| `docs/new_feture/taskes/task-XX-*/task.md` | دامنه و معیار پذیرش هر تسک |
|
||||
| `docs/new_feture/taskes/task-XX-*/architecture.md` | فایلها، سرویسها، قواعد UI |
|
||||
| `docs/new_feture/taskes/task-XX-*/database.md` | جداول، ایندکس، migration، backfill |
|
||||
| `docs/new_feture/taskes/task-XX-*/implementation_notes.md` | edge case و فهرست تست |
|
||||
| `docs/new_feture/taskes/task-XX-*/checklist.md` | **وضعیت پایدار — منبع حقیقت پیشرفت** |
|
||||
| `docs/new_feture/taskes/task-XX-*/user_flow.md` | جریان کاربری (۰۰، ۰۰ب، ۰۵، ۰۶، ۰۷، ۱۲) |
|
||||
| `CLAUDE.md` · `docs/architecture/tenancy.md` | قواعد ثابت پروژه |
|
||||
|
||||
---
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
هر شانزده `checklist.md` ساخته شده و **همهٔ ردیفهایشان `⏳` است**. هیچ تسکی شروع نشده.
|
||||
|
||||
ساختار ثابت هر چکلیست:
|
||||
|
||||
```markdown
|
||||
# چکلیست — تسک XX (عنوان)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
|
||||
## ۰. خط سرخ
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
...
|
||||
|
||||
## ۱. بکاند
|
||||
## ۲. دیتابیس
|
||||
## ۳. UI
|
||||
## ۴. تست
|
||||
## ۵. مستندات
|
||||
## ۶. بازبینی پایانی
|
||||
```
|
||||
|
||||
نمادها (از `_shared/definition-of-done.md`):
|
||||
|
||||
| نماد | معنی | اجازهٔ باقیماندن در پایان تسک |
|
||||
|---|---|---|
|
||||
| ✅ | انجامشده و تأییدشده | بله |
|
||||
| 🔄 | در حال انجام | **نه** |
|
||||
| ⏳ | انجامنشده | **نه** — مگر با دلیل مکتوب و تسک مقصد |
|
||||
| ⚠️ | نیازمند بررسی یا تست | **نه** — باید تعیین تکلیف شود |
|
||||
|
||||
ترتیب و وابستگی (از `README.md`):
|
||||
|
||||
```
|
||||
۰۰ ── ۰۰ب
|
||||
│
|
||||
├─ ۰۱ ─┬─ ۰۲ ── ۰۳ ─┐
|
||||
│ └─ ۰۴ ── ۰۵ ─┴─ ۰۶ ── ۰۷ ─┬─ ۰۸ ─┬─ ۰۹ ── ۱۰
|
||||
│ │ └─ ۱۱ ── ۱۲
|
||||
│ ├─ ۱۳
|
||||
│ └─ ۱۴
|
||||
```
|
||||
|
||||
| تسک | پروژه | وابستگی |
|
||||
|---|---|---|
|
||||
| ۰۰ تکمیل نوبتدهی سرویسی | `clinicpro` | — |
|
||||
| ۰۰ب سازگارسازی سایت | `nobat724_front` | ۰۰ |
|
||||
| ۰۱ شعبه و اتاق | `clinicpro` | ۰۰ |
|
||||
| ۰۲ منبع، مهارت، استخر | `clinicpro` | ۰۱ |
|
||||
| ۰۳ تقویم منبع | `clinicpro` | ۰۱، ۰۲ |
|
||||
| ۰۴ کاتالوگ خدمات v2 | `clinicpro` | ۰۱ |
|
||||
| ۰۵ بخشهای نوبت | `clinicpro` | ۰۲، ۰۴ |
|
||||
| ۰۶ جستجوی وقت چندمنبعی | `clinicpro` | ۰۳، ۰۵ |
|
||||
| ۰۷ رزرو موقت و ثبت | `clinicpro` | ۰۶ |
|
||||
| ۰۸ قیمت و snapshot | `clinicpro` | ۰۴، ۰۷ |
|
||||
| ۰۹ موتور قوانین | `clinicpro` | ۰۵، ۰۶، ۰۸ |
|
||||
| ۱۰ فرم و sandbox قانون | `clinicpro` | ۰۹ |
|
||||
| ۱۱ پکیج و دفتر اعتبار | `clinicpro` | ۰۸ |
|
||||
| ۱۲ دوره درمان | `clinicpro` | ۰۷، ۱۱ |
|
||||
| ۱۳ لغو، عدم حضور، انتظار | `clinicpro` | ۰۷ |
|
||||
| ۱۴ رویدادها و بهرهوری | `clinicpro` | ۰۷ |
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
> ⚠️ این هفت وظیفه **مراحل یک اجرا**ی راهبرند، نه هفت قابلیت مستقل. `todo` را از روی
|
||||
> ردیفهای `checklist.md` تسکِ انتخابشده بساز، نه از روی این هفت مرحله.
|
||||
|
||||
### ۱. خواندن قواعد حاکم — پیش از هر چیز
|
||||
|
||||
سه سند را کامل بخوان و در همین اجرا اعمال کن:
|
||||
|
||||
```
|
||||
docs/new_feture/taskes/_shared/red-lines.md
|
||||
docs/new_feture/taskes/_shared/ui-conventions.md
|
||||
docs/new_feture/taskes/_shared/definition-of-done.md
|
||||
```
|
||||
|
||||
در تناقض با متن هر تسک، **این سه برندهاند**.
|
||||
|
||||
مهمترین بندشان:
|
||||
|
||||
- ⛔ منطق اسلاتی (`booking_mode = 'slot'`) در **هیچ تسکی** دستکاری نمیشود. فهرست فایلها و
|
||||
متدهای قفلشده در `red-lines.md` است.
|
||||
- 🎨 هر صفحه یا کامپوننت جدید عیناً با دیزاینسیستم موجود — توکنهای `assets/admin/styles.css`،
|
||||
کامپوننتهای `assets/admin/components/ui/`، پنج قاعدهٔ غیرقابلمذاکره.
|
||||
- ☑️ هیچ تسکی بدون تکمیل چکلیستش تمام نیست.
|
||||
|
||||
**نحوه تست:** بعد از خواندن، در گزارش شروع بنویس کدام سه سند خوانده شد و مهمترین قید
|
||||
مربوط به تسک انتخابشده چیست (یک جمله).
|
||||
|
||||
---
|
||||
|
||||
### ۲. شناسایی وضعیت و انتخاب تسک
|
||||
|
||||
همهٔ چکلیستها را بخوان و وضعیت هر تسک را از **ردیفهایش** استنتاج کن، نه از خط
|
||||
«وضعیت کلی» (که ممکن است بهروز نشده باشد):
|
||||
|
||||
```bash
|
||||
# فهرست چکلیستها به ترتیب
|
||||
ls -1 docs/new_feture/taskes/*/checklist.md | sort
|
||||
|
||||
# شمارش نمادها per تسک — دیدِ سریع وضعیت
|
||||
for f in docs/new_feture/taskes/*/checklist.md; do
|
||||
echo "$(dirname "$f" | xargs basename): ✅=$(grep -c '| ✅ |' "$f") 🔄=$(grep -c '| 🔄 |' "$f") ⏳=$(grep -c '| ⏳ |' "$f") ⚠️=$(grep -c '| ⚠️ |' "$f")"
|
||||
done
|
||||
```
|
||||
|
||||
قاعدهٔ استنتاج:
|
||||
|
||||
```
|
||||
تمامشده = هیچ ردیف 🔄 · هیچ ردیف ⚠️ · هیچ ردیف ⏳ بدون یادداشتِ دلیل
|
||||
در جریان = حداقل یک ردیف 🔄 یا ⚠️
|
||||
شروع نشده = همهٔ ردیفها ⏳ و هیچ یادداشتی
|
||||
```
|
||||
|
||||
قاعدهٔ انتخاب، به همین ترتیب:
|
||||
|
||||
1. اگر تسکی **در جریان** است → همان. (تسک نیمهکاره رها نمیشود.)
|
||||
2. وگرنه اولین تسکِ **شروع نشده** که همهٔ وابستگیهایش **تمامشده** اند.
|
||||
3. اگر کاربر `--task=XX` داد → همان، ولی وابستگیها همچنان بررسی میشوند.
|
||||
4. اگر همه تمامشدهاند → پیام و توقف.
|
||||
5. اگر تسک انتخابشده وابستگی ناقص دارد → توقف با نام تسک پیشنیاز.
|
||||
|
||||
جدول وضعیت را به کاربر نشان بده و تسک انتخابشده را اعلام کن:
|
||||
|
||||
```
|
||||
📊 وضعیت تسکها
|
||||
۰۰ تکمیل سرویسی ✅ تمامشده
|
||||
۰۰ب سازگارسازی سایت ✅ تمامشده
|
||||
۰۱ شعبه و اتاق 🔄 در جریان (۱۲/۳۸)
|
||||
۰۲ منبع و مهارت ⏳ شروع نشده
|
||||
…
|
||||
🎯 تسک انتخابشده: ۰۱ — شعبه و اتاق (ادامهٔ کار نیمهکاره)
|
||||
```
|
||||
|
||||
**نحوه تست:** دستور شمارش بالا را اجرا کن و خروجی واقعیاش را در گزارش بگذار. جدول
|
||||
نمایشدادهشده باید با آن خروجی بخواند.
|
||||
|
||||
---
|
||||
|
||||
### ۳. تحویل به پرامپت همتا اگر تسک ۰۰ب انتخاب شد
|
||||
|
||||
تسک ۰۰ب روی `nobat724_front` است و در این پروژه هیچ کدی ندارد.
|
||||
|
||||
اگر انتخاب ۰۰ب شد، **هیچ فایلی را تغییر نده** و این را چاپ کن و بایست:
|
||||
|
||||
```
|
||||
🔀 تسک ۰۰ب روی پروژهٔ nobat724_front است.
|
||||
اجرا کن: /run-prompt nobat724_front/.claude/prompt/booking-engine-task-00b-service-mode.md
|
||||
بعد از تمام شدنش، همین راهبر را دوباره اجرا کن تا تسک ۰۱ برود.
|
||||
```
|
||||
|
||||
**نحوه تست:** موقتاً همهٔ ردیفهای `task-00-service-mode-completion/checklist.md` را ✅ کن،
|
||||
راهبر را اجرا کن، و ببین همین پیام چاپ میشود و هیچ فایلی در `src/` عوض نمیشود.
|
||||
بعد تغییر موقت را برگردان.
|
||||
|
||||
---
|
||||
|
||||
### ۴. اجرای تسک انتخابشده — یکی، کامل
|
||||
|
||||
چهار (یا پنج) فایل تسک را **کامل** بخوان:
|
||||
|
||||
```
|
||||
task-XX-*/task.md ← دامنه، endpoint ها، معیار پذیرش
|
||||
task-XX-*/architecture.md ← فایلها، سرویسها، الگوها، قواعد UI
|
||||
task-XX-*/database.md ← جداول، ایندکس، migration، backfill
|
||||
task-XX-*/implementation_notes.md ← edge case ها، فهرست تست، تلههای مشخص
|
||||
task-XX-*/user_flow.md ← اگر وجود دارد
|
||||
```
|
||||
|
||||
بعد کد واقعی مرتبط را بخوان — **هیچ فرضی از حافظه** (guidelines §۱).
|
||||
|
||||
سپس `todo` بساز: **یک آیتم به ازای هر ردیفِ غیر-✅ چکلیست**، مرتب بر اساس بخش
|
||||
(۰ خط سرخ → ۱ بکاند → ۲ دیتابیس → ۳ UI → ۴ تست → ۵ مستندات → ۶ بازبینی پایانی).
|
||||
|
||||
اجرای هر ردیف با همان هفتمرحلهٔ `run-prompt`:
|
||||
|
||||
```
|
||||
① تحلیل ② طراحی ③ پیادهسازی ④ تست ⑤ رفع خطا ⑥ مستندسازی ⑦ چکلیست + گزارش
|
||||
```
|
||||
|
||||
قواعدی که در این مرحله شکستنی نیستند:
|
||||
|
||||
- **بخش ۰ چکلیست اول از همه.** خط سرخها پیش از هر کد بررسی میشوند. در تسک ۰۰ این یعنی
|
||||
ساختن `SlotModeFrozenTest` و سه fixture **قبل** از هر تغییر — وگرنه snapshot وضعیت
|
||||
تغییریافته گرفته میشود و چیزی را تضمین نمیکند.
|
||||
- **یک ردیف در هر لحظه `in_progress`.**
|
||||
- **ردیف فقط با هر سه ✅ میشود:** پیادهسازی + تست سبز + مستند بهروز.
|
||||
- **خطا = توقف کامل.** هیچ ردیفی روی خرابهٔ ردیف قبلی ساخته نمیشود.
|
||||
- **کشف کار جدید** (باگ جانبی، وابستگی پنهان) → ردیف جدید به همان `checklist.md` **اضافه
|
||||
کن** و به کاربر بگو. انجام بیصدا ممنوع.
|
||||
|
||||
**نحوه تست:** دستورهای هر پروژه بعد از هر ردیفِ منطقدار:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console cache:clear
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction # اگر entity عوض شد
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/phpunit tests/<Domain> # تست همان تسک
|
||||
ddev exec php bin/phpunit --group=slot-mode-frozen # ⛔ بعد از هر ردیف
|
||||
ddev exec php vendor/bin/phpstan analyse src/<Domain>
|
||||
npx tsc --noEmit --project tsconfig.json # اگر tsx عوض شد
|
||||
yarn dev # بررسی خطای build
|
||||
yarn test
|
||||
```
|
||||
|
||||
برای هر endpoint، تست واقعی با داده واقعی — نه فقط `php -l`. کاربر تست:
|
||||
`09390039833 / 09390039833` (اگر کار نکرد، اعتبارهای per-نقش با `QaTest@1234`).
|
||||
|
||||
---
|
||||
|
||||
### ۵. بهروزرسانی زندهٔ `checklist.md`
|
||||
|
||||
چکلیست **حافظهٔ بیناجرایی** است. اگر بهروز نشود، اجرای بعدی کار انجامشده را دوباره
|
||||
انجام میدهد.
|
||||
|
||||
قواعد نوشتن:
|
||||
|
||||
- ردیف را **همان لحظه** که شروع میکنی `⏳ → 🔄` کن، نه در پایان تسک
|
||||
- تمام شد → `🔄 → ✅` با یادداشت کوتاه اگر چیزی برای گفتن هست (مسیر فایل، عدد اندازهگیریشده)
|
||||
- شکست خورد یا بلاک شد → `⚠️` با **دلیل** در ستون یادداشت
|
||||
- به تعویق افتاد → `⏳` با **دلیل + تسک مقصد** در یادداشت. `⏳` بیدلیل = تسک تمام نشده.
|
||||
- خط سربرگ را هم بهروز کن:
|
||||
|
||||
```markdown
|
||||
**وضعیت کلی:** 🔄 در حال انجام · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۳
|
||||
```
|
||||
|
||||
پیش از رفتن به وظیفهٔ ۶، **بخش ۶ (یا ۷/۸ بسته به تسک) بازبینی پایانی** را ردیفبهردیف
|
||||
اجرا کن. ردیفهای مشترک همهٔ تسکها:
|
||||
|
||||
```
|
||||
همهٔ ردیفهای بالا وضعیت نهایی دارند (هیچ 🔄 و ⏳ بیدلیل)
|
||||
ddev exec php bin/phpunit کامل سبز
|
||||
ddev exec php bin/phpunit --group=slot-mode-frozen سبز
|
||||
ddev exec php vendor/bin/phpstan analyse بدون خطای جدید
|
||||
npx tsc --noEmit بدون خطا
|
||||
yarn test سبز
|
||||
TenantSchemaCoverageTest + TenantLookupInventoryTest سبز
|
||||
docs/api/* بهروز شد
|
||||
چکلیست UI کامل شد (اگر تسک صفحه دارد)
|
||||
nobat724_front و clinic-pro-tauri دستی بررسی شدند
|
||||
commit شد، سپس graphify update .
|
||||
موارد بهتعویقافتاده با دلیل و تسک مقصد ثبت شدند
|
||||
```
|
||||
|
||||
ردیف «دو کلاینت دیگر» در build هیچکدام خطا نمیدهد — بررسی **فقط دستی** ممکن است
|
||||
(guidelines §۳). نتیجه را با ذکر فایلهای بررسیشده گزارش بده، نه با «بررسی شد».
|
||||
|
||||
**نحوه تست:** بعد از بهروزرسانی، دستور شمارش وظیفهٔ ۲ را دوباره اجرا کن. تسک باید
|
||||
`✅=<همه> 🔄=0 ⏳=0 ⚠️=0` بدهد.
|
||||
|
||||
---
|
||||
|
||||
### ۶. Commit و بهروزرسانی گراف
|
||||
|
||||
**ترتیب مهم است:** اول commit، بعد `graphify update` — وگرنه گراف وضعیت کامیتنشده را
|
||||
ایندکس میکند.
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(booking): <عنوان انگلیسی تسک>
|
||||
|
||||
<دو-سه خط: چه چیزی اضافه شد، چه چیزی عمداً دستنخورده ماند>
|
||||
|
||||
Task: docs/new_feture/taskes/task-XX-<name>/
|
||||
Slot-mode contract: unchanged (--group=slot-mode-frozen green)
|
||||
|
||||
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
|
||||
graphify update .
|
||||
```
|
||||
|
||||
پیام کامیت انگلیسی (قاعدهٔ پروژه). خط `Slot-mode contract` اجباری است — رد مکتوبِ
|
||||
اینکه خط سرخ رعایت شده.
|
||||
|
||||
اگر روی برنچ `main` هستی، **اول برنچ بساز**:
|
||||
|
||||
```bash
|
||||
git checkout -b feat/booking-engine-task-XX
|
||||
```
|
||||
|
||||
**نحوه تست:** `git log --oneline -1` و `git status` را در گزارش بگذار.
|
||||
|
||||
---
|
||||
|
||||
### ۷. گزارش پایانی و اعلام تسک بعدی
|
||||
|
||||
اجرا با این قالب تمام میشود و **تسک بعدی شروع نمیشود**:
|
||||
|
||||
```
|
||||
✅ تسک XX — <عنوان> تمام شد
|
||||
|
||||
پیادهسازی
|
||||
• <سه تا پنج خط: چه ساخته شد، با مسیر فایل>
|
||||
|
||||
تست
|
||||
• bin/phpunit : <N> تست، سبز
|
||||
• --group=slot-mode-frozen : سبز
|
||||
• phpstan / tsc / yarn test : سبز
|
||||
• تستهای جدید این تسک : <فهرست کوتاه>
|
||||
|
||||
مستندات
|
||||
• <فایلهای docs/api/* و docs/architecture/* که عوض شدند>
|
||||
|
||||
خط سرخ
|
||||
• منطق اسلاتی دستنخورده — <یک جمله شاهد، مثلاً: هیچ متد SlotCalculatorService عوض نشد>
|
||||
|
||||
کلاینتهای دیگر
|
||||
• nobat724_front : <چه بررسی شد و نتیجه>
|
||||
• clinic-pro-tauri: <همان>
|
||||
|
||||
به تعویق افتاد (اگر هست)
|
||||
• <ردیف> — دلیل: <…> — تسک مقصد: <XX>
|
||||
|
||||
commit: <hash کوتاه> · graphify: بهروز شد
|
||||
|
||||
────────────────────────────────
|
||||
🎯 تسک بعدی: XX — <عنوان> (وابستگیها: ✅)
|
||||
اجرا کن: /run-prompt clinicpro/.claude/prompt/booking-engine-task-runner.md
|
||||
```
|
||||
|
||||
اگر تسک ناتمام ماند، بهجای `✅` بنویس:
|
||||
|
||||
```
|
||||
⛔ تسک XX ناتمام ماند
|
||||
|
||||
بلاککننده: <چه چیزی و کجا>
|
||||
ردیفهای باقی: <فهرست با وضعیت>
|
||||
چکلیست بهروز شد؛ اجرای بعدی از همینجا ادامه میدهد.
|
||||
```
|
||||
|
||||
**نحوه تست:** گزارش باید با خروجی واقعی دستورها بخواند. عدد تست ساختگی ننویس.
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
### خط سرخ — تنها چیزی که هیچ تسکی نمیتواند نقض کند
|
||||
|
||||
`booking_mode = 'slot'` منطق تولیدیِ زنده است. `_shared/red-lines.md` فهرست دقیق دارد؛
|
||||
خلاصهاش:
|
||||
|
||||
| قفل | یعنی |
|
||||
|---|---|
|
||||
| هفت متد عمومی `SlotCalculatorService` | امضا و رفتار عوض نمیشود |
|
||||
| `GET /api/v1/appointment-slots` | قرارداد request/response |
|
||||
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | قرارداد |
|
||||
| `Appointment::active_slot_key` و `refreshActiveSlotKey()` | مکانیزم یکتایی موجود |
|
||||
| `AppointmentRepository::isSlotTaken` | امضا و معنا |
|
||||
| `WeeklySchedule::DEFAULT_META['booking_mode']` | مقدار `slot` میماند |
|
||||
|
||||
**مجاز:** افزودن متد جدید · کلاس موازی · ستون تهیپذیر · کلید جدید در `meta` با حفظ پیشفرض
|
||||
**ممنوع:** تغییر امضای متد موجود · refactor «یکدستسازی» · اعمال قوانین جدید روی حالت `slot`
|
||||
|
||||
fixture های `tests/Appointment/fixtures/slot-mode-contract.json` و
|
||||
`month-availability-contract.json` و `slot-calculator-signatures.php` **read-only** اند.
|
||||
اگر تست قرمز شد، **کد برمیگردد، نه fixture**. تسک ۰۰ آنها را میسازد؛ هیچ تسک دیگری
|
||||
اجازهٔ بهروزرسانیشان را ندارد.
|
||||
|
||||
### فاز ۰ اختیاری نیست
|
||||
|
||||
تسک ۰۰ و ۰۰ب پیشنیاز واقعیاند، نه توصیه. حالت `service` امروز پنج شکاف در چرخهٔ عمر
|
||||
نوبت دارد (ویرایش، جابهجایی، رزرو، پنل بیمار، دیزاینسیستم سایت — جزئیات در
|
||||
`00-current-state-report.md` بخش ۲-۵ب). اگر حالت `resource` (تسک ۰۶) روی این پایه ساخته
|
||||
شود، هر باگ موجود به موتور جدید ارث میرسد و تشخیص منبعش غیرممکن میشود.
|
||||
|
||||
### دیزاینسیستم — هر صفحهٔ جدید
|
||||
|
||||
`_shared/ui-conventions.md` کامل است. پنج قاعدهای که رعایت نکردنشان یعنی رد شدن ردیف:
|
||||
|
||||
1. `SearchableSelect`، هرگز `<select>` بومی
|
||||
2. زیرصفحهها `backTo` یا `<BackButton fallback="…" />` — دکمهٔ دستساز نه
|
||||
3. وضعیت لیست (جستجو/فیلتر/صفحه) در URL با `hooks/useUrlState.ts`، نه `useState`
|
||||
4. داده فقط TanStack Query از `lib/api.ts`؛ استخراج: paginated → `data?.data` +
|
||||
`data?.meta?.totalRecords` · single → `data?.data` · Category → `data?.data?.data ?? []`
|
||||
5. فرم با React Hook Form + Zod
|
||||
|
||||
و: هیچ رنگ/شعاع/سایهٔ hard-code — همه از توکنهای `:root` در `assets/admin/styles.css`.
|
||||
دارکمود (`[data-theme="dark"]`) و حالت فشرده (`[data-density="compact"]`) بررسی شوند.
|
||||
مقدار hex در دارکمود میشکند.
|
||||
|
||||
پیش از ساختن هر کامپوننت، `assets/admin/components/ui/` را بگرد. ساختن نسخهٔ موازیِ
|
||||
`DataTable`/`Modal`/`ConfirmDialog`/`PageHeader`/`StatCard`/`StatusBadge`/`Pagination`/
|
||||
`PersianDatePicker`/`PriceInput` رد میشود.
|
||||
|
||||
### قواعد ثابت پروژه در هر تسک
|
||||
|
||||
از `CLAUDE.md` و `docs/architecture/tenancy.md`:
|
||||
|
||||
- entity جدید یا `TenantOwnedTrait` میگیرد یا با دلیل در `App\Shared\Tenant\GlobalTables`
|
||||
ثبت میشود؛ `TenantSchemaCoverageTest` هر دو حالت را اجبار میکند
|
||||
- `entity_type, entity_id` **ستونهای اول** هر ایندکس ترکیبیِ لیست — وگرنه MariaDB برای
|
||||
شرط `TenantFilter` از ایندکس استفاده نمیکند
|
||||
- هر uuid که از request میآید با `App\Shared\Tenant\TenantOwnershipChecker` سنجیده
|
||||
میشود؛ `TenantLookupInventoryTest` شمارنده per-file دارد و `findByUuid` تازه تست را
|
||||
قرمز میکند
|
||||
- timestamp ها `int` یونیکس، نه `DateTime`؛ نمایش شمسی فقط در UI با `formatDate`
|
||||
- کنترلر نازک · `extends BaseController` · `$this->success()/paginated()/error()`
|
||||
- خطا با `throw new AppException(ErrorCodes::ERR_XXX, null, $status)`؛ کد و پیام فارسی در
|
||||
`src/Shared/Constant/ErrorCodes.php`
|
||||
- لیستهای ادمین با `->getArrayResult()`
|
||||
- **API جدید فقط وقتی هیچ endpoint موجودی — حتی با توسعه — کافی نباشد؛ دلیلش نوشته شود**
|
||||
- هر endpoint جدید یا تغییریافته → `docs/api/*.md` در **همان** نشست
|
||||
|
||||
### الگوهای معماری که تسکها تصریح کردهاند
|
||||
|
||||
| الگو | تسک | دلیل انتخاب (در همان تسک مکتوب است) |
|
||||
|---|---|---|
|
||||
| Strategy + tagged_iterator | ۰۶ `ResourcePickerInterface` | افزودن استراتژی پنجم نباید کلاس موجود را عوض کند (OCP) |
|
||||
| Repository + سرویس واحدِ نویسنده | ۰۷ `OccupancyWriter` | تنها نقطهٔ نوشتن در `resource_occupancy` |
|
||||
| Registry بسته | ۰۹ `FieldRegistry`/`OperatorRegistry`/`EffectRegistry` | مستند بند ۸: کد دلخواه در قانون ممنوع، وگرنه جستجوی وقت کند میشود |
|
||||
| Outbox | ۱۴ `DomainEventPublisher` | commit اتمی رویداد با کار؛ نه «پیامک رفته و نوبت نیست» |
|
||||
| Ledger (دفتر) نه شمارنده | ۱۱ `SessionCreditLedger` | مستند بند ۱۲: با شمارنده، اولین اشتباه غیرقابلردیابی است |
|
||||
| کلید یکتای سطل زمانی | ۰۷ `resource_occupancy_slot` | MariaDB `EXCLUDE` ندارد؛ تضمین از دیتابیس نه از کد |
|
||||
|
||||
اینها تصمیم گرفتهشدهاند، نه پیشنهاد. اگر در اجرا به مشکل خوردی، **قبل از تغییر
|
||||
الگو** دلیل را به کاربر بگو و تعیین تکلیف بخواه (guidelines §۶).
|
||||
|
||||
### تسکهای پرخطر — جای کندی و دقت
|
||||
|
||||
| تسک | ریسک | کاری که باید بکنی |
|
||||
|---|---|---|
|
||||
| ۰۰ | fixture بعد از تغییرات ساخته شود | fixture **اول**، بعد کد |
|
||||
| ۰۶ | I/O داخل حلقه → جستجو کند | `AvailabilityPerformanceTest` تعداد کوئری را قفل میکند: ≤۵ |
|
||||
| ۰۷ | تست همزمانی با mock بیارزش است | دو اتصال DBAL واقعی، `assertSame(1, ok1+ok2)` |
|
||||
| ۰۹ | `spacing` per-slot حلقه بزند | `forbiddenRanges()` کوئریمحور؛ `AvailabilityPerformanceTest` باید با قوانین فعال هم سبز بماند |
|
||||
| ۱۰ | شبیهسازی داده واقعی بنویسد | rollback در `finally` + `em->clear()` + تست شمارش ردیف |
|
||||
| ۱۱ | `quote` اعتبار مصرف کند | مصرف **فقط** در `confirm`؛ تست: ده `quote` → مانده بیتغییر |
|
||||
| ۱۲ | `book-all` نیمهکاره | همه یا هیچ در یک تراکنش |
|
||||
|
||||
### cross-repo — بررسی دستی اجباری
|
||||
|
||||
`nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` هر دو
|
||||
مصرفکنندهٔ همان `/api/v1/...` اند. تغییر قرارداد در build هیچکدام خطا نمیدهد و در
|
||||
runtime میشکند (guidelines §۳).
|
||||
|
||||
تسکهایی که قرارداد را واقعاً عوض میکنند و بررسی دستیشان بحرانی است:
|
||||
|
||||
- **۰۰** — `service_item` تکی باید با `service_items` همگام بماند (چهار مصرفکننده رویش خواندهاند)
|
||||
- **۰۴** — مدت نوبتهای چندسرویسی **عوض میشود** (فرمول «زمان تنها/زمان اضافه»)
|
||||
- **۰۶** — پس از ارتقای یک کلینیک به `resource`، کلاینت باید مسیر جدید صدا بزند
|
||||
- **۰۸** — مبلغ نمایشی رزرو ممکن است عوض شود
|
||||
- **۱۳** — سایت باید `cancellation-preview` را نشان دهد
|
||||
|
||||
در گزارش هر تسک، **فایلهای بررسیشده را نام ببر**؛ «بررسی شد» بیارزش است.
|
||||
|
||||
### وقتی متن تسک با کد واقعی نمیخواند
|
||||
|
||||
تسکها از خواندن کد در تاریخ نوشتنشان ساخته شدهاند. اگر جایی متن با کد امروز نخواند:
|
||||
|
||||
1. **کد واقعی مرجع است**، نه متن تسک
|
||||
2. اختلاف را به کاربر بگو
|
||||
3. متن تسک را اصلاح کن (فایل تسک هم سند است — guidelines §۴)
|
||||
4. بعد پیادهسازی کن
|
||||
|
||||
موارد شناختهشدهای که تسکها خودشان علامت زدهاند و باید **پیش از کدنویسی** تأیید شوند:
|
||||
|
||||
- تسک ۱۴ ردیف ۲.۹ — وجود `patient_sessions.started_at/ended_at`؛ اگر نبود، گزارش
|
||||
`plan-accuracy` به تسک جدا موکول میشود، **نه** ساخته شدن با داده حدسی
|
||||
- تسک ۰۰ب بخش ۱ — وجود `service_items` و `service_total_minutes` در پاسخ
|
||||
`GET /api/v1/appointments/user`؛ اگر نبود، به تسک ۰۰ برمیگردد
|
||||
- تسک ۰۷ database.md — وابستگی معکوس: جدول `resource_occupancy` در تسک **۰۶** migrate
|
||||
میشود، تعریفش در ۰۷ است
|
||||
@@ -0,0 +1,307 @@
|
||||
# جستجوی پزشک روی همهٔ تخصصها و برگرداندن ساختار والد/فرزند
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` — بکاند Symfony.
|
||||
|
||||
**cross-repo.** قرارداد `GET /api/v1/doctors` را سایت عمومی مصرف میکند.
|
||||
پرامپت همتا: `nobat724_front/.claude/prompt/doctor-multi-specialty-ui.md`.
|
||||
آن را **بعد از** این یکی اجرا کن — کارت پزشک به `parent_id` در پاسخ همین اندپوینت نیاز دارد.
|
||||
|
||||
## زمینه
|
||||
|
||||
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب،
|
||||
شناسهٔ ۱۴۹۹۲:
|
||||
|
||||
```
|
||||
13 جراحی عمومی (والد)
|
||||
14 جراحی پلاستیک و زیبایی
|
||||
167 جراحی لاپاراسکوپی
|
||||
168 جراح تیروئید
|
||||
169 جراح گوارش
|
||||
170 جراحی سرطانها
|
||||
```
|
||||
|
||||
هنگام ذخیره، `expandWithAncestors` والدها را خودکار اضافه میکند، پس والد معمولاً روی پزشک نشسته است.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه شکاف در بکاند:
|
||||
|
||||
۱. **جستجوی متنی تخصص را نمیبیند.** تایپ «جراح گوارش» در کادر جستجو هیچ پزشکی برنمیگرداند،
|
||||
چون فقط `d.name` گشته میشود.
|
||||
|
||||
۲. **فیلتر `specialty_id` به فرزندان گسترش نمییابد.** امروز فقط بهخاطر عارضهٔ جانبیِ
|
||||
`expandWithAncestors` در زمان ذخیره کار میکند. هر پزشکی که از مسیر دیگری وارد شود
|
||||
(مثلاً import دستهای) این تضمین را ندارد. تکیهٔ یک قابلیت جستجو بر یک side effect ذخیره، شکننده است.
|
||||
|
||||
۳. **پاسخ لیست `parent_id` ندارد.** کارت پزشک در سایت عمومی نمیتواند تشخیص دهد کدام تخصص
|
||||
والد است، پس نمیتواند «تخصص اصلی + N» بسازد.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: `GET /api/v1/doctors?specialty_id=13` پزشکی را که **فقط** `169` (جراح گوارش) دارد و
|
||||
والد روی او ثبت نشده هم برمیگرداند.
|
||||
- ✅ موفق: `GET /api/v1/doctors?name=جراح گوارش` دکتر جهانتاب را برمیگرداند.
|
||||
- ✅ موفق: `GET /api/v1/doctors?name=جهانتاب` همچنان همان پزشک را برمیگرداند — تطابق نام نشکسته.
|
||||
- ✅ موفق: هر آیتم پاسخ، در `specialties[]` کلید `parent_id` دارد؛ برای ریشه `null`.
|
||||
- ❌ خطا: `specialty_id` با شناسهٔ ناموجود → `200` و آرایهٔ خالی، نه `500`.
|
||||
- ❌ خطا: `name` با عبارتی که به هیچ پزشک و هیچ تخصصی نمیخورد → `200` و `totalRecords: 0`.
|
||||
- ⚠️ مرزی: **`specialty_id` و `name` با هم**. `?specialty_id=13&name=جهانتاب` باید همان پزشک را
|
||||
بدهد. این حالت با پیادهسازی ساده میشکند — پایین توضیح داده شده.
|
||||
- ⚠️ مرزی: تخصص برگ (بدون فرزند) — `?specialty_id=169` فقط پزشکان همان تخصص، نه کل گروه.
|
||||
- ⚠️ مرزی: `totalRecords` باید با تعداد ردیفهای واقعی بخواند. join چندبهچند بدون `DISTINCT`
|
||||
در شمارش، پزشک چندتخصصی را چند بار میشمارد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Specialty/Repository/SpecialtyRepository.php` | `parentMap()` و `expandWithAncestors()` موجود |
|
||||
| `src/Doctor/Repository/DoctorRepository.php` | `findWithFilters()` — همهٔ فیلترهای لیست عمومی |
|
||||
| `src/Doctor/Entity/Doctor.php` | `toListArray()` — شکل آیتم لیست |
|
||||
| `docs/api/doctor.md` | سند اندپوینت |
|
||||
| `docs/api/specialty.md` | سند تخصص |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### فیلترها — تکشناسه و فقط نام پزشک
|
||||
|
||||
```php
|
||||
// src/Doctor/Repository/DoctorRepository.php:51
|
||||
$qb = $this->createQueryBuilder('d')
|
||||
->leftJoin('d.specialties', 's')
|
||||
->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d')
|
||||
->distinct();
|
||||
```
|
||||
|
||||
```php
|
||||
// همان فایل:76
|
||||
if (!empty($filters['specialty_id'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// همان فایل:89
|
||||
if (!empty($filters['name'])) {
|
||||
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
|
||||
}
|
||||
```
|
||||
|
||||
### گسترش به بالا وجود دارد، به پایین نه
|
||||
|
||||
```php
|
||||
// src/Specialty/Repository/SpecialtyRepository.php:75
|
||||
public function expandWithAncestors(array $ids): array
|
||||
{
|
||||
$map = $this->parentMap();
|
||||
$out = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$cur = (int) $id;
|
||||
$seen = [];
|
||||
while (array_key_exists($cur, $map) && !isset($seen[$cur])) {
|
||||
$seen[$cur] = true;
|
||||
$out[$cur] = true;
|
||||
$cur = $map[$cur] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($out);
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int,?int> id => parentId for every specialty */
|
||||
private function parentMap(): array
|
||||
{
|
||||
if ($this->parentMap === null) {
|
||||
$rows = $this->createQueryBuilder('s')
|
||||
->select('s.id AS id', 'IDENTITY(s.parent) AS parent')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
$this->parentMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$this->parentMap[(int) $row['id']] = $row['parent'] !== null ? (int) $row['parent'] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parentMap;
|
||||
}
|
||||
```
|
||||
|
||||
### پاسخ لیست — بدون parent_id
|
||||
|
||||
```php
|
||||
// src/Doctor/Entity/Doctor.php:578
|
||||
'specialties' => array_map(fn(Specialty $s) => [
|
||||
'uuid' => $s->getUuid(),
|
||||
'id' => (string) $s->getId(),
|
||||
'name' => $s->getName(),
|
||||
], $this->specialties->toArray()),
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. `expandWithDescendants` در `SpecialtyRepository`
|
||||
|
||||
قرینهٔ `expandWithAncestors`. از همان `parentMap()` کششده استفاده میکند، پس کوئری اضافه ندارد.
|
||||
|
||||
درخت امروز دقیقاً دو سطح است — شمارش نوهها صفر است — ولی مثل قرینهاش **generic** بنویس،
|
||||
نه یکسطحی. عمقِ فرضی، بدهیِ فردا است.
|
||||
|
||||
```php
|
||||
/**
|
||||
* شناسههای دادهشده بهعلاوهٔ همهٔ نوادگانشان.
|
||||
*
|
||||
* قرینهٔ expandWithAncestors: آن برای «این زیرتخصص یعنی والدش هم» است و این برای
|
||||
* «این گروه یعنی همهٔ زیرشاخههایش هم».
|
||||
*
|
||||
* @param list<int|string> $ids
|
||||
* @return list<int>
|
||||
*/
|
||||
public function expandWithDescendants(array $ids): array
|
||||
{
|
||||
$map = $this->parentMap();
|
||||
$children = [];
|
||||
foreach ($map as $id => $parent) {
|
||||
if ($parent !== null) {
|
||||
$children[$parent][] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$queue = array_map('intval', $ids);
|
||||
while ($queue) {
|
||||
$cur = array_pop($queue);
|
||||
if (isset($out[$cur])) {
|
||||
continue;
|
||||
}
|
||||
$out[$cur] = true;
|
||||
foreach ($children[$cur] ?? [] as $child) {
|
||||
$queue[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($out);
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
```
|
||||
|
||||
`isset($out[$cur])` هم dedupe است و هم محافظ حلقه، اگر دادهای چرخه بسازد.
|
||||
|
||||
**نحوه تست:** unit test در `tests/Specialty/SpecialtyDescendantsTest.php` —
|
||||
`expandWithDescendants([13])` باید `13` و همهٔ فرزندانش را بدهد؛
|
||||
`expandWithDescendants([169])` فقط `[169]`؛ آرایهٔ خالی → آرایهٔ خالی؛
|
||||
شناسهٔ ناموجود → همان شناسه بدون خطا.
|
||||
|
||||
### ۲. فیلتر `specialty_id` به نوادگان گسترش یابد
|
||||
|
||||
**دقت کن — اینجا یک تلهٔ واقعی هست.** alias فعلی `s` هم برای فیلتر تخصص استفاده میشود و هم
|
||||
(در وظیفهٔ ۳) برای جستجوی نام تخصص. اگر هر دو روی همان alias بنشینند، DQL مجبور میشود
|
||||
**یک ردیفِ join** هر دو شرط را با هم ارضا کند. پزشکی که با تخصص A فیلتر را پاس میکند و
|
||||
نامِ تخصص B را دارد، حذف میشود.
|
||||
|
||||
پس هر دو فیلتر را با زیرکوئری `EXISTS` بنویس و join اصلی `s` را دست نزن — آن فقط برای
|
||||
hydration است.
|
||||
|
||||
```php
|
||||
if (!empty($filters['specialty_id'])) {
|
||||
$ids = $this->specialtyRepo->expandWithDescendants([(int) $filters['specialty_id']]);
|
||||
$qb->andWhere(
|
||||
$qb->expr()->exists(
|
||||
'SELECT sf.id FROM ' . Specialty::class . ' sf'
|
||||
. ' WHERE sf MEMBER OF d.specialties AND sf.id IN (:specialtyIds)'
|
||||
)
|
||||
)->setParameter('specialtyIds', $ids);
|
||||
}
|
||||
```
|
||||
|
||||
`SpecialtyRepository` را با constructor injection بگیر، `new` نکن.
|
||||
|
||||
**نحوه تست:** تست فانکشنال در `tests/Doctor/DoctorSpecialtySearchTest.php`.
|
||||
پزشکی بساز که **فقط** یک زیرتخصص دارد و والد روی او ثبت **نیست** (مستقیم با
|
||||
`$doctor->getSpecialties()->add($child)` و flush، نه از راه اندپوینت که والد را اضافه میکند).
|
||||
سپس `GET /api/v1/doctors?specialty_id=<parentId>` باید او را برگرداند.
|
||||
همچنین `?specialty_id=<leafId>` نباید پزشکِ یک زیرتخصصِ خواهر را برگرداند.
|
||||
|
||||
### ۳. `name` نام تخصص را هم بگردد
|
||||
|
||||
```php
|
||||
if (!empty($filters['name'])) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
'd.name LIKE :name',
|
||||
$qb->expr()->exists(
|
||||
'SELECT sn.id FROM ' . Specialty::class . ' sn'
|
||||
. ' WHERE sn MEMBER OF d.specialties AND sn.name LIKE :name'
|
||||
)
|
||||
)
|
||||
)->setParameter('name', '%' . $filters['name'] . '%');
|
||||
}
|
||||
```
|
||||
|
||||
ترتیب نتایج را عوض نکن. مرتبسازی فعلی سر جایش میماند؛ اگر بعداً «نام پزشک اول» خواسته شد،
|
||||
تسک جداست.
|
||||
|
||||
**نحوه تست:** در همان تست فانکشنال —
|
||||
`?name=<نام تخصص>` پزشک را میدهد؛ `?name=<بخشی از نام پزشک>` همچنان میدهد؛
|
||||
`?specialty_id=<parentId>&name=<نام پزشک>` هم میدهد (همان حالت مرزی که با alias مشترک میشکست)؛
|
||||
عبارت بیربط → `totalRecords: 0`.
|
||||
|
||||
### ۴. `parent_id` در پاسخ لیست
|
||||
|
||||
```php
|
||||
'specialties' => array_map(fn(Specialty $s) => [
|
||||
'uuid' => $s->getUuid(),
|
||||
'id' => (string) $s->getId(),
|
||||
'name' => $s->getName(),
|
||||
// کلاینت بدون این نمیتواند «تخصص اصلی» را از زیرتخصص تشخیص دهد.
|
||||
'parent_id' => $s->getParent()?->getId(),
|
||||
], $this->specialties->toArray()),
|
||||
```
|
||||
|
||||
افزودنی است و هیچ کلید موجودی را عوض نمیکند.
|
||||
|
||||
**مراقب N+1 باش.** `getParent()` روی proxy یعنی یک کوئری بهازای هر تخصص بهازای هر پزشک.
|
||||
در `findWithFilters` روی همان join موجود `addSelect('s')` بگذار و والد را هم join و select کن.
|
||||
با تست شمارش کوئری تثبیتش کن — `ApiTestCase::countQueries()` برای همین هست.
|
||||
|
||||
**نحوه تست:** `GET /api/v1/doctors?limit=10` و بررسی اینکه هر آیتم `parent_id` دارد و برای
|
||||
ریشه `null` است. سپس `countQueries()` دور همان فراخوانی: تعداد کوئری با ۱۰ پزشک نباید
|
||||
بهطور معنادار از حالت ۱ پزشک بیشتر باشد.
|
||||
|
||||
### ۵. مستندات
|
||||
|
||||
`docs/api/doctor.md` — بخش `GET /api/v1/doctors`:
|
||||
|
||||
- `specialty_id` حالا «این تخصص و همهٔ زیرشاخههایش» است. صریح بنویس، چون معنایش عوض شده.
|
||||
- `name` حالا نام پزشک **یا** نام هر یک از تخصصهایش را میگردد.
|
||||
- شکل `specialties[]` با `parent_id` تازه، و **JSON واقعی از اجرای واقعی** نه دستساز.
|
||||
|
||||
`docs/api/specialty.md` — یادآوری کن که `GET /api/v1/specialties` بدون `parent_id` همهٔ
|
||||
تخصصهای فعال را با `parent_id` و `slug` میدهد و **سایت عمومی از همین برای ساخت
|
||||
`data/specialties.json` در زمان build استفاده میکند**. اگر شکل این پاسخ عوض شود، آن اسکریپت میشکند.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **تلهٔ alias مشترک، مهمترین نکتهٔ این تسک است.** اگر وظیفهٔ ۲ و ۳ هر دو روی `s` بنویسند،
|
||||
تستهای تکی سبز میشوند و فقط حالت ترکیبیِ `specialty_id` و `name` میشکند — یعنی همان چیزی
|
||||
که کاربر در فیلترها با هم میزند.
|
||||
- **`DISTINCT` و شمارش.** `findWithFilters` الان `->distinct()` دارد. مطمئن شو کوئری شمارش هم
|
||||
همان تمایز را دارد، وگرنه `totalRecords` برای پزشک چندتخصصی باد میکند و صفحهبندی میشکند.
|
||||
- **این تغییر هیچ فیلتری را تنگ نمیکند، فقط باز میکند.** اگر تستی از رفتار فعلی لیست عمومی
|
||||
شکست، یعنی زیرکوئری اشتباه بسته شده.
|
||||
- **`expandWithAncestors` را دست نزن.** آن در `hydrateDoctor` و `RepresentationActionController`
|
||||
استفاده میشود و کارِ دیگری میکند. دو تابع، دو جهت.
|
||||
- **Repository نازک، Controller نازکتر.** منطق گسترش در `SpecialtyRepository` میماند، نه در
|
||||
`DoctorRepository` و نه در کنترلر.
|
||||
- **cross-repo:** بعد از این تغییر، `nobat724_front` باید دستی بررسی شود. تغییر شکل پاسخ در
|
||||
build آن خطا نمیدهد و فقط در رانتایم دیده میشود. صفحهٔ `/doctors` و `/specialties/[slug]`
|
||||
را باز کن و مطمئن شو چیزی نشکسته.
|
||||
- دیتابیس تست هرگز reset نمیشود؛ دادهٔ هر تست را با `uniqid()` یکتا بساز تا اجرای دوم هم سبز بماند.
|
||||
@@ -0,0 +1,366 @@
|
||||
# رجیستری واحدِ مجوزها برای منشی و پزشکِ دعوتشده
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین). cross-repo نیست.
|
||||
|
||||
## زمینه
|
||||
|
||||
دو نقش در یک کلینیک مجوز per-resource دارند: **منشی** و **پزشکِ دعوتشده**. هر کدام
|
||||
جدول مجوز خودش را دارد و هر کدام یک فهرست جدا از «منابع» در کد.
|
||||
|
||||
امروز آن فهرست **چهار بار** نوشته شده و هیچکدام از هم خبر ندارند:
|
||||
|
||||
| کجا | چه چیزی |
|
||||
|---|---|
|
||||
| `src/Secretary/Entity/DoctorSecretary.php:26` | `DEFAULT_PERMISSIONS` — ۱۵ منبع |
|
||||
| `src/Clinic/Entity/ClinicDoctorPermission.php:21` | `DEFAULT_PERMISSIONS` — ۱۳ منبع |
|
||||
| `assets/admin/pages/MySecretariesPage.tsx:70` | `EMPTY_PERMISSIONS` + `PERMISSION_SECTIONS` |
|
||||
| `assets/admin/components/ui/DoctorPermissionsModal.tsx:24` | `RESOURCE_LABELS` |
|
||||
|
||||
نتیجهاش را میشود در خودِ کد دید.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**۱. دو فهرست backend با هم فرق دارند.**
|
||||
|
||||
منشی این دو را دارد و پزشک ندارد: `clinic_doctors`، `subscription`.
|
||||
و برای `services`:
|
||||
|
||||
```php
|
||||
// DoctorSecretary
|
||||
'services' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
// ClinicDoctorPermission
|
||||
'services' => ['view' => true, 'update' => false],
|
||||
```
|
||||
|
||||
یعنی همان منبع در دو نقش دو مجموعهٔ action دارد. `ClinicDoctorPermission::apply()`
|
||||
هر actionی را که در `DEFAULT_PERMISSIONS` نباشد دور میاندازد، پس `create` و `delete`
|
||||
برای پزشک اصلاً قابل ذخیره نیست.
|
||||
|
||||
**۲. صفحههای تازه، مجوزِ صفحهٔ دیگری را قرض میگیرند.**
|
||||
|
||||
از ۸۴ route، ۳۲ تا گیت مجوز دارند. ولی پنج صفحهٔ متفاوت روی **یک** منبع نشستهاند:
|
||||
|
||||
```
|
||||
resources · resource-types · catalog-categories · skills · resource-pools
|
||||
→ همه: permission={['appointment_settings', 'view']}
|
||||
treatment-cases
|
||||
→ permission={['appointments', 'view']}
|
||||
```
|
||||
|
||||
هیچکدام از اینها واقعاً «تنظیمات نوبتدهی» یا «نوبتها» نیستند. دلیلش روشن است:
|
||||
افزودن یک منبع تازه یعنی ویرایش دستیِ چهار فهرست، پس هرکس نزدیکترین منبع موجود را
|
||||
برداشته. مجوزها از ساختار صفحهها جدا افتادهاند.
|
||||
|
||||
**۳. جواب سؤال «آیا میشود داینامیک باشد؟» بله است.**
|
||||
|
||||
یک رجیستری در PHP بهعنوان منبع واحد، یک اندپوینت که آن را میدهد، و دو UI که بهجای
|
||||
فهرست هاردکد از همان میخوانند. صفحهٔ تازه = یک ردیف در رجیستری، نه چهار ویرایش.
|
||||
|
||||
---
|
||||
|
||||
## ⚠ دو تصمیم که باید قبل از کد روشن باشد
|
||||
|
||||
### الف) «دقیقاً شبیه پزشک» یعنی فهرست یکی شود، نه پیشفرضها
|
||||
|
||||
**فهرستِ منابع و actionها** برای هر دو نقش یکی میشود — این خواستهٔ روشنِ کاربر است.
|
||||
|
||||
**مقادیر پیشفرض** یکی نمیشوند و نباید بشوند: پزشکِ عضو کلینیک بهطور طبیعی از منشی
|
||||
دسترسی بیشتری دارد (مثلاً `patients.update` پیشفرضش `true` است و برای منشی `false`).
|
||||
یکی کردنشان یعنی یا منشی بیش از حد باز شود یا پزشک بیجهت بسته.
|
||||
|
||||
پس رجیستری **شکل** را میدهد و هر نقش **پیشفرضِ خودش** را.
|
||||
|
||||
اگر منظور کاربر این نبوده و واقعاً میخواهد پیشفرضها هم یکی شود، قبل از پیادهسازی
|
||||
بپرس — این تغییر روی همهٔ منشیهای موجود اثر میگذارد.
|
||||
|
||||
### ب) دادهٔ ذخیرهشده نباید پاک شود
|
||||
|
||||
هر دو جدول ستون `permission` از نوع `json` دارند و مقدارِ فعلیِ منشیها و پزشکها
|
||||
داخلش است. افزودن منبع تازه به رجیستری **نباید** مقدار ذخیرهشده را بازنویسی کند.
|
||||
|
||||
قاعده: خواندن = merge رجیستری با مقدارِ ذخیرهشده؛ کلیدِ نبوده از پیشفرضِ رجیستری
|
||||
پر میشود. **migration دادهٔ انبوه لازم نیست** و نباید نوشته شود.
|
||||
|
||||
---
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: `GET /api/v1/permission-catalog` با توکن مالک کلینیک → ۲۰۰ و فهرست همهٔ
|
||||
منابع با actionها و برچسب فارسی. صفحهٔ `/admin/my-secretaries` و مودال مجوز پزشک
|
||||
هر دو از همین پاسخ رندر میشوند و هیچ فهرست هاردکدی ندارند.
|
||||
- ✅ موفق: افزودن یک منبع تازه به رجیستری (مثلاً `treatment`) → بدون هیچ تغییر دیگری
|
||||
در فرانت، هم در فرم منشی و هم در مودال پزشک ظاهر میشود.
|
||||
- ✅ موفق: منشیِ بدون `treatment.view` وارد `/admin/treatment-cases` شود → به داشبورد
|
||||
برگردانده شود (`RoleRoute`)، و `GET /api/v1/treatment-cases` برایش ۴۰۳ بدهد.
|
||||
- ❌ خطا: `PUT` مجوز با منبعِ ناشناخته یا actionِ ناشناخته → ۴۲۲، و مقدار قبلی
|
||||
دستنخورده بماند.
|
||||
- ⚠️ مرزی: منشیِ ساختهشده **قبل** از این تغییر که `treatment` در JSONش نیست →
|
||||
خواندنش خطا ندهد و آن منبع با پیشفرضِ رجیستری برگردد، نه `null`.
|
||||
- ⚠️ مرزی: مالک کلینیک و ادمین همیشه مجازند — رجیستری نباید این را عوض کند
|
||||
(`ClinicDoctorPermissionChecker::can()` خط ۲۶).
|
||||
- ⚠️ مرزی: محیط شخصیِ پزشک `permissions` ندارد؛ `usePermissions` نبودش را «محدودیتی
|
||||
نیست» میخواند و این باید همان بماند.
|
||||
|
||||
---
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Secretary/Entity/DoctorSecretary.php` | `DEFAULT_PERMISSIONS` منشی |
|
||||
| `src/Clinic/Entity/ClinicDoctorPermission.php` | `DEFAULT_PERMISSIONS` پزشک + `apply()` که actionِ ناشناخته را دور میریزد |
|
||||
| `src/Secretary/Security/SecretaryPermissionChecker.php` | بررسی مجوز منشی |
|
||||
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | بررسی مجوز پزشک |
|
||||
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مجوز منشی |
|
||||
| `assets/admin/components/ui/DoctorPermissionsModal.tsx` | مودال مجوز پزشک |
|
||||
| `assets/admin/hooks/usePermissions.ts` | `can(resource, action)` در فرانت |
|
||||
| `assets/admin/App.tsx` | گیتِ route با `permission={[resource, action]}` |
|
||||
| `docs/api/secretary.md` · `docs/api/clinic.md` | سند اندپوینتها |
|
||||
|
||||
---
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### رجیستری منشی
|
||||
|
||||
```php
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
|
||||
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
// …
|
||||
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'subscription' => ['view' => false, 'create' => false],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### رجیستری پزشک — دو منبع کمتر، و `services` با actionهای متفاوت
|
||||
|
||||
```php
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
|
||||
'services' => ['view' => true, 'update' => false],
|
||||
// clinic_doctors و subscription اصلاً نیستند
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### فرانت — همان فهرست، بار سوم و چهارم
|
||||
|
||||
```tsx
|
||||
// DoctorPermissionsModal.tsx
|
||||
const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, string> }> = {
|
||||
appointments: {
|
||||
label: 'نوبتها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', cancel: 'لغو', update_status: 'تغییر وضعیت' },
|
||||
},
|
||||
// …
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
// MySecretariesPage.tsx
|
||||
const EMPTY_PERMISSIONS: SecretaryPermissions = {
|
||||
appointments: { view: false, create: false, cancel: false, update_status: false },
|
||||
// …
|
||||
};
|
||||
```
|
||||
|
||||
### گیتِ فعلیِ صفحههای تازه
|
||||
|
||||
```tsx
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="treatment-cases" element={<RoleRoute roles={['doctor','clinic','secretary']} permission={['appointments', 'view']}><TreatmentCasesPage /></RoleRoute>} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. رجیستری منابع — منبع واحد
|
||||
|
||||
`src/Shared/Security/PermissionCatalog.php`
|
||||
|
||||
یک کلاس با یک ثابت: هر منبع، actionهایش، و برچسب فارسی. **هیچ پیشفرضی اینجا نیست** —
|
||||
اینجا فقط «چه چیزهایی وجود دارند».
|
||||
|
||||
```php
|
||||
final class PermissionCatalog
|
||||
{
|
||||
/** @var array<string, array{label: string, actions: array<string, string>}> */
|
||||
public const RESOURCES = [
|
||||
'appointments' => [
|
||||
'label' => 'نوبتها',
|
||||
'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'cancel' => 'لغو', 'update_status' => 'تغییر وضعیت'],
|
||||
],
|
||||
// …
|
||||
'resources' => [
|
||||
'label' => 'منابع و دستگاهها',
|
||||
'actions' => ['view' => 'مشاهده', 'create' => 'ایجاد', 'update' => 'ویرایش', 'delete' => 'حذف'],
|
||||
],
|
||||
'treatment' => [
|
||||
'label' => 'درمانهای چندجلسهای',
|
||||
'actions' => ['view' => 'مشاهده', 'update' => 'ویرایش'],
|
||||
],
|
||||
];
|
||||
|
||||
/** شکلِ خالی — برای merge با مقدارِ ذخیرهشده. */
|
||||
public static function blank(): array { /* همهٔ actionها false */ }
|
||||
|
||||
/** مقدارِ ذخیرهشده + کلیدهای نبوده از پیشفرضِ نقش. */
|
||||
public static function merge(array $stored, array $roleDefaults): array { /* … */ }
|
||||
}
|
||||
```
|
||||
|
||||
**دو منبع تازه که باید اضافه شوند** (چون صفحهشان امروز مجوزِ قرضی دارد):
|
||||
`resources` برای پنج صفحهٔ منابع، و `treatment` برای `treatment-cases`.
|
||||
|
||||
**الگو: Registry/Catalog.** دلیل: چهار فهرستِ موازی که دستی همگام میشوند، دیر یا زود
|
||||
واگرا میشوند — و همین حالا شدهاند. یک ثابت که هر چهار مصرفکننده از آن میخوانند،
|
||||
واگرایی را از نظر ساختاری غیرممکن میکند.
|
||||
|
||||
**نحوه تست:** تست واحد در `tests/Shared/` — `blank()` باید برای هر منبعِ رجیستری کلید
|
||||
بدهد؛ `merge()` با یک `stored` که یک منبع کم دارد باید آن را از پیشفرضِ نقش پر کند و
|
||||
مقادیرِ موجود را **دست نزند**.
|
||||
|
||||
---
|
||||
|
||||
### ۲. هر دو Entity از رجیستری بخوانند
|
||||
|
||||
`DEFAULT_PERMISSIONS` هر دو کلاس میماند ولی معنیاش عوض میشود: فقط **پیشفرضِ نقش**،
|
||||
نه تعریفِ ساختار. اعتبارسنجیِ `apply()` باید به `PermissionCatalog::RESOURCES` نگاه کند
|
||||
نه به `self::DEFAULT_PERMISSIONS`.
|
||||
|
||||
```php
|
||||
// ClinicDoctorPermission::apply() — امروز:
|
||||
if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { continue; }
|
||||
// باید بشود:
|
||||
if (!is_array($actions) || !isset(PermissionCatalog::RESOURCES[$resource])) { continue; }
|
||||
```
|
||||
|
||||
با این تغییر، `services.create` برای پزشک هم قابل ذخیره میشود — چون رجیستری واحد
|
||||
است. همین «شبیه شدنِ منشی و پزشک» است.
|
||||
|
||||
**نحوه تست:** تست موجودِ مجوز پزشک را اجرا کن، بعد یک تست تازه: ذخیرهٔ
|
||||
`services.create = true` برای پزشک، خواندن دوباره، و بررسی اینکه مانده — امروز دور
|
||||
ریخته میشود.
|
||||
|
||||
---
|
||||
|
||||
### ۳. اندپوینت کاتالوگ
|
||||
|
||||
`GET /api/v1/permission-catalog`
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY`. پاسخ ثابت است و به کاربر بستگی ندارد، پس
|
||||
جای مناسبی برای cache سمت کلاینت است (`staleTime` بلند).
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { "resources": [
|
||||
{ "key": "appointments", "label": "نوبتها",
|
||||
"actions": [ { "key": "view", "label": "مشاهده" }, … ] }
|
||||
] } }
|
||||
```
|
||||
|
||||
آرایه است نه object، تا ترتیبِ نمایش تضمین شود؛ ترتیبِ کلیدهای JSON قرارداد نیست.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
TOKEN=... # 09390039833 / QaTest@1234
|
||||
curl -sk https://clinic-pro.ddev.site/api/v1/permission-catalog -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
باید همهٔ منابع بیایند. بدون توکن → ۴۰۱.
|
||||
|
||||
---
|
||||
|
||||
### ۴. هر دو UI از کاتالوگ رندر شوند
|
||||
|
||||
`RESOURCE_LABELS` و `EMPTY_PERMISSIONS` و `PERMISSION_SECTIONS` حذف میشوند و جایشان
|
||||
یک hook میآید:
|
||||
|
||||
```ts
|
||||
// assets/admin/hooks/usePermissionCatalog.ts
|
||||
export function usePermissionCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ['permission-catalog'],
|
||||
queryFn: () => api.get<ApiResponse<{ resources: CatalogResource[] }>>('/api/v1/permission-catalog'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`clinicOnly` که امروز در `PERMISSION_SECTIONS` است باید به رجیستری منتقل شود، وگرنه
|
||||
همان منطق در فرانت هاردکد میماند.
|
||||
|
||||
سه حالت داده اجباری است: تا وقتی کاتالوگ نیامده، فرم اسکلتون نشان دهد نه فهرست خالی —
|
||||
«هیچ مجوزی وجود ندارد» با «در حال خواندن» یکی نیست.
|
||||
|
||||
**نحوه تست:** vitest برای هر دو کامپوننت با mock کردن کاتالوگ؛ سناریو: کاتالوگ با سه
|
||||
منبع → سه بخش رندر شود. سپس یک منبع به mock اضافه کن و بررسی کن بدون تغییر کد ظاهر شود
|
||||
— این همان «داینامیک بودن» است و باید تست داشته باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۵. گیتِ درستِ صفحهها
|
||||
|
||||
`App.tsx` — پنج صفحهٔ منابع از `appointment_settings` به `resources` منتقل شوند و
|
||||
`treatment-cases` به `treatment`.
|
||||
|
||||
سمت backend هم باید همانجا بسته شود، وگرنه گیتِ فرانت فقط دکوراسیون است:
|
||||
`TreatmentCaseController` و کنترلرهای `src/Resource/` باید
|
||||
`secretaryAccess->denyUnlessGranted($user, 'treatment', 'view')` بزنند — الگوی موجود در
|
||||
`StaffController::list()` را ببین.
|
||||
|
||||
> **این را قبل از کد بررسی کن:** آیا صفحهٔ دیگری هم مجوزِ قرضی دارد؟ خروجی این دستور را
|
||||
> با فهرست صفحهها مقایسه کن:
|
||||
> ```bash
|
||||
> grep -oE 'path="[a-z-]+"|permission=\{\[[^]]*\]' assets/admin/App.tsx
|
||||
> ```
|
||||
|
||||
**نحوه تست:** منشیای با `treatment.view = false` بساز، توکنش را بگیر و
|
||||
`GET /api/v1/treatment-cases` بزن → باید ۴۰۳ بدهد. بعد `true` کن و ۲۰۰ بگیر.
|
||||
|
||||
---
|
||||
|
||||
### ۶. بررسی و تست همهٔ صفحهها
|
||||
|
||||
کاربر صریح خواسته «همه صفحات بررسی و تست شود». این یعنی یک عبورِ سیستماتیک، نه نگاه
|
||||
اجمالی:
|
||||
|
||||
۱. با کاربر منشی و **همهٔ مجوزها خاموش**، هر route را باز کن. هیچ صفحهای نباید داده
|
||||
نشان دهد؛ همه باید به داشبورد برگردند.
|
||||
۲. یکییکی `view` هر منبع را روشن کن و بررسی کن **فقط** صفحههای همان منبع باز شوند.
|
||||
۳. همان دو مرحله برای پزشکِ دعوتشده.
|
||||
|
||||
اسکریپت کمکی برای مرحلهٔ ۱ (درایورِ اسکرینشات آدرسِ نهایی را گزارش میکند):
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs shot "<url>" --out /tmp/x.png --wait 5000
|
||||
# ⚠ WRONG PAGE یعنی گیت کار کرده
|
||||
```
|
||||
|
||||
نتیجه را بهصورت جدول گزارش کن: route، منبعِ گیت، رفتار مشاهدهشده. **صفحهای که تست
|
||||
نشده را «تست شد» ننویس.**
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **رجیستری فقط ساختار میدهد، نه سیاست.** پیشفرضِ هر نقش در همان Entity میماند.
|
||||
آوردنشان به رجیستری یعنی یک فایل که هم «چه چیزی هست» و هم «چه کسی چه دارد» را
|
||||
میداند — دو مسئولیت.
|
||||
- **هیچ migration دادهٔ انبوهی ننویس.** `merge()` در زمان خواندن کار را میکند و
|
||||
ستون `json` هر دو جدول نیازی به تغییر schema ندارد. migration فقط اگر ستونی اضافه
|
||||
شود، که اینجا نمیشود.
|
||||
- مالک کلینیک و ادمین همیشه مجازند — این قاعده در `ClinicDoctorPermissionChecker::can()`
|
||||
است و دست نمیخورد. مالک هرگز نباید بتواند خودش را قفل کند.
|
||||
- `usePermissions` نبودِ `permissions` را «محدودیتی نیست» میخواند (محیط شخصی). این
|
||||
رفتار نباید عوض شود؛ رجیستری فقط جایی اثر دارد که مجوز واقعاً ذخیره شده باشد.
|
||||
- بعد از تغییر اندپوینتها، `docs/api/secretary.md` و `docs/api/clinic.md` در همان
|
||||
جلسه بهروز شوند، با JSON واقعی از اجرای واقعی.
|
||||
- تستِ «داینامیک بودن» را جدی بگیر: تستی که فقط منابعِ امروز را چک کند، فردا که منبع
|
||||
تازه اضافه شود چیزی به تو نمیگوید. تست باید **افزودنِ یک منبع به mock** را بررسی کند.
|
||||
@@ -0,0 +1,442 @@
|
||||
# طول درمان و پرونده درمان چندجلسهای (فاز اول: لیزر)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین). هیچ تغییری در `nobat724_front` و `clinic-pro-tauri` لازم نیست.
|
||||
|
||||
## زمینه
|
||||
|
||||
این سند خروجی یک جلسه grilling است. ۲۳ تصمیم قفل شد، شش ADR و یک glossary نوشته شد.
|
||||
پیش از شروع اینها را بخوان:
|
||||
|
||||
- `clinicpro/CONTEXT.md` — واژگان رسمی این دامنه
|
||||
- `clinicpro/docs/adr/0001-treatment-sessions-are-not-appointments.md`
|
||||
- `clinicpro/docs/adr/0002-treatment-areas-are-snapshotted.md`
|
||||
- `clinicpro/docs/adr/0003-resource-backed-appointments-drop-the-doctor-slot-key.md`
|
||||
- `clinicpro/docs/adr/0004-session-parameters-are-json-keyed-by-resource-type.md`
|
||||
- `clinicpro/docs/adr/0005-treatment-workflows-are-tagged-services.md`
|
||||
- `clinicpro/docs/adr/0006-clinical-record-is-separate-from-the-visit-record.md`
|
||||
- `clinicpro/docs/architecture/resource-first-model.md`
|
||||
|
||||
از واژگان `CONTEXT.md` استفاده کن. مترادف نساز.
|
||||
|
||||
## هدف
|
||||
|
||||
کلینیک بتواند سرویسی تعریف کند که درمانش چند جلسه طول میکشد، و سیستم برای هر بیمار
|
||||
پرونده درمان بسازد، جلسات را بشمارد، سررسید جلسه بعد را حساب کند، و اپراتور بتواند
|
||||
برای هر ناحیه بدن، دستگاه و پارامترهایش را ثبت کند.
|
||||
|
||||
فاز اول فقط لیزر. ولی هیچجای کد نباید کلمه «لیزر» را بداند، جز پیادهسازی workflow.
|
||||
|
||||
## آنچه از قبل هست و باید استفاده شود
|
||||
|
||||
| چیز | کجا | نکته |
|
||||
|---|---|---|
|
||||
| دستهبندی گرافی خدمات | `CatalogCategory` + `CatalogCategoryInclude` | نواحی بدن همینجا تعریف میشوند |
|
||||
| بستار گذرا و تشخیص تعارض | `CategoryClosureResolver` | `descendants()` و `overlaps()` |
|
||||
| سرویس | `ServiceItem` با `catalogCategory` | قیمت و مدت اینجاست |
|
||||
| نوع منبع داینامیک | `ResourceType` | کدهای سیستمی `doctor` / `staff` / `room` |
|
||||
| منبع با پزشک ناظر | `ClinicResource.supervisor` | تعریف شده ولی در مسیر رزرو خوانده نمیشود |
|
||||
| اشغال منبع در سطح دیتابیس | `OccupancyBucket` | `uniq_bucket_resource_seat(resource_id, bucket_at, seat)` |
|
||||
| پرسنل و نقش | `ClinicStaff` + `ROLE_STAFF` | `ClinicStaff.user` اختیاری است |
|
||||
| داشبورد پرسنل | `GET /api/v1/dashboard/staff` | فیلتر روی `a.staff` |
|
||||
| صفحه پرسنل | `assets/admin/pages/StaffMyServicesPage.tsx` | فقط سرویسهای تخصیصیافته |
|
||||
| مراجعه مالی | `PatientSession` | در `AppointmentConfirmationService::onConfirmed` ساخته میشود |
|
||||
|
||||
## آنچه نیست
|
||||
|
||||
هیچ موجودیتی برای پرونده درمان، جلسه درمان، ثبت ناحیه، پروتکل، یا حوزه فعالیت.
|
||||
`ServiceItem.sessionCount` هست ولی هیچ منطقی از آن استفاده نمیکند — عملاً مرده.
|
||||
|
||||
---
|
||||
|
||||
## تصمیمهای قفلشده
|
||||
|
||||
اینها در جلسه grilling تصمیمگیری شدهاند. اجرایشان کن، دوباره طراحی نکن.
|
||||
اگر جایی از کد با یکی از اینها تناقض داشت، **متوقف شو و بپرس**؛ خودت تصمیم را عوض نکن.
|
||||
|
||||
1. **حوزه فعالیت از `Specialty` جداست.** `Specialty` قرارداد سایت عمومی است و دست نمیخورد.
|
||||
2. **هر کلینیک یک حوزه فعالیت دارد.** نال یعنی تنظیمنشده و رفتار امروز.
|
||||
3. **جلسه درمان موجودیت مستقل است**، نه `Appointment`.
|
||||
4. **زمانبندی با لیست صریح گامها.** فاصله ثابت نداریم.
|
||||
5. **دوره پایان مشخص دارد.** دوره بیپایان وجود ندارد.
|
||||
6. **همه جلسات از ابتدا ساخته میشوند**، ولی فقط جلسه بعدی نوبت میگیرد.
|
||||
7. **ناحیه درمان همان `CatalogCategory` است.** هیچ entity جدیدی برای ناحیه ساخته نشود.
|
||||
8. **نواحی از دسته سرویس مشتق میشوند**، هنگام رزرو انتخاب نمیشوند.
|
||||
9. **فهرست نواحی هنگام ساخت پرونده قفل میشود** (snapshot).
|
||||
10. **پزشک ناظرِ منبع روی نوبت مینشیند.** پرسنل انجام میدهد.
|
||||
11. **نوبتی که منبع دارد `active_slot_key` ندارد.** حفاظت فقط با `OccupancyBucket`.
|
||||
12. **`TreatmentProtocol` موجودیت جداست**، یکبهیک با `ServiceItem`.
|
||||
13. **پارامترهای دستگاه در JSON**، تعریفشان روی `ResourceType`.
|
||||
14. **Workflow با tagged service.** موتور دادهمحور نداریم.
|
||||
15. **حوزه فعالیت را فقط ادمین پلتفرم میسازد.**
|
||||
16. **جلسه وضعیت مستقل دارد** و نوبت را همگام میکند. بستن جلسه با ناحیه ناتمام مجاز است.
|
||||
17. **`TreatmentSession` بالینی است، `PatientSession` مالی.** هیچ فیلد پولی روی جلسه درمان.
|
||||
18. **قیمت هر جلسه با قیمت روز.** پکیج قیمت نداریم. قیمت هیچوقت قفل نمیشود.
|
||||
19. **جلسه بعد پیشنهاد میشود، منشی تأیید میکند.** رزرو خودکار بدون انسان نداریم.
|
||||
20. **حوزه فعالیت فقط workflow را انتخاب میکند.** داشبورد اختصاصی بهازای تخصص نداریم.
|
||||
21. **سررسید هر جلسه نسبی به تاریخ واقعی جلسه قبل است.**
|
||||
22. **no-show جلسه را نمیسوزاند.** تعداد جلسات ثابت میماند.
|
||||
23. **زمان واقعی جدا ثبت میشود.** `slotStart` و `slotEnd` هرگز بازنویسی نمیشوند.
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
هر وظیفه را جداگانه پیاده کن، تست بنویس، و بعد سراغ بعدی برو.
|
||||
|
||||
### ۱. حوزه فعالیت کلینیک
|
||||
|
||||
موجودیت جدید در `src/PracticeDomain/Entity/PracticeDomain.php`:
|
||||
|
||||
```php
|
||||
// جدول سراسری (global) — نه per-tenant. در GlobalTables ثبت شود.
|
||||
id, uuid, code (unique), name, sort_order, active, created_at, updated_at
|
||||
```
|
||||
|
||||
`code` پایدار است و workflow به آن bind میشود. بعد از ساخت قابل ویرایش نیست.
|
||||
|
||||
روی `Clinic` یک `ManyToOne` نالپذیر اضافه کن:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: PracticeDomain::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?PracticeDomain $practiceDomain = null;
|
||||
```
|
||||
|
||||
migration نباید مقدار پیشفرض برای کلینیکهای موجود بگذارد. نال بماند.
|
||||
|
||||
اندپوینتها:
|
||||
|
||||
- `GET /api/v1/practice-domains` — فهرست فعالها. برای همه نقشهای پنلی.
|
||||
- `POST /api/v1/admin/practice-domains` — فقط `ROLE_ADMIN`.
|
||||
- `PATCH /api/v1/admin/practice-domains/{uuid}` — فقط `name` و `active` و `sort_order`.
|
||||
- `PATCH /api/v1/clinic/{uuid}/practice-domain` — مدیر کلینیک انتخاب میکند.
|
||||
|
||||
در پاسخ هر حوزه یک فیلد `has_workflow` بگذار که از `TreatmentWorkflowRegistry` میآید.
|
||||
پنل ادمین پلتفرم باید بتواند نشان دهد کدام حوزه هنوز workflow ندارد.
|
||||
|
||||
seed اولیه با migration: `beauty` = «کلینیک زیبایی».
|
||||
|
||||
### ۲. پروتکل درمان
|
||||
|
||||
سه موجودیت در `src/Treatment/Entity/`:
|
||||
|
||||
```php
|
||||
// TreatmentProtocol — وجود این ردیف یعنی سوییچ «طول درمان» روشن است
|
||||
id, uuid, service_item_id (unique, ON DELETE CASCADE),
|
||||
supervisor_doctor_id (nullable), active, created_at, updated_at
|
||||
|
||||
// TreatmentProtocolStep — گامهای دوره
|
||||
id, protocol_id, step_number, offset_days, created_at
|
||||
// UNIQUE(protocol_id, step_number)
|
||||
// offset_days یعنی «فاصله از جلسهٔ قبل»، نه از شروع دوره. گام ۱ همیشه offset_days = 0.
|
||||
|
||||
// TreatmentProtocolStaff — پرسنل مجاز به انجام
|
||||
id, protocol_id, staff_id
|
||||
// UNIQUE(protocol_id, staff_id)
|
||||
```
|
||||
|
||||
قواعد اعتبارسنجی:
|
||||
|
||||
- حداقل دو گام. پروتکل یکجلسهای معنی ندارد؛ آن یعنی سوییچ خاموش.
|
||||
- `step_number` پیوسته از ۱.
|
||||
- گام اول `offset_days = 0`. بقیه بزرگتر از صفر.
|
||||
- حداقل یک پرسنل مجاز.
|
||||
- `supervisor_doctor_id` باید پزشکِ همان محیط باشد.
|
||||
|
||||
`ServiceItem::$sessionCount` را `@deprecated` علامت بزن، از `toArray()` بیرون **نبر**
|
||||
(پنل و تایپ TS به آن وابستهاند) ولی هیچ منطق جدیدی از آن نخوان. تعداد جلسات
|
||||
همیشه `count(protocol.steps)` است.
|
||||
|
||||
اندپوینتها زیر `/api/v1/clinic-services/{serviceUuid}/treatment-protocol`:
|
||||
`GET`، `PUT` (کل پروتکل با گامها و پرسنل یکجا)، `DELETE` (خاموش کردن سوییچ).
|
||||
|
||||
### ۳. پرونده و جلسه درمان
|
||||
|
||||
```php
|
||||
// TreatmentCase
|
||||
id, uuid, entity_type, entity_id, // tenant
|
||||
patient_record_id, service_item_id, protocol_id,
|
||||
supervisor_doctor_id (nullable),
|
||||
status, // active | completed | abandoned
|
||||
total_sessions, // snapshot از تعداد گامها
|
||||
opened_at, closed_at (nullable),
|
||||
created_at, updated_at
|
||||
|
||||
// TreatmentCaseArea — snapshot نواحی (تصمیم ۹)
|
||||
id, case_id, catalog_category_id, name_snapshot, sort_order
|
||||
// UNIQUE(case_id, catalog_category_id)
|
||||
|
||||
// TreatmentSession
|
||||
id, uuid, case_id, session_number,
|
||||
appointment_id (nullable, ON DELETE SET NULL),
|
||||
performed_by_staff_id (nullable), // تصمیم ۲۳
|
||||
status, // planned | booked | in_progress | done | cancelled | no_show
|
||||
due_at (nullable), // تخمینی، بعد از هر جلسه بازمحاسبه میشود
|
||||
started_at (nullable), finished_at (nullable),
|
||||
note (nullable),
|
||||
created_at, updated_at
|
||||
// UNIQUE(case_id, session_number)
|
||||
|
||||
// SessionAreaRecord
|
||||
id, uuid, session_id, case_area_id,
|
||||
resource_id (nullable), // دستگاه — در سطح ناحیه
|
||||
status, // pending | in_progress | completed | skipped
|
||||
parameters JSON (nullable), // { "energy": 18, "pulse": 3, "shots": 212 }
|
||||
started_at (nullable), finished_at (nullable),
|
||||
note (nullable),
|
||||
created_at, updated_at
|
||||
// UNIQUE(session_id, case_area_id)
|
||||
```
|
||||
|
||||
**هیچ ستون پولی روی این جدولها نگذار.** ADR-0006.
|
||||
|
||||
`name_snapshot` روی `TreatmentCaseArea` عمدی است: اگر مدیر بعداً اسم دسته را عوض کند،
|
||||
سابقه درمان نباید تغییر کند.
|
||||
|
||||
نواحی هنگام ساخت پرونده اینطور حساب میشوند:
|
||||
|
||||
```
|
||||
leaves = برگهای CategoryClosureResolver::descendants(service.catalogCategory)
|
||||
اگر descendants خالی بود → خودِ service.catalogCategory تنها ناحیه است
|
||||
```
|
||||
|
||||
«برگ» یعنی دستهای که خودش `descendants` ندارد. دستههای میانی فقط گروهبندیاند و
|
||||
ناحیه درمان نیستند.
|
||||
|
||||
### ۴. اتصال پزشک ناظر به مسیر رزرو
|
||||
|
||||
در `AppointmentController` بلوکی که پزشک را از منبع استنتاج میکند (حدود خط ۴۷۱)
|
||||
یک fallback اضافه کن:
|
||||
|
||||
```php
|
||||
if ($doctorUuid === '' && $resource->subject() instanceof Doctor) {
|
||||
$doctorUuid = $resource->subject()->getUuid();
|
||||
}
|
||||
// جدید:
|
||||
if ($doctorUuid === '' && $resource->getSupervisor() !== null) {
|
||||
$doctorUuid = $resource->getSupervisor()->getUuid();
|
||||
}
|
||||
```
|
||||
|
||||
اگر منبعی نه پزشک است نه ناظر دارد، خطای واضح بده:
|
||||
«این منبع پزشک ناظر ندارد؛ ابتدا در تنظیمات منابع پزشک ناظر را مشخص کنید».
|
||||
پیام فعلی (`doctor_uuid یا resource_uuid ...`) گمراهکننده است.
|
||||
|
||||
### ۵. رزرو منبع مستقل از پزشک
|
||||
|
||||
**این وظیفه بعد از بررسی داده واقعی بازنویسی شد. ADR-0003 را بخوان.**
|
||||
|
||||
آنچه با داده تأیید شد:
|
||||
|
||||
- دو مسیر رزرو داریم و هیچکدام ردیف دیگری نمیسازد.
|
||||
مسیر پنل روی `appointments.resource_id` مینشیند، مسیر hold روی `resource_occupancy`.
|
||||
- `bookAtomically` روی **پزشک** قفل میگیرد و `isSlotTaken` تداخل بازهای را فقط روی پزشک
|
||||
میسنجد. منبع در آن کوئری نیست.
|
||||
- در دیتابیس فعلی هر محیط چند منبع با یک پزشک ناظر مشترک دارد. کلینیک ۲ شش منبع با پزشک ۶،
|
||||
کلینیک ۳ سه منبع با پزشک ۹. پس این باگ همین حالا فعال است.
|
||||
- برنامه هفتگی پزشک مانع نیست؛ `resolveSlotLocationId` فقط `null` برمیگرداند.
|
||||
|
||||
پنج تغییر:
|
||||
|
||||
۱. مسیر پنل هنگام رزرو `ResourceOccupancy` بسازد، همانطور که `HoldService` میسازد.
|
||||
منطق مشترک در یک سرویس باشد، در دو جا کپی نشود.
|
||||
|
||||
۲. لغو یا انقضای نوبت، ردیف اشغال را `released` کند.
|
||||
|
||||
۳. در `Appointment::refreshActiveSlotKey()` وقتی منبع هست کلید `null` بماند:
|
||||
|
||||
```php
|
||||
$this->activeSlotKey = (!$this->isReserve
|
||||
&& $this->resource === null
|
||||
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true))
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
```
|
||||
|
||||
`setResource()` باید `refreshActiveSlotKey()` را صدا بزند.
|
||||
|
||||
۴. `bookAtomically` وقتی نوبت منبع دارد روی پزشک قفل نگیرد و `isSlotTaken` را صدا نزند.
|
||||
تضمین یکتایی از `uniq_bucket_resource_seat` میآید که ظرفیت و `seat` را میفهمد.
|
||||
|
||||
۵. شعبه نوبتِ منبعدار از `ClinicResource.getAddress()` بیاید، نه از برنامه پزشک.
|
||||
|
||||
**قبل از شروع:** همه مسیرهای ساخت نوبت را فهرست کن و بنویس کدامها منبع ست نمیکنند.
|
||||
آنها کلید پزشک و قفل پزشک را نگه میدارند. این فهرست باید در گزارش بیاید.
|
||||
|
||||
migration برای `active_slot_key` لازم نیست. برای ردیفهای اشغالِ گذشتهٔ مسیر پنل یک
|
||||
migration دادهای لازم است تا نوبتهای فعالِ منبعدار موجود ردیف اشغال بگیرند.
|
||||
|
||||
### ۶. تعریف فیلد روی نوع منبع
|
||||
|
||||
ستون JSON روی `ResourceType`:
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'field_schema', type: 'json', nullable: true)]
|
||||
private ?array $fieldSchema = null;
|
||||
```
|
||||
|
||||
قالب هر فیلد:
|
||||
|
||||
```json
|
||||
{ "key": "energy", "label": "انرژی", "type": "select",
|
||||
"options": [7, 8, 9, 10, 12, 14, 16, 18], "required": true, "sort_order": 1 }
|
||||
```
|
||||
|
||||
`type` مجاز: `select` و `number` و `text`. همین سه تا، نه بیشتر.
|
||||
|
||||
اعتبارسنجی مقادیر `SessionAreaRecord.parameters` از همین schema میآید. یک سرویس
|
||||
`FieldSchemaValidator` بنویس که هم در ذخیره اندپوینت استفاده شود هم در تست.
|
||||
|
||||
کلیدهای ناشناخته که در schema نیستند رد شوند، نه اینکه بیصدا ذخیره شوند.
|
||||
|
||||
seed اولیه: یک `ResourceType` با کد `laser_device` و نام «دستگاه لیزر» و همان سه فیلد
|
||||
بالا بهعلاوه `shots` از نوع `number`. `is_system = false` تا مدیر بتواند ویرایشش کند.
|
||||
|
||||
### ۷. Workflow قابل توسعه
|
||||
|
||||
```php
|
||||
// src/Treatment/Workflow/TreatmentWorkflow.php
|
||||
#[AutoconfigureTag('app.treatment_workflow')]
|
||||
interface TreatmentWorkflow
|
||||
{
|
||||
public function supports(?string $practiceDomainCode): bool;
|
||||
|
||||
/** بعد از تأیید اولین نوبتِ یک سرویسِ پروتکلدار */
|
||||
public function openCase(Appointment $appointment, TreatmentProtocol $protocol): TreatmentCase;
|
||||
|
||||
/** بعد از بسته شدن یک جلسه — سررسید جلسه بعد را حساب میکند */
|
||||
public function onSessionFinished(TreatmentSession $session): void;
|
||||
}
|
||||
```
|
||||
|
||||
`TreatmentWorkflowRegistry` با `#[TaggedIterator('app.treatment_workflow')]` ساخته شود و
|
||||
اولین workflow ای که `supports()` بدهد را برگرداند. اگر هیچکدام، `DefaultTreatmentWorkflow`
|
||||
که رفتار عمومی دارد.
|
||||
|
||||
**هسته نباید بداند لیزر چیست.** `AppointmentConfirmationService` فقط این را میکند:
|
||||
|
||||
```
|
||||
اگر سرویسِ نوبت پروتکل فعال دارد و بیمار پروندهٔ باز برای همان سرویس ندارد
|
||||
→ registry->for(clinic.practiceDomain?.code)->openCase(...)
|
||||
```
|
||||
|
||||
`LaserTreatmentWorkflow` در فاز اول تقریباً همان `DefaultTreatmentWorkflow` است.
|
||||
جدا نگهش دار حتی اگر خالی باشد — نقطه اتصال آینده است.
|
||||
|
||||
### ۸. سررسید و جلسه بعد
|
||||
|
||||
قاعده محاسبه (تصمیم ۲۱):
|
||||
|
||||
```
|
||||
due_at(session n) = finished_at(session n-1) + protocol.step[n].offset_days
|
||||
جلسه ۱ سررسید ندارد؛ تاریخش همان نوبت اول است.
|
||||
```
|
||||
|
||||
بعد از `finished_at` شدن هر جلسه، فقط `due_at` **جلسه بعدی** بازمحاسبه شود، نه کل دوره.
|
||||
جلسات دورتر تخمین قبلیشان را نگه میدارند تا نوبتشان برسد.
|
||||
|
||||
no-show (تصمیم ۲۲): جلسه به `no_show` میرود، `session_number` عوض نمیشود،
|
||||
`total_sessions` عوض نمیشود. یک جلسه جایگزین با همان شماره **ساخته نمیشود**؛ همان جلسه
|
||||
دوباره به `planned` برمیگردد و `due_at` از تاریخ جلسه قبلِ **انجامشده** حساب میشود.
|
||||
|
||||
رزرو جلسه بعد (تصمیم ۱۹) **خودکار نیست**. اندپوینت پیشنهاد بده:
|
||||
|
||||
```
|
||||
GET /api/v1/treatment-sessions/{uuid}/slot-suggestions
|
||||
→ اسلاتهای آزاد منبع، از due_at به بعد، با استفاده از ResourceFreeTimeCalculator
|
||||
```
|
||||
|
||||
منشی یکی را انتخاب میکند و مسیر عادی ساخت نوبت اجرا میشود، سپس نوبت به جلسه وصل میشود.
|
||||
|
||||
### ۹. اندپوینتهای اجرای جلسه
|
||||
|
||||
همه زیر `ROLE_STAFF` یا بالاتر. علاوه بر نقش، بررسی کن پرسنل واقعاً به این جلسه دسترسی
|
||||
دارد — الگویش در `DashboardController::staff` هست (`findActiveByUserAndEntity`).
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/staff/treatment-sessions جلسات امروز پرسنل
|
||||
GET /api/v1/treatment-sessions/{uuid} جزئیات + نواحی + فیلدهای دستگاه
|
||||
POST /api/v1/treatment-sessions/{uuid}/start → in_progress، started_at
|
||||
POST /api/v1/treatment-sessions/{uuid}/finish → done، finished_at، note
|
||||
POST /api/v1/session-areas/{uuid}/start → in_progress، started_at
|
||||
POST /api/v1/session-areas/{uuid}/complete → completed، parameters، finished_at
|
||||
POST /api/v1/session-areas/{uuid}/skip → skipped
|
||||
GET /api/v1/treatment-cases فهرست پروندهها با فیلتر
|
||||
GET /api/v1/treatment-cases/{uuid} پرونده + همه جلسات
|
||||
```
|
||||
|
||||
همگامسازی وضعیت نوبت (تصمیم ۱۶):
|
||||
|
||||
```
|
||||
session start → Appointment::STATUS_SALON
|
||||
session finish → Appointment::STATUS_COMPLETED
|
||||
```
|
||||
|
||||
از `ALLOWED_TRANSITIONS` عبور کن، مستقیم `setStatus` نزن. اگر گذار مجاز نبود، جلسه را
|
||||
ببند ولی نوبت را دست نزن و لاگ بگذار — خطای ۵۰۰ نده.
|
||||
|
||||
بستن جلسه با ناحیه ناتمام **مجاز است** (تصمیم ۱۶). فقط در پاسخ تعداد ناتمامها را برگردان
|
||||
تا پنل هشدار نشان دهد.
|
||||
|
||||
`started_at` و `finished_at` هرگز روی `Appointment` نوشته نشوند (تصمیم ۲۳).
|
||||
|
||||
### ۱۰. پنل ادمین
|
||||
|
||||
قواعد اجباری این پروژه:
|
||||
|
||||
- هر `select` باید `components/ui/SearchableSelect` باشد. `<select>` بومی ممنوع.
|
||||
- هر بولین باید `components/ui/Switch` باشد. checkbox بومی ممنوع.
|
||||
- صفحه جدید با تم و Layout و کامپوننتهای موجود ساخته شود. طراحی جدید نکن.
|
||||
- تاریخها شمسی. متنها فارسی. RTL.
|
||||
|
||||
صفحهها و تغییرها:
|
||||
|
||||
1. **فرم سرویس** — سوییچ «طول درمان». روشن که شد: جدول گامها (شماره و فاصله از جلسه قبل)،
|
||||
`SearchableSelect` چندتایی پرسنل مجاز، `SearchableSelect` پزشک ناظر.
|
||||
2. **تنظیمات کلینیک** — `SearchableSelect` حوزه فعالیت.
|
||||
3. **صفحه نوع منابع** — ویرایشگر `fieldSchema`. افزودن و حذف فیلد، انتخاب نوع، گزینهها.
|
||||
4. **پنل ادمین پلتفرم** — CRUD حوزههای فعالیت با نشان «workflow دارد / ندارد».
|
||||
5. **فهرست پروندههای درمان** — نام بیمار، سرویس، جلسه چندم از چند، وضعیت، سررسید بعدی، اپراتور.
|
||||
6. **صفحه اجرای جلسه** — کارت هر ناحیه با دکمه شروع، تایمر زنده، فرم داینامیک از `fieldSchema`،
|
||||
دکمه اتمام ناحیه، دکمه لغو ناحیه. پایین صفحه یادداشت کلی و دکمه اتمام جلسه با هشدار
|
||||
نواحی ناتمام. اسکرینشاتهای مرجع در تیکت این کار هست.
|
||||
7. **صف «جلسات بدون نوبت»** — جلساتی که `status = planned` و `due_at` گذشته یا نزدیک است.
|
||||
از هر ردیف مستقیم به انتخاب اسلات پیشنهادی.
|
||||
|
||||
تایمر فقط نمایشی است. زمان معتبر همان `started_at` و `finished_at` سرور است.
|
||||
|
||||
---
|
||||
|
||||
## ترتیب اجرا
|
||||
|
||||
```
|
||||
۱ → ۲ → ۳ → ۶ → ۷ → ۴ → ۵ → ۸ → ۹ → ۱۰
|
||||
```
|
||||
|
||||
وظیفه ۵ (کلید اسلات) روی پرترافیکترین جدول سیستم است. بعد از وظیفه ۴ انجامش بده و
|
||||
قبل و بعدش تست رگرسیون رزرو را کامل اجرا کن.
|
||||
|
||||
## تست
|
||||
|
||||
- unit برای `CategoryClosureResolver` در حالت برگیابی نواحی
|
||||
- unit برای محاسبه `due_at` با گامهای نامساوی (سناریوی بوتاکس: ۰، ۱۵، ۳۰، ۳۰)
|
||||
- unit برای `FieldSchemaValidator` با کلید ناشناخته و مقدار خارج از `options`
|
||||
- functional: پزشک ناظر مشترک بین دو دستگاه، دو رزرو همساعت، هر دو باید موفق شوند
|
||||
- functional: اتاق با `capacity = 3`، سه رزرو همساعت، هر سه باید موفق شوند
|
||||
- functional: چرخه کامل یک دوره سهجلسهای شامل یک no-show
|
||||
- رگرسیون: رزرو بدون منبع همچنان با کلید پزشک از دوبار رزرو جلوگیری کند
|
||||
|
||||
## خارج از دامنه فاز اول
|
||||
|
||||
- seeder هوشمند با AI برای پیشنهاد دستهبندی و منابع
|
||||
- گزارشهای تحلیلی روی `parameters` (کوئری JSON بدون ایندکس)
|
||||
- حوزه فعالیت دوم غیر از زیبایی
|
||||
- داشبورد اختصاصی بهازای هر تخصص
|
||||
- قیمت پکیج و قفل قیمت
|
||||
|
||||
## مستندسازی
|
||||
|
||||
طبق قانون ثابت این پروژه، `docs/api/*` در همان session بهروز شود.
|
||||
اگر تصمیمی در حین اجرا عوض شد، ADR مربوطه بهروز شود یا ADR جدید نوشته شود.
|
||||
@@ -0,0 +1,327 @@
|
||||
# الگوی شماره پرونده — تنظیمات per-tenant + تولید خودکار شماره پرونده
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend Symfony + پنل ادمین React). cross-repo نیست: `record_number` در `nobat724_front` مصرف نمیشود (بررسی شد).
|
||||
|
||||
## زمینه
|
||||
|
||||
`PatientRecord` از قبل ستون `record_number` دارد، ولی امروز:
|
||||
|
||||
- **متن آزاد و دستی** است: کاربر در فرم ساخت پرونده تایپش میکند و zod فقط `min(1)` را چک میکند.
|
||||
- **هیچ قید یکتایی ندارد**: تنها unique روی `patient_records` جفت `(entity_type, entity_id, user_id)` است، نه شماره پرونده. دو پرونده میتوانند شمارهٔ یکسان بگیرند.
|
||||
- **پروندههای خودکار اصلاً شماره نمیگیرند**: وقتی نوبت قطعی میشود، `PatientService` پرونده را `new PatientRecord(...)` میسازد و `record_number` را `null` میگذارد. یعنی بخش بزرگی از پروندهها بیشمارهاند.
|
||||
|
||||
صاحب کلینیک/پزشک میخواهد یک **روند** داشته باشد: الگوی شماره را یکبار تعریف کند و از آن به بعد هر پروندهای که ثبت میشود خودش شماره بگیرد.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
۱. یک تنظیمِ per-tenant «الگوی شماره پرونده» با توکنهای مشخص و شمارندهٔ خودکار.
|
||||
۲. تولید شمارهٔ پرونده **سمت سرور** در **هر دو** مسیر ساخت پرونده (دستی از پنل، خودکار از نوبت).
|
||||
۳. یکتایی شماره در هر محیط، حتی زیر درخواستهای همزمان.
|
||||
|
||||
### تصمیمهای گرفتهشده (توسط کاربر — تغییرشان ندهید)
|
||||
|
||||
| تصمیم | مقدار |
|
||||
|---|---|
|
||||
| ریست شمارنده | قابل انتخاب: `none` / `yearly` / `monthly` (بر مبنای تقویم **شمسی**) |
|
||||
| ورود دستی شماره | فقط **صاحب محیط** (پزشکِ مالک مطب / مالک کلینیک) و `ROLE_ADMIN`. منشی و پرسنل و پزشکِ مهمان → فقط شمارهٔ تولیدشده |
|
||||
| پروندههای قدیمیِ بیشماره | **lazy backfill**: اولین بار که پرونده باز میشود (`GET /api/v1/patient/{uuid}`) شماره میگیرد |
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ **موفق (تنظیمات):** `PUT /api/v1/patient-record-number-settings` با بدنهٔ `{enabled: true, pattern: "MD-{YY}-{SEQ:4}", reset_policy: "yearly"}` توسط مالک کلینیک → `200` و `data.preview === "MD-05-0001"` (برای سال ۱۴۰۵ و شمارندهٔ صفر). `GET` همان مسیر مقدار ذخیرهشده + `next_preview` را برمیگرداند.
|
||||
- ✅ **موفق (ساخت دستی):** با الگوی فعال، `POST /api/v1/patient` بدون فیلد `record_number` → `201` و `data.record_number === "MD-05-0001"`؛ پروندهٔ بعدی `MD-05-0002`.
|
||||
- ✅ **موفق (ساخت خودکار):** قطعیکردن یک نوبت که پرونده ندارد → پروندهٔ ساختهشده توسط `PatientService` هم `record_number` غیر `null` دارد و در همان دنبالهٔ شمارهها است.
|
||||
- ✅ **موفق (backfill تنبل):** پروندهای با `record_number = null` که پیش از فعالشدن الگو ساخته شده، بعد از یک `GET /api/v1/patient/{uuid}` شماره میگیرد و در `GET` دوم **همان** شماره برمیگردد (نه شمارهٔ تازه).
|
||||
- ❌ **خطا (الگوی نامعتبر):** `PUT` با `pattern: "MD-{FOO}"` → `422` با `field: pattern` و پیام فارسی؛ `pattern` بدون هیچ `{SEQ}` → `422` (وگرنه همهٔ پروندهها یک شماره میگرفتند).
|
||||
- ❌ **خطا (دسترسی):** منشیِ همان کلینیک روی `PUT` تنظیمات → `403`. منشیای که در `POST /api/v1/patient` فیلد `record_number` میفرستد → `403` (یا فیلد بیصدا نادیده گرفته نشود؛ خطا صریح باشد).
|
||||
- ⚠️ **مرزی (همزمانی):** دو `POST /api/v1/patient` همزمان در یک محیط → دو شمارهٔ **متفاوت**؛ هیچکدام ۵۰۰ نمیدهد.
|
||||
- ⚠️ **مرزی (ریست سالانه):** با `reset_policy: yearly`، اولین پروندهٔ سال شمسی بعدی دوباره از `{SEQ}=1` شروع میشود و با پروندهٔ سال قبل تداخل ندارد (چون `{YY}` در الگوست).
|
||||
- ⚠️ **مرزی (الگوی خاموش):** با `enabled: false` رفتار امروز حفظ میشود — شماره تولید نمیشود و ورود دستی برای همه باز است.
|
||||
- ⚠️ **مرزی (سرریز شمارنده):** `{SEQ:3}` وقتی شمارنده به ۱۰۰۰ میرسد → شماره بدون پدینگ اضافه ادامه پیدا کند (`1000`)، نه اینکه بریده شود یا خطا بدهد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `clinicpro/src/Patient/Entity/PatientRecord.php` | ستون `record_number` (خط ۴۸) — هدفِ مقداردهی |
|
||||
| `clinicpro/src/Patient/Controller/PatientController.php` | `POST /api/v1/patient` (خط ~۷۸۴)، `GET /api/v1/patient/{uuid}` (خط ۷۹۹)، `PATCH` (خط ~۹۳۵) |
|
||||
| `clinicpro/src/Patient/Service/PatientService.php` | ساخت خودکار پرونده از نوبت (خط ~۲۱۱) |
|
||||
| `clinicpro/src/Insurance/Entity/TenantServiceCategorySetting.php` | **الگوی مرجع** برای یک تنظیم per-tenant |
|
||||
| `clinicpro/src/Representation/Service/JalaliDateService.php` | تبدیل شمسی (`gregorianToJalali`) برای توکنهای `{YY}`/`{MM}` |
|
||||
| `clinicpro/src/Shared/Context/EntityContextResolver.php` | `ownedEntity($user)` — تشخیص «صاحب محیط» برای گیتِ ورود دستی |
|
||||
| `clinicpro/assets/admin/pages/PatientRecordFormPage.tsx` | فیلد و اعتبارسنجی `record_number` (خطوط ۲۲، ۱۱۹) |
|
||||
| `clinicpro/assets/admin/components/layout/settingsMenu.ts` | افزودن آیتم تنظیمات |
|
||||
| `clinicpro/docs/api/patient.md` | مستند endpointها |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
**`src/Patient/Entity/PatientRecord.php:44-49`** — ستون بدون قید یکتایی:
|
||||
|
||||
```php
|
||||
// Clinic-scoped case-file number. Patient identity/demographics (gender,
|
||||
// date_of_birth, referral_source, description, insurance, …) live on the
|
||||
// patient's UserProfile and are set via PATCH /patient/{uuid}.
|
||||
#[ORM\Column(name: 'record_number', type: 'string', length: 40, nullable: true)]
|
||||
private ?string $recordNumber = null;
|
||||
```
|
||||
|
||||
**`src/Patient/Controller/PatientController.php:784-795`** — ساخت دستی، شماره فقط اگر کاربر فرستاده باشد:
|
||||
|
||||
```php
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
|
||||
|
||||
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
|
||||
$record->setRecordNumber($rn);
|
||||
}
|
||||
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
|
||||
if ($tagError !== null) {
|
||||
return $tagError;
|
||||
}
|
||||
|
||||
$this->recordRepo->save($record);
|
||||
|
||||
return $this->success($record->toArray(), 201);
|
||||
```
|
||||
|
||||
**`src/Patient/Service/PatientService.php:209-213`** — ساخت خودکار، بدون هیچ شمارهای:
|
||||
|
||||
```php
|
||||
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
|
||||
if ($record === null) {
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
```
|
||||
|
||||
**`assets/admin/pages/PatientRecordFormPage.tsx:22,119-120`** — فیلد دستیِ الزامی:
|
||||
|
||||
```tsx
|
||||
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
|
||||
...
|
||||
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
|
||||
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. Entity + migration تنظیمات الگو
|
||||
|
||||
`src/Patient/Entity/RecordNumberPattern.php` — یک ردیف به ازای هر محیط، دقیقاً با سبک `TenantServiceCategorySetting` (همان `entity_type/entity_id` + unique constraint، timestamp عدد صحیح):
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: RecordNumberPatternRepository::class)]
|
||||
#[ORM\Table(name: 'record_number_patterns')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_record_number_pattern', columns: ['entity_type', 'entity_id'])]
|
||||
class RecordNumberPattern
|
||||
{
|
||||
public const RESET_NONE = 'none';
|
||||
public const RESET_YEARLY = 'yearly';
|
||||
public const RESET_MONTHLY = 'monthly';
|
||||
|
||||
// entity_type, entity_id, enabled(bool), pattern(string 60),
|
||||
// reset_policy(string 10), counter(int), counter_period(string 7, nullable),
|
||||
// updated_at(int)
|
||||
}
|
||||
```
|
||||
|
||||
`counter_period` مهر دورهٔ فعلیِ شمارنده است (`'1405'` برای yearly، `'1405-05'` برای monthly، `null` برای none). با ورود دورهٔ جدید، `counter` صفر و `counter_period` بهروز میشود — بدون این ستون، «آیا سال عوض شده؟» فقط با حدس از `updated_at` قابل جواب بود.
|
||||
|
||||
سپس:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` سبز باشد؛ جدول با `ddev exec mysql -uroot -proot db -e "DESCRIBE record_number_patterns;"` وجود داشته باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۲. یکتایی شمارهٔ پرونده در سطح دیتابیس
|
||||
|
||||
قبل از افزودن ایندکس، تداخلهای موجود را ببین:
|
||||
|
||||
```sql
|
||||
SELECT entity_type, entity_id, record_number, COUNT(*) c
|
||||
FROM patient_records WHERE record_number IS NOT NULL
|
||||
GROUP BY 1,2,3 HAVING c > 1;
|
||||
```
|
||||
|
||||
اگر ردیفی برگشت، **به کاربر گزارش بده و متوقف شو** — پاککردن خودسرانهٔ شمارهٔ پروندهٔ واقعی مجاز نیست. اگر خالی بود، unique index روی `(entity_type, entity_id, record_number)` اضافه کن (MariaDB چند `NULL` را در unique میپذیرد، پس پروندههای بیشماره مانع نمیشوند).
|
||||
|
||||
این ایندکس **تور ایمنی** است، نه مکانیزم اصلی؛ مکانیزم اصلی قفلِ وظیفهٔ ۳ است.
|
||||
|
||||
**نحوه تست:** درج دستی دو ردیف با شمارهٔ یکسان در یک محیط → خطای دیتابیس.
|
||||
|
||||
---
|
||||
|
||||
### ۳. سرویس تولید شماره — `RecordNumberGenerator`
|
||||
|
||||
`src/Patient/Service/RecordNumberGenerator.php`، تنها جایی که شماره ساخته میشود. سه مسئولیت جدا:
|
||||
|
||||
**الف) رندر الگو (خالص، بدون I/O):**
|
||||
|
||||
توکنهای مجاز — هر چیز دیگری `422`:
|
||||
|
||||
| توکن | معنی |
|
||||
|---|---|
|
||||
| `{YY}` | دو رقم آخر سال شمسی (`05`) |
|
||||
| `{YYYY}` | سال شمسی کامل (`1405`) |
|
||||
| `{MM}` | ماه شمسی دو رقمی (`05`) |
|
||||
| `{SEQ}` / `{SEQ:n}` | شمارنده، با `n` رقم پدینگ صفر (پیشفرض ۱) |
|
||||
|
||||
سال/ماه شمسی از `JalaliDateService::gregorianToJalali()` میآید — **پیادهسازی دستی ننویس**؛ همان فایل توضیح میدهد نسخهٔ دستساز قبلی غلط بود.
|
||||
|
||||
**ب) گرفتن شمارهٔ بعدی (تراکنشی):**
|
||||
|
||||
```php
|
||||
public function next(string $entityType, int $entityId): ?string
|
||||
{
|
||||
// بدون الگو یا با enabled=false → null (رفتار امروز)
|
||||
return $this->em->wrapInTransaction(function () use ($entityType, $entityId): ?string {
|
||||
$pattern = $this->repo->lockForUpdate($entityType, $entityId); // SELECT ... FOR UPDATE
|
||||
if ($pattern === null || !$pattern->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$period = $this->periodKey($pattern->getResetPolicy());
|
||||
if ($pattern->getCounterPeriod() !== $period) {
|
||||
$pattern->resetCounter($period);
|
||||
}
|
||||
$pattern->incrementCounter();
|
||||
|
||||
return $this->render($pattern->getPattern(), $pattern->getCounter());
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
قفلِ ردیف (`LockMode::PESSIMISTIC_WRITE`) شرطِ معیار پذیرشِ «دو درخواست همزمان» است؛ خواندن-افزایش-نوشتن بدون قفل، دو شمارهٔ یکسان میدهد.
|
||||
|
||||
**ج) پیشنمایش (بدون افزایش شمارنده):** `preview(string $pattern, string $resetPolicy): string` برای صفحهٔ تنظیمات.
|
||||
|
||||
**نکتهٔ SOLID:** این سرویس با constructor injection به `RecordNumberPatternRepository` و `JalaliDateService` وابسته است؛ `new` ممنوع. Controller و `PatientService` هر دو فقط `next()` را صدا میزنند — منطق دو جا کپی نشود.
|
||||
|
||||
**نحوه تست:** unit test روی `render()` برای هر توکن + سرریز `{SEQ:3}` روی ۱۰۰۰؛ و یک functional test که دو بار `next()` را صدا بزند و دو شمارهٔ متوالی بگیرد.
|
||||
|
||||
---
|
||||
|
||||
### ۴. Endpointهای تنظیمات
|
||||
|
||||
در `src/Patient/Controller/` (کنترلر جدید `RecordNumberSettingsController extends BaseController`):
|
||||
|
||||
| Method | Route | دسترسی |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/patient-record-number-settings` | هر کاربرِ محیط با `patients.view` |
|
||||
| `PUT` | `/api/v1/patient-record-number-settings` | فقط صاحب محیط یا `ROLE_ADMIN` |
|
||||
|
||||
پاسخ `GET`:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"enabled": true, "pattern": "MD-{YY}-{SEQ:4}", "reset_policy": "yearly",
|
||||
"counter": 12, "next_preview": "MD-05-0013", "can_edit": true
|
||||
} }
|
||||
```
|
||||
|
||||
`can_edit` را از `EntityContextResolver::ownedEntity($user)` بساز (بررسی کن که این متد واقعاً همان چیزی را میدهد که لازم است؛ اگر نه، از `ClinicDoctorAccessChecker`/مالکِ `Clinic::getUser()` استفاده کن). پنل با همین فلگ فرم را read-only میکند — ولی **گیت اصلی سمت سرور است**، نه UI.
|
||||
|
||||
اعتبارسنجی `PUT`: `pattern` غیرخالی، حداکثر ۶۰ نویسه، فقط توکنهای مجاز، حتماً شامل `{SEQ...}`، و `reset_policy` یکی از سه مقدار. هر خطا `422` با `field` درست از `$this->validationError()`/`$this->error()`.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
T=$(ddev exec php bin/console lexik:jwt:generate-token -c 'App\\Auth\\Entity\\User' -- 09390039833)
|
||||
curl -sk -X PUT https://clinic-pro.ddev.site/api/v1/patient-record-number-settings \
|
||||
-H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
|
||||
-d '{"enabled":true,"pattern":"MD-{YY}-{SEQ:4}","reset_policy":"yearly"}'
|
||||
# سپس همان با توکن منشی 09390039875 → باید 403 بدهد
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۵. اتصال به هر دو مسیر ساخت پرونده
|
||||
|
||||
**`PatientController::create`** — منطق فعلیِ خط ۷۸۶ عوض میشود:
|
||||
|
||||
```php
|
||||
$manual = trim((string) ($data['record_number'] ?? ''));
|
||||
if ($manual !== '' && !$this->canSetRecordNumberManually($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'ثبت دستی شماره پرونده مجاز نیست', 403, 'record_number');
|
||||
}
|
||||
$record->setRecordNumber($manual !== '' ? $manual : $this->recordNumbers->next($entityType, $entityId));
|
||||
```
|
||||
|
||||
**`PatientService`** (خط ~۲۱۱) — همان یک خط:
|
||||
|
||||
```php
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
|
||||
$record->setRecordNumber($this->recordNumbers->next($entityType, $entityId));
|
||||
$this->recordRepo->save($record);
|
||||
```
|
||||
|
||||
بدون این دومی، خواستهٔ «هر پروندهای که ثبت میشود» برآورده نمیشود — بیشترِ پروندهها از همین مسیرِ نوبت ساخته میشوند.
|
||||
|
||||
**نحوه تست:** با الگوی فعال یک `POST /api/v1/patient` بدون `record_number` بزن و شماره را ببین؛ سپس یک نوبت را قطعی کن (`POST /api/v1/appointment/{uuid}/confirm`) و شمارهٔ پروندهٔ ساختهشده را از `GET /api/v1/patients?search=...` بخوان.
|
||||
|
||||
---
|
||||
|
||||
### ۶. Backfill تنبل هنگام باز شدن پرونده
|
||||
|
||||
در `PatientController::show` (خط ۷۹۹)، **قبل از** ساخت پاسخ:
|
||||
|
||||
```php
|
||||
if ($record->getRecordNumber() === null) {
|
||||
$number = $this->recordNumbers->next($entityType, $entityId);
|
||||
if ($number !== null) {
|
||||
$record->setRecordNumber($number);
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
سه نکته که باید رعایت شود:
|
||||
|
||||
- فقط وقتی الگو فعال است (`next()` خودش `null` برمیگرداند) — وگرنه یک `GET` ساده تبدیل به نوشتن بیدلیل میشود.
|
||||
- idempotent: بار دوم چون شماره پر است، هیچ نوشتنی رخ نمیدهد. (معیار پذیرشِ «GET دوم همان شماره»)
|
||||
- دو `GET` همزمان روی یک پرونده: قفلِ وظیفهٔ ۳ دو شمارهٔ متفاوت میدهد ولی آخرین نوشتن برنده است و یک شماره هدر میرود. قابل قبول است؛ **در کد کامنت شود** که چرا (هدررفتِ یک شماره در برابر پیچیدگی قفل روی خودِ رکورد).
|
||||
|
||||
**نحوه تست:** یک `patient_records` با `record_number = NULL` در DB پیدا/بساز، دو بار `GET /api/v1/patient/{uuid}` بزن، هر دو بار شمارهٔ یکسان برگردد.
|
||||
|
||||
---
|
||||
|
||||
### ۷. پنل ادمین — صفحهٔ تنظیمات + فرم پرونده
|
||||
|
||||
**الف) صفحهٔ تنظیمات** `assets/admin/pages/RecordNumberSettingsPage.tsx`:
|
||||
|
||||
- داخل `SettingsLayout` مثل بقیهٔ صفحات تنظیمات.
|
||||
- سوییچ «شمارهگذاری خودکار پرونده»، ورودی الگو، `SearchableSelect` برای سیاست ریست (**نه** `<select>` بومی — قاعدهٔ پروژه)، و یک خط پیشنمایشِ زنده: «شمارهٔ بعدی: MD-05-0013».
|
||||
- راهنمای کوتاه توکنها زیر فیلد.
|
||||
- وقتی `can_edit === false`: فرم read-only + یک خط توضیح که فقط صاحب حساب میتواند تغییر دهد.
|
||||
- route در `App.tsx` با `RoleRoute roles={['doctor','clinic','secretary']} blockClinicScope permission={['patients','view']}` و آیتم در `settingsMenu.ts` (کلید `record-number`، آیکون `HashtagIcon`، همان `perm`).
|
||||
|
||||
**ب) فرم پرونده** `PatientRecordFormPage.tsx`:
|
||||
|
||||
- وقتی الگو فعال است و کاربر اجازهٔ دستی ندارد: فیلد `record_number` **در حالت ساخت** پنهان/read-only شود و قاعدهٔ zod از `min(1)` به اختیاری تغییر کند — وگرنه فرم با فیلدی که کاربر نمیتواند پر کند قفل میشود. در حالت ویرایش، رفتار فعلی برای صاحب حساب میماند.
|
||||
- مقدار الگو/دسترسی از همان `GET /api/v1/patient-record-number-settings` (یک هوک `useRecordNumberSettings` در `assets/admin/hooks/`).
|
||||
|
||||
**نحوه تست:** `npx tsc --noEmit` + `npx vitest run assets/admin/pages/RecordNumberSettingsPage.test.tsx` با سه سناریو: ذخیرهٔ الگو، پیشنمایش درست، و read-only بودن فرم وقتی `can_edit === false`. برای فرم پرونده یک تست که با الگوی فعال، ارسال بدون `record_number` را مجاز بداند.
|
||||
|
||||
---
|
||||
|
||||
### ۸. مستندات
|
||||
|
||||
- `docs/api/patient.md`: بخش تازه برای دو endpoint تنظیمات (method/path/permission، بدنهٔ کامل request، JSON واقعیِ response از اجرای واقعی، همهٔ کدهای خطا) + بهروزرسانی توضیح `record_number` در `POST /api/v1/patient` و `PATCH /patient/{uuid}` (چه کسی میتواند دستی بفرستد، چه زمانی سرور تولید میکند).
|
||||
- در همان فایل بنویس که `GET /api/v1/patient/{uuid}` ممکن است شمارهٔ پرونده را تخصیص دهد (اثر جانبیِ عمدی روی یک `GET` — سند بدون این، رفتار را غافلگیرکننده میکند).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگوی طراحی:** یک سرویسِ تکی (`RecordNumberGenerator`) کافی است؛ Strategy برای «انواع الگو» نساز — الان فقط یک زبانِ توکن وجود دارد و abstraction دوم مصرف ندارد (guidelines §۵). ریستها فقط سه مقدار ثابتاند و با یک `match` روی `periodKey()` حل میشوند، نه سه کلاس.
|
||||
- **جداسازی محیط:** جدول جدید `entity_type/entity_id` دارد، پس یا `TenantOwnedTrait` بگیرد یا با دلیل در `GlobalTables` ثبت شود — `TenantSchemaCoverageTest` در غیر این صورت قرمز میشود. (مسیر درست: `TenantOwnedTrait`.)
|
||||
- **شمارنده روی همان ردیفِ تنظیمات است**، نه `MAX(record_number)+1`. شمارش از روی مقادیر موجود، با شمارهٔ دستیِ صاحب حساب یا با تغییر الگو میشکند.
|
||||
- **گیتِ ورود دستی سمت سرور اجباری است.** پنهانکردن فیلد در UI کافی نیست؛ منشی میتواند مستقیم به API بزند.
|
||||
- **رفتار قبلی نباید بشکند:** محیطی که الگو ندارد یا `enabled=false` است باید دقیقاً مثل امروز کار کند (ورود دستی آزاد، شماره تولید نشود). این را تست کن.
|
||||
- **`record_number` در جستجوی بیماران استفاده میشود** (`/api/v1/patients?search=`, placeholder «جستجوی نام، شماره تماس، شماره پرونده...»). بعد از این تغییر، جستجو با شمارهٔ تولیدشده را دستی امتحان کن.
|
||||
- **مصرفکنندهٔ دیگر ندارد:** `nobat724_front` و `clinic-pro-tauri` این فیلد را نمیخوانند (grep شد)، پس تغییر cross-repo لازم نیست — ولی اگر در حین کار خلافش دیده شد، گزارش بده.
|
||||
@@ -0,0 +1,370 @@
|
||||
# تب «نوبتهای بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین). cross-repo نیست.
|
||||
|
||||
## زمینه
|
||||
|
||||
درمان چندجلسهای امروز فقط از یک صفحهٔ سراسری دیده میشود:
|
||||
`/admin/treatment-cases`. آنجا فهرست همهٔ پروندههای محیط است، نه پروندهٔ یک بیمار.
|
||||
|
||||
نتیجه: منشی که پروندهٔ یک بیمار را باز کرده (`/admin/patients/{uuid}`) هیچ راهی ندارد
|
||||
ببیند این بیمار چه دورههایی دارد، جلسهٔ بعدش کِی است، و جلسات قبلی چه چیزی ثبت کردهاند.
|
||||
|
||||
`TreatmentScheduler` هم فقط سررسید **جلسهٔ بعدی** را مینویسد و بقیه `null` میمانند
|
||||
(تصمیم عمدی؛ کامنت خودِ کلاس). پس هیچجا نمیشود کل تقویم یک دوره را دید.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه چیز:
|
||||
|
||||
1. **تب «نوبتهای بعدی» در پروندهٔ بیمار.** دورههای همان بیمار، و برای هر دوره کارتِ
|
||||
همهٔ جلسات با تاریخ و ساعتِ محاسبهشده. هر کارت دکمهٔ «ثبت نوبت» دارد که همان مودال
|
||||
`NewAppointmentModal` را با همان تاریخ/ساعت باز میکند و کاربر میتواند تاریخ و ساعت
|
||||
دیگری هم بگذارد.
|
||||
2. **دیدن جزئیات انجامشده.** برای جلسات تمامشده باید معلوم باشد چه کسی، روی چه
|
||||
ناحیهای، با چه دستگاهی و با چه خواندههایی (انرژی/پالس/شات) کار کرده.
|
||||
3. **واژهٔ «اپراتور» → «پرسنل»** در رشتههای کاربرپسند، و پیشفرض شدن پرسنلِ پروتکل در
|
||||
مودال ثبت نوبت.
|
||||
|
||||
---
|
||||
|
||||
## ⚠ تصمیم معماری که باید قبل از کد روشن باشد
|
||||
|
||||
خواستهٔ «همهٔ نوبتها را بر اساس طول درمان محاسبه کن» با یک تصمیم ثبتشدهٔ پروژه در
|
||||
تضاد ظاهری است. متن خودِ `TreatmentScheduler`:
|
||||
|
||||
> فقط جلسهٔ **بعدی** بازمحاسبه میشود. جلسات دورتر حدسِ قبلیشان را نگه میدارند تا
|
||||
> نوبتشان برسد — عددی که هنوز به هیچ واقعیتی گره نخورده، بازمحاسبهاش دقیقترش نمیکند.
|
||||
|
||||
**راهحل انتخابی: محاسبه بشود ولی ذخیره نشود.**
|
||||
|
||||
- یک سرویس **فقطخواندنی** تقویم کل دوره را از روی آخرین لنگرِ واقعی میسازد.
|
||||
- `TreatmentSession.due_at` دستنخورده میماند و همچنان فقط جلسهٔ بعدی را نگه میدارد.
|
||||
- در UI، جلسهای که `due_at` واقعی دارد با جلسهای که فقط تخمین است **متفاوت** نشان
|
||||
داده میشود.
|
||||
|
||||
**چرا این و نه ذخیره کردن همه:** اگر همهٔ سررسیدها ذخیره شوند، هر بار که بیمار دیر
|
||||
میآید باید کل زنجیره بازنویسی شود و هر نسخهٔ ذخیرهشده یک ادعای غلط دربارهٔ آینده است.
|
||||
تخمینِ محاسبهشده در لحظهٔ نمایش، همیشه با آخرین واقعیت همخوان است و چیزی برای
|
||||
نگهداشتن ندارد.
|
||||
|
||||
**ریسک این انتخاب:** تاریخهای کارت با هر بار باز کردن صفحه ممکن است عوض شوند (اگر
|
||||
جلسهای بین دو بازدید تمام شده باشد). این پذیرفته است و باید در UI با برچسب «تخمینی»
|
||||
اعلام شود.
|
||||
|
||||
**گزینهٔ جایگزینی که رد شد:** ساختن نوبتهای `pending` برای کل دوره. رد شد چون جای
|
||||
دستگاه را میگیرد و بیمارِ نیامده وقت منبع را میسوزاند — همان دلیلی که «رزرو خودکار»
|
||||
قبلاً رد شده بود.
|
||||
|
||||
---
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: `GET /api/v1/treatment-cases?record=<record_uuid>` با توکن منشی → ۲۰۰ و فقط
|
||||
پروندههای همان بیمار. `GET /api/v1/treatment-case/{uuid}/plan` → ۲۰۰ با آرایهٔ
|
||||
جلسات که هر کدام `planned_at` و `is_estimate` دارند.
|
||||
- ✅ موفق: در `/admin/patients/{uuid}?tab=treatment` کارت هر جلسه تاریخ و ساعت شمسی
|
||||
نشان میدهد و دکمهٔ «ثبت نوبت» مودال `NewAppointmentModal` را با همان تاریخ/ساعت
|
||||
و همان بیمار و سرویس باز میکند.
|
||||
- ✅ موفق: در مودال ثبت نوبت، وقتی سرویسی با پروتکل فعال انتخاب میشود، فیلد «پرسنل»
|
||||
از `TreatmentProtocol.staff` پیشفرض پر میشود.
|
||||
- ❌ خطا: `GET /api/v1/treatment-case/{uuid}/plan` برای پروندهٔ محیط دیگر → ۴۰۴ با
|
||||
envelope خطا. `?record=` با uuid ناموجود → آرایهٔ خالی، نه ۵۰۰.
|
||||
- ⚠️ مرزی: بیمارِ بدون هیچ دوره → تب «نوبتهای بعدی» حالت خالیِ طراحیشده دارد، نه
|
||||
اسکلتون بیپایان.
|
||||
- ⚠️ مرزی: دورهای که همهٔ جلساتش تمام شده → کارتها همه «انجامشده» با جزئیات، و هیچ
|
||||
دکمهٔ «ثبت نوبت» فعالی ندارند.
|
||||
- ⚠️ مرزی: جلسهای که نوبت دارد → دکمهاش «ثبت نوبت» نیست؛ نوبت موجود نشان داده میشود.
|
||||
- ⚠️ مرزی: پروتکلی که هیچ پرسنلی ندارد → فیلد «پرسنل» خالی میماند و فرم قفل نمیشود.
|
||||
|
||||
---
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Treatment/Controller/TreatmentCaseController.php` | فهرست و جزئیات پرونده؛ اندپوینتهای جدید اینجا |
|
||||
| `src/Treatment/Repository/TreatmentCaseRepository.php` | `findForTenant` — فیلتر `record` اینجا اضافه میشود |
|
||||
| `src/Treatment/Service/TreatmentScheduler.php` | محاسبهٔ سررسید؛ **دستنخورده میماند** |
|
||||
| `src/Treatment/Entity/TreatmentCase.php` | `toArray(withSessions)` — امروز نواحی را نمیفرستد |
|
||||
| `src/Treatment/Entity/TreatmentSession.php` | `toArray(withAreas)` |
|
||||
| `src/Treatment/Entity/SessionAreaRecord.php` | خواندههای دستگاه — منبع «ورکفلوی انجامشده» |
|
||||
| `assets/admin/pages/PatientDetailPage.tsx` | تبهای پروندهٔ بیمار |
|
||||
| `assets/admin/components/appointments/NewAppointmentModal.tsx` | مودال ثبت نوبت |
|
||||
| `assets/admin/components/TreatmentCaseEditModal.tsx` | لیبل «اپراتور» |
|
||||
| `assets/admin/pages/TreatmentCasesPage.tsx` | رشتهٔ «اپراتور:» |
|
||||
| `assets/admin/pages/StaffSessionDetailPage.tsx` | رشتهٔ «اپراتور:» |
|
||||
| `docs/api/treatment.md` | سند اندپوینتها |
|
||||
|
||||
---
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### فیلتر پرونده بر اساس بیمار وجود ندارد
|
||||
|
||||
`TreatmentCaseRepository::findForTenant` فقط محیط، وضعیت، جستجو و بازهٔ تاریخ دارد:
|
||||
|
||||
```php
|
||||
public function findForTenant(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
?string $status = null,
|
||||
?string $q = null,
|
||||
?int $openedFrom = null,
|
||||
?int $openedTo = null,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->where('c.entityType = :type')
|
||||
->andWhere('c.entityId = :id')
|
||||
// ...
|
||||
```
|
||||
|
||||
### جزئیات پرونده نواحی جلسات را نمیفرستد
|
||||
|
||||
`TreatmentCase::toArray()`:
|
||||
|
||||
```php
|
||||
if ($withSessions) {
|
||||
$data['sessions'] = array_map(
|
||||
static fn (TreatmentSession $s): array => $s->toArray(),
|
||||
$this->sessions->toArray(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`TreatmentSession::toArray(bool $withAreas = false)` نواحی را فقط با آرگومان میدهد و
|
||||
اینجا فرستاده نمیشود. پس «چه کاری روی چه ناحیهای انجام شد» در پاسخ نیست.
|
||||
|
||||
### تبهای پروندهٔ بیمار
|
||||
|
||||
```tsx
|
||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
|
||||
{ key: 'services', label: 'سرویسها', icon: (c) => <TabServices color={c} /> },
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
### فیلد پرسنل در مودال ثبت نوبت — بدون پیشفرض
|
||||
|
||||
```tsx
|
||||
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز میگذارد،
|
||||
// پر کردنش آن را از قبل به یک نفر میدهد.
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
```
|
||||
|
||||
```tsx
|
||||
<label htmlFor="appt-operator">اپراتور <span className="opt">(اختیاری)</span></label>
|
||||
```
|
||||
|
||||
### پروتکل، پرسنل مجاز را از قبل میفرستد
|
||||
|
||||
`TreatmentProtocol::toArray()`:
|
||||
|
||||
```php
|
||||
'staff' => array_map(
|
||||
static fn (TreatmentProtocolStaff $m): array => $m->toArray(),
|
||||
$this->allowedStaff->toArray(),
|
||||
),
|
||||
```
|
||||
|
||||
یعنی دادهٔ لازم برای پیشفرض هست؛ فقط مصرف نمیشود.
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. فیلتر پرونده بر اساس بیمار
|
||||
|
||||
به `findForTenant` پارامتر `?string $recordUuid = null` اضافه کن و در کنترلر از
|
||||
`?record=` بخوان.
|
||||
|
||||
```php
|
||||
if ($recordUuid !== null && $recordUuid !== '') {
|
||||
$qb->join('c.patientRecord', 'prf')
|
||||
->andWhere('prf.uuid = :record')
|
||||
->setParameter('record', $recordUuid);
|
||||
}
|
||||
```
|
||||
|
||||
> اگر `q` هم فرستاده شده باشد، `patientRecord` قبلاً join شده — از alias تکراری
|
||||
> پرهیز کن (یک join مشترک، نه دو تا).
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
TOKEN=... # 09390039833 / QaTest@1234
|
||||
curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-cases?record=<record_uuid>" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
باید فقط پروندههای همان بیمار برگردد. با `record=` نامعتبر → آرایهٔ خالی.
|
||||
تست PHPUnit در `tests/Treatment/TreatmentCaseEditTest.php` کنار تستهای `findForTenant`.
|
||||
|
||||
---
|
||||
|
||||
### ۲. سرویس تقویم دوره (فقطخواندنی)
|
||||
|
||||
`src/Treatment/Service/TreatmentPlanProjector.php`
|
||||
|
||||
مسئولیت واحد: از روی جلسات یک پرونده، تاریخ **همهٔ** جلسات را بساز.
|
||||
|
||||
قاعده:
|
||||
- جلسهٔ انجامشده → `planned_at = finished_at`، `is_estimate = false`
|
||||
- جلسهای که نوبت دارد → `planned_at = appointment.slot_start`، `is_estimate = false`
|
||||
- جلسهای که `due_at` دارد → `planned_at = due_at`، `is_estimate = false`
|
||||
- بقیه → از آخرین لنگر به بعد، با جمع زدن `offset_days` گامها، `is_estimate = true`
|
||||
|
||||
```php
|
||||
final class TreatmentPlanProjector
|
||||
{
|
||||
private const DAY = 86400;
|
||||
|
||||
/** @return list<array{session: TreatmentSession, planned_at: ?int, is_estimate: bool}> */
|
||||
public function project(TreatmentCase $case): array
|
||||
{
|
||||
// جلسات به ترتیب شماره؛ لنگر = آخرین زمان قطعیِ دیدهشده
|
||||
// برای هر جلسهٔ بیلنگر: anchor += offsetDays(stepNumber) * DAY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**چرا سرویس جدا و نه متد روی `TreatmentScheduler`:** آن کلاس *مینویسد* و این فقط
|
||||
*میخواند*. قاطی کردنشان یعنی یک کلاس با دو مسئولیت و ریسک اینکه تخمین اشتباهی
|
||||
ذخیره شود (guidelines §۵، اصل S).
|
||||
|
||||
**نحوه تست:** تست واحد در `tests/Treatment/` — پروندهای با ۴ جلسه بساز، جلسهٔ ۱ را
|
||||
تمام کن، و بررسی کن جلسهٔ ۲ `is_estimate=false` (چون `due_at` گرفته) و جلسات ۳ و ۴
|
||||
`is_estimate=true` با فاصلهٔ درستِ گامهایشان باشند. حالت مرزی: پروندهای که هیچ جلسهٔ
|
||||
تمامشدهای ندارد و نوبت هم ندارد → همه `is_estimate=true` با لنگرِ `opened_at`.
|
||||
|
||||
---
|
||||
|
||||
### ۳. اندپوینت تقویم + جزئیات انجامشده
|
||||
|
||||
`GET /api/v1/treatment-case/{uuid}/plan`
|
||||
|
||||
پاسخ برای هر جلسه: `uuid`، `session_number`، `status`، `planned_at`، `is_estimate`،
|
||||
`appointment` (اگر دارد)، `performed_by`، و **`areas`** با `parameters`، `resource`،
|
||||
`note`، `started_at`، `finished_at`.
|
||||
|
||||
نواحی همان چیزی است که کاربر «ورکفلوی انجامشده» مینامد. `SessionAreaRecord::toArray()`
|
||||
از قبل همه را دارد؛ فقط باید `withAreas: true` فرستاده شود.
|
||||
|
||||
**تصمیم:** اندپوینت جدا از `GET /treatment-case/{uuid}` باشد، نه اضافه کردن به آن.
|
||||
دلیل: پاسخ فعلی مصرفکنندهٔ دیگری دارد (مودال ویرایش) که نواحی را لازم ندارد و
|
||||
بزرگترش کردن یعنی هزینهٔ بیمصرف روی همان مسیر.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-case/<uuid>/plan" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
باید همهٔ جلسات با `planned_at` بیایند و جلسهٔ انجامشده `areas[].parameters` داشته باشد.
|
||||
پروندهٔ محیط دیگر → ۴۰۴.
|
||||
|
||||
---
|
||||
|
||||
### ۴. تب «نوبتهای بعدی» در پروندهٔ بیمار
|
||||
|
||||
`TabKey` را با `'treatment'` گسترش بده و به `TABS` اضافه کن.
|
||||
|
||||
کامپوننت جدید `assets/admin/components/patient/PatientTreatmentTab.tsx`:
|
||||
|
||||
- `GET /api/v1/treatment-cases?record={uuid}` → فهرست دورهها
|
||||
- برای دورهٔ باز، `GET /api/v1/treatment-case/{uuid}/plan`
|
||||
- هر جلسه یک کارت: شمارهٔ جلسه، تاریخ و ساعت شمسی (`formatDateTime`)، وضعیت با
|
||||
`StatusBadge type="treatment-session"`، و برچسب «تخمینی» وقتی `is_estimate`
|
||||
- جلسهٔ انجامشده: پرسنل + نواحی + خواندههای دستگاه
|
||||
- جلسهٔ بدون نوبت: دکمهٔ «ثبت نوبت»
|
||||
|
||||
**دکمهٔ «ثبت نوبت» باید همان `NewAppointmentModal` را باز کند** — نه صفحهٔ جدید و نه
|
||||
مودال تازه. همان مودالی که در `/admin/appointments?resource=…` استفاده میشود.
|
||||
|
||||
مودال این propها را میگیرد: `slot`، `services`، `date`، `clinicUuid`، `resource`.
|
||||
برای اینکه تاریخ و ساعتِ کارت پیشفرض شود، `date` را از `planned_at` بده.
|
||||
|
||||
> **این را قبل از کد بررسی کن:** مودال امروز `treatment_session_uuid` را نمیفرستد
|
||||
> (فقط صفحهٔ `AppointmentCreatePage` میفرستد). برای اینکه نوبت به همان جلسه بچسبد،
|
||||
> باید prop تازهای مثل `treatmentSessionUuid` به مودال اضافه شود و در payload برود.
|
||||
> بدون آن، اتصال دوباره به حدسِ سرویس میافتد — همان چیزی که در
|
||||
> `SessionBookingLink` صریح شد.
|
||||
|
||||
**نحوه تست UI:**
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs shot \
|
||||
"https://clinic-pro.ddev.site/admin/patients/<uuid>?tab=treatment" \
|
||||
--out /tmp/plan.png --full --wait 5000
|
||||
```
|
||||
سناریو: بیماری با دورهٔ باز → کارتها دیده شوند؛ کلیک روی «ثبت نوبت» → مودال با تاریخ
|
||||
همان کارت باز شود؛ ثبت → جلسه `booked` شود و کارت بهروز شود.
|
||||
|
||||
---
|
||||
|
||||
### ۵. پیشفرض شدن پرسنلِ پروتکل در مودال ثبت نوبت
|
||||
|
||||
در `NewAppointmentModal`، وقتی `pick.serviceUuids` تغییر میکند، پروتکل سرویس اول را
|
||||
بخوان و اگر `staff` دارد و کاربر هنوز دستی چیزی انتخاب نکرده، اولین پرسنل را بگذار.
|
||||
|
||||
```tsx
|
||||
const protocolQ = useQuery({
|
||||
queryKey: ['service-protocol', pick.serviceUuids[0]],
|
||||
queryFn: () => api.get(`/api/v1/service-item/${pick.serviceUuids[0]}/treatment-protocol`),
|
||||
enabled: pick.serviceUuids.length > 0,
|
||||
});
|
||||
```
|
||||
|
||||
**قاعده:** فقط وقتی پیشفرض بگذار که کاربر دست نزده باشد (یک فلگ `staffTouched`).
|
||||
وگرنه انتخاب دستیِ منشی با هر تغییر سرویس پاک میشود.
|
||||
|
||||
**نحوه تست:** vitest در `assets/admin/components/appointments/ResourceBookingModal.test.tsx` —
|
||||
mock کردن پاسخ پروتکل با یک پرسنل، انتخاب سرویس، و بررسی اینکه `staff_uuid` در payload
|
||||
همان است. حالت مرزی: پروتکل بدون پرسنل → فیلد خالی و ثبت همچنان ممکن.
|
||||
|
||||
---
|
||||
|
||||
### ۶. «اپراتور» → «پرسنل»
|
||||
|
||||
فقط رشتههای کاربرپسند. چهار مورد:
|
||||
|
||||
| فایل | خط | رشته |
|
||||
|------|----|------|
|
||||
| `assets/admin/components/appointments/NewAppointmentModal.tsx` | ۲۸۴ | `اپراتور (اختیاری)` |
|
||||
| `assets/admin/components/TreatmentCaseEditModal.tsx` | ۲۱۲ | `اپراتور` |
|
||||
| `assets/admin/pages/TreatmentCasesPage.tsx` | ۳۵۷ | `اپراتور: …` |
|
||||
| `assets/admin/pages/StaffSessionDetailPage.tsx` | ۱۱۶ | `اپراتور: …` |
|
||||
|
||||
**دست نزن به** `assets/admin/pages/ResourcesPage.tsx:107`:
|
||||
|
||||
```tsx
|
||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||
```
|
||||
|
||||
آنجا «اپراتور» یک **نوع منبع** است، نه رکورد `ClinicStaff`. عوض کردنش معنی جمله را
|
||||
خراب میکند. اگر مطمئن نیستی، از کاربر بپرس.
|
||||
|
||||
کامنتهای کد را هم میتوانی هماهنگ کنی ولی اولویت ندارد.
|
||||
|
||||
**نحوه تست:** `npx vitest run` — تستی که به متن «اپراتور» تکیه کرده باشد باید بهروز
|
||||
شود؛ بعد grep بزن که هیچ رشتهٔ کاربرپسندِ «اپراتور» جز مورد `ResourcesPage` نمانده باشد.
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **`TreatmentScheduler` تغییر نمیکند.** تقویم تخمینی فقط خوانده میشود و `due_at`
|
||||
هیچوقت از این مسیر نوشته نمیشود. اگر خودت را در حال `setDueAt` دیدی، از مسیر
|
||||
خارج شدهای.
|
||||
- الگوی بهکاررفته: **Projection/Read Model** — یک سرویس فقطخواندنی که از دادههای
|
||||
موجود یک نمای مشتق میسازد. دلیل انتخاب: نما با هر تغییرِ واقعیت خودش بهروز است و
|
||||
هیچ دادهای برای همگامسازی ندارد.
|
||||
- سه حالت داده (Loading / Empty / Error) برای تب جدید اجباری است — الگوی موجود در
|
||||
`TreatmentCasesPage.tsx` را ببین: خطای سرور نباید «دورهای ندارد» خوانده شود.
|
||||
- ارقام با `formatNumber` و تاریخها با `formatDateTime` (شمسی). ارقام لاتین وسط متن
|
||||
فارسی، نقصِ تکرارشوندهٔ این صفحهها بوده.
|
||||
- وضعیت تب در URL است (`?tab=treatment`) — `PatientDetailPage` از قبل `searchParams`
|
||||
را میخواند.
|
||||
- بعد از هر تغییر API، `docs/api/treatment.md` در همان جلسه بهروز شود.
|
||||
- تنانسی: هر دو اندپوینت جدید باید از `requireCase`/`branches->pair($user)` عبور کنند؛
|
||||
پروندهٔ محیط دیگر ۴۰۴ میگیرد نه ۴۰۳.
|
||||
- migration لازم **نیست** — هیچ Entity تغییر نمیکند.
|
||||
@@ -0,0 +1,412 @@
|
||||
# نوبتدهی آنلاین منبعمحور — اندپوینتهای عمومی
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend).
|
||||
|
||||
پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/public-resource-booking-ui.md`.
|
||||
**اول این را اجرا کن، بعد آن را.** قرارداد API که اینجا ساخته میشود مصرفکنندهٔ مستقیم دارد و
|
||||
تغییرش در build سایت خطا نمیدهد.
|
||||
|
||||
## زمینه
|
||||
|
||||
منبع (`ClinicResource`) هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
|
||||
هر منبع تقویم خودش را دارد و سرویسهایی که ارائه میدهد در `resource_service_offerings` ثبت شدهاند
|
||||
(`ResourceServiceOffering`)، با مدت و قیمتِ اختصاصیِ همان جفتِ منبع↔سرویس.
|
||||
|
||||
روی `ServiceItem` یک توگل به نام `bookable` هست که در پنل با برچسب «نمایش در نوبتدهی آنلاین»
|
||||
دیده میشود:
|
||||
|
||||
```php
|
||||
// src/ClinicService/Entity/ServiceItem.php:107
|
||||
/** نمایش این سرویس در نوبتدهی (پزشک ممکن است همهٔ سرویسها را ارائه ندهد). */
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $bookable = false;
|
||||
```
|
||||
|
||||
امروز این توگل فقط جریانِ **پزشکمحورِ** سایت را تغذیه میکند
|
||||
(`AppointmentController::bookableServices()` → `ServiceItemRepository::findBookableByEntity()`).
|
||||
هیچ مسیر عمومیای منابع را نمیبیند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
اسلاتهای منبع فقط از داخل پنل قابل خواندناند:
|
||||
|
||||
```php
|
||||
// src/Resource/Controller/ResourceBookingSlotController.php:26
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ResourceBookingSlotController extends BaseController
|
||||
{
|
||||
use ResourcePermissionTrait;
|
||||
// هر اکشن با denyUnlessGrantedForBooking($user) گیت میشود
|
||||
```
|
||||
|
||||
`ResourceContext::resource($user, $uuid)` هم منبع را در محیطِ کاربرِ احرازشده حل میکند، پس برای
|
||||
بازدیدکنندهٔ ناشناسِ سایت اصلاً قابل استفاده نیست.
|
||||
|
||||
هدف: سه اندپوینت عمومیِ جدید تا سایت بتواند
|
||||
۱) منابعِ قابلِ رزروِ یک پزشک را ببیند، ۲) وقتهای خالیِ یک منبع برای سرویسهای انتخابشده را
|
||||
بگیرد، ۳) روزهای فعالِ ماه را برای تقویم بگیرد.
|
||||
بهعلاوه رفع یک ناسازگاریِ موجود در مسیر عمومیِ ثبت نوبت (وظیفهٔ ۴).
|
||||
|
||||
**گیتِ عمومیشدن دقیقاً همان توگل است:** منبع وقتی در سایت دیده میشود که دستکم یک
|
||||
`ResourceServiceOffering` فعال به یک `ServiceItem` با `bookable = true` و `active = true` داشته باشد.
|
||||
|
||||
**تصمیم دامنه (تأییدشده):** روی صفحهٔ یک پزشک فقط منابعی میآیند که پزشکِ ناظرشان
|
||||
(`supervisor`) همان پزشک است، یا خودشان پلِ همان پزشکاند (`doctor_id`). فهرست کلینیکمحور
|
||||
خارج از این تسک است.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق:
|
||||
- `GET /api/v1/appointment-booking-resources/{doctorUuid}` بدون هیچ توکنی → ۲۰۰ با
|
||||
`{ success: true, data: { doctor_uuid, clinic_uuid, resources: [...] } }`؛ هر منبع فیلد
|
||||
`services[]` دارد و در آن فقط سرویسهای `bookable=true` و `active=true` با
|
||||
`duration_minutes` و `price_rials`ِ حلشده از `ResourceServiceResolver` هستند.
|
||||
- `GET /api/v1/appointment-resource-slots?resource_uuid=..&date=..&service_item_uuids[]=..`
|
||||
بدون توکن → ۲۰۰ با `start_times[]` که هر عضوش `{start, end, start_time, end_time}` است، و
|
||||
`total_duration_minutes` برابرِ مجموعِ مدتِ حلشدهٔ همان منبع.
|
||||
- `GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=`
|
||||
بدون توکن → ۲۰۰ با `enabled_dates[]` و `disabled_dates[]` که مجموعشان همهٔ روزهای آن ماه است.
|
||||
- `POST /api/v1/appointment` با `resource_uuid` + `service_item_uuids[]` + یکی از
|
||||
`start_times`ِ بالا → ۲۰۱، و `slot_end` دقیقاً برابرِ `end`ِ همان start_time.
|
||||
- ❌ خطا:
|
||||
- منبعی که هیچ سرویسِ `bookable` ندارد → در پاسخِ فهرست **نمیآید**؛ و
|
||||
`appointment-resource-slots` رویش → ۴۲۲ با کد `ERR_VALIDATION_001` و
|
||||
`field: service_item_uuids`.
|
||||
- سرویسی که `bookable=false` است ولی روی منبع offering فعال دارد → `resource-slots` → ۴۲۲
|
||||
(پیام: «این سرویس برای نوبتدهی آنلاین فعال نیست»). یعنی مسیر عمومی سختگیرتر از پنل است.
|
||||
- `date` با فرمت نادرست → ۴۲۲ با `field: date`. `resource_uuid` ناموجود یا `active=false` →
|
||||
۴۲۲ با `field: resource_uuid` (نه ۴۰۴؛ همان الگوی موجود در `AppointmentController`).
|
||||
- `POST /api/v1/appointment` روی بازهای که منبع در آن پر است → ۴۰۹ با
|
||||
`field: resource_uuid` (رفتار موجودِ `ResourceOccupier`؛ نباید بشکند).
|
||||
- ⚠️ مرزی:
|
||||
- پزشکی که هیچ منبعی ندارد → ۲۰۰ با `resources: []`، نه ۴۰۴.
|
||||
- منبعِ با `capacity > 1`: وقتی یک نوبت روی آن نشسته، همان بازه هنوز باید در `start_times` بیاید
|
||||
(ظرفیت هنوز پر نشده). `ResourceBookingSlotService::freeIntervals()` این را میداند؛ فقط با
|
||||
داده تست شود.
|
||||
- روزی که منبع شیفت ندارد → `start_times: []` و همان روز در `disabled_dates` ماه.
|
||||
- `service_item_uuids` خالی → ۴۲۲ با پیام «انتخاب حداقل یک سرویس الزامی است» (رفتار موجودِ
|
||||
`resolveDuration`).
|
||||
- آخرین روز ماه شمسی/میلادی: `month-availability` بر پایهٔ سال/ماهِ **میلادی** است — همان
|
||||
قرارداد `appointment-settings/month-availability` که سایت از قبل با آن کار میکند. تغییرش نده.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Resource/Entity/ClinicResource.php` | منبع؛ `address`, `type`, `supervisor`, `doctor`, `capacity`, `active` |
|
||||
| `src/Resource/Entity/ResourceServiceOffering.php` | جفتِ منبع↔سرویس با مدت/قیمت/`active` |
|
||||
| `src/Resource/Repository/ClinicResourceRepository.php` | کوئریهای منبع؛ متد جدید اینجا |
|
||||
| `src/Resource/Repository/ResourceServiceOfferingRepository.php` | `findForResource`, `bookableResourceIds`, `activeResourceIdsFor` |
|
||||
| `src/Resource/Service/ResourceBookingSlotService.php` | `resolveDuration`, `startTimes`, `freeIntervals`, `assertOffered` |
|
||||
| `src/ClinicService/Service/ResourceServiceResolver.php` | زنجیرهٔ حلِ مدت و قیمت |
|
||||
| `src/Resource/Controller/ResourceBookingSlotController.php` | نسخهٔ پنلیِ همین اسلاتها — الگوی مرجع |
|
||||
| `src/Appointment/Controller/AppointmentController.php` | اندپوینتهای عمومی موجود + `POST /api/v1/appointment` |
|
||||
| `config/packages/security.yaml` | `access_control` |
|
||||
| `docs/api/resource.md`، `docs/api/appointment.md` | مستندات |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
منبعِ حقیقتِ مدت و قیمت، این زنجیره است:
|
||||
|
||||
```php
|
||||
// src/ClinicService/Service/ResourceServiceResolver.php:41
|
||||
public function resolve(
|
||||
ClinicResource $resource,
|
||||
ServiceItem $item,
|
||||
DoctorAddress $address,
|
||||
?ServiceItem $parentService = null,
|
||||
): ResolvedServiceSpec
|
||||
// ۱. منبع+گزینه → ۲. منبع+سرویس → ۳. شعبه → ۴. پیشفرض سرویس
|
||||
```
|
||||
|
||||
و اسلاتها:
|
||||
|
||||
```php
|
||||
// src/Resource/Service/ResourceBookingSlotService.php:87
|
||||
public function startTimes(
|
||||
ClinicResource $resource,
|
||||
string $date,
|
||||
int $totalMinutes,
|
||||
?int $excludeAppointmentId = null,
|
||||
): array
|
||||
```
|
||||
|
||||
مسیر عمومیِ ثبت نوبت، مدت را از calculatorِ پزشکمحور میگیرد — حتی وقتی منبع دارد:
|
||||
|
||||
```php
|
||||
// src/Appointment/Controller/AppointmentController.php:528-538
|
||||
$duration = null;
|
||||
if ($hasServices) {
|
||||
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
|
||||
$slotEnd = $duration->endFor($slotStart);
|
||||
}
|
||||
|
||||
if ($resource !== null) {
|
||||
[$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair();
|
||||
// ...
|
||||
```
|
||||
|
||||
مسیر پنل همین را درست انجام میدهد و باید الگو باشد:
|
||||
|
||||
```php
|
||||
// src/Appointment/Controller/MyAppointmentsController.php:188-197
|
||||
if (!empty($serviceUuids) && !$isReserve && $resource !== null) {
|
||||
// نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع میآید (هر دستگاه مدت خودش را
|
||||
// دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` مینشیند.
|
||||
['minutes' => $resourceMinutes, 'items' => $serviceItems] =
|
||||
$this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides);
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. کوئری منابعِ قابلِ رزروِ عمومی
|
||||
|
||||
در `src/Resource/Repository/ClinicResourceRepository.php` متد جدید:
|
||||
|
||||
```php
|
||||
/**
|
||||
* منابعِ یک پزشک در یک محیط که در سایت عمومی قابل رزروند.
|
||||
*
|
||||
* سه شرط با هم: منبع فعال، دستکم یک offering فعال، و سرویسِ آن offering هم
|
||||
* `bookable` و هم `active`. توگلِ «نمایش در نوبتدهی آنلاین» تنها گیتِ عمومیشدن است؛
|
||||
* منبعی که سرویسِ روشنی ندارد اصلاً نباید در پاسخ دیده شود.
|
||||
*
|
||||
* @return ClinicResource[]
|
||||
*/
|
||||
public function findPublicBookableForDoctor(string $entityType, int $entityId, Doctor $doctor): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->join('r.type', 't')
|
||||
->addSelect('t')
|
||||
->join(ResourceServiceOffering::class, 'o', 'WITH', 'o.resource = r')
|
||||
->join('o.serviceItem', 'i')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->andWhere('r.active = true')
|
||||
->andWhere('o.active = true')
|
||||
->andWhere('i.bookable = true')
|
||||
->andWhere('i.active = true')
|
||||
// فقط منابع همین پزشک: یا خودش پلِ پزشک است، یا پزشک ناظرش همین است.
|
||||
->andWhere('r.doctor = :doctor OR r.supervisor = :doctor')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('doctor', $doctor)
|
||||
->distinct()
|
||||
->orderBy('r.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
```
|
||||
|
||||
**نحوه تست:** یک unit/functional تست که سه سناریو بسازد — منبع با سرویسِ bookable (باید بیاید)،
|
||||
منبع با سرویسِ `bookable=false` (نباید بیاید)، منبع با offering غیرفعال (نباید بیاید) — و منبعی
|
||||
که ناظرش پزشک دیگری است (نباید بیاید).
|
||||
|
||||
### ۲. سرویسِ ساختِ payload عمومی
|
||||
|
||||
فایل جدید `src/Resource/Service/PublicResourceBookingService.php`.
|
||||
کنترلر نازک میماند؛ این کلاس تنها مسئولیتش «منبع → آرایهٔ عمومی» است (SRP).
|
||||
|
||||
```php
|
||||
/**
|
||||
* نمای عمومیِ منبع برای سایت نوبتدهی.
|
||||
*
|
||||
* از نمای پنل جداست چون سؤالِ دیگری جواب میدهد: پنل همهٔ سرویسهای منبع را
|
||||
* میخواهد، سایت فقط آنهایی را که مالک روشن کرده. ادغامشان یعنی یک `if ($public)`
|
||||
* در دلِ کد پنل و یک راهِ تازه برای نشتِ سرویسِ خاموش.
|
||||
*/
|
||||
final class PublicResourceBookingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly ResourceServiceOfferingRepository $offerings,
|
||||
private readonly ResourceServiceResolver $resolver,
|
||||
) {}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function resourcesFor(Doctor $doctor, ?Clinic $clinic): array;
|
||||
|
||||
/**
|
||||
* سرویسهای عمومیِ یک منبع — همان گیتِ فهرست، تا اسلات و فهرست از هم واگرا نشوند.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function publicServices(ClinicResource $resource): array;
|
||||
|
||||
/** @throws AppException ۴۲۲ روی سرویسی که در سایت روشن نیست */
|
||||
public function assertPublicService(ClinicResource $resource, ServiceItem $item): void;
|
||||
}
|
||||
```
|
||||
|
||||
شکل هر منبع در پاسخ:
|
||||
|
||||
```php
|
||||
[
|
||||
'uuid' => $resource->getUuid(),
|
||||
'name' => $resource->getName(),
|
||||
'type' => ['code' => $type->getCode(), 'name' => $type->getName()],
|
||||
'capacity' => $resource->getCapacity(),
|
||||
'location' => [
|
||||
'uuid' => $address->getUuid(),
|
||||
'title' => $address->getName() ?: 'مطب شخصی',
|
||||
'address' => $address->getAddress(),
|
||||
],
|
||||
'supervisor' => ['uuid' => ..., 'full_name' => ...] | null,
|
||||
'services' => [
|
||||
[
|
||||
'uuid' => $item->getUuid(),
|
||||
'name' => $item->getName(),
|
||||
'duration_minutes' => $spec->durationMinutes, // از resolver، نه از پیشفرض خام
|
||||
'price_rials' => $spec->priceRials,
|
||||
'service_section' => ['uuid' => ..., 'name' => ...],
|
||||
],
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
نکتهٔ کارایی: `publicServices()` نباید به ازای هر سرویس یک `findOneFor` جدا بزند وقتی
|
||||
`findForResource($resource)` همه را یکجا میدهد. offeringهای همان منبع را یکبار بخوان و
|
||||
سرویسهای `bookable && active` را از رویش فیلتر کن؛ `resolver->resolve()` را فقط برای همانها صدا بزن.
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/phpunit` روی یک تستِ سرویس با دو سرویس (یکی روشن، یکی خاموش)
|
||||
و یک offering با `duration_minutes` اختصاصی؛ ادعا: خروجی یک عضو دارد و مدتش عددِ offering است،
|
||||
نه `solo_duration_minutes`ِ سرویس.
|
||||
|
||||
### ۳. کنترلر عمومی + مسیرها
|
||||
|
||||
فایل جدید `src/Resource/Controller/PublicResourceBookingController.php` — `extends BaseController`،
|
||||
**بدون** `IsGranted` و بدون `ResourcePermissionTrait`.
|
||||
|
||||
```php
|
||||
/**
|
||||
* نوبتدهی منبعمحور برای سایت عمومی.
|
||||
*
|
||||
* از {@see ResourceBookingSlotController} جداست و نه یک پرچمِ `public` روی آن: آنجا منبع
|
||||
* از محیطِ کاربرِ احرازشده حل میشود (`ResourceContext::resource($user, $uuid)`) و اینجا
|
||||
* کاربری وجود ندارد. یک کنترلر با دو مدلِ اعتماد، همانجایی است که نشت اتفاق میافتد.
|
||||
*/
|
||||
#[OA\Tag(name: 'Resource')]
|
||||
class PublicResourceBookingController extends BaseController
|
||||
{
|
||||
// GET /api/v1/appointment-booking-resources/{doctorUuid}?clinic_uuid=
|
||||
// GET /api/v1/appointment-resource-slots?resource_uuid=&date=&service_item_uuids[]=
|
||||
// GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=
|
||||
}
|
||||
```
|
||||
|
||||
قواعدی که باید رعایت شوند:
|
||||
|
||||
- محیط با همان الگوی موجود حل شود:
|
||||
`[$type, $id] = EntityContext::forBooking($doctor, $clinic)->toEntityPair();`
|
||||
و `$clinic` از `AppointmentController::bookingClinic()` — اگر متد `private` است، منطقش را
|
||||
کپی نکن؛ یا در یک سرویس مشترک بگذار یا از `BookingContextResolver` استفاده کن. تصمیم و دلیلش
|
||||
را در کامنت بنویس.
|
||||
- در `resource-slots` و `month-availability` منبع با `ClinicResourceRepository::findByUuid()`
|
||||
گرفته میشود و **باید** بررسی شود: `isActive()` و اینکه دستکم یک سرویس عمومی دارد. منبعِ
|
||||
ناموجود یا خاموش → ۴۲۲ با `field: resource_uuid`.
|
||||
- برای هر uuid در `service_item_uuids` اول `assertPublicService()` صدا زده شود (گیتِ `bookable`)،
|
||||
بعد `ResourceBookingSlotService::resolveDuration()` (گیتِ offering + مدت). ترتیب مهم است:
|
||||
پیامِ «در سایت فعال نیست» گویاتر از «این منبع این سرویس را ارائه نمیدهد» است.
|
||||
- `durations[]` که نسخهٔ پنلی میپذیرد **در مسیر عمومی پذیرفته نشود** — override مدت ابزار منشی
|
||||
است؛ در دست بازدیدکننده یعنی ساختن ظرفیتِ جعلی. یعنی `resolveDuration($resource, $uuids)`
|
||||
بدون آرگومان سوم.
|
||||
- `month-availability` روی روزهای ماه حلقه بزند و برای هر روز `startTimes(...) !== []` را
|
||||
بسنجد — همان الگوی `AppointmentController::monthAvailability()` که با `hasAnyAvailability`
|
||||
کار میکند. اگر روی ۳۱ روز کند بود، بهجای `startTimes` از `freeIntervals` استفاده کن و فقط
|
||||
وجودِ یک بازهٔ بهاندازهٔ کافی بلند را چک کن.
|
||||
|
||||
سپس در `config/packages/security.yaml`، بخش `access_control`، کنار همتاهای موجود:
|
||||
|
||||
```yaml
|
||||
- { path: ^/api/v1/appointment-booking-resources/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/appointment-resource-slots, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/appointment-resource-month-availability/, roles: PUBLIC_ACCESS }
|
||||
```
|
||||
|
||||
مسیرها روی firewallِ `api` میمانند (به `public_endpoints` **اضافه نشوند**) — دقیقاً به همان
|
||||
دلیلی که در کامنت بالای `public_endpoints` نوشته شده: توکن اختیاری بماند.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console debug:router | grep resource
|
||||
# سه مسیر جدید باید دیده شوند
|
||||
|
||||
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-booking-resources/<DOCTOR_UUID>" | jq
|
||||
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-slots?resource_uuid=<R>&date=2026-08-15&service_item_uuids[]=<S>" | jq
|
||||
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-month-availability/<R>?year=2026&month=8&service_item_uuids[]=<S>" | jq
|
||||
```
|
||||
|
||||
هر سه بدون هدر `Authorization` و همه باید ۲۰۰ بدهند. سپس همان `curl`ها را روی سرویسی بزن که
|
||||
`bookable` را در پنل خاموش کردهای و ۴۲۲ بگیر.
|
||||
اکانت تست پنل برای ساختن داده: `09390039833` / `09390039833`.
|
||||
|
||||
### ۴. رفعِ مدتِ نوبتِ منبعدار در مسیر عمومی
|
||||
|
||||
در `src/Appointment/Controller/AppointmentController.php::book()`، شاخهٔ محاسبهٔ مدت باید مثل
|
||||
مسیر پنل، وقتی منبع هست از `ResourceBookingSlotService::resolveDuration()` استفاده کند:
|
||||
|
||||
```php
|
||||
$duration = null;
|
||||
$resourceItems = [];
|
||||
|
||||
if ($hasServices && $resource !== null) {
|
||||
// مدت از زنجیرهٔ حلِ همان منبع میآید، وگرنه `slot_end` با start_timesِ
|
||||
// appointment-resource-slots یکی نمیشود و بیمار وقتی را میگیرد که سرور
|
||||
// جای دیگری آزاد حساب کرده بود.
|
||||
foreach ($serviceUuids as $u) { /* assertPublicService(...) */ }
|
||||
['minutes' => $minutes, 'items' => $resourceItems] =
|
||||
$this->resourceSlots->resolveDuration($resource, $serviceUuids);
|
||||
$slotEnd = $slotStart + $minutes * 60;
|
||||
} elseif ($hasServices) {
|
||||
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
|
||||
$slotEnd = $duration->endFor($slotStart);
|
||||
}
|
||||
```
|
||||
|
||||
سه قید:
|
||||
|
||||
- ترازِ سطلِ اشغال (`OccupancyBucket::alignWindow`) در خط ۵۱۵ **قبل از** محاسبهٔ مدت اجرا
|
||||
میشود و در آنجا `$slotEnd` هنوز مقدارِ کلاینت است. بعد از بازنویسیِ `$slotEnd`، تراز باید
|
||||
دوباره اعمال شود؛ وگرنه بازهٔ نهایی ناتراز میماند.
|
||||
- ذخیرهٔ سرویسها روی نوبت (خط ~۶۰۵، `replaceServiceItems` + `setServiceDuration`) نباید بشکند.
|
||||
در شاخهٔ منبع، `$duration` تهی است، پس این بلوک باید `items` و `minutes`ِ منبع را هم بپذیرد.
|
||||
- بررسیِ موجودِ «این منبع این سرویس را ارائه میدهد؟» (خط ۵۵۸ با `hasAnyFor`/`activeResourceIdsFor`)
|
||||
حالا با `resolveDuration` تکراری میشود. یکی را نگه دار — `resolveDuration` سختگیرتر است چون
|
||||
`offering.active` را هم میبیند. حذفِ کدِ تکراری را در همان commit توضیح بده.
|
||||
|
||||
**نحوه تست:** یک تست functional که:
|
||||
۱) از `appointment-resource-slots` یک `start_time` بگیرد،
|
||||
۲) با همان `start` و همان `service_item_uuids` روی `POST /api/v1/appointment` بزند،
|
||||
۳) ادعا کند `slot_end` پاسخ برابر `end`ِ همان start_time است.
|
||||
قبل از این تغییر باید قرمز شود.
|
||||
|
||||
### ۵. مستندات
|
||||
|
||||
- `docs/api/resource.md`: سه اندپوینت عمومی جدید با نمونهٔ درخواست/پاسخ و جدول خطاها.
|
||||
- `docs/api/appointment.md`: رفتار `resource_uuid` در `POST /api/v1/appointment` — که مدت از
|
||||
منبع میآید و در مسیر عمومی سرویس باید `bookable` باشد.
|
||||
- اگر فایل `docs/api/appointment-booking.md` جریان عمومی را توصیف میکند، مرحلهٔ منبعمحور را
|
||||
هم آنجا اضافه کن.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **گیتِ عمومی یک جا تعریف شود.** «سرویس در سایت دیده میشود» = `bookable && active` + offering
|
||||
فعال. این شرط در سه جا لازم است (فهرست، اسلات، ثبت نوبت). یک متد در
|
||||
`PublicResourceBookingService` و صدا زدنش از هر سه — نه سهبار نوشتنِ همان `if`.
|
||||
- **جداسازی محیط:** منبع با uuid از بیرون میآید و `TenantFilter` پوششش نمیدهد
|
||||
(`ResourceServiceOffering` فرزندِ aggregate است). در هر سه اندپوینت، تطابقِ
|
||||
`(entity_type, entity_id)`ِ منبع با محیطِ رزروِ همان پزشک/کلینیک باید صریح بررسی شود — همان
|
||||
کاری که `AppointmentController` در خط ۵۴۹ میکند.
|
||||
- **fail-safe عمومی:** پارامتر `management=1` در این کنترلر معنا ندارد و پیاده نشود. اگر روزی
|
||||
پنل بخواهد همین دید را داشته باشد، مسیر پنلیِ خودش را دارد.
|
||||
- **`durations[]` در مسیر عمومی ممنوع** — دلیلش بالا آمده.
|
||||
- ظرفیت و اشغال را خودت حساب نکن؛ `ResourceBookingSlotService` هر دو منبعِ اشغال را میبیند
|
||||
(`appointments.resource_id` و `resource_occupancy`). دور زدنش یعنی نوبتِ نامرئی.
|
||||
- تاریخها Unix timestamp صحیحاند؛ `start_time`/`end_time` رشتهٔ `H:i` به وقتِ محلیِ شعبهٔ منبع
|
||||
است (`ResourceBookingSlotService::dayStart()` این را از `address->getTimezone()` میگیرد).
|
||||
- **مصرفکنندهٔ cross-repo:** این قرارداد را `nobat724_front/services/response.js` مصرف میکند.
|
||||
هر تغییر در نام فیلدها بعد از این، در build سایت خطا **نمیدهد** — پس شکل پاسخ را قبل از
|
||||
merge نهایی کن.
|
||||
@@ -0,0 +1,510 @@
|
||||
# مجوز ویرایش پروفایل پزشک و کلینیک برای نمایندهٔ ثبتکننده
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` — بکاند Symfony و پنل ادمین React.
|
||||
|
||||
تکریپو است. `nobat724_front` فقط سه فراخوانی داشبورد نماینده دارد
|
||||
(`services/response.js` خطوط ۲۰۲ تا ۲۱۱) و به هیچکدام از اندپوینتهای این تسک دست نمیزند.
|
||||
|
||||
## زمینه
|
||||
|
||||
نماینده امروز میتواند پزشک و کلینیک بسازد. هنگام ساخت،
|
||||
`representation_id` روی رکورد ست میشود:
|
||||
|
||||
```php
|
||||
// src/Representation/Controller/RepresentationActionController.php:299
|
||||
$rep = $this->representationRepo->findByUser($user);
|
||||
if ($rep !== null) {
|
||||
$doctor->setRepresentationId($rep->getId());
|
||||
}
|
||||
```
|
||||
|
||||
ولی بعد از ساخت، هیچ راهی برای کامل کردن پروفایل ندارد.
|
||||
نه لگو، نه گالری، نه متن معرفی، نه تخصص، نه آدرس.
|
||||
عملاً onboarding نیمهکاره میماند و پزشک تازهساخته روی سایت عمومی
|
||||
یک رکورد خالی است.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
نماینده باید روی هر پزشک و کلینیکی که `representation_id` آن به او اشاره دارد،
|
||||
فیلدهای **محتوایی و ظاهری** را ویرایش کند — و فقط همانها.
|
||||
|
||||
مجوز **دائمی** است و به `representation_id` گره میخورد.
|
||||
هیچ فیلد جدید، هیچ migration، هیچ state تازهای لازم نیست.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: نمایندهای که پزشک X را ساخته،
|
||||
`PATCH /api/v1/doctor/{X.uuid}` با بدنهٔ `{"info": "متن جدید", "images": [...]}` میفرستد
|
||||
→ `200` و رکورد ذخیره میشود.
|
||||
همین برای `PATCH /api/v1/clinic/{uuid}` با `{"clinic_logo": "...", "info": "..."}`.
|
||||
- ✅ موفق: `GET /api/v1/doctor/{X.uuid}` با توکن همان نماینده → `can_edit: true`.
|
||||
همان درخواست با توکن نمایندهٔ دیگر → `can_edit: false`.
|
||||
بدون توکن → `can_edit: false` و بقیهٔ پاسخ مثل قبل.
|
||||
- ❌ خطا: نمایندهٔ **دیگری** (که این پزشک را نساخته) همان PATCH را بفرستد
|
||||
→ `403` با `ERR_AUTH_006`.
|
||||
- ❌ خطا: نمایندهٔ مالک، کلید ممنوع بفرستد
|
||||
(`medical_system_code` یا `active` برای پزشک، `doctors` برای کلینیک)
|
||||
→ `403` با نام همان فیلد در `errors[0].field`. **هیچ چیزی ذخیره نمیشود.**
|
||||
- ⚠️ مرزی: کاربری با `ROLE_REPRESENTATION` که ردیف `Representation` ندارد
|
||||
→ `403`، نه `500`.
|
||||
- ⚠️ مرزی: پزشکی که `representation_id` آن `null` است
|
||||
→ هیچ نمایندهای اجازه ندارد؛ `403`.
|
||||
- ⚠️ مرزی: خودِ پزشک و مالک کلینیک و ادمین **دقیقاً مثل قبل** رفتار میکنند —
|
||||
whitelist روی آنها اعمال نمیشود و همچنان میتوانند `medical_system_code`
|
||||
و `doctors` را عوض کنند. این تسک هیچ دسترسی موجودی را تنگ نمیکند.
|
||||
- ⚠️ مرزی: هر PATCH موفقِ نماینده دقیقاً یک ردیف `AppLog` میسازد.
|
||||
PATCH مالک یا ادمین هیچ ردیفی نمیسازد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Representation/Security/RepresentationEditPolicy.php` | **جدید** — مالکیت و whitelist |
|
||||
| `src/Doctor/Controller/DoctorController.php` | `update`، `show`، سه اکشن آدرس پزشک |
|
||||
| `src/Clinic/Controller/ClinicController.php` | `update`، `show`، سه اکشن آدرس کلینیک |
|
||||
| `src/Representation/Repository/RepresentationRepository.php` | `findByUser()` موجود است |
|
||||
| `src/Shared/Logging/AppLog.php` | entity لاگ موجود |
|
||||
| `assets/admin/pages/DoctorDetailPage.tsx` | `isReadOnly` خط ۱۱۲۵ |
|
||||
| `assets/admin/pages/ClinicDetailPage.tsx` | `isReadOnly` خط ۳۶۴ |
|
||||
| `docs/api/doctor.md` · `docs/api/clinic.md` | سند اندپوینتها |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### دروازهٔ پزشک — فقط خود پزشک یا ادمین
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:340
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (!empty($data['title'])) $doctor->setName(PersianText::stripDoctorTitle($data['title']));
|
||||
|
||||
$this->hydrateDoctor($doctor, $data);
|
||||
$this->doctorRepo->save($doctor);
|
||||
|
||||
return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]);
|
||||
}
|
||||
```
|
||||
|
||||
### دروازهٔ کلینیک — از checker موجود رد میشود
|
||||
|
||||
```php
|
||||
// src/Clinic/Controller/ClinicController.php:223
|
||||
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
// مالک و ادمین همیشه؛ پزشکِ عضو فقط با مجوز clinic_info.update
|
||||
if (!$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (($err = $this->validateGallerySize($data)) !== null) {
|
||||
return $err;
|
||||
}
|
||||
$this->hydrateClinic($clinic, $data);
|
||||
$this->clinicRepo->save($clinic);
|
||||
|
||||
[$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic);
|
||||
|
||||
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]);
|
||||
}
|
||||
```
|
||||
|
||||
`ClinicDoctorPermissionChecker::can()` نقش نماینده را نمیشناسد:
|
||||
|
||||
```php
|
||||
// src/Clinic/Security/ClinicDoctorPermissionChecker.php:44
|
||||
public function can(User $user, Clinic $clinic, string $resource, string $action): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->permRepo->getOrCreate($clinic, $doctor)->can($resource, $action);
|
||||
}
|
||||
```
|
||||
|
||||
### آدرسها — دروازهٔ جدا
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:656 — PATCH آدرس پزشک
|
||||
if ($address->getDoctor()?->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Clinic/Controller/ClinicController.php:766 — PATCH آدرس کلینیک (و DELETE، خط ۷۹۱)
|
||||
if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:542 — POST آدرس پزشک
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر میتواند آدرس اضافه کند', 403);
|
||||
}
|
||||
```
|
||||
|
||||
### UI — عمداً قفل است
|
||||
|
||||
```tsx
|
||||
// assets/admin/pages/DoctorDetailPage.tsx:1124
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
```
|
||||
|
||||
```tsx
|
||||
// assets/admin/pages/ClinicDetailPage.tsx:363
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
```
|
||||
|
||||
### GET جزئیات — هنوز کاربر جاری را نمیگیرد
|
||||
|
||||
```php
|
||||
// src/Doctor/Controller/DoctorController.php:150
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Clinic/Controller/ClinicController.php:159
|
||||
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse
|
||||
```
|
||||
|
||||
هر دو عمومیاند و `#[CurrentUser]` ندارند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. کلاس سیاست — `RepresentationEditPolicy`
|
||||
|
||||
فایل جدید: `src/Representation/Security/RepresentationEditPolicy.php`
|
||||
|
||||
تنها تصمیمگیرندهٔ «این نماینده روی این رکورد چه اجازهای دارد».
|
||||
هیچ controllerی نباید `representation_id` را دستی مقایسه کند.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Representation\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
|
||||
/**
|
||||
* «نمایندهٔ ثبتکننده روی پروفایلی که خودش ساخته چه اجازهای دارد؟»
|
||||
*
|
||||
* مجوز دائمی است و تنها به representation_id گره میخورد — نماینده تا وقتی
|
||||
* رکورد به او اشاره میکند مالکِ محتوای آن است. عمداً فقط فیلدهای محتوایی
|
||||
* باز است: عضویت پزشکان در کلینیک و کد نظام پزشکی و فعال/غیرفعال بودن،
|
||||
* تصمیمهای صاحبِ رکوردند نه فروشندهای که او را ثبت کرده.
|
||||
*/
|
||||
class RepresentationEditPolicy
|
||||
{
|
||||
/** فیلدهایی که نماینده روی پروفایل پزشک میتواند بفرستد. */
|
||||
public const DOCTOR_FIELDS = [
|
||||
'title', 'gender', 'degree', 'info', 'detail',
|
||||
'mobile_number', 'activity_time',
|
||||
'images', 'image_data', 'social_media',
|
||||
'specialties', 'doctor_services', 'expertise',
|
||||
'states', 'cities',
|
||||
];
|
||||
|
||||
/** فیلدهایی که نماینده روی پروفایل کلینیک میتواند بفرستد. */
|
||||
public const CLINIC_FIELDS = [
|
||||
'name', 'info', 'address', 'telephone',
|
||||
'working_days', '24_7', 'latitude', 'longitude',
|
||||
'practice_domain_uuid', 'state', 'city',
|
||||
'social_media', 'image_clinic', 'clinic_logo',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly RepresentationRepository $repRepo,
|
||||
) {}
|
||||
|
||||
public function ownsDoctor(User $user, Doctor $doctor): bool
|
||||
{
|
||||
return $this->matches($user, $doctor->getRepresentationId());
|
||||
}
|
||||
|
||||
public function ownsClinic(User $user, Clinic $clinic): bool
|
||||
{
|
||||
return $this->matches($user, $clinic->getRepresentationId());
|
||||
}
|
||||
|
||||
/**
|
||||
* اولین کلیدِ ممنوع در بدنهٔ درخواست، یا null اگر همه مجاز باشند.
|
||||
*
|
||||
* @param list<string> $allowed یکی از DOCTOR_FIELDS یا CLINIC_FIELDS
|
||||
*/
|
||||
public function firstForbiddenField(array $data, array $allowed): ?string
|
||||
{
|
||||
foreach (array_keys($data) as $key) {
|
||||
if (!in_array($key, $allowed, true)) {
|
||||
return (string) $key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function matches(User $user, ?int $representationId): bool
|
||||
{
|
||||
if ($representationId === null || !$user->hasRole('ROLE_REPRESENTATION')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rep = $this->repRepo->findByUser($user);
|
||||
|
||||
return $rep !== null && $rep->getId() === $representationId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
نکتههای اجباری:
|
||||
|
||||
- `matches()` وقتی `ROLE_REPRESENTATION` نیست، **بدون کوئری** برمیگردد.
|
||||
این همان حالت مرزی «کاربر با نقش نماینده ولی بدون ردیف Representation» را هم
|
||||
به `false` میبندد، نه به exception.
|
||||
- کلاس هیچ HTTP نمیشناسد. پاسخ ۴۰۳ کارِ controller است.
|
||||
|
||||
**نحوه تست:** unit test خالص با entityهای ساختگی —
|
||||
`tests/Representation/RepresentationEditPolicyTest.php`.
|
||||
سناریوها: مالکِ درست `true`؛ نمایندهٔ دیگر `false`؛ `representation_id === null` → `false`؛
|
||||
کاربر بدون `ROLE_REPRESENTATION` → `false`؛ `firstForbiddenField` روی
|
||||
`['info' => 'x', 'doctors' => []]` با `CLINIC_FIELDS` باید `'doctors'` بدهد و
|
||||
روی `['info' => 'x']` باید `null` بدهد.
|
||||
|
||||
### ۲. لاگکردن ویرایشِ نماینده
|
||||
|
||||
سرویس کوچک کنار سیاست: `src/Representation/Security/RepresentationEditLogger.php`
|
||||
|
||||
از entity موجود `App\Shared\Logging\AppLog` استفاده کن — هیچ جدول جدیدی نساز.
|
||||
سازندهٔ آن `(level, message, context, channel, path)` میگیرد.
|
||||
|
||||
```php
|
||||
$this->em->persist(new AppLog(
|
||||
'info',
|
||||
sprintf('نماینده #%d پروفایل %s %s را ویرایش کرد', $repId, $entityType, $uuid),
|
||||
json_encode(['representation_id' => $repId, 'fields' => array_keys($data)], JSON_UNESCAPED_UNICODE),
|
||||
'representation_edit',
|
||||
$request->getPathInfo(),
|
||||
));
|
||||
```
|
||||
|
||||
فقط وقتی لاگ بنویس که ویرایشکننده **نماینده** باشد.
|
||||
مالک و ادمین هیچ ردیفی نمیسازند — وگرنه `/admin/logs` پر از نویز میشود.
|
||||
|
||||
**نحوه تست:** بعد از یک PATCH موفقِ نماینده،
|
||||
`SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'` باید یکی زیاد شده باشد.
|
||||
بعد از PATCH ادمین روی همان رکورد، عددی تغییر نکند.
|
||||
|
||||
### ۳. باز کردن `PATCH /api/v1/doctor/{uuid}`
|
||||
|
||||
در `DoctorController::update` شرط ۴۰۳ فعلی را نگه دار و یک شاخهٔ نماینده کنارش بگذار.
|
||||
ترتیب مهم است: اول مالکیت، بعد whitelist، بعد hydrate.
|
||||
|
||||
```php
|
||||
$isOwnerOrAdmin = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN');
|
||||
$isRepOwner = !$isOwnerOrAdmin && $this->editPolicy->ownsDoctor($user, $doctor);
|
||||
|
||||
if (!$isOwnerOrAdmin && !$isRepOwner) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if ($isRepOwner) {
|
||||
$bad = $this->editPolicy->firstForbiddenField($data, RepresentationEditPolicy::DOCTOR_FIELDS);
|
||||
if ($bad !== null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'نماینده اجازهٔ تغییر این فیلد را ندارد', 403, $bad);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
whitelist **فقط** روی `$isRepOwner` اجرا میشود. مسیر مالک و ادمین دستنخورده میماند.
|
||||
|
||||
**نحوه تست:** تست فانکشنال با `ApiTestCase` —
|
||||
`tests/Representation/RepresentationProfileEditTest.php`.
|
||||
یک نماینده و پزشکش بساز (از `POST /api/v1/representation/doctor` استفاده کن، نه fixture دستی)،
|
||||
بعد PATCH با `info` بزن و `200` بگیر، بعد PATCH با `medical_system_code` بزن و `403` بگیر
|
||||
و مطمئن شو مقدار قبلی در دیتابیس عوض نشده.
|
||||
|
||||
### ۴. باز کردن `PATCH /api/v1/clinic/{uuid}`
|
||||
|
||||
همان الگو. ولی اینجا `permChecker` جلوی راه است.
|
||||
|
||||
**آن را تغییر نده.** `ClinicDoctorPermissionChecker` دربارهٔ عضویت پزشک در کلینیک است
|
||||
و نماینده اصلاً پزشکِ عضو نیست؛ اضافهکردن نقش نماینده به آن، مسئولیتِ کلاس را دوتا میکند.
|
||||
بهجایش در controller کنارش بگذار:
|
||||
|
||||
```php
|
||||
$isRepOwner = $this->editPolicy->ownsClinic($user, $clinic);
|
||||
|
||||
if (!$isRepOwner && !$this->permChecker->can($user, $clinic, 'clinic_info', 'update')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
حواست به خط ۲۳۰ باشد:
|
||||
|
||||
```php
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
|
||||
```
|
||||
|
||||
این پیشچکِ منشی است و پیش از واکشی رکورد اجرا میشود.
|
||||
بررسی کن که برای کاربرِ `ROLE_REPRESENTATION` (که منشی نیست) throw نکند.
|
||||
اگر throw میکند، شرطش را طوری بگذار که فقط برای نقش منشی اجرا شود — و در تست ثابتش کن.
|
||||
|
||||
whitelist با `RepresentationEditPolicy::CLINIC_FIELDS`.
|
||||
|
||||
**نحوه تست:** نماینده و کلینیکش را با `POST /api/v1/representation/clinic` بساز.
|
||||
`PATCH` با `{"clinic_logo": "https://x/y.png", "info": "..."}` → `200`.
|
||||
`PATCH` با `{"doctors": [1]}` → `403` و `errors[0].field === 'doctors'`.
|
||||
یک نمایندهٔ دوم بساز و همان PATCH را بزن → `403`.
|
||||
|
||||
### ۵. آدرسها — شش اکشن
|
||||
|
||||
همان سیاست را در اینها هم صدا بزن:
|
||||
|
||||
- `POST /api/v1/clinic-pro/doctor-address` — نماینده باید `doctor_uuid` بفرستد،
|
||||
دقیقاً مثل مسیر ادمین در خط ۵۵۲. مالکیت همان پزشک بررسی شود.
|
||||
- `PATCH /api/v1/clinic-pro/doctor-address/{id}`
|
||||
- `DELETE /api/v1/clinic-pro/doctor-address/{id}`
|
||||
- `POST /api/v1/clinic/{clinicUuid}/address`
|
||||
- `PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
- `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
|
||||
|
||||
روی آدرسها whitelist لازم نیست — کل رکورد آدرس محتوایی است.
|
||||
شرط `TYPE_PERSONAL` در خط ۶۵۲ باید سر جایش بماند؛ نماینده هم نباید
|
||||
آدرس کلینیک را از مسیر آدرسِ پزشک عوض کند.
|
||||
|
||||
**نحوه تست:** نماینده برای پزشکش آدرس بسازد، ویرایش کند، حذف کند — هر سه `200`.
|
||||
نمایندهٔ دوم روی همان آدرس `403` بگیرد.
|
||||
|
||||
### ۶. فلگ `can_edit` در پاسخ GET جزئیات
|
||||
|
||||
هر دو `show` را طوری عوض کن که کاربر جاری را اختیاری بگیرند:
|
||||
|
||||
```php
|
||||
public function show(string $uuid, #[CurrentUser] ?User $user = null): JsonResponse
|
||||
```
|
||||
|
||||
و در آرایهٔ خروجی:
|
||||
|
||||
```php
|
||||
'can_edit' => $user !== null && (
|
||||
$doctor->getUser()->getId() === $user->getId()
|
||||
|| $user->hasRole('ROLE_ADMIN')
|
||||
|| $this->editPolicy->ownsDoctor($user, $doctor)
|
||||
),
|
||||
```
|
||||
|
||||
برای کلینیک همان با `permChecker->can(...) || editPolicy->ownsClinic(...)`.
|
||||
|
||||
هر دو اندپوینت عمومیاند. بدون توکن باید `can_edit: false` بدهند و
|
||||
هیچ بخش دیگری از پاسخ عوض نشود — سایت عمومی همینها را مصرف میکند.
|
||||
|
||||
**نحوه تست:** سه بار `GET /api/v1/doctor/{uuid}` — بدون توکن، با توکن نمایندهٔ مالک،
|
||||
با توکن نمایندهٔ دیگر. مقادیر `false` و `true` و `false`.
|
||||
|
||||
### ۷. باز کردن UI پنل
|
||||
|
||||
در `DoctorDetailPage.tsx` و `ClinicDetailPage.tsx` این خط را بردار:
|
||||
|
||||
```tsx
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
```
|
||||
|
||||
و جایش از پاسخ سرور بخوان:
|
||||
|
||||
```tsx
|
||||
const isReadOnly = primaryRole === 'representation' && !doctor?.can_edit;
|
||||
```
|
||||
|
||||
هیچ منطق مجوزی را در فرانت بازنویسی نکن. `can_edit` تنها منبع حقیقت است.
|
||||
|
||||
فیلدهای بیرون از whitelist باید برای نماینده در فرم **مخفی یا disabled** باشند،
|
||||
نه اینکه ارسال شوند و ۴۰۳ بگیرند:
|
||||
|
||||
- پزشک: کد نظام پزشکی، و کلید فعال/غیرفعال
|
||||
- کلینیک: مدیریت پزشکان کلینیک
|
||||
|
||||
نماینده همچنان میتواند پزشک را فعال/غیرفعال کند، ولی از اندپوینت اختصاصی خودش:
|
||||
`POST /api/v1/representation/doctors/{uuid}/status`.
|
||||
اگر آن دکمه در صفحه هست، به همان اندپوینت وصلش کن نه به `PATCH`.
|
||||
|
||||
تایپها را در `assets/admin/types/index.ts` بهروز کن: `can_edit?: boolean`.
|
||||
|
||||
**نحوه تست:** `npx tsc --noEmit --project tsconfig.json` سبز،
|
||||
`ddev exec yarn dev` بدون خطا، و ورود دستی با یک کاربر نماینده در
|
||||
`https://clinic-pro.ddev.site/admin/doctors/<uuid>` — دکمهٔ ویرایش و آپلود لگو دیده شود
|
||||
و کد نظام پزشکی دیده نشود.
|
||||
|
||||
### ۸. مستندات
|
||||
|
||||
- `docs/api/doctor.md` — `PATCH /api/v1/doctor/{uuid}`: نقش نماینده، فهرست فیلدهای مجاز،
|
||||
و ۴۰۳ فیلد ممنوع. `GET`: فیلد `can_edit`.
|
||||
- `docs/api/clinic.md` — همان برای کلینیک.
|
||||
- اندپوینتهای آدرس در هر دو سند.
|
||||
- JSON نمونه باید **خروجی اجرای واقعی** باشد، نه دستساز.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگو: Policy Object.** یک کلاس، یک سؤال: «این نماینده چه اجازهای دارد».
|
||||
دلیل انتخاب: منطق مجوز الان در هشت اکشن تکرار میشود؛ اگر inline بنویسی،
|
||||
فردا که قاعده عوض شود هشت جا باید عوض شود و یکی جا میماند.
|
||||
`ClinicDoctorPermissionChecker` را گسترش نده — آن دربارهٔ عضویت پزشک در کلینیک است،
|
||||
و پزشکِ نماینده اصلاً عضو نیست.
|
||||
- **این تسک هیچ دسترسی موجودی را تنگ نمیکند.** فقط باز میکند.
|
||||
اگر تستی از رفتار پزشک یا مالک یا ادمین شکست، یعنی whitelist اشتباه به آن مسیر هم خورده.
|
||||
- **`active` عمداً بیرون است.** نماینده اندپوینت اختصاصی دارد:
|
||||
`POST /api/v1/representation/doctors/{uuid}/status` (خط ۶۳۲ همان controller).
|
||||
دو مسیر برای یک کار نساز.
|
||||
- **اندپوینتهای آپلود دستنخورده میمانند.**
|
||||
`/file/upload/clinic_pro/doctor/field_image` و `.../clinic/field_clinic_logo`
|
||||
الان هم برای هر کاربر لاگینشده بازند و فقط URL برمیگردانند؛
|
||||
دروازهٔ واقعی همان PATCH است که URL را ذخیره میکند.
|
||||
- **کلید ممنوع = ۴۰۳، نه حذف بیصدا.** حذف بیصدا یعنی نماینده فکر میکند ذخیره شده
|
||||
و تا مدتها کسی نمیفهمد. `$this->error(..., 403, $fieldName)` امضای فیلد را هم میگیرد.
|
||||
- **`مالکیت` را از `representation_id` بخوان، نه از نقش.**
|
||||
هر کاربری میتواند `ROLE_REPRESENTATION` داشته باشد؛ آنچه مهم است اینکه
|
||||
رکورد `Representation` او همان id باشد که روی پزشک/کلینیک نشسته.
|
||||
- **قرارداد API عوض نمیشود، فقط گسترده میشود.** `can_edit` فیلد جدید و اختیاری است.
|
||||
ولی `GET /api/v1/doctor/{uuid}` و `GET /api/v1/clinic/{uuid}` را
|
||||
`nobat724_front` هم مصرف میکند؛ بعد از تغییر، صفحهٔ پزشک و کلینیک سایت عمومی را
|
||||
دستی باز کن و مطمئن شو چیزی نشکسته. build آنها خطا نمیدهد.
|
||||
- تستها زیر `tests/Representation/` بروند. دیتابیس تست هرگز reset نمیشود،
|
||||
پس دادهٔ هر تست را با مقدار یکتا بساز (`uniqid()`) تا اجرای دوم هم سبز بماند.
|
||||
@@ -0,0 +1,368 @@
|
||||
# نوبتدهی منبعمحور در صفحهٔ نوبتها — تب هر منبع + رزرو سرویسی + حذف تایملاین منابع
|
||||
|
||||
> **وضعیت: انجام شد (۱۴۰۵/۰۵/۱۲).** کامیتها: `3e3a2482` (تب منابع + فیلتر)،
|
||||
> `fd27ceef` (مودال رزرو + حذف تایملاین).
|
||||
>
|
||||
> ## این پرامپت در اجرا سهبار غلط از آب درآمد — چیزی که واقعاً شد:
|
||||
>
|
||||
> **۱. «نوبت بدون پزشک» پیاده شد و بعد کاملاً برگردانده شد.** `doctor` تهیپذیر شد و
|
||||
> migration اجرا شد، ولی وسط کار معلوم شد مدل مجوز منشی روی سهتایی
|
||||
> `(منشی، کلینیک، پزشک)` بنا شده و `DoctorSecretary.doctor` تهیپذیر نیست — یعنی برای
|
||||
> نوبتِ بدون پزشک **هیچ ردیف مجوزی وجود ندارد**. تصمیم کاربر: منبع پزشکِ مسئول داشته
|
||||
> باشد. migration رولبک و کد `git checkout` شد.
|
||||
>
|
||||
> **۲. «۷۳ فراخوانی getDoctor()» بیشبرآورد بود.** بیشترشان روی entityهای دیگرند
|
||||
> (`WeeklySchedule`، `Holiday`، `Rate`، `DoctorAddress`). عدد واقعی روی `Appointment`
|
||||
> حدود ۲۵ بود، و diff سطح ۸ دقیقاً **۳۳ نقطهٔ جدید در ۱۶ فایل** داد.
|
||||
>
|
||||
> **۳. مهمترین: کل موتور از قبل ساخته شده بود و این پرامپت از وجودش بیخبر بود.**
|
||||
> `POST /api/v1/appointment-availability` (با `assignment` per اسلات)، `appointment-hold`،
|
||||
> `appointment-confirm`، هوک `useResourceBooking.ts`، و حتی یک صفحهٔ کامل
|
||||
> `ResourceBookingPage.tsx` روی `/admin/resource-booking`. `confirm` هم از قبل
|
||||
> `doctor_uuid` میگیرد. پس **هیچ تغییر بکاندی برای رزرو لازم نبود** و تنها افزودنی
|
||||
> بکاند، فیلتر `resource_uuid` روی فهرست نوبتها شد.
|
||||
>
|
||||
> ## دو تلهٔ ابزاری که باید بدانی
|
||||
>
|
||||
> - **`phpstan` این پروژه تهیپذیری را چک نمیکند.** بررسی «صدا زدن متد روی تهی» سطح ۸
|
||||
> است و `phpstan.neon` روی سطح ۵. با یک خطای عمدی تست شد: `[OK] No errors`. برای این
|
||||
> جنس تغییر، گیت واقعی PHPUnit است، نه phpstan.
|
||||
> - **`doctrine:migrations:diff` تغییر nullable را ندید** و بهجایش یک migration بیربط
|
||||
> `messenger_messages` ساخت. migration دستی نوشته شد.
|
||||
>
|
||||
> ## معماریای که ماند
|
||||
>
|
||||
> - رزرو منبع از `hold → confirm` میرود، نه `POST /api/v1/appointment`. دلیلش حیاتی است:
|
||||
> فقط `HoldService` رکورد `resource_occupancy_buckets` مینویسد و قید یکتای
|
||||
> `uniq_bucket_resource_seat` تداخل را غیرممکن میکند. `book()` هیچ occupancy نمینویسد،
|
||||
> پس رزرو منبع از آن مسیر بیگارد است.
|
||||
> - موتور خدمتمحور جواب میدهد؛ مودال نتیجه را به اسلاتهایی تنگ میکند که `assignment`شان
|
||||
> همین منبع را دارد و آن نقش را به منبع قفل میکند.
|
||||
>
|
||||
> بقیهٔ این فایل متن اولیهٔ پرامپت است و برای تاریخچه نگه داشته شده — **بهعنوان دستورالعمل
|
||||
> اجرا معتبر نیست.**
|
||||
|
||||
---
|
||||
|
||||
## زمینه
|
||||
|
||||
صفحهٔ `/admin/appointments` امروز کاملاً پزشکمحور است: تبها فقط پزشکاند
|
||||
(`AppointmentsPage.tsx:841`)، و منابع فقط یک نوار **فقطخواندنی** زیر زمانبندی دارند
|
||||
(`AppointmentsPage.tsx:892-903`) که هیچ اقدامی روی آن ممکن نیست.
|
||||
|
||||
در مدل Resource-First، منبع واحد ظرفیت است: «لیزر CO2» و «لیزر NdYAG» سرویسهای خودشان
|
||||
(`ResourceServiceOffering`)، تقویم خودشان (`ResourceCalendar`) و استثناهای خودشان را دارند.
|
||||
ولی کاربر نمیتواند برای آنها نوبت ثبت کند، چون کل مسیر رزرو از پزشک عبور میکند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**هدف:** هر منبع مثل پزشک تب خودش را داشته باشد؛ «افزودن نوبت» روی تب یک منبع، مودال
|
||||
نوبتدهی **سرویسی** را برای همان منبع باز کند (لیست سرویسهای همان منبع → انتخاب →
|
||||
زمان خالی → بیمار → ثبت)؛ و نوار «منابع» زیر زمانبندی حذف شود.
|
||||
|
||||
**سه مانع واقعی در کد امروز:**
|
||||
|
||||
۱. **نوبت بدون پزشک ممکن نیست.** `Appointment::$doctor` با `nullable: false` تعریف شده
|
||||
(`Appointment.php:100-101`) و سازندهٔ entity هم `Doctor` میگیرد (`Appointment.php:256`).
|
||||
`book()` بدون `doctor_uuid` خطای ۴۲۲ میدهد (`AppointmentController.php:484`) و منبع فقط
|
||||
وقتی پزشک پیدا میکند که خودش پزشک باشد (`AppointmentController.php:471-473`). یعنی
|
||||
دستگاهِ بدون پزشک اصلاً قابل رزرو نیست.
|
||||
|
||||
۲. **اسلات سرویسی فقط پزشکمحور است.** `GET /api/v1/appointment-service-slots` پزشک
|
||||
میخواهد و شرط میکند `booking_mode` همان پزشک `service` باشد
|
||||
(`AppointmentController.php:193-209`). منبع `WeeklySchedule` ندارد.
|
||||
|
||||
۳. تایملاین منابع باید حذف شود.
|
||||
|
||||
**تصمیم گرفتهشده (توسط کاربر):** مسیر «نوبت بدون پزشک» — `doctor` تهیپذیر شود.
|
||||
|
||||
### چرا این تصمیم آنقدر که بهنظر میرسد پرریسک نیست (شواهد از کد)
|
||||
|
||||
- `Appointment::$resource` **از قبل وجود دارد** و تهیپذیر است (`Appointment.php:174-176`).
|
||||
- تداخل منابع **از قبل در سطح دیتابیس** تضمین شده، نه در کد: `OccupancyBucket` با
|
||||
`UniqueConstraint('uniq_bucket_resource_seat', ['resource_id','bucket_at','seat'])`
|
||||
(`OccupancyBucket.php:22`). پس `activeSlotKey` مسئول تداخل **منبع** نیست.
|
||||
- `activeSlotKey` فقط دوبارهرزروی **همان پزشک** را میگیرد:
|
||||
`sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)` (`Appointment.php:283`).
|
||||
وقتی پزشکی وجود ندارد، «دوبارهرزروی پزشک» بیمعناست و `null` بودنِ کلید معنای درستی
|
||||
است — نه یک حفرهٔ ایمنی.
|
||||
|
||||
**ریسک واقعی و باقیمانده:** ۷۳ فراخوانی `getDoctor()` در ۲۱+ فایل `src/` که همه امروز
|
||||
`Doctor` غیرتهی فرض میکنند. این بخش سنگین کار است و باید تکتک بررسی شود.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ **موفق:** با توکن مالک کلینیک، `POST /api/v1/appointment` با بدنهٔ
|
||||
`{resource_uuid, service_item_uuids[], slot_start, patient_national_code, patient_gender}`
|
||||
و **بدون** `doctor_uuid`، برای منبعِ دستگاهی (`subject_kind = null`) → `200` و نوبت ذخیره
|
||||
میشود با `doctor_id = NULL` و `resource_id` پرشده. در UI: تب «لیزر CO2» → «افزودن نوبت» →
|
||||
انتخاب سرویس → انتخاب زمان → ثبت → نوبت در فهرست همان تب دیده میشود.
|
||||
- ✅ **موفق:** `GET /api/v1/appointment-service-slots?resource_uuid=…&date=…&service_item_uuids[]=…`
|
||||
→ `200` با همان شکل پاسخِ حالت پزشک (`start_times[]`, `total_duration_minutes`).
|
||||
- ❌ **خطا:** رزرو منبعی که آن سرویس را ارائه نمیدهد → `422` با پیام
|
||||
«این منبع این سرویس را ارائه نمیدهد» و `field = resource_uuid` (این بررسی از قبل در
|
||||
`AppointmentController.php:533-540` هست و باید در مسیر بدونپزشک هم اجرا شود).
|
||||
- ❌ **خطا:** رزرو منبعِ محیط دیگر → `422` «منبع یافت نشد» با `field = resource_uuid`.
|
||||
- ❌ **خطا:** نه `doctor_uuid` و نه `resource_uuid` → `422` با envelope خطا.
|
||||
- ⚠️ **مرزی:** دو رزروِ همزمان روی یک منبع با ظرفیت ۱ در یک بازه → دومی باید با
|
||||
`409` رد شود (از قید یکتای `uniq_bucket_resource_seat`، نه از بررسی در کد).
|
||||
- ⚠️ **مرزی:** منبعی که آن روز شیفت ندارد → `start_times` خالی و پیام «زمان خالی کافی
|
||||
نیست»، نه خطای ۵۰۰.
|
||||
- ⚠️ **مرزی:** نوبتهای قدیمیِ دارای پزشک باید بدون تغییر کار کنند (هم API، هم پنل، هم
|
||||
سایت عمومی) — `doctor` تهیپذیر شده، حذف نشده.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Appointment/Entity/Appointment.php` | `doctor` تهیپذیر، `activeSlotKey`، `toArray()` |
|
||||
| `src/Appointment/Controller/AppointmentController.php` | `book()` و `serviceSlots()` |
|
||||
| `src/Appointment/Booking/Entity/OccupancyBucket.php` | تضمین یکتاییِ اشغال منبع (فقط مرجع — تغییر نمیکند) |
|
||||
| `src/Resource/Entity/ResourceServiceOffering.php` | سرویسها و مدت مؤثرِ هر منبع |
|
||||
| `src/Resource/Entity/ResourceCalendar.php` | شیفت هفتگی منبع (مبنای اسلات) |
|
||||
| `migrations/` | migration تهیپذیر کردن `appointments.doctor_id` |
|
||||
| `assets/admin/pages/AppointmentsPage.tsx` | تبها، مودال ثبت، حذف بخش منابع |
|
||||
| `assets/admin/components/appointments/DoctorTabs.tsx` | تبها (باید عمومی شود) |
|
||||
| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | انتخاب سرویس/زمان (باید منبع را هم بپذیرد) |
|
||||
| `assets/admin/components/appointments/ResourceTimeline.tsx` + `.test.tsx` | **حذف** |
|
||||
| `assets/admin/hooks/useResourceTimeline.ts` | **حذف** اگر مصرفکنندهٔ دیگری ندارد |
|
||||
| `docs/api/appointment-booking.md`, `docs/api/appointment.md`, `docs/api/resource.md` | مستندات |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`Appointment.php` — پزشک اجباری و کلید یکتا بر پایهٔ پزشک:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private Doctor $doctor;
|
||||
|
||||
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
|
||||
|
||||
private function refreshActiveSlotKey(): void
|
||||
{
|
||||
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
```
|
||||
|
||||
`AppointmentController::book()` — بدون پزشک رد میشود:
|
||||
|
||||
```php
|
||||
if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) {
|
||||
$doctorUuid = $resource->subject()->getUuid();
|
||||
}
|
||||
…
|
||||
if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid بههمراه slot_start الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
```
|
||||
|
||||
`AppointmentController::serviceSlots()` — پزشکمحور و مقیّد به `booking_mode` پزشک:
|
||||
|
||||
```php
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
…
|
||||
$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
|
||||
if ($mode !== WeeklySchedule::MODE_SERVICE) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبتدهی سرویسی نیست', 422);
|
||||
}
|
||||
```
|
||||
|
||||
`AppointmentsPage.tsx` — تب فقط پزشک، و بخش منابع که باید حذف شود:
|
||||
|
||||
```tsx
|
||||
{showDoctorTabs && (
|
||||
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
|
||||
)}
|
||||
…
|
||||
{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت میتواند همزمان اتاق و
|
||||
دستگاه را بگیرد و آن است که ظرفیت را تمام میکند. */}
|
||||
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
|
||||
<h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
|
||||
<ResourceTimeline
|
||||
lanes={resourceDay?.resources ?? []}
|
||||
dayStart={resourceDay?.date ?? 0}
|
||||
loading={resourceTimelineLoading}
|
||||
error={resourceTimelineError}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
> ترتیب اجباری است: بکاند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی
|
||||
> خرابه ساخته میشوند.
|
||||
|
||||
### ۱. تهیپذیر کردن `Appointment::$doctor`
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
private ?Doctor $doctor = null;
|
||||
|
||||
public function __construct(?Doctor $doctor, User $user, int $slotStart, int $slotEnd)
|
||||
|
||||
public function getDoctor(): ?Doctor { return $this->doctor; }
|
||||
|
||||
/**
|
||||
* کلید یکتای اسلات فقط دوبارهرزروی «همان پزشک» را میگیرد. نوبتِ منبعمحورِ بدون
|
||||
* پزشک چنین تداخلی ندارد؛ تداخلِ خودِ منبع را قید یکتای
|
||||
* `uniq_bucket_resource_seat` روی `resource_occupancy_buckets` میگیرد.
|
||||
*/
|
||||
private function refreshActiveSlotKey(): void
|
||||
{
|
||||
$this->activeSlotKey = $this->doctor !== null
|
||||
&& !$this->isReserve
|
||||
&& in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
}
|
||||
```
|
||||
|
||||
سپس **همهٔ ۷۳ فراخوانی `getDoctor()`** را بررسی کن:
|
||||
|
||||
```bash
|
||||
ddev exec grep -rn "getDoctor()" src/ | wc -l # باید ۷۳ باشد
|
||||
ddev exec php vendor/bin/phpstan analyse # سطح ۵ — تهیپذیرهای بررسینشده را میگیرد
|
||||
```
|
||||
|
||||
قاعده: هر جا پزشک واقعاً لازم است (تعرفه، برنامهٔ هفتگی، پیامک پزشک) → `null` را با
|
||||
خطای معنادار رد کن؛ هر جا فقط نمایشی است (`toArray()` خط ۴۹۹ و ۵۰۳) → مقدار تهی برگردان
|
||||
نه استثنا.
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/phpunit tests/Appointment/` باید کامل سبز بماند (رگرسیون
|
||||
مسیر پزشکدار). بهعلاوه `ddev exec php vendor/bin/phpstan analyse` بدون خطای تهیپذیری.
|
||||
|
||||
### ۲. Migration
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
**نحوه تست:** بعد از migrate، `DESCRIBE appointments;` باید `doctor_id` را `YES` (nullable)
|
||||
نشان دهد، و ردیفهای موجود دستنخورده بمانند:
|
||||
```bash
|
||||
ddev exec mysql -e "SELECT COUNT(*) FROM appointments WHERE doctor_id IS NULL;" # قبل از فیچر: 0
|
||||
```
|
||||
|
||||
### ۳. اسلات سرویسیِ منبعمحور
|
||||
|
||||
`GET /api/v1/appointment-service-slots` را طوری توسعه بده که **یا** `doctor_uuid` بگیرد
|
||||
**یا** `resource_uuid` — با همان شکل پاسخ.
|
||||
|
||||
**الگو: Strategy.** یک interface مثل `ServiceSlotSource` با دو پیادهسازی
|
||||
`DoctorServiceSlotSource` و `ResourceServiceSlotSource`. دلیل انتخاب (guidelines §۵):
|
||||
همین حالا **دو** پیادهسازی واقعی وجود دارد — پزشک از `WeeklySchedule` و اسلاتهای پزشک
|
||||
میآید، منبع از `ResourceCalendar` + `ResourceException` + `resource_blocks` + اشغال.
|
||||
بدون Strategy، این میشد یک `if` روی نوعِ شناسه داخل کنترلر که با هر منبعِ جدید رشد میکند.
|
||||
|
||||
نکات محاسبه برای منبع:
|
||||
- مدت هر سرویس از `ResourceServiceOffering` همان منبع (مدت مؤثر)، نه از پیشفرض سرویس.
|
||||
- شرط `booking_mode === MODE_SERVICE` برای منبع **اعمال نشود** — آن قید مالِ برنامهٔ
|
||||
هفتگی پزشک است و منبع اصلاً `WeeklySchedule` ندارد.
|
||||
- بازههای اشغال از همان مسیری بیاید که تایملاین منبع میخواند
|
||||
(`ResourceOccupancyRepository`)، نه یک کوئری موازیِ جدید.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
TOKEN=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"0912000201","password":"QaTest@1234"}' | jq -r .access_token)
|
||||
|
||||
curl -sk "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?resource_uuid=<UUID>&date=$(date +%F)&management=1&service_item_uuids[]=<SERVICE_UUID>" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq
|
||||
# انتظار: success:true و start_times آرایهای از {start,end,start_time}
|
||||
```
|
||||
|
||||
### ۴. رزرو بدون پزشک در `book()`
|
||||
|
||||
- اگر `resource_uuid` آمده و منبع پزشکپشت ندارد، دیگر به `doctor_uuid` اصرار نکن.
|
||||
- شرط خطا به این تغییر کند: «حداقل یکی از `doctor_uuid` یا `resource_uuid` لازم است».
|
||||
- بررسی «این منبع این سرویس را ارائه نمیدهد» (`AppointmentController.php:533-540`) باید
|
||||
در مسیر بدونپزشک هم اجرا شود — امروز داخل بلوکی است که به `$duration` وابسته است.
|
||||
- بررسی مالکیت محیط منبع (`EntityContext::forBooking`) وقتی پزشک نداریم باید از **محیط
|
||||
کاربر جاری** بیاید، نه از پزشک.
|
||||
|
||||
**نحوه تست:** رزرو واقعی روی یک منبع دستگاهی (بدون `doctor_uuid`) → ۲۰۰؛ سپس همان بازه
|
||||
دوباره → ۴۰۹. هر دو با `curl` و توکن بالا. و
|
||||
`ddev exec mysql -e "SELECT doctor_id, resource_id FROM appointments ORDER BY id DESC LIMIT 1;"`.
|
||||
|
||||
### ۵. تب منابع در کنار تب پزشکان
|
||||
|
||||
`DoctorTabs.tsx` امروز فقط `{uuid, name}` میگیرد و رفتارش کاملاً عمومی است — **همان را
|
||||
عمومی کن** (مثلاً `EntityTabs`) بهجای ساختن کامپوننت دوم؛ اسم فعلیاش تنها چیزِ پزشکیِ
|
||||
آن است (guidelines §۵: اول بگرد، بعد توسعه بده، در آخر بساز).
|
||||
|
||||
تب انتخابشده باید در URL بنشیند (`useUrlState`) وگرنه «بازگشت» نما را میپراند —
|
||||
همان قاعدهای که در `CLAUDE.md` برای وضعیت لیست آمده. پیشنهاد: `?tab=doctor:<uuid>` و
|
||||
`?tab=resource:<uuid>` تا یک کلید هر دو نوع را بگیرد.
|
||||
|
||||
**نحوه تست:** `npx vitest run assets/admin/pages/AppointmentsPage.test.tsx` + تست جدید:
|
||||
کلیک روی تب یک منبع → فهرست همان منبع؛ رفرش صفحه → همان تب فعال بماند.
|
||||
|
||||
### ۶. مودال ثبت نوبت برای منبع
|
||||
|
||||
`ServiceSlotPicker` باید بهجای `doctorUuid` اجباری، یکی از این دو را بگیرد. سرویسها
|
||||
برای منبع از `GET /api/v1/resource/{uuid}/services` میآید (نه از
|
||||
`appointment-booking-services/{doctorUuid}`).
|
||||
|
||||
ترتیب مودال دقیقاً مثل عکس مرجع و مثل حالت پزشک بماند:
|
||||
سرویسهای منبع → سرویسهای انتخابشده (با مدت قابل ویرایش) → زمانهای خالی → جستجوی
|
||||
بیمار → هزینه → ثبت.
|
||||
|
||||
**نحوه تست:** `npx vitest run assets/admin/components/appointments/ServiceSlotPicker.test.tsx`
|
||||
(تست موجود نباید بشکند) + تست جدید برای حالت منبع. سناریوی UI: تب «لیزر CO2» → افزودن
|
||||
نوبت → یک سرویس → یک زمان → کد ملی بیمار → ثبت → نوبت در فهرست ظاهر شود.
|
||||
|
||||
### ۷. حذف تایملاین منابع
|
||||
|
||||
- بلوک `<h2>منابع</h2> + <ResourceTimeline …>` از `AppointmentsPage.tsx` حذف شود.
|
||||
- `ResourceTimeline.tsx` و `ResourceTimeline.test.tsx` حذف شوند.
|
||||
- `useResourceTimeline.ts` **فقط اگر** مصرفکنندهٔ دیگری ندارد حذف شود:
|
||||
```bash
|
||||
grep -rn "useResourceTimeline" assets/admin/
|
||||
```
|
||||
- `GET /api/v1/resources/timeline` در بکاند **دستنخورده** بماند (اندپوینت خودش
|
||||
مشکلی ندارد؛ فقط این مصرفکننده حذف میشود). اگر بعد از حذف هیچ کلاینتی ندارد، در
|
||||
گزارش پایانی ذکر کن تا کاربر تصمیم بگیرد.
|
||||
|
||||
**نحوه تست:** `npx vitest run` کامل سبز؛ و اسکرینشات صفحه بعد از
|
||||
`ddev exec yarn dev` که دیگر بخش «منابع» زیر زمانبندی ندارد.
|
||||
|
||||
### ۸. مستندات
|
||||
|
||||
- `docs/api/appointment-booking.md`: `resource_uuid` بدون `doctor_uuid`، و اینکه
|
||||
`doctor` در پاسخ میتواند `null` باشد.
|
||||
- `docs/api/appointment.md`: پارامتر `resource_uuid` در `appointment-service-slots` با
|
||||
JSON واقعیِ اجرا (نه دستساز).
|
||||
- `docs/api/resource.md`: اشاره به اینکه سرویسهای منبع مبنای رزرو منبعمحورند.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **این تغییر cross-repo است.** `nobat724_front` و `clinic-pro-tauri` هر دو کلاینت همین
|
||||
APIاند و `appointment.doctor` را غیرتهی فرض میکنند. تغییر قرارداد در build آنها خطا
|
||||
**نمیدهد** و در رانتایم میشکند (guidelines §۳). بعد از وظیفهٔ ۱، مصرف واقعی را در
|
||||
`nobat724_front/services/response.js` و صفحات نوبت دستی دنبال کن و نتیجه را گزارش بده.
|
||||
- `activeSlotKey` را برای نوبت بدون پزشک `null` بگذار — این حفره نیست؛ تداخل منبع را
|
||||
`uniq_bucket_resource_seat` میگیرد. اگر وسوسه شدی کلید را `r<id>:<slot>` کنی، اول
|
||||
بررسی کن که با ظرفیت >۱ منبع نمیشکند (منبع سهظرفیتی سه رزرو همزمان دارد).
|
||||
- Controller نازک بماند: منطق انتخاب اسلات در Service/Strategy، کوئری در Repository،
|
||||
تزریق با constructor injection.
|
||||
- خطاها با `AppException(ErrorCodes::ERR_XXX)` و پیام فارسی؛ پاسخها با
|
||||
`$this->success()` / `$this->error()`.
|
||||
- تاریخها Unix timestamp صحیح؛ رشتههای UI فارسی و تاریخهای نمایشی جلالی.
|
||||
- اگر وسط کار معلوم شد یکی از ۷۳ فراخوانی `getDoctor()` نیازمند تصمیم محصولی است
|
||||
(مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، **متوقف شو و بپرس** — حدس نزن.
|
||||
@@ -0,0 +1,385 @@
|
||||
# نوبتدهی بر پایهٔ منبع: رابطهٔ منبع↔سرویس، گزینهٔ سرویس، و حل مدت/قیمت
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین).
|
||||
**cross-repo:** قرارداد `POST /api/v1/appointment` و `GET /api/v1/appointment-booking-locations/{doctorUuid}` را سایت عمومی مصرف میکند
|
||||
(`nobat724_front/services/response.js` → `getBookingLocations`, `getServiceSlots`, `postAppointment`).
|
||||
تغییر قرارداد در build سایت خطا نمیدهد؛ باید دستی دنبال شود.
|
||||
|
||||
## قواعد غیرقابلمذاکره
|
||||
|
||||
این سه بند شرط پذیرشاند، نه توصیه. کاری که اینها را نقض کند «تمامشده» نیست حتی اگر تستهایش سبز باشد.
|
||||
|
||||
**۱. هر UI جدید داخل تم فعلی، در حد یک متخصص UI/UX.** هیچ تم، پالت، فونت یا کتابخانهٔ CSS تازهای
|
||||
ساخته نمیشود. مشخصاً:
|
||||
|
||||
- رنگها فقط از توکنهای `assets/admin/styles.css` (`var(--primary)`، `var(--surface)`، `var(--border)`،
|
||||
`var(--text-2)`، …). هیچ hex خامی در کامپوننت جدید.
|
||||
- کامپوننت از `assets/admin/components/ui/` استفاده شود، نه نسخهٔ دستساز: `DataTable`، `Modal`،
|
||||
`ConfirmDialog`، `PageHeader` (با `backTo`)، `SearchableSelect`، `StatusBadge`، `Pagination`،
|
||||
`PersianDateInput`. **`<select>` خام ممنوع** — همیشه `SearchableSelect`.
|
||||
- سه حالت نمایش باید سالم باشند: دارکمود (`[data-theme="dark"]`)، حالت فشرده
|
||||
(`[data-density="compact"]`) و موبایل ۳۹۰px بدون اسکرول افقی.
|
||||
- دو تلهٔ شناختهشدهٔ همین CSS: `.card` **padding ندارد** (برای فاصله `card-pad` اضافه کن) و
|
||||
`.field` خودش جعبهٔ ورودی است (برچسبِ بالای فیلد با `.field-block` میآید، نه داخل `.field`).
|
||||
- RTL و متن فارسی؛ تاریخها شمسی با `formatDate()`.
|
||||
- صفحهٔ زیرمجموعه بدون دکمهٔ بازگشت پذیرفته نیست: `PageHeader backTo=…` یا `<BackButton fallback=…/>`.
|
||||
- وضعیت لیستها (جستجو، فیلتر، صفحه) در query string با `hooks/useUrlState.ts`، نه در `useState`.
|
||||
|
||||
**۲. هر چیزِ اضافه حذف میشود.** فقط منبع، سرویس، گزینهٔ سرویس و دستهبندی میماند. وظیفهٔ ۸ فهرست
|
||||
حذف را دارد؛ ولی قاعده کلیتر است: در همین کار هم صفحه، فیلد، endpoint یا گزینهای که سند
|
||||
نخواسته اضافه نکن.
|
||||
|
||||
**۳. ساختار ساده.** بدون abstraction برای آینده:
|
||||
|
||||
- جدول جدید فقط وقتی هیچ جدول موجودی — حتی با یک ستون تازه — کافی نباشد؛ دلیلش نوشته شود.
|
||||
(به همین دلیل «گزینهٔ سرویس» جدول جدید نمیگیرد — پایینتر.)
|
||||
- Controller نازک، منطق در Service، کوئری در Repository، وابستگی با constructor injection.
|
||||
- interface و کلاس پایه فقط وقتی **الان** بیش از یک پیادهسازی دارد.
|
||||
- نامگذاری و سبک کد دقیقاً مثل فایلهای همسایه.
|
||||
|
||||
## زمینه
|
||||
|
||||
تسکهای `docs/new_feture/taskes/` لایهٔ منبع را ساختهاند — `ResourceType`، `ClinicResource`،
|
||||
تقویم منبع، استثنا، استخر، مهارت، موتور دسترسپذیری و اشغال واقعی. ولی **واحد رزرو هنوز پزشک است**:
|
||||
نوبت به `Doctor` گره خورده، و «کدام منبع این سرویس را میدهد» و «مدت/قیمت این سرویس برای این منبع»
|
||||
هیچجا داده نیست. سند مالک محصول میگوید مدل باید بر پایهٔ منبع باشد، و هر چیزی خارج از
|
||||
منبع/سرویس/گزینه از محصول حذف شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه شکاف واقعی نسبت به سند:
|
||||
|
||||
1. **رابطهٔ منبع↔سرویس وجود ندارد.** انتخاب منبع فقط با `type + skill` انجام میشود، پس
|
||||
«دستگاه لیزر ۱ این سرویس را میدهد ولی دستگاه ۲ نه» قابل بیان نیست.
|
||||
2. **مدت و قیمت بُعد منبع ندارند.** override موجود per **شعبه** است؛ «دکتر احمدی ۳۰ دقیقه /
|
||||
دکتر رضایی ۴۵ دقیقه برای همان گزینه» نمایشدادنی نیست.
|
||||
3. **نوبت منبع را نگه نمیدارد.** `Appointment.doctor` غیرتهی است و رزرو بدون پزشک ممکن نیست،
|
||||
پس نوبتِ «دستگاه لیزر ۲» در مدل جا ندارد.
|
||||
|
||||
هدف: منبع واحد رزرو شود، مدت و قیمت با زنجیرهٔ **منبع+گزینه → منبع+سرویس → پیشفرض** حل شود،
|
||||
و نوبت منبع و snapshot را نگه دارد.
|
||||
|
||||
> **گزینهٔ سرویس در این کدبیس جدول جدید نمیخواهد.** «گزینه» همان `ServiceItem` عضو یک
|
||||
> `ItemGroup` است (`select_min`/`select_max` از قبل هست) و «سرویس» همان آیتم والد. ساختن
|
||||
> جدول سوم `service_options` یعنی دو منبع حقیقت برای یک چیز، و همهٔ مسیرهای امروز
|
||||
> (`appointment-booking-services`، `appointment-service-slots`، `PriceListItem`، `Tariff`)
|
||||
> باید دوباره نوشته شوند. دلیل انتخاب در «نکات مهم» ثبت شده است.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ **موفق:** برای سرویس «لیزر» و گزینهٔ «پا» دو ردیف منبع ثبت شود (دستگاه ۱: ۲۰ دقیقه/۸ میلیون ریال،
|
||||
دستگاه ۲: ۱۵ دقیقه/۹٫۵ میلیون ریال). `GET /api/v1/resource/{uuid}/services` هر دو را برگرداند و
|
||||
`POST /api/v1/appointment` با `resource_uuid` دستگاه ۲ → `201` با
|
||||
`service_total_minutes: 15` و `price_snapshot.final_rials: 9500000`.
|
||||
- ❌ **خطا:** رزرو با `resource_uuid` منبعی که آن سرویس را ندارد → `422` با
|
||||
`ERR_VALIDATION_001` و پیام «این منبع این سرویس را ارائه نمیدهد»؛ بدون توکن → `401` با envelope خطا.
|
||||
- ✅ **دستهبندی:** دستهٔ «تمام بدن» شامل «دست» و «پا» تعریف شود؛ انتخاب همزمان «لیزر تمام بدن» و
|
||||
«لیزر دست» → `422` با پیام فارسی، و یک دستگاه بتواند همزمان به چند دسته وصل باشد.
|
||||
- ⚠️ **مرزی:** منبعی که برای گزینه مقدار ندارد ولی برای سرویس دارد → مقدار سطح سرویس استفاده شود؛
|
||||
منبعی که هیچکدام را ندارد → پیشفرض خودِ آیتم؛ و نوبتهای **قبلاً ثبتشده** (بدون `resource_id`)
|
||||
باید همچنان در لیستها و پنل بدون خطا نمایش داده شوند.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|---|---|
|
||||
| `src/Resource/Entity/ClinicResource.php` | منبع؛ امروز به `Doctor`/`ClinicStaff`/`Room` لینک میشود |
|
||||
| `src/Resource/Repository/ClinicResourceRepository.php` | `findEligible()` — انتخاب کاندید با type+skill |
|
||||
| `src/ClinicService/Entity/ServiceItem.php` | سرویس و گزینه (هر دو item) با `solo/additional` و قیمت |
|
||||
| `src/ClinicService/Entity/ItemGroup.php` · `ItemGroupMember.php` | گروه گزینهها با بازهٔ انتخاب |
|
||||
| `src/ClinicService/Entity/CatalogCategory.php` | دستهٔ سراسری محیط؛ `parent` تکوالدی و `MAX_DEPTH = 4` |
|
||||
| `src/ClinicService/Service/ServiceSelectionValidator.php` | اعتبارسنجی ترکیب انتخابها |
|
||||
| `src/ClinicService/Entity/ServiceBranchOverride.php` | override per شعبه (باید در زنجیره بماند) |
|
||||
| `src/ClinicService/Service/DurationCalculator.php` | جمع solo/additional |
|
||||
| `src/Pricing/Service/PricingEngine.php` | `quote()` — بدون بُعد منبع |
|
||||
| `src/Appointment/Entity/Appointment.php` | `private Doctor $doctor` غیرتهی |
|
||||
| `src/Appointment/Controller/AppointmentController.php` | `book()` — `doctor_uuid` الزامی |
|
||||
| `src/Appointment/Plan/Service/AppointmentPlanBuilder.php` | ساخت برنامه و نیازمندیها |
|
||||
| `src/Appointment/Availability/Service/AvailabilityEngine.php` | جستجوی وقت آزاد روی منابع |
|
||||
| `assets/admin/pages/ResourcesPage.tsx` | صفحهٔ منابع پنل |
|
||||
| `docs/api/appointment.md` · `docs/api/clinic.md` | سند endpointها |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
انتخاب کاندید هیچ ربطی به سرویس ندارد — `src/Resource/Repository/ClinicResourceRepository.php:108`:
|
||||
|
||||
```php
|
||||
public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = []): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->where('r.address = :address')
|
||||
->andWhere('r.type = :type')
|
||||
->andWhere('r.active = true')
|
||||
// … فیلتر مهارت
|
||||
```
|
||||
|
||||
override فقط per شعبه است — `src/ClinicService/Entity/ServiceBranchOverride.php:41`:
|
||||
|
||||
```php
|
||||
#[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)]
|
||||
private ?int $priceRials = null;
|
||||
|
||||
#[ORM\Column(name: 'solo_duration_minutes', type: 'smallint', nullable: true)]
|
||||
private ?int $soloDurationMinutes = null;
|
||||
```
|
||||
|
||||
نوبت بدون پزشک ساخته نمیشود — `src/Appointment/Entity/Appointment.php:101` و `:242`:
|
||||
|
||||
```php
|
||||
private Doctor $doctor;
|
||||
|
||||
public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)
|
||||
```
|
||||
|
||||
قیمتگذاری بُعد منبع ندارد — `src/Pricing/Service/PricingEngine.php:59`:
|
||||
|
||||
```php
|
||||
public function quote(
|
||||
ServiceItem $service,
|
||||
array $items,
|
||||
DoctorAddress $address,
|
||||
int $at,
|
||||
array $policy = [],
|
||||
?PatientRecord $patient = null,
|
||||
): PriceQuote {
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. جدول رابطهٔ منبع↔سرویس
|
||||
|
||||
`src/Resource/Entity/ResourceServiceOffering.php` — رابطهٔ چندبهچند با تنظیمات اختصاصی.
|
||||
همان الگوی `ResourceSkill`/`ResourcePoolMember`: entity رابطهای با جفت یکتا.
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: ResourceServiceOfferingRepository::class)]
|
||||
#[ORM\Table(name: 'resource_service_offerings')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_service', columns: ['resource_id', 'service_item_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'service_item_id'], name: 'idx_offering_tenant_service')]
|
||||
class ResourceServiceOffering
|
||||
{
|
||||
use TenantOwnedTrait; // جفت از خود منبع مشتق میشود، نه از بدنهٔ درخواست
|
||||
|
||||
public function __construct(ClinicResource $resource, ServiceItem $serviceItem) { … }
|
||||
|
||||
private ?int $durationMinutes = null; // null = ارث از سطح بالاتر
|
||||
private ?int $priceRials = null; // null = ارث از سطح بالاتر
|
||||
private bool $active = true;
|
||||
}
|
||||
```
|
||||
|
||||
نکته: چون «گزینه» هم `ServiceItem` است، همین یک جدول هر دو سطحِ سند را پوشش میدهد —
|
||||
ردیف با آیتمِ والد = «منبع + سرویس»، ردیف با آیتمِ عضو گروه = «منبع + گزینه».
|
||||
|
||||
**نحوه تست:** migration ساخته و اجرا شود؛ سپس
|
||||
`ddev exec php bin/phpunit tests/Resource/ResourceServiceOfferingTest.php` با سه تست:
|
||||
ثبت ردیف، جفت تکراری → خطای یکتایی، و اینکه جفت محیط از منبع گرفته میشود نه از ورودی.
|
||||
|
||||
### ۲. Resolver مدت و قیمت
|
||||
|
||||
`src/ClinicService/Service/ResourceServiceResolver.php` — الگوی **Chain of Responsibility**،
|
||||
چون سند صریحاً ترتیب اولویت تعریف کرده و افزودن سطح بعدی (مثلاً قرارداد بیمه) نباید
|
||||
`if` تازه در دل موتور بگذارد.
|
||||
|
||||
ترتیب (از خاص به عام) — سطح شعبه عمداً در زنجیره میماند چون دادهاش امروز وجود دارد:
|
||||
|
||||
```
|
||||
۱. منبع + گزینه → ResourceServiceOffering(resource, optionItem)
|
||||
۲. منبع + سرویس → ResourceServiceOffering(resource, parentItem)
|
||||
۳. شعبه + آیتم → ServiceBranchOverride(item, address)
|
||||
۴. پیشفرض خودِ آیتم → ServiceItem::getSoloDurationMinutes() / getPriceRials()
|
||||
```
|
||||
|
||||
```php
|
||||
public function resolve(ClinicResource $resource, ServiceItem $item, DoctorAddress $address): ResolvedServiceSpec
|
||||
{
|
||||
// اولین سطحی که مقدارِ غیرnull دارد برنده است — مدت و قیمت **جدا** حل میشوند:
|
||||
// منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
|
||||
}
|
||||
```
|
||||
|
||||
`ResolvedServiceSpec` باید بگوید هر مقدار از کدام سطح آمده (`durationSource`, `priceSource`) —
|
||||
بدون این، دیباگِ «چرا این عدد؟» در پنل غیرممکن است.
|
||||
|
||||
**نحوه تست:** `tests/ClinicService/ResourceServiceResolverTest.php` — چهار تست، هر سطح یکی، بهعلاوهٔ
|
||||
تستِ مرزی «مدت از سطح ۱ و قیمت از سطح ۳».
|
||||
|
||||
### ۳. فیلتر کاندیدها بر اساس سرویس
|
||||
|
||||
`ClinicResourceRepository::findEligible()` یک آرگومان اختیاری `?ServiceItem $service` بگیرد و
|
||||
وقتی داده شد، فقط منابعی برگردد که ردیف فعال در `resource_service_offerings` دارند.
|
||||
|
||||
**قاعدهٔ سازگاری عقبرو:** اگر برای آن سرویس **هیچ** ردیفی ثبت نشده باشد، فیلتر اعمال نشود
|
||||
(همان رفتار امروز). وگرنه هر محیطی که هنوز رابطهها را پر نکرده، یکشبه بدون وقت آزاد میشود.
|
||||
|
||||
`AppointmentPlanBuilder::planRequirements()` سرویس را به `eligibleFor()` پاس بدهد.
|
||||
|
||||
**نحوه تست:** دو منبع از یک نوع بساز، فقط یکی را به سرویس وصل کن، `POST /api/v1/appointment-availability`
|
||||
بزن و مطمئن شو `assignment` همیشه همان یک منبع است. سپس ردیف را غیرفعال کن → `slots` خالی با `reason`.
|
||||
|
||||
### ۴. منبع و snapshot روی نوبت
|
||||
|
||||
- `Appointment::$resource` (`ManyToOne`, **nullable**) + `Appointment::$serviceOptionItem` (nullable).
|
||||
nullable بودن اجباری است: ۷۲ نوبت موجود منبع ندارند و migration نباید آنها را بشکند.
|
||||
- `service_total_minutes` و `PriceSnapshot` از قبل هستند — فقط باید از خروجی resolver پر شوند،
|
||||
نه از `ServiceItem` مستقیم.
|
||||
- `Appointment::toArray()` باید `resource` (`uuid`, `name`, `type`) و `service_option` را برگرداند.
|
||||
|
||||
**نحوه تست:** `tests/Appointment/ResourceBookingTest.php` — رزرو با منبع، سپس تغییر قیمت سرویس و
|
||||
اطمینان از اینکه `price_snapshot` نوبت قبلی تکان نمیخورد (همان قاعدهٔ snapshot سند).
|
||||
|
||||
### ۵. رزرو با منبع در endpointها
|
||||
|
||||
- `POST /api/v1/appointment`: `resource_uuid` پذیرفته شود. اگر آمد، `doctor_uuid` اختیاری است و
|
||||
پزشک از `resource->subject()` استنتاج میشود (اگر منبع پزشک باشد). اگر منبع دستگاه باشد و
|
||||
نوبت پزشک ندارد، `Appointment.doctor` باید nullable شود — این تغییر schema است و migration جدا میخواهد.
|
||||
- `GET /api/v1/appointment-booking-services/{doctorUuid}` مکمل بگیرد:
|
||||
`GET /api/v1/resource/{uuid}/services` → سرویسهای آن منبع با مدت و قیمتِ **حلشده**.
|
||||
- `POST /api/v1/appointment` بدون هیچکدام → `422`.
|
||||
|
||||
**نحوه تست:** با `curl` و توکن بیمار روی دادهٔ `app:seed-scenarios`: یکبار با `resource_uuid` دستگاه،
|
||||
یکبار با `doctor_uuid` (مسیر قدیمی باید سالم بماند)، یکبار با منبعِ بیارتباط → `422`.
|
||||
|
||||
### ۶. پنل ادمین: تب «سرویسهای این منبع»
|
||||
|
||||
در `assets/admin/pages/ResourcesPage.tsx` (یا صفحهٔ جزئیات منبع) جدولی با ستونهای
|
||||
سرویس/گزینه · مدت · قیمت · فعال، با ویرایش inline. خالیگذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر» و
|
||||
باید مقدار مؤثر را بهصورت placeholder با برچسب منبعش نشان دهد (`از شعبه`، `پیشفرض سرویس`).
|
||||
|
||||
از `SearchableSelect` استفاده شود، نه `<select>` خام؛ فرم با React Hook Form + Zod؛ داده با TanStack Query.
|
||||
|
||||
**صفحهٔ تازه ساخته نشود** — این یک تب روی صفحهٔ منبع موجود است. جدول با `DataTable` و ستون وضعیت با
|
||||
`StatusBadge`؛ حذف رابطه با `ConfirmDialog`. طبق قاعدهٔ ۱، هیچ رنگ خام و هیچ کامپوننت موازی.
|
||||
|
||||
**نحوه تست:** `npx vitest run assets/admin/pages/ResourceServicesTab.test.tsx` — سه تست:
|
||||
نمایش مقدار مؤثر، ذخیرهٔ override، پاککردن override → بازگشت به ارث.
|
||||
سپس **بازبینی چشمی** در مرورگر: دارکمود، حالت فشرده و موبایل ۳۹۰px — هر سه بدون شکستگی و
|
||||
بدون اسکرول افقی. بدون این سه اسکرینشات، وظیفه تمامشده نیست.
|
||||
|
||||
### ۷. دستهبندی سراسری کلینیک، مشترک بین سرویس و منبع، با «شامل بودن»
|
||||
|
||||
سند مالک محصول: دستهبندی باید در سطح کلینیک تعریف شود و **هم سرویسها هم منابع** از همان
|
||||
استفاده کنند؛ و یک دسته میتواند شامل دستههای دیگر باشد («تمام بدن» شامل دست و پا و …).
|
||||
|
||||
**وضعیت امروز:** `CatalogCategory` از قبل سراسریِ محیط است (جفت `(entity_type, entity_id)` +
|
||||
`parent` + `MAX_DEPTH = 4`) و روی `ServiceItem::$catalogCategory` مینشیند. دو چیز کم است:
|
||||
|
||||
الف) `ClinicResource` هیچ فیلد دستهای ندارد، پس «این دستگاه برای دست و پا است» گفتنی نیست.
|
||||
|
||||
ب) **`parent` برای «شامل بودن» کافی نیست.** درخت تکوالدی است: «دست» نمیتواند همزمان زیر
|
||||
«تمام بدن» و زیر «اندام فوقانی» باشد، در حالی که در لیزر مجموعهها روی هم میافتند. پس
|
||||
containment یک **گراف جهتدار بدون دور** است، جدا از سلسلهمراتب نمایشی.
|
||||
|
||||
سه تغییر:
|
||||
|
||||
```php
|
||||
// ۱) عضویت چندگانهٔ منبع در دستهها — m2m، چون یک دستگاه چند ناحیه را پوشش میدهد
|
||||
#[ORM\Table(name: 'resource_catalog_categories')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_category', columns: ['resource_id', 'category_id'])]
|
||||
|
||||
// ۲) یال «شامل بودن» بین دستهها — DAG، نه درخت
|
||||
#[ORM\Table(name: 'catalog_category_includes')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_category_include', columns: ['parent_category_id', 'child_category_id'])]
|
||||
class CatalogCategoryInclude
|
||||
{
|
||||
// «تمام بدن» → «دست» ، «تمام بدن» → «پا» …
|
||||
// parent === child ممنوع، و بستارِ گذرا نباید به خودش برگردد.
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// ۳) بستار گذرا: «تمام بدن» شامل «نیمتنهٔ پایین» و آن شامل «پا» ⇒ تمام بدن شامل پا
|
||||
final class CategoryClosureResolver
|
||||
{
|
||||
/** @return int[] شناسهٔ همهٔ دستههای زیرمجموعه، با پیمایش عمقی و محافظ دور */
|
||||
public function descendants(CatalogCategory $category): array { … }
|
||||
|
||||
public function overlaps(CatalogCategory $a, CatalogCategory $b): bool { … }
|
||||
}
|
||||
```
|
||||
|
||||
مصرفش در دو نقطه:
|
||||
|
||||
- **تعارض انتخاب:** `ServiceSelectionValidator` وقتی دو آیتم انتخابشده دستههایی دارند که یکی
|
||||
دیگری را شامل میشود → `422` با پیام «تمام بدن شامل دست است؛ هر دو با هم انتخاب نمیشوند».
|
||||
این جای رابطهٔ دستیِ `incompatible_with` را برای این حالت میگیرد — یک بار در دسته تعریف
|
||||
میشود، نه بهازای هر جفت آیتم.
|
||||
- **فیلتر منبع:** در `findEligible()` (وظیفهٔ ۳) اگر سرویس دسته دارد، منابعی که آن دسته یا یکی از
|
||||
اجدادش را پوشش میدهند مقدماند.
|
||||
|
||||
**حلقه ممنوع:** پیش از ذخیرهٔ یال، `descendants($child)` بررسی شود و اگر `$parent` در آن بود
|
||||
`422` برگردد. بدون این، `descendants()` تا سرریز استک میرود.
|
||||
|
||||
**نحوه تست:** `tests/ClinicService/CategoryClosureTest.php` —
|
||||
✅ «تمام بدن» → دست/پا ثبت شود و `descendants` هر دو را بدهد ·
|
||||
✅ زنجیرهٔ سهسطحی بستار گذرا را درست بدهد ·
|
||||
❌ یال دوری («دست شامل تمام بدن») → `422` ·
|
||||
⚠️ دستهای که هیچ یالی ندارد → آرایهٔ خالی، نه خطا.
|
||||
سپس با `curl`: انتخاب همزمان «لیزر تمام بدن» و «لیزر دست» در `POST /api/v1/appointment` → `422`.
|
||||
|
||||
### ۸. حذف زیرسیستمهای خارج از این مدل
|
||||
|
||||
**تصمیم مالک محصول (۱۴۰۵/۰۵/۱۰):** هر چیزی خارج از منبع/سرویس/گزینه از محصول حذف شود.
|
||||
ریسکش گفته شد (جریمهٔ لغو و پکیج معمولاً نیاز واقعی کلینیکاند) و مالک محصول تصمیم را تکرار کرد.
|
||||
|
||||
حذف کامل این پنج زیرسیستم — کد، جدول، endpoint، تست، صفحهٔ پنل، سند:
|
||||
|
||||
| دامنه | مسیر | حجم |
|
||||
|---|---|---|
|
||||
| موتور سیاست | `src/Policy/` | ۲۷ فایل · ۶ فایل تست |
|
||||
| پکیج و دفتر اعتبار | `src/Package/` | ۱۳ فایل · ۲ تست |
|
||||
| دورهٔ درمان | `src/Course/` | ۱۴ فایل · ۲ تست |
|
||||
| لغو/جریمه/لیست انتظار | `src/Cancellation/` + `src/Waitlist/` | ۱۹ فایل · ۲ تست |
|
||||
| رویداد و گزارش | `src/Report/` + `src/Shared/Event/` | ۳ فایل · ۳ تست |
|
||||
|
||||
صفحات پنل: `PolicyFormPage`, `PolicySimulationPage`, `PackagesPage`, `PatientPackageLedgerPage`,
|
||||
`CourseProtocolsPage`, `CancellationPolicyPage`, `ResourceUtilizationPage` (+ تستهایشان) و مسیرهایشان در `App.tsx`.
|
||||
|
||||
**قلابهایی که باید از کد باقیمانده کنده شوند** (اینجا کامپایل میشکند، پس ترتیب مهم است):
|
||||
|
||||
```
|
||||
src/Appointment/Plan/Service/AppointmentPlanBuilder.php → applyTimingPolicies() و applyResourcePolicies()
|
||||
src/Pricing/Service/PricingEngine.php → PricingPolicyEngine و PackageConsumptionService
|
||||
src/Appointment/Booking/Controller/BookingController.php → BookingPolicyGuard
|
||||
src/ClinicService/Service/ServiceSelectionValidator.php → SelectionPolicyEngine
|
||||
src/Appointment/Booking/Service/BookingService.php → PackageConsumptionService، CreditLedgerService، CourseSessionLinker
|
||||
src/Shared/Tenant/GlobalTables.php → ردیفهای همین دامنهها
|
||||
src/Shared/Command/BookingEngineSeeder.php → متدهای policies/packagesAndCourses/cancellationAndWaitlist
|
||||
```
|
||||
|
||||
migration جدا برای `DROP TABLE` جدولهای این دامنهها با `down()` واقعی.
|
||||
|
||||
**نحوه تست:** بعد از حذف: `ddev exec php bin/phpunit` کامل سبز ·
|
||||
`ddev exec php vendor/bin/phpstan analyse` روی همان baseline ۱۴ خطا ·
|
||||
`ddev exec npx tsc --noEmit` بدون خطا · `ddev exec php bin/console debug:router | grep -cE "policy|package|course|waitlist|cancellation"` → `0` ·
|
||||
`ddev exec php bin/console app:seed-scenarios --reset -n` بدون خطا.
|
||||
|
||||
### ۹. مستندات
|
||||
|
||||
- `docs/api/appointment.md`: فیلد `resource_uuid` در رزرو + پاسخ `resource`/`service_option`، و حذف
|
||||
بخشهای سیاست/پکیج/دوره/لغو.
|
||||
- `docs/api/clinic.md`: endpoint جدید `resource/{uuid}/services`.
|
||||
- `docs/architecture/`: سند مدل منبعمحور با همان چهار سطح زنجیره.
|
||||
- `docs/new_feture/taskes/`: چکلیست تسکهای ۹ تا ۱۴ با وضعیت «حذفشده به تصمیم مالک محصول» و تاریخ.
|
||||
- `TEST_USERS.md`: جدول «موتور نوبتدهی» باید سطرهای حذفشده را از دست بدهد.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **خط قرمز:** منطق نوبتدهی اسلاتی نباید تغییر کند. `ddev exec php bin/phpunit --group=slot-mode-frozen`
|
||||
باید در هر مرحله سبز بماند.
|
||||
- **چرا جدول `service_options` جدید نمیسازیم:** گزینه از قبل `ServiceItem` است و `ItemGroup`
|
||||
قواعد «حداقل یکی، حداکثر سهتا» را دارد. جدول سوم یعنی `PriceListItem`، `Tariff`،
|
||||
`appointment_service_items`، `SegmentTemplate` و کل مسیر `appointment-service-slots` باید دو نوع
|
||||
ورودی بشناسند — دو منبع حقیقت برای یک مفهوم.
|
||||
- **مدت و قیمت جدا حل میشوند.** منبعی که فقط مدت را override کرده نباید قیمتش هم از همان سطح بیاید.
|
||||
- **جفت محیط (`entity_type`,`entity_id`) از خود منبع مشتق شود**، نه از بدنهٔ درخواست — همان قاعدهای که
|
||||
`ClinicResource` و `ServiceItem` رعایت میکنند. `TenantSchemaCoverageTest` entity طبقهبندینشده را قرمز میکند.
|
||||
- **سازگاری داده:** `resource_id` و `service_option_item_id` روی نوبت nullable؛ فیلتر سرویس در
|
||||
`findEligible` فقط وقتی رابطهای ثبت شده باشد.
|
||||
- **cross-repo:** بعد از تغییر قرارداد، مصرف واقعی در `nobat724_front/services/response.js` و
|
||||
`nobat724_front/components/appointment/` دستی بررسی شود؛ سایت امروز فقط `doctor_uuid` میفرستد و
|
||||
با اختیاریشدنش نمیشکند، ولی برای رزرو دستگاه باید بهروز شود.
|
||||
- **ترتیب اجرا:** اول وظیفهٔ ۷ (حذف) یا اول ۱ تا ۶؟ حذف اول انجام شود — وگرنه resolver و
|
||||
`PlanBuilder` را دوبار مینویسی: یکبار با قلاب سیاست، یکبار بدون آن.
|
||||
@@ -0,0 +1,520 @@
|
||||
# آدیت امنیتی دلتایی — سطح حملهٔ ساختهشده بعد از ۲۰۲۶-۰۷-۱۹
|
||||
|
||||
## زمینه
|
||||
|
||||
آخرین آدیت امنیتی `clinicpro` در `docs/security/AUDIT-2026-07-19.md` ثبت شده. از آن تاریخ تا
|
||||
امروز (۲۰۲۶-۰۸-۰۷) روی این repo **۳۵۴ کامیت** زده شده و تمرکز غالب آنها دقیقاً روی سطحی است
|
||||
که آدیت قبلی ندیده بود:
|
||||
|
||||
- رجیستری واحد مجوزها (`PermissionCatalog`) و اندپوینت `GET /api/v1/permission-catalog`
|
||||
- بازنویسی گیتهای منشی و پزشکِ مهمانِ کلینیک
|
||||
- دامنهٔ کاملاً جدید `Treatment` — پروندهٔ درمان، پروتکل، اجرای جلسه توسط پرسنل
|
||||
- نقش/پنل `staff` با گاردِ متمرکز `StaffRouteGuardSubscriber`
|
||||
- `PatientRecordScopeResolver` برای محدودکردن دید پروندهها
|
||||
|
||||
آدیت قبلی خودش در بخش «محدودیت پوشش» نوشته بود که ماتریس authz ناقص مانده، چون فقط کاربر
|
||||
`admin` و `doctor` در DB بود. آن محدودیت حالا برطرفشدنی است.
|
||||
|
||||
این پرامپت **آدیت کامل از صفر نیست**. عمداً دلتایی است: یافتههای قبلی فقط regression میشوند،
|
||||
و بودجهٔ اصلی صرف کدی میشود که هرگز آدیت نشده.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**مشکل:** بزرگترین سطح حملهٔ فعلی پروژه — authorization چندنقشی و دامنهٔ `Treatment` — هیچوقت
|
||||
تست امنیتی نشده. یک رجیستری مجوز که در UI رندر میشود ولی در backend enforce نشود، یعنی هر
|
||||
منشی/پزشکِ مهمان میتواند با یک درخواست مستقیم به API از مجوزش فرار کند.
|
||||
|
||||
**هدف:** پیدا کردن و رفع آسیبپذیریهای این سطح، بهعلاوهٔ تأیید اینکه یافتههای آدیت قبلی
|
||||
برنگشتهاند.
|
||||
|
||||
**سیاست رفع (تصمیم کاربر):**
|
||||
|
||||
- 🟥 Critical و 🟧 High: **همان جلسه خودکار رفع شود** + تست رگرسیون نوشته شود.
|
||||
- 🟨 Medium و پایینتر: **اول گزارش، بعد تأیید کاربر، بعد رفع.** بدون تأیید دست نزن.
|
||||
|
||||
**خارج از محدوده (تصمیم کاربر):** مهاجرت CKEditor از `@ckeditor/ckeditor5-build-classic` به
|
||||
پکیج umbrella `ckeditor5` v45+. فقط بهعنوان «risk پذیرفتهشده» در گزارش ثبت شود، پیاده نشود.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ **موفق:** گزارش `docs/security/AUDIT-2026-08-07.md` تولید شده و برای **هر** یافته یک بازتولید
|
||||
اجراشده دارد (دستور + خروجی واقعی). هر ردیف `PermissionCatalog::RESOURCES` یک تست دارد که
|
||||
ثابت میکند خاموشبودن آن مجوز، درخواستِ متناظر API را با **403** رد میکند — نه اینکه فقط
|
||||
دکمه را در UI پنهان کند.
|
||||
- ❌ **خطا:** درخواست به هر اندپوینت `/api/v1/treatment-*` با توکن کاربری از tenant دیگر →
|
||||
**403 یا 404** با envelope خطای `BaseController` و کد از `ErrorCodes`؛ هرگز 200 با دادهٔ
|
||||
tenant دیگر و هرگز 500 با stack trace.
|
||||
- ⚠️ **مرزی:** کاربر چندنقشی (مثلاً هم `ROLE_STAFF` هم `ROLE_SECRETARY`) پس از
|
||||
`POST /api/v1/auth/switch-context` دقیقاً دسترسی همان context فعال را دارد، نه اجتماع دو
|
||||
نقش. همچنین کاربری که **فقط** `ROLE_STAFF` است، روی هر مسیر خارج از allowlist ــ از جمله
|
||||
مسیرهایی که بعد از نوشتن گارد اضافه شدهاند ــ 403 میگیرد.
|
||||
- ✅ تستها سبزند: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
|
||||
- ✅ اگر رفتار یا قرارداد هر endpoint عوض شد، فایل متناظر در `docs/api/` همان جلسه بهروز شد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `../.claude/skills/symfony-security-audit/driver.mjs` | درایور آدیت؛ **در روت workspace است، نه در `clinicpro/`** |
|
||||
| `.claude/skills/qa-clinicpro/driver.mjs` | ساخت/بررسی اکانت نقشها (`roles`) |
|
||||
| `docs/security/AUDIT-2026-07-19.md` | یافتههای قبلی برای regression |
|
||||
| `docs/security-audit.md` | آدیت ۲۰۲۶-۰۶-۰۹ (قدیمیتر) |
|
||||
| `src/Shared/Security/PermissionCatalog.php` | منبع واحد منابع/اکشنهای مجوزدهی |
|
||||
| `src/Shared/Controller/PermissionCatalogController.php` | `GET /api/v1/permission-catalog` |
|
||||
| `src/Secretary/Security/SecretaryPermissionChecker.php` | enforce مجوز منشی |
|
||||
| `src/Secretary/Security/SecretaryAccessChecker.php` | حل tenant منشی |
|
||||
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | enforce مجوز پزشکِ مهمان |
|
||||
| `src/Clinic/Security/ClinicDoctorAccessChecker.php` | حل tenant پزشکِ مهمان |
|
||||
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | allowlist مسیرهای پرسنل |
|
||||
| `src/Staff/Security/StaffPermissions.php` | مجوزهای پرسنل |
|
||||
| `src/Patient/Security/PatientRecordScopeResolver.php` | محدودسازی دید پروندهٔ بیمار |
|
||||
| `src/Treatment/Controller/TreatmentCaseController.php` | پروندهٔ درمان |
|
||||
| `src/Treatment/Controller/TreatmentProtocolController.php` | پروتکل سرویس |
|
||||
| `src/Treatment/Controller/SessionExecutionController.php` | اجرای جلسه توسط پرسنل |
|
||||
| `src/Resource/Controller/ResourcePermissionTrait.php` | گیت منابع/دستگاهها |
|
||||
| `src/Shared/Tenant/TenantFilter.php` | فیلتر Doctrine جداسازی محیط |
|
||||
| `src/Shared/Tenant/TenantOwnershipChecker.php` | بررسی مالکیت محیط |
|
||||
| `src/Shared/Tenant/GlobalTables.php` | entityهای عمداً غیر-tenant |
|
||||
| `config/packages/security.yaml` | firewall و مسیرهای public |
|
||||
| `assets/admin/` | پنل ادمین؛ مجوزهای UI |
|
||||
| `docs/architecture/tenancy.md` | «چه تضمین میدهد و چه نمیدهد» |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### ۱. عدم تقارن گیت در دامنهٔ Treatment — قویترین lead
|
||||
|
||||
`TreatmentCaseController` کلاسش فقط با احراز هویت گارد شده و مجوز را متدبهمتد میسنجد:
|
||||
|
||||
```php
|
||||
// src/Treatment/Controller/TreatmentCaseController.php:31
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentCaseController extends BaseController
|
||||
{
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'view');
|
||||
|
||||
$case = $this->requireCase($user, $uuid);
|
||||
// ...
|
||||
}
|
||||
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ولی `TreatmentProtocolController` **هیچ** `denyUnlessGranted` ندارد — نه روی خواندن، نه روی
|
||||
`PUT`، نه روی `DELETE`:
|
||||
|
||||
```php
|
||||
// src/Treatment/Controller/TreatmentProtocolController.php:24
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentProtocolController extends BaseController
|
||||
{
|
||||
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', methods: ['PUT'])]
|
||||
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data);
|
||||
|
||||
return $this->success($protocol->toArray());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
یعنی تنها دفاع، `requireItem($user, $uuid)` است. اگر آن فقط مالکیت tenant را بسنجد و نه مجوز
|
||||
اکشن، هر کاربرِ داخل همان کلینیک — از جمله منشیای که مجوز `services` ندارد — میتواند پروتکل
|
||||
درمان یک سرویس را بازنویسی یا حذف کند. **باید بازتولید شود، نه فرض.**
|
||||
|
||||
### ۲. گاردِ پرسنل مبتنی بر allowlist مسیر
|
||||
|
||||
```php
|
||||
// src/Staff/Security/StaffRouteGuardSubscriber.php
|
||||
private const ALLOWED_PREFIXES = [
|
||||
'/api/v1/dashboard/staff',
|
||||
'/api/v1/auth/switch-context',
|
||||
'/api/v1/user/change-password',
|
||||
];
|
||||
|
||||
private const OVERRIDING_ROLES = [
|
||||
'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION',
|
||||
];
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// ...
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (!str_starts_with($path, '/api/')) {
|
||||
return;
|
||||
}
|
||||
// ...
|
||||
foreach (self::ALLOWED_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403);
|
||||
}
|
||||
```
|
||||
|
||||
دو ریسک ساختاری که باید تست شوند:
|
||||
|
||||
- گارد روی `getPathInfo()` و `str_starts_with` کار میکند. مسیر نرمالنشده
|
||||
(`/api/v1/../v1/patients`، دابلاسلش، درصد-انکود) ممکن است هم از شرط `/api/` رد شود هم از
|
||||
allowlist بیفتد یا برعکس، از روتر عبور کند ولی از گارد نه.
|
||||
- `OVERRIDING_ROLES` گارد را برای کاربر چندنقشی **کاملاً** کنار میگذارد. اگر کاربری هم پرسنل
|
||||
و هم منشی باشد و context فعالش پرسنل باشد، این گارد اجرا نمیشود.
|
||||
|
||||
### ۳. یافتههای باز از ۲۰۲۶-۰۷-۱۹
|
||||
|
||||
| # | یافته | شدت | وضعیت ثبتشده |
|
||||
|---|-------|-----|----------------|
|
||||
| 2 | `lodash` code injection via `_.template` | 🟧 High | نیمهرفع |
|
||||
| 2b | ۶۱ moderate در CKEditor build-classic (deprecated) | 🟨 Medium | باز — خارج از محدودهٔ این پرامپت |
|
||||
| 5 | پسورد sandbox درگاه ملت هاردکد در `MellatGateway.php:23` | ⬜ Info | باز |
|
||||
|
||||
### ۴. وضعیت اکانتهای تست
|
||||
|
||||
`TEST_USERS.md` بیاعتبار است و `create_test_users.php` که به آن ارجاع میدهد در repo نیست.
|
||||
|
||||
**وضعیت واقعی DB لوکال، سنجیدهشده در ۲۰۲۶-۰۸-۰۷** — نه از حافظه، خروجی
|
||||
`driver.mjs roles` و `ddev mysql`:
|
||||
|
||||
```
|
||||
admin 09120671756 ROLE_USER,ROLE_ADMIN ✓ login موفق
|
||||
representation 09124000001 ROLE_USER,ROLE_REPRESENTATION ✓ login موفق
|
||||
doctor 09390039833 ROLE_USER,ROLE_CLINIC ⚠ نقشش عوض شده — دیگر DOCTOR نیست
|
||||
clinic 09127000000 — ✗ کاربر در DB نیست
|
||||
secretary 09123456778 — ✗ کاربر در DB نیست
|
||||
```
|
||||
|
||||
DB از زمان آدیت قبلی دوباره seed شده. کاربران قابل استفاده که واقعاً وجود دارند:
|
||||
|
||||
```
|
||||
id=47 09128726723 ROLE_USER,ROLE_STAFF ← کاربرِ «فقط پرسنل»، موجود است
|
||||
id=11 0912000201 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC ← کاربر چندنقشی، موجود است
|
||||
id=24 0912000301 ROLE_USER,ROLE_CLINIC
|
||||
id=5 0912000109 ROLE_USER,ROLE_SECRETARY
|
||||
id=15 0912000209 ROLE_USER,ROLE_SECRETARY
|
||||
id=28 0912000309 ROLE_USER,ROLE_SECRETARY
|
||||
```
|
||||
|
||||
پس کاربر پرسنل و کاربر چندنقشی **ساخته نمیشوند** — فقط پسوردشان باید معلوم/ست شود.
|
||||
پسورد سری `0912000xxx` نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش.
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. آمادهسازی ماتریس نقشها
|
||||
|
||||
اول وضعیت واقعی اکانتها را بسنج، بعد کمبود را بساز:
|
||||
|
||||
```bash
|
||||
ddev start
|
||||
node .claude/skills/qa-clinicpro/driver.mjs roles
|
||||
```
|
||||
|
||||
کاربر «فقط پرسنل» (`09128726723`) و کاربر چندنقشی (`0912000201`) از قبل در DB هستند — ساخته
|
||||
نمیشوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console security:hash-password 'QaTest@1234'
|
||||
ddev mysql -e "UPDATE users SET password_hash='<hash>' WHERE mobile_number='09128726723';"
|
||||
```
|
||||
|
||||
این تغییر فقط روی DB لوکال است و در گزارش ثبت میشود.
|
||||
|
||||
علاوه بر آن، برای تست IDOR لازم است:
|
||||
|
||||
- **tenant دوم** (کلینیک B) با حداقل یک پروندهٔ درمان، یک سرویس با پروتکل، و یک بیمار.
|
||||
اول بگرد ببین از قبل هست؛ فقط اگر نبود بساز.
|
||||
|
||||
خروجی این وظیفه یک جدول creds در ابتدای گزارش است.
|
||||
|
||||
**نحوه تست:** برای هر کاربر ساختهشده، `POST /api/v1/auth/login` باید 200 و توکن بدهد؛ در
|
||||
گزارش، `roles` هر توکن decodeشده ثبت شود.
|
||||
|
||||
---
|
||||
|
||||
### ۲. اجرای درایور بهعنوان baseline
|
||||
|
||||
**با درایور شروع کن، نه با grep دستی.** اسکیل `symfony-security-audit` را طبق
|
||||
`.claude/skills/symfony-security-audit/SKILL.md` اجرا کن (white-box: deps/sinks/guards/secrets/config —
|
||||
black-box: authz/headers/cors/injection).
|
||||
|
||||
خروجی خام درایور **یافته نیست، lead است**. هر lead باید با خواندن کد تأیید یا رد شود، و
|
||||
leadهای ردشده با دلیل در بخش «رد شد» گزارش بیایند — دقیقاً همان قانونی که آدیت ۲۰۲۶-۰۷-۱۹
|
||||
رعایت کرده بود.
|
||||
|
||||
**نحوه تست:** خروجی درایور با تعداد lead به تفکیک شدت در گزارش ثبت شود.
|
||||
|
||||
---
|
||||
|
||||
### ۳. regression یافتههای آدیت قبلی
|
||||
|
||||
برای هر یافتهٔ `docs/security/AUDIT-2026-07-19.md` که «رفع شد» علامت خورده، بازتولیدِ همان
|
||||
گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است:
|
||||
|
||||
```bash
|
||||
# CSP روی SPA ادمین
|
||||
curl -sI https://clinic-pro.ddev.site/admin | grep -i content-security-policy
|
||||
|
||||
# APP_SECRET در فایل env تحت git
|
||||
git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET='
|
||||
|
||||
# فلگهای session cookie
|
||||
ddev exec php bin/console debug:config framework session
|
||||
|
||||
# وابستگیهای npm
|
||||
npm audit --json | python3 -c "import json,sys; m=json.load(sys.stdin)['metadata']['vulnerabilities']; print(m)"
|
||||
```
|
||||
|
||||
هر کدام برگشته بود، **رگرسیون** است و شدتش یک درجه بالاتر ثبت میشود — چون قبلاً رفع شده بوده
|
||||
و دوباره شکسته.
|
||||
|
||||
**نحوه تست:** جدول «یافتهٔ قبلی / وضعیت امروز / خروجی بازتولید» در گزارش.
|
||||
|
||||
---
|
||||
|
||||
### ۴. ماتریس enforcement مجوزها — هستهٔ این آدیت
|
||||
|
||||
`PermissionCatalog::RESOURCES` منبع واحد است. برای **هر** جفت `(resource, action)` در آن، این
|
||||
سه سؤال جواب داده شود:
|
||||
|
||||
1. کدام اندپوینت(ها) این مجوز را نمایندگی میکنند؟
|
||||
2. آیا backend واقعاً enforce میکند، یا فقط UI دکمه را پنهان میکند؟
|
||||
3. آیا منشی و پزشکِ مهمان **هر دو** enforce میشوند، یا فقط یکی؟
|
||||
|
||||
روش: با اکانت منشی، مجوز X را در DB خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن.
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile":"09123456778","password":"QaTest@1234"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["token"])')
|
||||
|
||||
# مجوز treatment.update خاموش است → باید 403 بدهد
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
|
||||
https://clinic-pro.ddev.site/api/v1/treatment-case/<uuid> \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"status":"closed"}'
|
||||
```
|
||||
|
||||
هر جفتی که **200** بدهد یک یافتهٔ 🟧 High است (bypass مجوز)، مگر اینکه با خواندن کد ثابت شود
|
||||
عمداً باز است — که آنوقت باید در `docs/api/` مستند باشد.
|
||||
|
||||
خروجی: جدول کامل `resource × action × role × HTTP status` در گزارش.
|
||||
|
||||
**نحوه تست:** خودِ جدول تست است. هر ردیف باید دستور و کد وضعیت واقعی داشته باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۵. آدیت دامنهٔ Treatment — IDOR و عدم تقارن گیت
|
||||
|
||||
سه کنترلر `src/Treatment/Controller/*` کامل خوانده شوند. مشخصاً:
|
||||
|
||||
الف) **`TreatmentProtocolController` بدون `denyUnlessGranted`** (بخش «وضعیت فعلی ۱»). بررسی کن
|
||||
`requireItem()` دقیقاً چه میسنجد — فقط tenant، یا مجوز اکشن هم؟
|
||||
|
||||
```bash
|
||||
# با توکن منشیِ بدون مجوز services
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
|
||||
https://clinic-pro.ddev.site/api/v1/service-item/<uuid>/treatment-protocol \
|
||||
-H "Authorization: Bearer $SECRETARY_TOKEN"
|
||||
# انتظار: 403 — اگر 200/204 داد، یافتهٔ High
|
||||
```
|
||||
|
||||
ب) **IDOR بین tenant.** با توکن کلینیک A، uuid منابع کلینیک B را صدا بزن. برای هر مسیر:
|
||||
|
||||
```
|
||||
GET /api/v1/treatment-case/{uuid}
|
||||
PATCH /api/v1/treatment-case/{uuid}
|
||||
GET /api/v1/treatment-case/{uuid}/plan
|
||||
GET /api/v1/treatment-session/{uuid}
|
||||
GET /api/v1/treatment-session/{uuid}/slot-suggestions
|
||||
GET /api/v1/service-item/{uuid}/treatment-protocol
|
||||
PUT /api/v1/service-item/{uuid}/treatment-protocol
|
||||
DELETE /api/v1/service-item/{uuid}/treatment-protocol
|
||||
POST /api/v1/dashboard/staff/treatment-session/{uuid}/start
|
||||
POST /api/v1/dashboard/staff/treatment-session/{uuid}/finish
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/start
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/complete
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/skip
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/reopen
|
||||
```
|
||||
|
||||
انتظار: 403 یا 404. هر 200 یا 500 یافته است.
|
||||
|
||||
ج) **پرسنلِ کلینیک A روی جلسهٔ کلینیک B.** `SessionExecutionController` با `ROLE_STAFF` گارد
|
||||
شده، ولی نقش ≠ مالکیت. تست کن که `start`/`finish` روی جلسهٔ tenant دیگر رد میشود.
|
||||
|
||||
د) **دستکاری وضعیت.** `finish` روی جلسهای که `start` نشده، `reopen` روی ناحیهٔ جلسهٔ بسته،
|
||||
`complete` دو بار پشت سر هم. اگر منجر به وضعیت ناسازگار یا 500 شود، ثبت شود.
|
||||
|
||||
**نحوه تست:** هر خط بالا با `curl` و کد وضعیت واقعی. برای هر یافتهٔ تأییدشده، یک تست PHPUnit
|
||||
در `tests/` که آن مسیر را با کاربر بیگانه میزند و 403 انتظار دارد.
|
||||
|
||||
---
|
||||
|
||||
### ۶. آدیت فرار از tenant
|
||||
|
||||
`docs/architecture/tenancy.md` صریح میگوید `TenantFilter` یک تور ایمنی است، نه authorization،
|
||||
و روی سه چیز اعمال **نمیشود**: SQL خام، `getReference()`، و فرزندان aggregate.
|
||||
|
||||
```bash
|
||||
# SQL خام
|
||||
grep -rnE "createNativeQuery|->getConnection\(\)|executeQuery\(|executeStatement\(" src/ --include=*.php
|
||||
|
||||
# getReference
|
||||
grep -rn "getReference(" src/ --include=*.php
|
||||
|
||||
# entityهای بدون طبقهبندی tenant
|
||||
ddev exec php bin/phpunit --filter TenantSchemaCoverageTest
|
||||
```
|
||||
|
||||
هر hit را بخوان و جواب بده: ورودی کاربر مستقیم وارد کوئری میشود؟ tenant دستی چک شده؟
|
||||
|
||||
**نحوه تست:** `TenantSchemaCoverageTest` سبز باشد. برای هر SQL خامی که ورودی کاربر میگیرد،
|
||||
یک تست تزریق با payload واقعی (`' OR '1'='1`, `1; DROP`) و تأیید اینکه پارامتریسازی شده.
|
||||
|
||||
---
|
||||
|
||||
### ۷. آدیت گاردِ پرسنل
|
||||
|
||||
الف) **نرمالسازی مسیر** (بخش «وضعیت فعلی ۲»):
|
||||
|
||||
```bash
|
||||
for P in \
|
||||
'/api/v1/patients' \
|
||||
'/api//v1/patients' \
|
||||
'/api/v1/dashboard/staff/../../patients' \
|
||||
'/api/v1/%2e%2e/v1/patients' \
|
||||
'/API/v1/patients' ; do
|
||||
printf '%s -> ' "$P"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \
|
||||
"https://clinic-pro.ddev.site$P" -H "Authorization: Bearer $STAFF_TOKEN"
|
||||
done
|
||||
```
|
||||
|
||||
هر چیزی جز 403/404 روی این مسیرها یافتهٔ 🟧 High است.
|
||||
|
||||
ب) **کشف مسیرهای تازهای که گارد نمیبیند.** فهرست کامل روتها را بگیر و همه را با توکن پرسنل
|
||||
بزن:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console debug:router --format=json > /tmp/routes.json
|
||||
```
|
||||
|
||||
اندازهٔ واقعی (سنجیدهشده در ۲۰۲۶-۰۸-۰۷): **۴۹۳** روت زیر `/api/`، از این تعداد **۲۲۲** روت
|
||||
`GET` و **۱۲۸** روت `GET` بدون path parameter.
|
||||
|
||||
**سقف این وظیفه همان ۱۲۸ روتِ بدون پارامتر است** — چون روت پارامتردار به uuid معتبر نیاز دارد و
|
||||
404 آن با 403 قابل تفکیک نیست. روتهای پارامتردار در وظیفهٔ ۵ (IDOR) با uuid واقعی پوشش داده
|
||||
میشوند. اگر به هر دلیل کمتر از ۱۲۸ روت زده شد، تعداد و دلیلش در گزارش بیاید — سکوت ممنوع.
|
||||
|
||||
برای هر روت درخواست بزن و کد وضعیت را ثبت کن. هر 200 خارج از allowlist یافته است.
|
||||
|
||||
ج) **کاربر چندنقشی.** با کاربر `staff + secretary`: بعد از `switch-context` به پرسنل، آیا هنوز
|
||||
به مسیرهای منشی دسترسی دارد؟ اگر بله، تصمیم بگیر این طراحی است یا نشت — و در گزارش با دلیل
|
||||
بنویس. اگر نشت است، گارد باید به **context فعال** نگاه کند نه صرفاً به مجموعهٔ نقشها.
|
||||
|
||||
**نحوه تست:** جدول `مسیر → کد وضعیت` برای هر سه بخش.
|
||||
|
||||
---
|
||||
|
||||
### ۸. آدیت پنل ادمین
|
||||
|
||||
مجوزهای UI نباید تنها لایهٔ دفاع باشند. برای هر جایی که `assets/admin/` بر اساس مجوز چیزی را
|
||||
پنهان میکند، تأیید کن endpoint متناظر هم بسته است — نتیجهٔ وظیفهٔ ۴ همین را میدهد؛ اینجا فقط
|
||||
نگاشت UI به endpoint ثبت شود.
|
||||
|
||||
علاوه بر آن:
|
||||
|
||||
- توکن JWT در `localStorage['clinicpro-auth']` است. تأیید کن هیچ مسیر جدیدی HTML کاربرساخته را
|
||||
بدون sanitize رندر نمیکند (`dangerouslySetInnerHTML`).
|
||||
- CSP روی `/admin` هنوز فعال است (وظیفهٔ ۳) و صفحات جدید (treatment، staff، permissions) خطای
|
||||
CSP در کنسول نمیدهند.
|
||||
|
||||
```bash
|
||||
grep -rn "dangerouslySetInnerHTML" assets/admin/
|
||||
```
|
||||
|
||||
**نحوه تست:** لود هر صفحهٔ جدید پنل و ثبت خطاهای کنسول؛ اگر درایور `qa-clinicpro` این را
|
||||
میدهد، از همان استفاده کن.
|
||||
|
||||
---
|
||||
|
||||
### ۹. رفع
|
||||
|
||||
طبق سیاست تعیینشده:
|
||||
|
||||
- **Critical/High:** همان جلسه رفع + تست رگرسیون در `tests/`. هر رفع کوچک و جدا باشد، نه یک دیف
|
||||
بزرگ.
|
||||
- **Medium و پایینتر:** فهرست پیشنهاد با دیف پیشنهادی به کاربر نشان بده و **منتظر تأیید بمان**.
|
||||
|
||||
قواعد پروژه هنگام رفع:
|
||||
|
||||
- گیت جدید در همان لایهای که بقیه هستند — `denyUnlessGranted` در کنترلر یا checker موجود، نه
|
||||
یک مکانیزم موازی جدید.
|
||||
- منطق در Service، کوئری در Repository، کنترلر نازک. وابستگی با constructor injection.
|
||||
- خطا با `AppException(ErrorCodes::ERR_XXX, null, $status)` و پیام فارسی از
|
||||
`src/Shared/Constant/ErrorCodes.php`. کد جدید لازم شد، همانجا اضافه شود.
|
||||
- اگر Entity عوض شد: `doctrine:migrations:diff` سپس `migrate`.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
ddev exec php bin/phpunit
|
||||
ddev exec php vendor/bin/phpstan analyse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۱۰. گزارش
|
||||
|
||||
فایل `docs/security/AUDIT-2026-08-07.md` با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹:
|
||||
|
||||
1. خلاصهٔ وضعیت — جدول `# / یافته / شدت / وضعیت`
|
||||
2. جدول creds نقشها که آدیت با آن اجرا شد
|
||||
3. برای هر یافته: فایل و خط، ریسک، شرح، **بازتولید با خروجی واقعی**، رفع اعمالشده، علت انتخاب
|
||||
راهحل
|
||||
4. جدول کامل ماتریس مجوز (وظیفهٔ ۴)
|
||||
5. «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند
|
||||
6. «leadهایی که رد شدند» با دلیل
|
||||
7. «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا
|
||||
8. «risk پذیرفتهشده» — مهاجرت CKEditor، با ارجاع به تصمیم امروز
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **قانون گزارش:** هیچ یافتهای بدون بازتولیدِ اجراشده ثبت نمیشود. خروجی درایور lead است، نه
|
||||
یافته. این قانون از گزارش قبلی میآید و باید حفظ شود.
|
||||
- **دلتا یعنی دلتا.** آدیت کامل OWASP از صفر تکرار نشود. اگر یک ناحیه در ۲۰۲۶-۰۷-۱۹ سبز بود و
|
||||
کدش عوض نشده، فقط اشاره شود که regression شد؛ عمیق نشو.
|
||||
- **نقش ≠ مالکیت.** `#[IsGranted('ROLE_STAFF')]` فقط میگوید کاربر پرسنل است، نه اینکه این جلسه
|
||||
مال اوست. هر جا فقط نقش چک شده و مالکیت نه، یک lead است.
|
||||
- **`TenantFilter` را authorization فرض نکن.** روی SQL خام، `getReference()` و فرزندان aggregate
|
||||
اعمال نمیشود. `docs/architecture/tenancy.md` مرجع است.
|
||||
- **پیام خطا نشت ندهد.** روی منبع tenant دیگر، تفاوت پیام «یافت نشد» و «دسترسی ندارید» خودش
|
||||
یک enumeration oracle است. رفتار فعلی را ثبت کن؛ اگر ناسازگار بود، یکدست کن.
|
||||
- **رفع نباید رفتار مجاز را بشکند.** قبل از هر گیت جدید، مسیر مجازِ همان اندپوینت با نقش درست
|
||||
تست شود که هنوز 200 میدهد.
|
||||
- **`docs/api/`** — هر تغییر در قرارداد، وضعیت یا مجوز یک endpoint، همان جلسه در فایل متناظر
|
||||
ثبت شود. این قانون ایستادهٔ پروژه است.
|
||||
- **`nobat724_front`** کلاینت همین API است. اگر گیتی روی endpointی اضافه شد که سایت عمومی صرف
|
||||
میکند، در گزارش هشدار بده — build کلاینت خطا نمیدهد.
|
||||
- **بدون DoS.** آدیت روی محیط لوکال ddev اجرا میشود. تست rate limit با چند درخواست شمارشی، نه
|
||||
با سیل ترافیک.
|
||||
- **secret واقعی در گزارش ننویس.** مقدار را ماسک کن و فقط فایل و خط را بده.
|
||||
@@ -0,0 +1,270 @@
|
||||
# گِیتِ مجوز برای ServiceCatalogController
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend + پنل ادمین). cross-repo نیست — بررسی شد، هیچکدام از دو کلاینت
|
||||
دیگر این اندپوینتها را مصرف نمیکنند.
|
||||
|
||||
## زمینه
|
||||
|
||||
بعد از یکیسازیِ مجوزها در `PermissionCatalog`، یک عبورِ سیستماتیک روی همهٔ routeها با
|
||||
منشیِ واقعی و **همهٔ مجوزها خاموش** انجام شد. ۱۰ منبع از ۱۰ منبع درست ۴۰۳ دادند، ولی
|
||||
`GET /api/v1/service-categories/tree` با همهٔ مجوزها خاموش هم `200` برگرداند.
|
||||
|
||||
علتش این است که `ServiceCatalogController` روی کلاسش فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]`
|
||||
دارد و **هیچکدام از ۱۵ routeاش** گِیت مجوز ندارند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
هر کاربرِ لاگینکردهای که محیطِ فعال دارد — از جمله منشی با `services` کاملاً خاموش —
|
||||
میتواند در **محیط خودش** دستهٔ سرویس بسازد، ویرایش و حذف کند، گروه بسازد، اقلام گروه
|
||||
را جایگزین کند و روابط و overrideهای شعبهای سرویس را بازنویسی کند.
|
||||
|
||||
### دامنهٔ دقیق ریسک — این IDOR نیست
|
||||
|
||||
مالکیتِ محیط **کاملاً enforce شده** است. هر route از `requireCategory` / `requireGroup` /
|
||||
`requireItem` رد میشود و همه به `owned()` میرسند:
|
||||
|
||||
```php
|
||||
private function owned(User $user, ?object $entity, string $message): object
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
```
|
||||
|
||||
پس دادهٔ هیچ محیطی به محیط دیگر نشت نمیکند. مسئله **بالا رفتن سطح دسترسی داخل همان
|
||||
محیط** است: منشیای که مالک صریحاً `services` را برایش خاموش کرده، همچنان کاتالوگ سرویس
|
||||
همان کلینیک را مینویسد. شدت: **متوسط**، نه بحرانی.
|
||||
|
||||
### ⚠ دلیلی که قبلاً برای دستنزدن گفته شد، غلط بود
|
||||
|
||||
در گزارش قبلی نوشته شد «endpointهایش در جریانِ ثبت نوبت مصرف میشوند و بستنشان
|
||||
نوبتدهی منشی را میشکند». این ادعا **بررسی نشده بود و رد شد**:
|
||||
|
||||
۱. `service-item/{uuid}/groups`، `item-group/*`، `service-item/{uuid}/relations` و
|
||||
`service-selection/validate` در **هیچکدام** از سه کلاینت مصرفکننده ندارند. تنها
|
||||
ارجاعشان hookهای بدون مصرفکننده در `assets/admin/hooks/useServiceCatalog.ts` است
|
||||
(`useServiceGroups`، `useServiceRelations`، `useSelectionPreview` — هیچ کامپوننتی
|
||||
صداشان نمیزند). در `nobat724_front` و `clinic-pro-tauri` هم صفر ارجاع.
|
||||
|
||||
۲. مهمتر: کنترلرِ خواهرِ همین دامنه، `ClinicServiceController`، **همین حالا** هر
|
||||
خواندنِ سرویس را پشت `services.view` بسته است — از جمله
|
||||
`GET /api/v1/service-items/{sectionUuid}` که مودالِ ثبت نوبت از آن سرویس میخواند.
|
||||
یعنی هر جریانی که به سرویس نیاز دارد **از قبل** به `services.view` نیاز دارد.
|
||||
بستنِ کاتالوگ چیز تازهای نمیشکند.
|
||||
|
||||
پس مسیر درست همان گِیتِ ساده است، نه استثنا و نه fallback.
|
||||
|
||||
### تنها مصرفکنندگانِ واقعی
|
||||
|
||||
| hook | صفحه/کامپوننت | route |
|
||||
|---|---|---|
|
||||
| `useCatalogCategories` | `CatalogCategoriesPage` (`/admin/service-categories`) | tree، create/update/delete دسته |
|
||||
| `useCategoryIncludes` | `CatalogCategoriesPage` + `ServiceCategoryTab` | includes: list/add/remove |
|
||||
| `useCatalogCategories` | `ServiceCategoryTab` داخل `ServiceDetailPage` | tree |
|
||||
|
||||
هر دو صفحه از قبل پشت `permission={['services','view']}` در `App.tsx` هستند. یعنی
|
||||
گِیتِ فرانت هست و فقط گِیتِ سمت API غایب است.
|
||||
|
||||
---
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: منشی با `services.view = true` → `GET /api/v1/service-categories/tree` جواب
|
||||
`200` و همان درخت قبلی. صفحهٔ `/admin/service-categories` مثل امروز کار میکند.
|
||||
- ✅ موفق: منشی با `services.update = true` → `PATCH /api/v1/service-category/{uuid}`
|
||||
جواب `200`.
|
||||
- ❌ خطا: منشی با `services` کاملاً خاموش → **هر ۱۵ route** جواب `403` با
|
||||
`ERR_FORBIDDEN_001`. امروز `tree` جواب `200` میدهد؛ همین تفاوتْ تستِ اصلی است.
|
||||
- ❌ خطا: منشی با `services.view = true` ولی `services.create = false` →
|
||||
`POST /api/v1/service-category` جواب `403`، در حالی که `tree` همچنان `200`.
|
||||
- ⚠️ مرزی: پزشکِ عضو کلینیک با `services.update = false` → نوشتنها `403`، خواندنها
|
||||
`200` (پیشفرضِ نقشش `view: true` است).
|
||||
- ⚠️ مرزی: مالکِ کلینیک، پزشکِ مطبِ شخصی و ادمین بدون تغییر عبور میکنند —
|
||||
`denyUnlessGranted` فقط نقشِ خودش را محدود میکند.
|
||||
- ⚠️ مرزی: مودالِ ثبت نوبت برای منشیِ دارای `services.view` نباید عوض شود. این را
|
||||
واقعاً باز کن و ببین، نه از روی کد حدس بزن.
|
||||
|
||||
---
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/ClinicService/Controller/ServiceCatalogController.php` | ۱۵ routeِ بدون گِیت |
|
||||
| `src/ClinicService/Controller/ClinicServiceController.php` | **الگوی مرجع** — `denyServices()` |
|
||||
| `assets/admin/pages/CatalogCategoriesPage.tsx` | `canUpdate` واحد برای هر سه دکمه |
|
||||
| `assets/admin/components/ServiceCategoryTab.tsx` | prop به نام `canEdit` |
|
||||
| `assets/admin/pages/ServiceDetailPage.tsx` | `canEdit={canUpdate}` را پاس میدهد |
|
||||
| `docs/api/clinic-services.md` | سند اندپوینتها |
|
||||
| `docs/api/secretary.md` | جدول enforcement — سطر `services` |
|
||||
|
||||
---
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### کلاس بدون هیچ گِیتی جز احراز هویت
|
||||
|
||||
```php
|
||||
#[OA\Tag(name: 'Treatment')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ServiceCatalogController extends BaseController
|
||||
```
|
||||
|
||||
### نمونهٔ نوشتنِ باز — ساخت دسته
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/service-category', name: 'service_category_create', methods: ['POST'])]
|
||||
public function createCategory(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : '';
|
||||
// ← هیچ denyUnlessGrantedای اینجا نیست
|
||||
```
|
||||
|
||||
### الگوی مرجع در کنترلرِ خواهر — همین دامنه
|
||||
|
||||
```php
|
||||
/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود میکند). */
|
||||
private function denyServices(User $user, string $action): void
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
|
||||
}
|
||||
```
|
||||
|
||||
و در عمل per-action صدا زده میشود: ۵ بار `view`، ۲ بار `create`، ۲ بار `update`.
|
||||
|
||||
### فرانت — یک توگل برای هر سه عمل
|
||||
|
||||
```tsx
|
||||
// CatalogCategoriesPage.tsx:33
|
||||
const canUpdate = can('services', 'update');
|
||||
// همین یکی هم دکمهٔ «افزودن» را کنترل میکند، هم ویرایش، هم حذف
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. گِیتِ per-action روی هر ۱۵ route
|
||||
|
||||
`denyServices()` را عیناً مثل `ClinicServiceController` به `ServiceCatalogController`
|
||||
اضافه کن (همان دو checker از constructor تزریق میشوند) و اولین خط هر action صدایش بزن.
|
||||
|
||||
نگاشت — هر ۱۵ تا، بدون استثنا:
|
||||
|
||||
| Action | Route | مجوز |
|
||||
|---|---|---|
|
||||
| `tree` | `GET /service-categories/tree` | `view` |
|
||||
| `listIncludes` | `GET /service-category/{uuid}/includes` | `view` |
|
||||
| `listGroups` | `GET /service-item/{uuid}/groups` | `view` |
|
||||
| `validateSelection` | `POST /service-selection/validate` | `view` |
|
||||
| `createCategory` | `POST /service-category` | `create` |
|
||||
| `addInclude` | `POST /service-category/{uuid}/includes` | `create` |
|
||||
| `createGroup` | `POST /service-item/{uuid}/groups` | `create` |
|
||||
| `updateCategory` | `PATCH /service-category/{uuid}` | `update` |
|
||||
| `updateGroup` | `PATCH /item-group/{uuid}` | `update` |
|
||||
| `replaceGroupItems` | `PUT /item-group/{uuid}/items` | `update` |
|
||||
| `replaceRelations` | `PUT /service-item/{uuid}/relations` | `update` |
|
||||
| `replaceBranchOverrides` | `PUT /service-item/{uuid}/branch-overrides` | `update` |
|
||||
| `deleteCategory` | `DELETE /service-category/{uuid}` | `delete` |
|
||||
| `removeInclude` | `DELETE /service-category/{uuid}/includes/{childUuid}` | `delete` |
|
||||
| `deleteGroup` | `DELETE /item-group/{uuid}` | `delete` |
|
||||
|
||||
`validateSelection` عمداً `view` است نه `create`: چیزی نمیسازد و فقط یک انتخاب را
|
||||
اعتبارسنجی میکند؛ POST بودنش بهخاطر حجمِ بدنه است، نه اثرِ جانبی.
|
||||
|
||||
**گِیت قبل از هر کار دیگری بیاید** — قبل از `json_decode`، قبل از `requireCategory`.
|
||||
وگرنه ترتیبِ خطاها ۴۰۴/۴۲۲ را جای ۴۰۳ برمیگرداند و همان oracleای میشود که گِیت
|
||||
قرار بود ببندد.
|
||||
|
||||
**نحوه تست:** منشیِ تست `0912000209` / `QaTest@1234`، محیط کلینیک
|
||||
`c3f1feac-4f56-45da-a3c4-b2182f4d6902`.
|
||||
|
||||
```bash
|
||||
TOK=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"0912000209","password":"QaTest@1234"}' \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||
|
||||
curl -sk -X POST https://clinic-pro.ddev.site/api/v1/auth/switch-context \
|
||||
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
|
||||
-d '{"db_uuid":"c3f1feac-4f56-45da-a3c4-b2182f4d6902","db_type":"clinic"}'
|
||||
|
||||
curl -sk -o /dev/null -w "%{http_code}\n" \
|
||||
https://clinic-pro.ddev.site/api/v1/service-categories/tree -H "Authorization: Bearer $TOK"
|
||||
```
|
||||
|
||||
> ⚠ **قبل از دستزدن به مجوزها backup بگیر و در پایان بایتبهبایت برگردان:**
|
||||
> ```bash
|
||||
> ddev mysql -N -e "SELECT id, permission FROM doctor_secretaries WHERE secretary_id=15;" > /tmp/bk.tsv
|
||||
> ```
|
||||
> و **توجه:** `JSON_SET` روی مسیرِ تودرتویی که والدش وجود ندارد بیصدا کاری نمیکند.
|
||||
> برای خاموشکردن حتماً کلِ آبجکت را بنویس:
|
||||
> ```sql
|
||||
> JSON_SET(permission,'$.resources.services',
|
||||
> JSON_OBJECT('view',false,'create',false,'update',false,'delete',false))
|
||||
> ```
|
||||
> و یادت باشد **حذفِ کلید یعنی «پیشفرضِ نقش را بگیر»، نه «ممنوع»** — چون
|
||||
> `getPermissions()` با رجیستری merge میکند.
|
||||
|
||||
### ۲. همترازیِ فرانت با گِیتِ per-action
|
||||
|
||||
الان یک `canUpdate` هر سه دکمه را کنترل میکند. با گِیتِ per-action، منشیِ دارای
|
||||
`update` ولی بدون `create` دکمهٔ «افزودن» را میبیند و ۴۰۳ میگیرد.
|
||||
|
||||
در `CatalogCategoriesPage.tsx` و `ServiceCategoryTab.tsx`:
|
||||
|
||||
```tsx
|
||||
const canCreate = can('services', 'create');
|
||||
const canUpdate = can('services', 'update');
|
||||
const canDelete = can('services', 'delete');
|
||||
```
|
||||
|
||||
و هر دکمه به مجوزِ خودش وصل شود. `ServiceCategoryTab` بهجای `canEdit: boolean` باید
|
||||
هر سه را بگیرد؛ `ServiceDetailPage` هم مقادیر درست را پاس بدهد.
|
||||
|
||||
**نحوه تست:** vitest برای `CatalogCategoriesPage` — با `can` که فقط `update` را `true`
|
||||
برمیگرداند، دکمهٔ افزودن نباید رندر شود ولی دکمهٔ ویرایش باید.
|
||||
|
||||
### ۳. تست backend
|
||||
|
||||
فایل تازه `tests/ClinicService/ServiceCatalogPermissionTest.php`، همسبک با
|
||||
`tests/Secretary/SecretaryResourceEnforcementTest.php`:
|
||||
|
||||
- منشی با `services` خاموش → هر ۱۵ route جواب `403`. **حلقه روی فهرست routeها بزن**،
|
||||
نه سهتا نمونه؛ همین تست است که جلوی routeِ تازهٔ بیگِیت را در آینده میگیرد.
|
||||
- `view` روشن → خواندنها `200`، نوشتنها همچنان `403`.
|
||||
- مالکِ کلینیک با همان مجوزهای خاموش → همهچیز `200`.
|
||||
|
||||
### ۴. مستندات
|
||||
|
||||
- `docs/api/clinic-services.md`: برای هر ۱۵ route ستون Permission اضافه شود.
|
||||
- `docs/api/secretary.md`: در جدول enforcement، سطر `services` باید
|
||||
`ServiceCatalogController` را هم نام ببرد و هشدارِ «گِیت ندارد» حذف شود.
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگو تازه نساز.** `denyServices()` عیناً از `ClinicServiceController` کپی شود؛ همان
|
||||
دامنه، همان دو checker، همان معنا. trait مشترک هم لازم نیست — دو متدِ چهارخطی در یک
|
||||
دامنه، abstraction نمیخواهد.
|
||||
- **مالکیتِ محیط از قبل درست است؛ دست نزن.** `owned()` و `requireItem()` کارشان را
|
||||
میکنند. این تسک فقط لایهٔ مجوز را اضافه میکند، نه لایهٔ tenant.
|
||||
- `denyUnlessGranted` برای نقشهای غیرمنشی/غیرعضو `true` است، پس مالک و ادمین خودبهخود
|
||||
عبور میکنند. «مالک هرگز نباید بتواند خودش را قفل کند» باید برقرار بماند.
|
||||
- **hookهای بیمصرف را در همین تسک حذف نکن.** `useServiceGroups`، `useServiceRelations`
|
||||
و `useSelectionPreview` مصرفکننده ندارند، ولی حذفشان کارِ این پرامپت نیست و ریسکِ
|
||||
بیدلیل اضافه میکند. فقط در گزارش ذکرشان کن.
|
||||
- بعد از تغییر، هر دو سوییت کامل سبز بمانند: `ddev exec php bin/phpunit` و
|
||||
`npx vitest run`. خط پایه: ۱۵۲۸ تست backend و ۷۹۵ تست frontend.
|
||||
- `AppointmentEditPage.serviceMode` زیر بار موازی flaky است؛ اگر افتاد تنها اجرایش کن
|
||||
و اگر سبز شد به این تغییر ربطی ندارد.
|
||||
@@ -0,0 +1,324 @@
|
||||
# پاکسازی نوبتدهی سرویسی: حذف شعبه، تعطیلات سراسری، تنظیمات منابع، تایملاین یکپارچه
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (بکاند Symfony + پنل ادمین React).
|
||||
|
||||
یک وظیفه **cross-repo** است و علامتگذاری شده: حذف شعبه به `nobat724_front` میرسد
|
||||
(`nobat724_front/services/response.js:165` اندپوینت `doctor-address/{id}` را صدا میزند).
|
||||
|
||||
## زمینه
|
||||
|
||||
مدل Resource-First پیاده شده است: منبع، سرویس، گزینهٔ سرویس، دستهٔ سراسری. حالا مالک
|
||||
محصول میخواهد لایههایی که در این مدل مصرفکننده ندارند برداشته شوند (شعبه/اتاق، گروههای
|
||||
انتخاب، تب بخشهای نوبت)، تعطیلات یک بار سراسری تعریف شود، و تنظیمات نوبتدهی منابع
|
||||
همشکل پزشکان شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
پنج تغییر مستقل، به همین ترتیب:
|
||||
|
||||
1. **حذف شعبه و اتاق** از محصول — بدون تغییر منطق نوبتدهی.
|
||||
2. **تعطیلات سراسری**: مدیر سیستم تعطیلات رسمی سال را ثبت کند؛ هر محیط بتواند
|
||||
غیرفعالشان کند؛ پزشک و منبع تعطیلی اختصاصی خودشان را داشته باشند.
|
||||
3. **تب منابع** در `/admin/settings/appointment-settings`، همشکل تب پزشک.
|
||||
4. **حذف تبهای «گروهها و آیتمها» و «بخشهای نوبت»** از صفحهٔ سرویس.
|
||||
5. **تایملاین یکپارچه**: پزشکانِ سرویسی و منابعِ قابلرزرو در یک نما، با ظرفیت و وقت آزاد.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ نقد پرامپت — قبل از شروع بخوان
|
||||
|
||||
خواستهٔ «همهچیز شعبه حذف شود، شامل `DoctorAddress`» با «منطق نوبتدهی بدون تغییر بماند»
|
||||
قابل جمع نیست. شواهد از خود کد و دیتابیس:
|
||||
|
||||
| شاهد | یعنی |
|
||||
|---|---|
|
||||
| `appointments.address_id` — **۷۵ از ۷۵ نوبت مقدار دارد** | آدرس، محلِ خودِ نوبت است نه یک بخش تنظیمات |
|
||||
| `nobat724_front/services/response.js:165` → `api/v1/clinic-pro/doctor-address/{id}` | سایت عمومی روی همین قرارداد رزرو میگیرد |
|
||||
| `ClinicResource::__construct()` → `assignTenantPair($address->tenantEntityType(), …)` | جفت محیط هر منبع از آدرس مشتق میشود |
|
||||
| `price_lists.address_id` · `resource_pools.address_id` · `service_branch_overrides.address_id` | سه زیرسیستم دیگر هم به آن گره خوردهاند |
|
||||
|
||||
پس **`DoctorAddress` در این پرامپت حذف نمیشود**؛ به یک لنگرِ نامرئی تنزل میکند: هیچ
|
||||
صفحه، منو یا مفهومی به کاربر نشان نمیدهد، ولی جدولش سر جایش میماند. آنچه واقعاً حذف
|
||||
میشود، دامنهٔ `Branch` است (اتاق، ساعت کاری شعبه، صفحهها، اندپوینتها).
|
||||
|
||||
حذف کامل `DoctorAddress` یک پرامپت جداست و بازنویسی جریان رزرو در **دو ریپو** را میخواهد؛
|
||||
قبل از شروع باید مالک محصول هزینهاش را ببیند. اگر پس از دیدن این ارقام باز هم حذف کامل
|
||||
خواسته شد، همانجا توقف کن و تکلیف را بپرس — با این پرامپت انجامش نده.
|
||||
|
||||
---
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
### قابلیت ۱ — حذف شعبه و اتاق
|
||||
|
||||
- ✅ موفق: `/admin/branches` و زیرصفحههایش ۴۰۴ میدهند، آیتم «شعبهها و اتاقها» از منوی
|
||||
تنظیمات رفته، و `ddev exec php bin/phpunit` کامل سبز است — یعنی جستجوی آزاد و رزرو
|
||||
دقیقاً همان نتایج قبلی را میدهد.
|
||||
- ❌ خطا: `GET /api/v1/branches` → **۴۰۴** (روت وجود ندارد)، نه ۵۰۰.
|
||||
- ⚠️ مرزی: منبعی که `subject_kind = 'room'` دارد باید همچنان کار کند — اتاق بهعنوان
|
||||
*منبع* میماند، فقط موجودیت `Room` میرود.
|
||||
|
||||
### قابلیت ۲ — تعطیلات سراسری
|
||||
|
||||
- ✅ موفق: با توکن `ROLE_ADMIN`، `POST /api/v1/admin/national-holidays` یک تعطیل میسازد و
|
||||
همان روز بلافاصله در `GET /api/v1/resource/{uuid}/availability` با دلیل
|
||||
`national_holiday` خالی برمیگردد.
|
||||
- ❌ خطا: همان `POST` با توکن پزشک → **۴۰۳**.
|
||||
- ⚠️ مرزی: محیطی که `TenantHolidayOverride(is_working = true)` دارد، همان روز **باز**
|
||||
است و ساعتش برمیگردد.
|
||||
|
||||
### قابلیت ۳ — تب منابع در تنظیمات نوبتدهی
|
||||
|
||||
- ✅ موفق: در `/admin/settings/appointment-settings` تب «منابع» ساعت کاری هفتگی، تاریخهای
|
||||
خاص و تعطیلات هر منبع را میدهد و ذخیرهاش در `GET /api/v1/resource/{uuid}/calendar`
|
||||
دیده میشود.
|
||||
- ❌ خطا: منشیِ بدون مجوز `appointment_settings.update` فیلدها را read-only میبیند و
|
||||
`PUT` سرور ۴۰۳ میدهد.
|
||||
- ⚠️ مرزی: کلینیکِ بدون هیچ منبعی، حالت خالی با لینک «تنظیمات ← منابع» نشان دهد، نه صفحهٔ سفید.
|
||||
|
||||
### قابلیت ۴ — حذف تبهای سرویس
|
||||
|
||||
- ✅ موفق: `/admin/service/{uuid}` پنج تب دارد (اطلاعات، تعرفهها، بیمهها، کالاها،
|
||||
دستهبندیها، لاگ) و هیچ ورودی به گروهها و بخشهای نوبت ندارد.
|
||||
- ❌ خطا: باز کردن مستقیم `?tab=segments` به تب اطلاعات برگردد، نه خطای رندر.
|
||||
- ⚠️ مرزی: سرویسی که همین حالا `SegmentTemplate` دارد باید **دقیقاً مثل قبل** رزرو شود —
|
||||
تستهای موجود `tests/Appointment` سبز بمانند.
|
||||
|
||||
### قابلیت ۵ — تایملاین یکپارچه
|
||||
|
||||
- ✅ موفق: نمای «زمانبندی» هم ردیف پزشکانِ سرویسی و هم ردیف منابعِ قابلرزرو را نشان دهد،
|
||||
با بازهٔ اشغال و وقت آزاد و ظرفیت هر ردیف.
|
||||
- ❌ خطا: روزی که هیچ ردیفی داده ندارد، پیام خالیِ صریح بدهد نه اسکلتِ همیشگی.
|
||||
- ⚠️ مرزی: منبعی با `capacity = 3` و دو نوبت همزمان، «۱ ظرفیت آزاد» نشان دهد نه «پر».
|
||||
|
||||
---
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Branch/` | کل دامنه: `BranchController`, `Room`, `BranchWorkingHours`, `RoomService`, `WorkingHoursService`, `BranchResolver` |
|
||||
| `src/Resource/Service/ResourceAvailabilityService.php` | تنها مصرفکنندهٔ `BranchWorkingHoursRepository` بیرون از `src/Branch` |
|
||||
| `src/Resource/Entity/ClinicResource.php` | `subject_kind='room'` و `ResourceLinker` به `Room` وصلاند |
|
||||
| `src/Resource/Controller/HolidayController.php` | `GET /national-holidays` + `POST/DELETE /holiday-overrides` — **POST برای national ندارد** |
|
||||
| `src/Resource/Entity/NationalHoliday.php` · `TenantHolidayOverride.php` | مدل تعطیلات، از قبل درست است |
|
||||
| `src/Appointment/Controller/AppointmentSettingsController.php` | تعطیلی اختصاصی پزشک (`Holiday`) |
|
||||
| `assets/admin/pages/HolidaysSettingsPage.tsx` | صفحهٔ `/admin/holidays` |
|
||||
| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | تببندی per پزشک با `.seg` |
|
||||
| `assets/admin/pages/ServiceDetailPage.tsx` | `TABS` — گروهها و بخشهای نوبت اینجاست |
|
||||
| `assets/admin/pages/AppointmentsPage.tsx` · `components/appointments/ResourceTimeline.tsx` · `TurnsTimeline.tsx` | سه نمای فعلی |
|
||||
| `assets/admin/components/resources/ResourceWorkingHoursPanel.tsx` · `ResourceExceptionsPanel.tsx` | پنلهای آمادهٔ منبع — در تب جدید همینها مصرف میشوند |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`ResourceAvailabilityService` ساعت واقعی منبع را از تقاطع با ساعت شعبه میسازد:
|
||||
|
||||
```php
|
||||
// src/Resource/Service/ResourceAvailabilityService.php
|
||||
private readonly BranchWorkingHoursRepository $branchHours,
|
||||
…
|
||||
$branchByDay = $this->branchHoursByDay($resource);
|
||||
…
|
||||
if ($branchByDay !== null) {
|
||||
$branchWindows = $branchByDay[$dayOfWeek] ?? [];
|
||||
if ($branchWindows === []) {
|
||||
// روز، بدون ساعت شعبه یعنی بسته
|
||||
```
|
||||
|
||||
`HolidayController` فقط خواندن تعطیلات ملی را دارد؛ هیچ مسیری برای ساختنشان نیست:
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/national-holidays', name: 'national_holidays_list', methods: ['GET'])]
|
||||
#[Route('/api/v1/holiday-overrides', name: 'holiday_override_create', methods: ['POST'])]
|
||||
#[Route('/api/v1/holiday-override/{uuid}', name: 'holiday_override_delete', methods: ['DELETE'])]
|
||||
```
|
||||
|
||||
`ServiceDetailPage` هفت تب دارد:
|
||||
|
||||
```tsx
|
||||
const TABS = [
|
||||
{ id: 'info', label: 'اطلاعات سرویس' },
|
||||
{ id: 'tariffs', label: 'تعرفهها' },
|
||||
{ id: 'insurance', label: 'بیمهها' },
|
||||
{ id: 'groups', label: 'گروهها و آیتمها' },
|
||||
{ id: 'segments', label: 'بخشهای نوبت' },
|
||||
{ id: 'categories',label: 'دستهبندیها' },
|
||||
{ id: 'goods', label: 'کالاهای مرتبط' },
|
||||
{ id: 'history', label: 'لاگ تغییرات' },
|
||||
] as const;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. حذف دامنهٔ شعبه و اتاق
|
||||
|
||||
**دامنهٔ حذف:** `src/Branch/` کامل، سه صفحهٔ پنل (`BranchesPage`, `BranchRoomsPage`,
|
||||
`BranchWorkingHoursPage`)، آیتم `branches` در `settingsMenu.ts`، و روتهایشان در `App.tsx`.
|
||||
|
||||
**دامنهٔ نگهداشتن:** `DoctorAddress` (لنگر محیط و محل نوبت — بالا را بخوان).
|
||||
|
||||
قبل از حذف، مهاجرت منابعِ نوع اتاق:
|
||||
|
||||
```php
|
||||
// ClinicResource.subject_kind === 'room' امروز به rooms.id اشاره میکند.
|
||||
// یک migration، آن ردیفها را به منبع بیsubject تبدیل میکند (نامشان میماند):
|
||||
UPDATE clinic_resources SET subject_kind = NULL, room_id = NULL WHERE subject_kind = 'room';
|
||||
```
|
||||
|
||||
سپس لایهٔ شعبه از موتور دسترسپذیری برداشته میشود. **این تنها جای منطق است که واقعاً
|
||||
تغییر میکند**، پس صریح بنویسش:
|
||||
|
||||
```php
|
||||
// ResourceAvailabilityService: تزریق BranchWorkingHoursRepository حذف، و
|
||||
// $branchByDay همهجا null میشود → لایهٔ «ساعت شعبه» از کسر بیرون میرود.
|
||||
// دلیل معماری: با حذف شعبه، تنها مرجع ساعت کاری، شیفت خودِ منبع است.
|
||||
```
|
||||
|
||||
دلیلِ `outside_branch_hours` و `branch_closed` و `branch_inactive` از
|
||||
`REASON_LABELS` فرانت هم برداشته شوند (`ResourceExceptionsPanel.tsx`).
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/phpunit # همه سبز — مخصوصاً tests/Appointment و tests/Resource
|
||||
ddev exec php bin/console debug:router | grep -c "branch\|room" # باید 0 باشد
|
||||
npx vitest run # خط پایه: ۱۰۰ فایل / ۶۶۰ تست
|
||||
```
|
||||
و یک رزرو واقعی از مسیر عمومی بگیر (`POST /api/v1/appointment-availability` سپس
|
||||
hold → confirm) تا ثابت شود همان اسلاتهای قبلی برمیگردند.
|
||||
|
||||
**cross-repo:** بعد از حذف، در `nobat724_front` دنبال `doctor-address` بگرد و گزارش بده
|
||||
کدام صفحهها مصرفش میکنند. اگر اندپوینت عمومی `clinic-pro/doctor-address/{id}` را دست
|
||||
نزدی (نباید بزنی)، سایت نمیشکند — همین را صریح در گزارش بنویس.
|
||||
|
||||
---
|
||||
|
||||
### ۲. تعطیلات سراسری، سه لایه
|
||||
|
||||
مدل از قبل درست است و ساخته نمیشود؛ فقط سه چیزِ کم اضافه میشود.
|
||||
|
||||
**الف) CRUD مدیر سیستم روی `NationalHoliday`:**
|
||||
|
||||
```php
|
||||
// src/Resource/Controller/HolidayController.php
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[Route('/api/v1/admin/national-holidays', methods: ['POST'])] // {jalali_date, title}
|
||||
#[Route('/api/v1/admin/national-holiday/{uuid}', methods: ['PATCH','DELETE'])]
|
||||
```
|
||||
|
||||
`jalali_date` ورودی است و `date` (نیمهشب تهران) و `jalali_year` از آن مشتق میشوند —
|
||||
مسئولیت تبدیل در یک Service بماند، نه Controller.
|
||||
|
||||
**ب) نمایش تعطیلات سراسری در تب تعطیلاتِ پزشک و منبع:** هر دو تب علاوه بر تعطیلی
|
||||
اختصاصی، فهرست تعطیلات ملی سال را **فقطخواندنی** با یک سوییچ «این روز باز هستیم» نشان
|
||||
دهند؛ سوییچ همان `POST /api/v1/holiday-overrides` موجود را صدا بزند.
|
||||
|
||||
**ج) `/admin/holidays`** جای مدیریت سراسری هر محیط بماند (همین حالا هست) و در توضیح
|
||||
صفحه بنویسد که اینها پیشفرضِ همهٔ پزشکان و منابعاند.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
# ✅ ادمین میسازد
|
||||
curl -X POST .../api/v1/admin/national-holidays -H "Authorization: Bearer $ADMIN" \
|
||||
-d '{"jalali_date":"1405-01-13","title":"سیزدهبدر"}'
|
||||
# ✅ همان روز در دسترسپذیری منبع خالی است، با دلیل national_holiday
|
||||
curl ".../api/v1/resource/$R/availability?from=…&to=…" -H "Authorization: Bearer $DOC"
|
||||
# ❌ پزشک نمیسازد → 403
|
||||
# ⚠️ بعد از POST /holiday-overrides با is_working=true همان روز باز میشود
|
||||
```
|
||||
تست PHPUnit: `tests/Resource/NationalHolidayEndpointTest.php` با هر سه سناریو.
|
||||
|
||||
---
|
||||
|
||||
### ۳. تب «منابع» در تنظیمات نوبتدهی کلینیک
|
||||
|
||||
در `ClinicAppointmentSettingsPage` یک سطح تب بالاتر اضافه کن: **پزشکان | منابع**. سطح
|
||||
دوم برای منابع همان الگوی فعلی است (یک `.seg` با نام هر منبع).
|
||||
|
||||
کامپوننت جدید لازم نیست — پنلها ساخته شدهاند:
|
||||
|
||||
```tsx
|
||||
{scope === 'resources' && selectedResource && (
|
||||
<div key={selectedResource}>
|
||||
<ResourceWorkingHoursPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
|
||||
<ResourceExceptionsPanel resourceUuid={selectedResource} canUpdate={canUpdate} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
`.seg` نکته دارد: کلاس فعالش `on` است (`active` هم alias شده) — تب بدون آن هیچ نشانهای
|
||||
ندارد.
|
||||
|
||||
**نحوه تست:** `npx vitest run assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx`
|
||||
با سه تست: تب منابع فهرست منابع را میدهد · انتخاب منبع پنل ساعت کاری را میآورد ·
|
||||
کلینیک بدون منبع حالت خالی میدهد. سپس اسکرینشات:
|
||||
`node .claude/skills/redesign-page/driver.mjs variants "https://clinic-pro.ddev.site/admin/settings/appointment-settings"`
|
||||
|
||||
---
|
||||
|
||||
### ۴. حذف دو تب از صفحهٔ سرویس
|
||||
|
||||
فقط **UI** حذف میشود: دو ورودی از `TABS` و رندرشان، بهعلاوهٔ کامپوننتهای
|
||||
`ServiceGroupsTab` و `ServiceSegmentsTab` و تستهایشان.
|
||||
|
||||
`SegmentTemplate`، `ServiceSelectionGroup`، `ServiceItemRelation` و اندپوینتهایشان
|
||||
**میمانند**: `AppointmentPlanBuilder` ورودیاش همینهاست و سرویسی که امروز اتاق و دستگاه
|
||||
را با هم میگیرد، بدونشان میشکند. سرویسِ بدون template هم از قبل با `singleSegment()`
|
||||
رزرو میشود، پس حذف تب هیچ رفتاری را عوض نمیکند.
|
||||
|
||||
`tab` در URL مینشیند؛ مقدار ناشناخته باید به `info` برگردد نه اینکه چیزی رندر نشود.
|
||||
|
||||
**نحوه تست:** `npx vitest run assets/admin/pages/ServiceDetailPage.test.tsx` +
|
||||
`ddev exec php bin/phpunit tests/Appointment` (باید بدون تغییر سبز بماند) + باز کردن
|
||||
`/admin/service/{uuid}?tab=segments` و دیدن تب اطلاعات.
|
||||
|
||||
---
|
||||
|
||||
### ۵. تایملاین یکپارچه
|
||||
|
||||
سه نمای فعلی (`table` · `timeline` · `resources`) به دو نما میرسند: **جدولی** و
|
||||
**زمانبندی**. نمای زمانبندی دو گروه ردیف دارد:
|
||||
|
||||
```
|
||||
پزشکان ← پزشکانی که حالت نوبتدهیشان service است
|
||||
منابع ← منابعی که برای سرویسی قابل رزروند (ResourceServiceOffering فعال دارند)
|
||||
```
|
||||
|
||||
هر ردیف باید سه چیز بدهد: بازهٔ اشغال، وقت آزاد، و ظرفیت. برای منبع، «آزاد» یعنی
|
||||
`capacity` منهای تعداد اشغال همپوشان در آن لحظه — نه صفر و یک؛ منبعِ ظرفیت۳ با دو نوبت
|
||||
همزمان هنوز یک جا دارد.
|
||||
|
||||
سمت سرور، `GET /api/v1/resources/timeline` موجود را توسعه بده (ساخت اندپوینت جدید ممنوع
|
||||
است تا وقتی این کافی است): فیلتر `only_bookable=1` و فیلد `free_slots` به هر ردیف اضافه
|
||||
شود. ردیف پزشک از همان `slots` فعلی میآید و در فرانت با ردیف منابع در یک نما ادغام
|
||||
میشود.
|
||||
|
||||
**نحوه تست:** تست PHPUnit برای `free_slots` روی منبع ظرفیت۳ با دو اشغال همپوشان
|
||||
(انتظار: ۱)؛ `npx vitest run assets/admin/components/appointments/`؛ و اسکرینشات نمای
|
||||
زمانبندی در تاریخ `2026-08-05` که دادهٔ واقعی دارد.
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **ترتیب اجرا اجباری است.** قابلیت ۱ موتور دسترسپذیری را تغییر میدهد؛ اگر بعد از
|
||||
قابلیت ۵ انجام شود، تایملاین را میشکند و علتش پیدا نیست.
|
||||
- **خط پایهٔ تست را اول بگیر:** `ddev exec php bin/phpunit` و `npx vitest run`
|
||||
(۱۰۰ فایل / ۶۶۰ تست، همه سبز در ۲۰۲۶-۰۸-۰۲). هر شکستی بعد از این، مالِ همین کار است.
|
||||
- **مسیر اسلاتی دست نخورد.** تستهای `--group=slot-mode-frozen` نگهبانند؛ حتی یک شکست
|
||||
یعنی توقف.
|
||||
- **`vitest` داخل ddev اجرا نمیشود** — روی هاست با `npx vitest run`.
|
||||
- **دو صفحه، یک منوی تنظیمات:** `settingsMenu.ts` منبع واحد سایدبار دسکتاپ و فهرست
|
||||
موبایل است. حذف آیتم شعبه فقط همانجا انجام شود.
|
||||
- **مستندات همین جلسه:** `docs/api/branch.md` حذف، `docs/api/resource.md` و
|
||||
`docs/api/resource-calendar.md` و `docs/api/clinic-services.md` بهروز، و
|
||||
`docs/architecture/resource-first-model.md` باید بگوید شعبه از مدل بیرون رفته.
|
||||
- **الگو:** برداشتن لایهٔ شعبه از `ResourceAvailabilityService` را بهصورت حذف یک لایه از
|
||||
زنجیرهٔ کسر انجام بده (همان ساختار فعلی)، نه با `if` تازه — کلاس باید کوچکتر شود نه شاخهدارتر.
|
||||
- **دادهٔ تست:** `ddev exec php bin/console app:seed-scenarios --reset -n` سه سناریو را
|
||||
میسازد؛ کاربرها در `TEST_USERS.md`. کاربر `0912000301` رمز ندارد — از `0912000201`
|
||||
استفاده کن.
|
||||
@@ -0,0 +1,623 @@
|
||||
# حساب کاربری برای پرسنل — نقش `ROLE_STAFF`، ورود به پنل، داشبورد اختصاصی و مشاهدهٔ سرویسهای تخصیصیافته
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (بکاند Symfony + پنل ادمین React). cross-repo نیست؛ `nobat724_front` تغییری ندارد.
|
||||
|
||||
## زمینه
|
||||
|
||||
امروز پرسنل (`clinic_staff`) فقط یک «رکورد اطلاعاتی» است: کلینیک یا پزشک از
|
||||
`/admin/staff` یک ردیف با نام/تلفن/سمت/کد ملی میسازد و همان ردیف در جاهای دیگر
|
||||
بهعنوان «مجری سرویس» انتخاب میشود:
|
||||
|
||||
- `ServiceItem::$staffMembers` (جدول `service_item_staff`) — پرسنل تخصیصیافته به هر سرویس
|
||||
- `Appointment::$staff` (`appointments.staff_id`) — پرسنل نوبت
|
||||
- `SessionService::$staff` — پرسنل مجری سرویس در جلسهٔ بیمار
|
||||
|
||||
اما `ClinicStaff` هیچ ارتباطی با `users` ندارد، پس پرسنل نه میتواند لاگین کند و نه
|
||||
داشبوردی دارد. الگوی مشابهی که در پروژه **کار میکند** «منشی» است: منشی یک `User`
|
||||
است با `ROLE_SECRETARY` که از طریق ردیف `DoctorSecretary` به مالک (پزشک/کلینیک) وصل
|
||||
میشود (`SecretaryService::resolveSecretaryUser`). همین الگو باید برای پرسنل تکرار شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
وقتی کلینیک یا پزشک در `/admin/staff` پرسنل اضافه میکند، اگر شمارهٔ موبایل بدهد و
|
||||
گزینهٔ «ایجاد حساب کاربری» را بزند:
|
||||
|
||||
1. یک `User` با نقش `ROLE_STAFF` ساخته/بهروزرسانی شود و به همان ردیف `ClinicStaff` وصل شود.
|
||||
2. آن کاربر بتواند با موبایل/رمز در `/admin/login` وارد شود.
|
||||
3. بعد از ورود، `primary_role = 'staff'` بگیرد و محیط کاریاش همان مطب/کلینیکِ مالک باشد.
|
||||
4. داشبورد اختصاصی «پرسنل» ببیند: سرویسهایی که به او تخصیص داده شده + نوبتهای خودش.
|
||||
5. **به هیچ چیز دیگری دسترسی نداشته باشد** — نه لیست بیماران، نه سرویسهای کل کلینیک،
|
||||
نه مالی، نه مدیریت پرسنل.
|
||||
|
||||
### تحلیل — نکتهٔ امنیتی که نباید نادیده گرفته شود
|
||||
|
||||
بند ۵ سختترین بخش کار است و اگر ساده گرفته شود یک نشت اطلاعات کامل میسازد:
|
||||
|
||||
- اکثر کنترلرها فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]` دارند و tenant را از
|
||||
`EntityContextResolver` میگیرند.
|
||||
- `SecretaryAccessChecker::denyUnlessGranted` و `ClinicDoctorAccessChecker` برای
|
||||
کاربری که منشی/پزشکِ مهمان **نیست** عملاً no-op هستند (فقط نقش خودشان را میسنجند).
|
||||
- پس بهمحض اینکه `EntityContextResolver` برای کاربر staff محیط کلینیک را resolve کند،
|
||||
`GET /api/v1/service-items` **همهٔ** سرویسهای کلینیک را برمیگرداند، `/api/v1/patients`
|
||||
همهٔ بیماران را، و…
|
||||
|
||||
بنابراین طراحی این تسک **default-deny** است: یک `StaffRouteGuardSubscriber` روی رویداد
|
||||
`kernel.controller` که برای کاربرِ «فقط staff» هر مسیر خارج از allowlist را ۴۰۳ میکند.
|
||||
دلیل انتخاب Subscriber بهجای افزودن `denyUnlessGranted` به دهها کنترلر: تکنقطهای
|
||||
بودن تصمیم (اگر فردا کنترلر جدیدی اضافه شود، بهصورت پیشفرض بسته است، نه باز).
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق:
|
||||
- `POST /api/v1/staff` با `{"full_name":"زهرا احمدی","phone":"09121110000","has_account":true,"password":"Staff@1234"}`
|
||||
توسط توکن کلینیک → `201` و در بدنه `has_account: true` و `user_uuid` غیرتهی؛ در DB
|
||||
یک `users` با `roles` شامل `ROLE_STAFF` و `clinic_staff.user_id` پرشده.
|
||||
- `POST /api/v1/user/login` با همان موبایل/رمز → `200` و `access_token`.
|
||||
- `GET /oauth/userinfo` با آن توکن → `primary_role: "staff"` و در `available_contexts`
|
||||
یک آیتم با `role: "staff"` و `db_uuid` برابر uuid کلینیک/پزشکِ مالک و
|
||||
`permissions.resources` فقط شامل `{"services":{"view":true},"appointments":{"view":true}}`.
|
||||
- `GET /api/v1/dashboard/staff` → `200` با `stats.today_appointments`، `services` (فقط
|
||||
سرویسهایی که این پرسنل در `service_item_staff` آنهاست) و `today_appointments`.
|
||||
- در پنل: ورود با آن کاربر → ریدایرکت به `/admin/dashboard` و نمایش «داشبورد پرسنل»؛
|
||||
سایدبار فقط «داشبورد» و «سرویسهای من» را دارد.
|
||||
- ❌ خطا:
|
||||
- `GET /api/v1/service-items` با توکن پرسنل → `403` با `ERR_FORBIDDEN_001` (نه ۲۰۰ با
|
||||
سرویسهای کلینیک). همینطور `/api/v1/staff` (GET/POST)، `/api/v1/patients`،
|
||||
`/api/v1/appointments`، `/api/v1/dashboard/clinic`.
|
||||
- `POST /api/v1/staff` با `has_account: true` و `phone` خالی یا نامعتبر →
|
||||
`422` با `ERR_STAFF_MOBILE_INVALID`.
|
||||
- ورود پرسنلِ `active=false` → `/oauth/userinfo` هیچ context با `role: "staff"` ندارد و
|
||||
`GET /api/v1/dashboard/staff` → `403`.
|
||||
- ⚠️ مرزی:
|
||||
- موبایلی که **از قبل** `User` دارد (مثلاً بیمار یا منشی): کاربر جدید ساخته نشود؛
|
||||
فقط `ROLE_STAFF` به نقشهایش اضافه شود و ردیف پرسنل به همان کاربر وصل شود. اگر آن
|
||||
کاربر هم منشی است و هم پرسنل → `primary_role` باید `secretary` بماند (نقش قویتر) و
|
||||
context مربوط به staff هم در `available_contexts` بیاید.
|
||||
- یک نفر پرسنلِ **دو** کلینیک: دو ردیف `clinic_staff` با یک `user_id` → دو context در
|
||||
لیست؛ بعد از `switch-context` داشبورد دادههای همان کلینیک را بدهد.
|
||||
- همان موبایل دوباره در همان کلینیک ثبت شود → `409` با `ERR_STAFF_MOBILE_TAKEN`
|
||||
(نه ساخت ردیف تکراری).
|
||||
- پرسنل بدون هیچ سرویس تخصیصیافته → `services: []` و پیام خالی در UI، نه ۵۰۰.
|
||||
- موبایل مالک (خودِ پزشک/کلینیک) بهعنوان پرسنل → `422` با `ERR_STAFF_MOBILE_INVALID`
|
||||
و پیام «شماره مالک نمیتواند پرسنل باشد» (جلوگیری از تنزل نقش/سردرگمی context).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Staff/Entity/ClinicStaff.php` | افزودن رابطهٔ `user` |
|
||||
| `src/Staff/Repository/ClinicStaffRepository.php` | کوئریهای `findActiveByUser`، `findActiveByUserAndEntity`، `findByEntityAndPhone` |
|
||||
| `src/Staff/Service/StaffAccountService.php` | **جدید** — ساخت/اتصال/قطع حساب کاربری پرسنل |
|
||||
| `src/Staff/Controller/StaffController.php` | پذیرش `has_account`/`password` در create/update |
|
||||
| `src/Staff/Controller/StaffDashboardController.php` یا `src/Dashboard/Controller/DashboardController.php` | اندپوینت داشبورد پرسنل |
|
||||
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | **جدید** — default-deny برای کاربر staff |
|
||||
| `src/Auth/Entity/User.php` | `isStaff()` باید `ROLE_STAFF` را هم بپذیرد |
|
||||
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` + `buildAvailableContexts()` |
|
||||
| `src/Shared/Context/EntityContextResolver.php` | resolve محیط برای کاربر staff |
|
||||
| `src/Shared/Constant/ErrorCodes.php` | کدهای خطای جدید |
|
||||
| `src/ClinicService/Repository/ServiceItemRepository.php` | `findByStaff(ClinicStaff)` |
|
||||
| `assets/admin/pages/StaffPage.tsx` | فیلد موبایل/حساب کاربری + ستون «حساب» |
|
||||
| `assets/admin/pages/DashboardPage.tsx` | `StaffDashboard` + dispatcher |
|
||||
| `assets/admin/pages/StaffMyServicesPage.tsx` | **جدید** — صفحهٔ «سرویسهای من» |
|
||||
| `assets/admin/App.tsx` | `ALLOWED_ROLES` + روتهای نقش staff |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش staff |
|
||||
| `assets/admin/types/index.ts` | فیلدهای جدید `ClinicStaff` |
|
||||
| `docs/api/staff.md`، `docs/api/auth.md`، `docs/api/dashboard.md` | مستندسازی (قانون ثابت پروژه) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### `src/Staff/Entity/ClinicStaff.php` — هیچ ارتباطی با `User` ندارد
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_staff')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
|
||||
class ClinicStaff
|
||||
{
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'full_name', type: 'string', length: 200)]
|
||||
private string $fullName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Auth/Entity/User.php:121` — گیت ورود به پنل
|
||||
|
||||
```php
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_ADMIN')
|
||||
|| $this->hasRole('ROLE_REPRESENTATION')
|
||||
|| $this->hasRole('ROLE_IMPORTER');
|
||||
}
|
||||
```
|
||||
|
||||
`PasswordAuthenticator::onAuthenticationSuccess:79` بدون این متد لاگین را ۴۰۳ میکند:
|
||||
|
||||
```php
|
||||
if (!$user->isStaff()) {
|
||||
return new JsonResponse([... ErrorCodes::ERR_AUTH_006 ...], 403);
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Auth/Controller/AuthController.php:690` — نقش اصلی و لیست محیطها
|
||||
|
||||
```php
|
||||
private function resolvePrimaryRole(User $user): string
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) return 'admin';
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
|
||||
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
|
||||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||||
return 'user';
|
||||
}
|
||||
```
|
||||
|
||||
و در `buildAvailableContexts()` منشی اینطور context میگیرد (الگوی مرجع برای پرسنل):
|
||||
|
||||
```php
|
||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
// …
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $rel->getDoctor()->getUuid(),
|
||||
'name' => 'مطب ' . $rel->getDoctor()->getName(),
|
||||
'role' => 'secretary',
|
||||
'scope' => 'doctor',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Secretary/Service/SecretaryService.php:38` — الگوی مرجع ساخت کاربر
|
||||
|
||||
```php
|
||||
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
|
||||
{
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$user->setRealName(trim($name));
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$roles[] = 'ROLE_SECRETARY';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
$this->userRepo->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
```
|
||||
|
||||
### `src/ClinicService/Entity/ServiceItem.php:44` — رابطهٔ سرویس ↔ پرسنل (منبع «سرویسهای من»)
|
||||
|
||||
```php
|
||||
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'service_item_staff')]
|
||||
private Collection $staffMembers;
|
||||
```
|
||||
|
||||
### `assets/admin/App.tsx:83` — نقشهای مجاز پنل
|
||||
|
||||
```tsx
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
```
|
||||
|
||||
### `assets/admin/hooks/usePermissions.ts` — نکتهٔ حیاتی
|
||||
|
||||
```ts
|
||||
const perms = context?.permissions as { resources?: ... } | undefined | null;
|
||||
if (!perms?.resources) return true; // نبودِ permissions یعنی «آزاد»، نه «بسته»
|
||||
```
|
||||
|
||||
پس context پرسنل **حتماً** باید `permissions.resources` صریح داشته باشد، وگرنه UI همهچیز
|
||||
را باز میکند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. مدل داده: اتصال `ClinicStaff` به `User`
|
||||
|
||||
`src/Staff/Entity/ClinicStaff.php`:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: \App\Auth\Entity\User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function hasAccount(): bool { return $this->user !== null; }
|
||||
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
|
||||
```
|
||||
|
||||
و در `toArray()`:
|
||||
|
||||
```php
|
||||
'has_account' => $this->user !== null,
|
||||
'user_uuid' => $this->user?->getUuid(),
|
||||
```
|
||||
|
||||
ایندکس لازم: `#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]`
|
||||
(چون `findActiveByUser` در هر بار `userinfo` صدا زده میشود).
|
||||
|
||||
`ClinicStaffRepository`:
|
||||
|
||||
```php
|
||||
/** @return ClinicStaff[] ردیفهای فعالِ این کاربر در همهٔ محیطها */
|
||||
public function findActiveByUser(User $user): array;
|
||||
|
||||
public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff;
|
||||
|
||||
/** برای جلوگیری از ثبت تکراری یک موبایل در همان محیط */
|
||||
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff;
|
||||
```
|
||||
|
||||
سپس migration:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` باید سبز باشد؛
|
||||
`DESCRIBE clinic_staff` ستون `user_id` را نشان دهد.
|
||||
|
||||
### ۲. `StaffAccountService` — تنها نقطهٔ ساخت/اتصال حساب پرسنل
|
||||
|
||||
`src/Staff/Service/StaffAccountService.php` (جدید). قرینهٔ `SecretaryService::resolveSecretaryUser`
|
||||
است، اما با اعتبارسنجی موبایل و قاعدهٔ «مالک نمیتواند پرسنل خودش باشد»:
|
||||
|
||||
```php
|
||||
class StaffAccountService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* حساب کاربری پرسنل را میسازد یا به کاربر موجود وصل میکند و ROLE_STAFF میدهد.
|
||||
*
|
||||
* @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
|
||||
*/
|
||||
public function attachAccount(ClinicStaff $staff, string $mobile, ?string $password, User $owner): User
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile); // ارقام فارسی → لاتین
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422);
|
||||
}
|
||||
if ($mobile === $owner->getMobileNumber()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, 'شماره مالک نمیتواند پرسنل باشد', 422);
|
||||
}
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
// یک موبایل، در یک محیط، فقط یک ردیف پرسنل
|
||||
$duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
|
||||
if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409);
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
}
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
$user->setRealName($staff->getFullName());
|
||||
$user->addRole('ROLE_STAFF');
|
||||
$this->userRepo->save($user);
|
||||
|
||||
$staff->setUser($user)->setPhone($mobile);
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
$this->sendWelcomeSms($mobile, $ownerName); // TAG_STAFF، مشابه TAG_SECRETARY
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** قطع دسترسی بدون حذف ردیف پرسنل (سوابق سرویس/نوبت حفظ میشود). */
|
||||
public function detachAccount(ClinicStaff $staff): void;
|
||||
}
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `addRole()` روی `User` از قبل هست (`src/Auth/Entity/User.php:107`) — از آن استفاده کن،
|
||||
آرایهٔ roles را دستی دستکاری نکن.
|
||||
- برای SMS: `SmsLog::TAG_STAFF` را به ثابتها و `SmsMessageTemplate` اضافه کن (الگوی
|
||||
`SmsLog::TAG_SECRETARY => [...]` در `src/Sms/Entity/SmsMessageTemplate.php:58`). اگر
|
||||
افزودن قالب پیامک ریسک/هزینه دارد، همان `TAG_SECRETARY` را استفاده نکن — بهجایش
|
||||
ارسال SMS را در این فاز حذف کن و در پاسخ API فقط `has_account` را برگردان.
|
||||
- کدهای خطای جدید در `src/Shared/Constant/ErrorCodes.php`:
|
||||
`ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست'`،
|
||||
`ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است'`.
|
||||
|
||||
**نحوه تست:** یونیتتست `tests/Staff/StaffAccountServiceTest.php` با سه سناریو:
|
||||
کاربر جدید ساخته میشود / کاربر موجود فقط نقش میگیرد و رمز قبلیاش پاک نمیشود اگر
|
||||
`password` خالی باشد / موبایل مالک → `AppException` با کد ۴۲۲.
|
||||
|
||||
### ۳. `StaffController` — پذیرش حساب کاربری در create/update
|
||||
|
||||
در `create()` و `update()` (فایل `src/Staff/Controller/StaffController.php`) بعد از
|
||||
`$this->staffRepo->save($staff)`:
|
||||
|
||||
```php
|
||||
$wantsAccount = (bool) ($data['has_account'] ?? false);
|
||||
if ($wantsAccount) {
|
||||
$this->staffAccounts->attachAccount($staff, (string) ($data['phone'] ?? ''), $data['password'] ?? null, $user);
|
||||
} elseif ($staff->hasAccount() && array_key_exists('has_account', $data)) {
|
||||
$this->staffAccounts->detachAccount($staff);
|
||||
}
|
||||
|
||||
return $this->success($staff->toArray(), 201);
|
||||
```
|
||||
|
||||
کنترلر نازک بماند: هیچ منطق hash/نقش/اعتبارسنجی موبایل داخل کنترلر نوشته نشود
|
||||
(`AppException` را `ExceptionSubscriber` به envelope خطا تبدیل میکند).
|
||||
|
||||
**مهم:** `resolveEntity()` همین کنترلر نباید برای `ROLE_STAFF` چیزی برگرداند — امروز به
|
||||
`['unknown', null]` میافتد و ۴۰۳ میدهد؛ همین رفتار درست است، دست نخورد (پرسنل حق
|
||||
مدیریت پرسنل ندارد).
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"09390039833","password":"09390039833"}' | jq -r .access_token)
|
||||
|
||||
curl -s -X POST https://clinic-pro.ddev.site/api/v1/staff \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"full_name":"زهرا احمدی","phone":"09121110000","job_title":"پرستار","has_account":true,"password":"Staff@1234"}' | jq
|
||||
# انتظار: 201، has_account:true، user_uuid غیرتهی
|
||||
```
|
||||
|
||||
### ۴. Auth — نقش، محیط کاری و مجوزهای پرسنل
|
||||
|
||||
الف) `src/Auth/Entity/User.php` → `isStaff()` را با `|| $this->hasRole('ROLE_STAFF')` کامل کن
|
||||
(بدون آن، لاگین پرسنل ۴۰۳ میگیرد).
|
||||
|
||||
ب) `AuthController::resolvePrimaryRole()` — بعد از `secretary` و قبل از `representation`:
|
||||
|
||||
```php
|
||||
if (in_array('ROLE_STAFF', $roles, true)) return 'staff';
|
||||
```
|
||||
|
||||
(ترتیب عمدی است: کسی که هم منشی است هم پرسنل، منشی میماند چون نقش پرتوانتر است.)
|
||||
|
||||
ج) `AuthController::buildAvailableContexts()` — بلوک جدید در انتها:
|
||||
|
||||
```php
|
||||
foreach ($this->staffRepo->findActiveByUser($user) as $row) {
|
||||
$owner = $row->getEntityType() === 'clinic'
|
||||
? $this->clinicRepo->find($row->getEntityId())
|
||||
: $this->doctorRepo->find($row->getEntityId());
|
||||
if ($owner === null) { continue; }
|
||||
|
||||
$contexts[] = [
|
||||
'type' => $row->getEntityType(),
|
||||
'db_uuid' => $owner->getUuid(),
|
||||
'name' => $row->getEntityType() === 'clinic' ? ($owner->getName() ?? '') : 'مطب ' . $owner->getName(),
|
||||
'role' => 'staff',
|
||||
'scope' => $row->getEntityType(),
|
||||
'permissions' => StaffPermissions::DEFAULT, // ثابت، نه قابل ویرایش در این فاز
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
با ثابتِ صریح (مثلاً `src/Staff/Security/StaffPermissions.php`):
|
||||
|
||||
```php
|
||||
public const DEFAULT = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'services' => ['view' => true],
|
||||
'appointments' => ['view' => true],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
د) `EntityContextResolver` — تا وقتی staff در `canActInClinic()` / `canActForDoctor()`
|
||||
شناخته نشود، `fromActiveContext()` برای او `null` برمیگرداند و داشبورد ۴۰۳ میدهد:
|
||||
|
||||
```php
|
||||
// canActInClinic()
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, 'clinic', $clinic->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
// canActForDoctor()
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, 'doctor', $doctor->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
`fromRole()` برای staff هیچ fallback ندهد (مثل منشی) — محیطش فقط از `UserActiveContext`
|
||||
میآید، چون میتواند پرسنل چند محیط باشد.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
STAFF=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"09121110000","password":"Staff@1234"}' | jq -r .access_token)
|
||||
curl -s https://clinic-pro.ddev.site/oauth/userinfo -H "Authorization: Bearer $STAFF" | jq '.data.primary_role, .data.available_contexts'
|
||||
# انتظار: "staff" و یک context با role=staff و permissions محدود
|
||||
```
|
||||
|
||||
### ۵. Default-deny: `StaffRouteGuardSubscriber`
|
||||
|
||||
`src/Staff/Security/StaffRouteGuardSubscriber.php` (جدید) روی `KernelEvents::CONTROLLER`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* کاربری که «فقط» ROLE_STAFF دارد به هیچ اندپوینتی جز allowlist دسترسی ندارد.
|
||||
*
|
||||
* چرایی: بیشتر کنترلرها tenant را از EntityContextResolver میگیرند و مجوز را فقط
|
||||
* برای منشی/پزشکِ مهمان میسنجند؛ بدون این گارد، کاربر staff با context حلشده به
|
||||
* دادهٔ کل کلینیک میرسد. تصمیم در یک نقطه متمرکز است تا کنترلرِ جدید هم بهصورت
|
||||
* پیشفرض بسته باشد.
|
||||
*/
|
||||
private const ALLOWED_PREFIXES = [
|
||||
'/api/v1/dashboard/staff',
|
||||
'/api/v1/staff/me',
|
||||
'/api/v1/auth/switch-context',
|
||||
'/api/v1/user/change-password',
|
||||
'/oauth/',
|
||||
];
|
||||
```
|
||||
|
||||
قواعد:
|
||||
- فقط وقتی فعال شود که کاربر `ROLE_STAFF` دارد و **هیچکدام** از
|
||||
`ROLE_ADMIN/ROLE_CLINIC/ROLE_DOCTOR/ROLE_SECRETARY/ROLE_REPRESENTATION` را ندارد.
|
||||
- در غیر allowlist: `AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403)`.
|
||||
- مسیرهای عمومی (غیر `/api`) دستنخورده بمانند.
|
||||
|
||||
**نحوه تست:** `tests/Staff/StaffRouteGuardTest.php` — با توکن پرسنل روی اینها ۴۰۳:
|
||||
`/api/v1/service-items`، `/api/v1/staff`، `/api/v1/patients`، `/api/v1/appointments`،
|
||||
`/api/v1/dashboard/clinic`؛ و روی `/api/v1/dashboard/staff` و `/oauth/userinfo` ۲۰۰.
|
||||
|
||||
### ۶. اندپوینت داشبورد پرسنل
|
||||
|
||||
`GET /api/v1/dashboard/staff` — قرینهٔ `/api/v1/dashboard/secretary`
|
||||
(`src/Dashboard/Controller/DashboardController.php:529`). طبق قاعدهٔ «اول بگرد، بعد بساز»:
|
||||
اندپوینت موجودی وجود ندارد که خروجی محدودشده به یک پرسنل بدهد، پس ساختش لازم است.
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_STAFF')]
|
||||
public function staff(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
[$entityType, $entityId] = $context->toEntityPair();
|
||||
|
||||
$row = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
|
||||
if ($row === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'scope' => $entityType,
|
||||
'staff' => ['uuid' => $row->getUuid(), 'full_name' => $row->getFullName(), 'job_title' => $row->getJobTitle()],
|
||||
'owner' => ['name' => $ownerName],
|
||||
'stats' => ['today_appointments' => $todayCount, 'services' => count($services)],
|
||||
'services' => $services, // از ServiceItemRepository::findByStaff()
|
||||
'today_appointments' => $todayAppointments, // appointments.staff_id = این پرسنل، امروز
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`ServiceItemRepository::findByStaff(ClinicStaff $staff): array` — DQL با
|
||||
`INNER JOIN i.staffMembers s WHERE s = :staff AND i.active = true`، محدود به همان tenant.
|
||||
خروجی سرویسها فقط فیلدهای لازم: `uuid, name, price_rials, duration_minutes, section_name, active`
|
||||
(قیمت لازم است چون پرسنل باید بداند چه سرویسی با چه تعرفهای به او تخصیص یافته).
|
||||
|
||||
نوبتهای امروز: DQL روی `Appointment` با `a.staff = :staff` و بازهٔ
|
||||
`strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` (تایماستمپ صحیح، نه DateTime).
|
||||
|
||||
**نحوه تست:** بعد از تخصیص یک سرویس به پرسنل از صفحهٔ سرویسها:
|
||||
```bash
|
||||
curl -s https://clinic-pro.ddev.site/api/v1/dashboard/staff -H "Authorization: Bearer $STAFF" | jq '.data.services, .data.stats'
|
||||
```
|
||||
|
||||
### ۷. پنل: فرم پرسنل + نقش staff در روتینگ و سایدبار
|
||||
|
||||
الف) `assets/admin/pages/StaffPage.tsx`:
|
||||
- در `schema` فیلدهای `has_account: z.boolean().optional()` و `password: z.string().optional()`
|
||||
اضافه شود؛ با `superRefine`: اگر `has_account` روشن است، `phone` باید `^09\d{9}$` باشد.
|
||||
- در `StaffFormFields` یک چکباکس «ایجاد حساب کاربری برای ورود به پنل» و ورودی رمز
|
||||
(فقط وقتی چکباکس روشن است). ورودی موبایل همان `phone` فعلی است با `numericField(..., 11)`.
|
||||
- یک ستون جدید در `columns`: «حساب کاربری» با `ActiveBadge`/متن «دارد / ندارد» از
|
||||
`s.has_account`.
|
||||
- `assets/admin/types/index.ts` → `ClinicStaff` با `has_account: boolean; user_uuid: string | null`.
|
||||
|
||||
ب) `assets/admin/App.tsx`:
|
||||
```tsx
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation', 'staff'] as const;
|
||||
```
|
||||
و روت جدید داخل `AdminLayout`:
|
||||
```tsx
|
||||
<Route path="/admin/my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
|
||||
```
|
||||
|
||||
ج) `assets/admin/components/layout/Sidebar.tsx` — بلوک `if (primaryRole === "staff")`
|
||||
قبل از `representation`، دقیقاً با ساختار بقیه (بخش «عمومی» با داشبورد + «مدیریت» با
|
||||
«سرویسهای من»). هیچ آیتم تنظیمات/مالی/بیمار نداشته باشد. `ROLE_LABELS` هم مقدار
|
||||
`staff: 'پرسنل'` بگیرد.
|
||||
|
||||
د) `assets/admin/pages/DashboardPage.tsx` — کامپوننت `StaffDashboard` قرینهٔ
|
||||
`SecretaryDashboard` (همان `LoadingSkeleton`، همان کارتهای KPI، همان حالت خطا) و در
|
||||
dispatcher: `if (primaryRole === 'staff') return <StaffDashboard />;`
|
||||
|
||||
ه) `assets/admin/pages/StaffMyServicesPage.tsx` — جدول سرویسهای تخصیصیافته با
|
||||
`DataTable` + `PageHeader` (بدون دکمهٔ ایجاد/ویرایش؛ فقط خواندنی). چون از داشبورد باز
|
||||
میشود، `backTo="/admin/dashboard"` بدهد.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json
|
||||
ddev exec yarn dev
|
||||
ddev exec yarn test
|
||||
```
|
||||
سپس دستی: ورود با `09121110000 / Staff@1234` در `/admin/login` →
|
||||
داشبورد پرسنل، سایدبار دو آیتمی، ورود مستقیم به `/admin/patients` → ریدایرکت به داشبورد.
|
||||
|
||||
### ۸. تستها و مستندات
|
||||
|
||||
- `tests/Staff/StaffAccountServiceTest.php` — یونیت (وظیفهٔ ۲).
|
||||
- `tests/Staff/StaffRouteGuardTest.php` — فانکشنال default-deny (وظیفهٔ ۵).
|
||||
- `tests/Staff/StaffDashboardTest.php` — موفق (۲۰۰ با سرویسهای خودش) / خطا (پرسنل
|
||||
غیرفعال → ۴۰۳) / مرزی (بدون سرویس → `services: []`).
|
||||
- `TenantSchemaCoverageTest` باید همچنان سبز باشد (`clinic_staff` از قبل tenant-keyed است؛
|
||||
ستون `user_id` طبقهبندی آن را عوض نمیکند — اگر تست قرمز شد، دلیلش را بررسی کن، نه
|
||||
اینکه entity را به `GlobalTables` اضافه کنی).
|
||||
- اجرای کامل: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
|
||||
- مستندات (قانون ثابت پروژه): `docs/api/staff.md` (فیلدهای جدید create/update + اندپوینت
|
||||
`/api/v1/staff/me` اگر ساخته شد)، `docs/api/auth.md` (نقش `staff` در `primary_role` و
|
||||
context جدید)، `docs/api/dashboard.md` (اندپوینت `/api/v1/dashboard/staff`).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **پرسنل ≠ منشی.** منشی مجوزهای قابلویرایش دارد (`DoctorSecretary.permission`)؛ پرسنل در
|
||||
این فاز مجوز ثابت و حداقلی دارد (`StaffPermissions::DEFAULT`). ویرایشگر مجوز پرسنل
|
||||
ساخته نشود — abstraction «برای آینده» ممنوع است.
|
||||
- **`usePermissions` نبودِ `permissions` را «آزاد» تفسیر میکند** — context پرسنل حتماً
|
||||
آبجکت صریح `resources` داشته باشد، وگرنه UI همهچیز را باز میکند.
|
||||
- **غیرفعالسازی پرسنل باید دسترسی را قطع کند:** `PATCH /api/v1/staff/{uuid}/toggle` وقتی
|
||||
`active=false` میشود، `findActiveByUser` دیگر آن ردیف را برنمیگرداند، پس context حذف
|
||||
میشود. اما توکن JWT قبلی تا انقضا معتبر است؛ به همین دلیل گارد وظیفهٔ ۶ (بررسی
|
||||
`findActiveByUserAndEntity` در هر درخواست داشبورد) لازم است و نمیتوان فقط به context
|
||||
اکتفا کرد.
|
||||
- **حذف نشدن سوابق:** `detachAccount` فقط `user_id` را `null` میکند؛ ردیف `clinic_staff` و
|
||||
ارجاعات `service_item_staff` / `appointments.staff_id` / `session_services.staff_id` دست
|
||||
نمیخورند.
|
||||
- **تایماستمپها `int` Unix** و تاریخها در UI شمسی با `formatDate` — طبق قواعد پروژه.
|
||||
- **الگو:** `StaffAccountService` نقش Service Layer را دارد (قرینهٔ `SecretaryService`) و
|
||||
`StaffRouteGuardSubscriber` الگوی Guard/Interceptor است؛ انتخابشان برای تکنقطهای کردن
|
||||
دو تصمیم است: «چه کسی حساب دارد» و «چه چیزی برای staff باز است».
|
||||
- **رشتههای UI فارسی** بمانند و صفحات جدید از همان `PageHeader` / `DataTable` /
|
||||
`SettingsLayout` و توکنهای `styles.css` استفاده کنند — طراحی جدید ساخته نشود.
|
||||
@@ -4,6 +4,8 @@ description: وقتی کاربر یک لینک figma.com/design با node-id م
|
||||
کن، نیازهای فرانتاند و بکاند را استخراج کن و پس از تأیید پیادهسازی کن.
|
||||
---
|
||||
|
||||
> **قبل از شروع، فایل `clinicpro/.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است.**
|
||||
|
||||
## بخش ۰ — زبان (قبل از هر کاری)
|
||||
- ورودی من فارسی است. منظور را استخراج کن، نه ترجمهی لغوی.
|
||||
- متن را به یک normalized English spec تبدیل کن با فیلدهای:
|
||||
|
||||
@@ -3,7 +3,7 @@ name: prompt-writer
|
||||
description: تولید فایل پرامپت .md برای یک قابلیت یا باگفیکس در پروژه ClinicPro. استفاده کن وقتی کاربر میگوید «یک پرامپت بنویس»، «پرامپت بساز»، «write a prompt»، «برام پرامپت بنویس برای X». این skill پروژه را تحلیل میکند، سپس یک فایل .md کامل در .claude/prompt/ میسازد و دستور اجرا را نمایش میدهد.
|
||||
---
|
||||
|
||||
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، معیار پذیرش، تست، مستندات، SOLID، رفتار تحلیلگر) را تعیین میکند؛ این skill فقط قواعد stack و مسیرها و قالب خروجی را دارد. در تناقض، guidelines برنده است.
|
||||
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، معیار پذیرش، تست، مستندات، SOLID، رفتار تحلیلگر) را تعیین میکند؛ این skill فقط قواعد stack و مسیرها و قالب خروجی را دارد. در تناقض، guidelines برنده است.
|
||||
|
||||
## نحوه دریافت ورودی
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ name: qa-clinicpro
|
||||
description: تست QA اپلیکیشن ClinicPro مثل یک کاربر واقعی — ابتدا ساخت همهٔ نقشها و پروفایلهای کامل (پزشک مستقل، پزشک عضو کلینیک، کلینیک، منشی، نماینده، بیمار، …) و تعیین ماتریس سطح دسترسی، سپس تست ماتریس دسترسی با تکتک آنها. هر مانعی سر راه تست را مثل یک دولوپر ارشد Symfony/React خودش رفع میکند و تست را ادامه میدهد. اجرای اپ، ورود با هر نقش، پیمایش صفحات پنل ادمین، اسکرینشات، کشف خطاهای کنسول و شبکه، تست UI/UX و RTL، تست دسترسی نقشها (authz)، تست قرارداد API و اندازهگیری کارایی، و تولید Bug Report. Use when asked to QA, test, smoke-test, find bugs in, screenshot, or verify ClinicPro's admin panel or API — «تست کن»، «باگ پیدا کن»، «QA کن»، «این صفحه را بررسی کن».
|
||||
---
|
||||
|
||||
> **قبل از شروع، فایل `clinicpro/.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است.**
|
||||
|
||||
# QA ClinicPro
|
||||
|
||||
ClinicPro = بکاند Symfony 7.4 + یک **SPA کلاینتساید React 19** که از `/admin/*` سرو میشود.
|
||||
|
||||
@@ -33,12 +33,17 @@ const PORT = Number(process.env.CDP_PORT ?? 9444);
|
||||
* provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
|
||||
* until they are. TEST_USERS.md is stale — its accounts do not exist.
|
||||
*/
|
||||
// Re-verified 2026-08-07 by real logins: the DB was reseeded, so the old clinic
|
||||
// and secretary numbers are gone and 09390039833 is now ROLE_CLINIC, not doctor.
|
||||
// `staff` was seeded with its mobile as the password rather than QaTest@1234.
|
||||
const ROLES = {
|
||||
admin: ['09120671756', 'QaTest@1234'],
|
||||
clinic: ['09127000000', 'QaTest@1234'],
|
||||
secretary: ['09123456778', 'QaTest@1234'],
|
||||
doctor: ['09390039833', 'QaTest@1234'],
|
||||
clinic: ['09390039833', 'QaTest@1234'],
|
||||
secretary: ['0912000109', 'QaTest@1234'],
|
||||
doctor: ['0912000101', 'QaTest@1234'],
|
||||
representation: ['09124000001', 'QaTest@1234'],
|
||||
staff: ['09128726723', '09128726723'],
|
||||
multirole: ['0912000201', 'QaTest@1234'],
|
||||
|
||||
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
|
||||
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
|
||||
@@ -227,9 +232,16 @@ async function withPage(url, opts, fn) {
|
||||
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
|
||||
version: 0,
|
||||
};
|
||||
// `--ui '{"darkMode":true,"density":"compact"}'` تم را پیش از اولین رندر میکارد.
|
||||
const ui = opts.ui
|
||||
? `localStorage.setItem('clinicpro-ui', ${JSON.stringify(
|
||||
JSON.stringify({ state: JSON.parse(opts.ui), version: 0 }),
|
||||
)});`
|
||||
: '';
|
||||
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
|
||||
localStorage.setItem('pwa-dismissed','1');`,
|
||||
localStorage.setItem('pwa-dismissed','1');${ui}`,
|
||||
});
|
||||
|
||||
// Errors before this point belong to the login page, not the page under test.
|
||||
@@ -458,6 +470,9 @@ const opts = {
|
||||
wait: Number(flag('wait', 4000)),
|
||||
full: argv.includes('--full'),
|
||||
body: flag('body', null),
|
||||
// تم و چگالی در `localStorage['clinicpro-ui']` مینشینند و بدون seed کردنشان
|
||||
// دارکمود و حالت فشرده اصلاً قابل اسکرینشات نیستند.
|
||||
ui: flag('ui', null),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,165 +1,222 @@
|
||||
---
|
||||
name: redesign-page
|
||||
description: بازطراحی UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرینشات گرفتن از صفحه، نگاشت URL به فایل سورس، آدیت انحرافها از دیزاینسیستم، و بازنویسی صفحه با کامپوننتها و توکنهای موجود. استفاده کن وقتی کاربر یک URL از /admin میدهد و میگوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «redesign this page»، «این قسمت را درست کن»، یا «screenshot این صفحه».
|
||||
description: نقد و بازطراحی حرفهای UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرینشات در چهار نما (روشن، تیره، فشرده، موبایل)، پروبِ دسترسیپذیری روی DOM زنده، نگاشت URL به فایل سورس، آدیت انحراف از دیزاینسیستم، و بازنویسی با کامپوننتها و توکنهای موجود. استفاده کن وقتی کاربر یک URL از /admin میدهد و میگوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «این صفحه را نقد کن»، «redesign this page»، «UI/UX review»، «این قسمت را درست کن»، یا «screenshot این صفحه».
|
||||
---
|
||||
|
||||
# بازطراحی صفحه پنل ادمین ClinicPro
|
||||
> **قبل از شروع، فایل `clinicpro/.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است.**
|
||||
|
||||
# نقد و بازطراحی صفحهٔ پنل ادمین ClinicPro
|
||||
|
||||
نقش: متخصص ارشد UI/UX. هدف **بهبود تجربهٔ کاربری در چهارچوب تم فعلی** است، نه ساختن
|
||||
هویت بصری جدید. هر تغییری که با دیزاینسیستم فعلی ناسازگار باشد، رد است.
|
||||
|
||||
پنل ادمین یک SPA کلاینتساید است (React 19 + Webpack Encore، سرو شده از `/admin/*`).
|
||||
یعنی `curl` و فلگ `--screenshot` کروم به درد نمیخورند: هر دو روی فرم لاگین مینشینند،
|
||||
چون توکن JWT در `localStorage['clinicpro-auth']` است.
|
||||
|
||||
درایور این skill آن کار را انجام میدهد: با API لاگین میکند، `localStorage` را seed
|
||||
میکند، بعد ناوبری و اسکرینشات میگیرد — با CDP روی `WebSocket` نیتیو Node 22،
|
||||
**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
|
||||
چون توکن JWT در `localStorage['clinicpro-auth']` است. درایور این skill آن کار را
|
||||
میکند: با API لاگین میکند، `localStorage` را seed میکند، تم/تراکم را مینشاند، بعد
|
||||
ناوبری و اسکرینشات میگیرد — با CDP روی `WebSocket` نیتیو Node 22، **بدون هیچ وابستگی
|
||||
npm** (نه playwright، نه puppeteer).
|
||||
|
||||
مسیرها نسبت به `clinicpro/` هستند.
|
||||
|
||||
## پیشنیازها
|
||||
|
||||
هیچ نصبی لازم نیست. فقط این دو:
|
||||
هیچ نصبی لازم نیست:
|
||||
|
||||
```bash
|
||||
ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
|
||||
ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
```
|
||||
|
||||
کروم در مسیر دیگری است؟ `CHROME_BIN` را ست کن.
|
||||
کروم جای دیگری است؟ `CHROME_BIN` را ست کن.
|
||||
|
||||
## گردش کار
|
||||
|
||||
### ۱. اسکرینشات صفحه فعلی
|
||||
### ۰. اول دیزاینسیستم را بخوان — قبل از هر چیز
|
||||
|
||||
**منبع حقیقتِ توکنها `assets/admin/styles.css` است**، نه `docs/admin-ui/ui-design-spec.md`
|
||||
(آن سند قدیمی است و پالت بنفشش با کد شیپشده نمیخواند).
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs shot \
|
||||
"https://clinic-pro.ddev.site/admin/appointments" --out /tmp/before.png
|
||||
node .claude/skills/redesign-page/driver.mjs ds # توکنها + کلاسها + کامپوننتها
|
||||
node .claude/skills/redesign-page/driver.mjs ds tokens # فقط توکنها
|
||||
node .claude/skills/redesign-page/driver.mjs ds components # فقط کامپوننتهای مشترک با Props
|
||||
```
|
||||
|
||||
**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** بدون دیدنِ صفحه، بازطراحی
|
||||
یعنی حدس زدن.
|
||||
خروجی واقعی: `TOKENS (57)` و `SHARED COMPONENTS (26)`. قانون ترتیب:
|
||||
**اول کامپوننت موجود، بعد توسعه/عمومیکردنش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
|
||||
|
||||
فلگها: `--w 1440 --h 900` (سایز ویوپورت)، `--wait 4000` (میلیثانیه صبر برای رندر)،
|
||||
`--full` (کل صفحه، نه فقط ویوپورت).
|
||||
|
||||
موبایل هم ببین — این پنل RTL و پرجدول است و بیشتر مشکلات ریسپانسیو آنجاست:
|
||||
### ۱. چهار نمای اجباری
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs shot \
|
||||
"https://clinic-pro.ddev.site/admin/appointments" --w 390 --h 844 --out /tmp/mobile.png
|
||||
node .claude/skills/redesign-page/driver.mjs variants \
|
||||
"https://clinic-pro.ddev.site/admin/resources" --dir /tmp/clinicpro-review
|
||||
```
|
||||
|
||||
### ۲. نگاشت URL به سورس + آدیت
|
||||
چهار فایل میسازد: `-light` · `-dark` · `-compact` · `-mobile`. **هر چهار را با ابزار
|
||||
Read باز کن و نگاه کن.** قضاوت با یک اسکرینشات یعنی صفحهای که در سه نمای دیگر خراب است.
|
||||
تم تیره و تراکم فشرده در این پنل تنظیمات واقعی کاربرند، نه فرض.
|
||||
|
||||
تکنما:
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs inspect \
|
||||
"https://clinic-pro.ddev.site/admin/clinics/41e325c4-e825-4067-8438-5d828ecaee09"
|
||||
node .claude/skills/redesign-page/driver.mjs shot "<url>" --out /tmp/x.png \
|
||||
--theme dark --density compact --w 390 --h 844 --full --wait 6000
|
||||
```
|
||||
|
||||
فلگها: `--w/--h` ویوپورت · `--wait` میلیثانیه · `--full` کل صفحه ·
|
||||
`--theme light|dark` · `--density comfortable|compact` · `--context clinic|personal` ·
|
||||
`--no-probe`.
|
||||
|
||||
هر `shot` یک **پروب رانتایم** هم میزند که فقط روی DOM رندرشده دیدنی است:
|
||||
|
||||
```
|
||||
RUNTIME
|
||||
⚠ 2 form field(s) with no label
|
||||
```
|
||||
|
||||
چه چیزهایی میگیرد: سرریز افقی، دکمهٔ آیکونیِ بینام (بدون `aria-label`/`title`)،
|
||||
فیلد بدون لیبل، و کنترل کوتاهتر از ۳۲px (هدف لمسی ۴۴px است).
|
||||
|
||||
### ۲. نگاشت URL به سورس + آدیت ایستا
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.site/admin/resources"
|
||||
```
|
||||
|
||||
خروجی واقعی:
|
||||
|
||||
```
|
||||
route clinics/:uuid
|
||||
component ClinicDetailPage
|
||||
file assets/admin/pages/ClinicDetailPage.tsx
|
||||
components ConfirmDialog, Modal, PageHeader, SearchableSelect, NotificationMobileCard
|
||||
lines 1035
|
||||
route resources
|
||||
component ResourcesPage
|
||||
file assets/admin/pages/ResourcesPage.tsx
|
||||
components PageHeader, DataTable, ResourceBlocksModal, ConfirmDialog, SearchableSelect, …
|
||||
lines 278
|
||||
test assets/admin/pages/ResourcesPage.test.tsx
|
||||
|
||||
AUDIT
|
||||
assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
|
||||
AUDIT clean
|
||||
```
|
||||
|
||||
روی هر فایل دلخواه هم مستقیم:
|
||||
روی هر فایل مستقیم:
|
||||
|
||||
```bash
|
||||
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/AppointmentsPage.tsx
|
||||
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/ClinicDetailPage.tsx
|
||||
# AUDIT
|
||||
# assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
|
||||
```
|
||||
|
||||
### ۳. قبل از نوشتن کد، دیزاینسیستم را بخوان
|
||||
آدیت اینها را میگیرد: `<select>` نیتیو، `.btn` بدون واریانت، هگز هاردکد، overlay دستی،
|
||||
`<label>` داخل `.field`، تاریخ میلادی، `type="date"`، دکمهٔ آیکونی بدون `aria-label`،
|
||||
توکن تعریفنشده، و `.seg` بدون کلاس `on/active`.
|
||||
|
||||
**منبع حقیقتِ توکنها `assets/admin/styles.css` است** — نه `docs/admin-ui/ui-design-spec.md`
|
||||
(آن سند قدیمی و پالت بنفشش با کد شیپشده نمیخواند).
|
||||
### ۳. گزارش — همیشه با این ۹ بخش
|
||||
|
||||
```bash
|
||||
sed -n '/^:root/,/^}/p' assets/admin/styles.css | head -60 # توکنها
|
||||
ls assets/admin/components/ui/ # کامپوننتهای آماده
|
||||
```
|
||||
۱. تحلیل صفحه چه کاری برای چه کاربری؛ جریان اصلی
|
||||
۲. مشکلات UI سلسلهمراتب بصری، فاصله، تایپوگرافی، رنگ، انحراف از DS
|
||||
۳. مشکلات UX جریان کار، تعداد کلیک، حالتهای Loading/Empty/Error، ریسپانسیو، دسترسیپذیری
|
||||
۴. پیشنهادهای بهبود برای هر مشکل، یک راهحل مشخص و قابل اجرا
|
||||
۵. ساختار جدید صفحه چیدمان پیشنهادی، در چهارچوب همین تم
|
||||
۶. کامپوننتهای قابل استفادهٔ مجدد از components/ui که همین حالا جواب میدهند
|
||||
۷. کامپوننتهای نیازمند بهبود کدام Props/API باید عمومیتر شود و چرا
|
||||
۸. کامپوننتهای جدید فقط در صورت ضرورت، با دلیل نبودِ جایگزین
|
||||
۹. دلیل هر تغییر چرا این تغییر تجربه را بهتر میکند
|
||||
```
|
||||
|
||||
قانون: **اول کامپوننت موجود، بعد توسعهاش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
|
||||
هر یافته باید به `file:line` وصل باشد یا به یکی از اسکرینشاتها. یافتهٔ بیارجاع، حدس است.
|
||||
|
||||
### ۴. بازنویسی، سپس مقایسه
|
||||
|
||||
بعد از ادیت، دوباره اسکرینشات بگیر و با `before.png` مقایسه کن:
|
||||
|
||||
```bash
|
||||
yarn dev # یا: yarn watch
|
||||
node .claude/skills/redesign-page/driver.mjs shot "<همان url>" --out /tmp/after.png
|
||||
ddev exec yarn dev
|
||||
node .claude/skills/redesign-page/driver.mjs variants "<همان url>" --dir /tmp/clinicpro-review-after
|
||||
```
|
||||
|
||||
before/after را کنار هم بگذار. اگر تفاوتی دیده نمیشود، باندل قدیمی است.
|
||||
|
||||
### ۵. تست + تایپچک (بدون این، تسک تمام نیست)
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
npx vitest run assets/admin/pages/<YourPage>.test.tsx
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json
|
||||
npx vitest run # روی هاست، نه داخل ddev
|
||||
```
|
||||
|
||||
توجه: سوییت کامل همین الان **۲۱ تست از پیش شکسته** دارد (`api.test.ts`، `LoginPage`،
|
||||
`PatientDetailPage`، …) که ربطی به کار تو ندارند. قبل از شروع یکبار `npx vitest run`
|
||||
بگیر و عدد پایه را یادداشت کن، وگرنه خطاهای موجود را به گردن تغییر خودت میاندازی.
|
||||
خط پایه در ۲۰۲۶-۰۸-۰۲: **۱۰۰ فایل، ۶۶۰ تست، همه سبز.** هر شکستی مالِ توست.
|
||||
(نسخهٔ قبلی این سند از «۲۱ تست از پیش شکسته» میگفت — دیگر درست نیست.)
|
||||
|
||||
## چکلیست بازطراحی
|
||||
|
||||
درایور موارد گرپشدنی را میگیرد؛ اینها را باید خودت با چشم ببینی:
|
||||
درایور موارد گرپشدنی و DOMی را میگیرد؛ اینها را باید خودت با چشم ببینی:
|
||||
|
||||
- **`.field` در مقابل `.field-block`** — `.field` یک باکس افقی بوردردار است که لیبل
|
||||
*داخلش* مینشیند. اگر `<label>` داخل `.field` بگذاری، لیبل کنار اینپوت میچسبد؛ و اگر
|
||||
`SearchableSelect` داخلش بگذاری، دو باکس تودرتو میشود. برای «لیبل بالای فیلد» از
|
||||
`.field-block` استفاده کن.
|
||||
- **`className="btn"` بدون واریانت** بیرنگ و بدون بوردر رندر میشود — عملاً نامرئی.
|
||||
همیشه `btn primary` / `btn ghost` / `btn soft` / `btn danger`.
|
||||
- **دکمههای فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
|
||||
- **توکن مرده** — مثلاً `var(--error)` وجود ندارد (`--danger` درست است). درایور این را میگیرد.
|
||||
- **سلسلهمراتب** — عنوان صفحه در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
|
||||
- **`.field` در مقابل `.field-block`** — `.field` خودش باکسِ بوردردار اینپوت است. لیبل
|
||||
داخلش یعنی لیبل چسبیده به اینپوت؛ `SearchableSelect` داخلش یعنی دو باکس تودرتو.
|
||||
«لیبل بالای فیلد» → `.field-block`.
|
||||
- **`.card` پدینگ ندارد** — `card-pad` آن را میدهد.
|
||||
- **`className="btn"` بدون واریانت** بیرنگ و بیبوردر رندر میشود، عملاً نامرئی.
|
||||
همیشه `btn primary` / `btn secondary` / `btn ghost` / `btn danger`.
|
||||
- **دکمهٔ فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
|
||||
- **`.seg`** فقط `button` و `a` را میشناسد و کلاس فعالش `on` است (`active` هم alias شد).
|
||||
تبِ فعالِ بیکلاس یعنی هیچ نشانهای ندارد.
|
||||
- **سلسلهمراتب** — عنوان در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
|
||||
- **صفحهٔ زیرمجموعه** حتماً `backTo` یا `<BackButton fallback=…>` دارد.
|
||||
- **وضعیت لیست در URL** — جستجو/فیلتر/صفحه با `useUrlState`، نه `useState`؛ وگرنه
|
||||
«بازگشت» نما را میپراند.
|
||||
- **RTL/جلالی** — رشتههای جدید فارسی، تاریخها جلالی، اعداد با `formatNumber`/`formatRial`.
|
||||
- **دارکمود** — چون توکن استفاده میکنی خودکار درست است؛ هگز هاردکد آن را میشکند.
|
||||
- **دارکمود** — با توکن خودکار درست است؛ یک هگز هاردکد آن را میشکند.
|
||||
- **سه حالت داده** — Loading / Empty / Error هر سه باید طراحی داشته باشند، نه فقط حالت پر.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **ریدایرکت خاموش نقشها.** `RoleRoute` کاربری که نقشش اجازه ندارد را بیصدا به
|
||||
`/admin/dashboard` میبرد. یعنی یک اسکرینشات کاملاً سالم از **صفحهٔ اشتباه** میگیری.
|
||||
درایور مسیر نهایی را با مسیر درخواستی مقایسه میکند و هشدار میدهد:
|
||||
- **کاربر پیشفرض درایور با دیتابیس فعلی هماهنگ است، ولی دیتابیس عوض میشود.**
|
||||
پیشفرض `0912000201` / `QaTest@1234` (پزشکِ مالک کلینیک) است. اگر لاگین `ERR_AUTH_005`
|
||||
داد، دیتابیس دوباره seed شده: `ddev exec php bin/console app:seed-scenarios --reset -n`.
|
||||
در اجرای ۲۰۲۶-۰۸-۰۲ کاربر `0912000301` (مالک غیرپزشک) **رمز نداشت** و لاگینش رد شد؛
|
||||
از `0912000201` یا `0912000101` استفاده کن.
|
||||
|
||||
- **ریدایرکت خاموش نقشها.** `RoleRoute` کاربرِ بیمجوز را بیصدا به `/admin/dashboard`
|
||||
میبرد — یعنی یک اسکرینشات کاملاً سالم از **صفحهٔ اشتباه**. درایور مقایسه میکند:
|
||||
|
||||
```
|
||||
⚠ WRONG PAGE: asked for /admin/clinics/…, landed on /admin/dashboard
|
||||
```
|
||||
|
||||
کاربر پیشفرض (`09390039833`) نقش **doctor** دارد. صفحات ادمین/کلینیک با آن باز نمیشوند.
|
||||
برای آنها `CLINICPRO_USER` / `CLINICPRO_PASS` را ست کن.
|
||||
- **محیط کاری، نه نقش.** کاربری که هم مطب شخصی دارد هم کلینیک، پیشفرض روی مطب مینشیند و
|
||||
صفحهٔ منابع/سرویسهای کلینیک **خالی** میآید. این باگ نیست: `--context clinic` بده.
|
||||
|
||||
- **کاربران تستی ممکن است seed نشده باشند.** `TEST_USERS.md` ادمین `09100000001` با رمز
|
||||
`Test@1234` را مستند میکند، ولی روی این دیتابیس وجود نداشت و لاگین `ERR_AUTH_005` داد.
|
||||
ساختنشان: `ddev exec php create_test_users.php` (دیتابیس را مینویسد — اول بپرس).
|
||||
- **مودال نصب PWA جلوی صفحه را میگیرد.** درایور `pwa-dismissed=1` را seed میکند. با
|
||||
کروم خام، این مودال وسط تصویر است.
|
||||
|
||||
- **مودال نصب PWA جلوی صفحه را میگیرد.** درایور `localStorage['pwa-dismissed']='1'` را
|
||||
seed میکند. اگر با کروم خام اسکرینشات بگیری، این مودال وسط تصویر است.
|
||||
- **تم فقط با صفت `data-theme` نمیماند** — بعد از hydrate از `localStorage['clinicpro-ui']`
|
||||
دوباره خوانده میشود. درایور هر دو را مینویسد و بعد از رندر یک بار دیگر صفت را میگذارد.
|
||||
|
||||
- **موبایل بدون `Emulation.setDeviceMetricsOverride` فقط «پنجرهٔ باریک» است** — مدیا
|
||||
کوئریهای `pointer: coarse` خاموش میمانند و ارتفاع لمسی ۴۴px دیده نمیشود. درایور برای
|
||||
عرض ≤۴۸۰ خودش این را روشن میکند.
|
||||
|
||||
- **کپچا (altcha) لوکال اجباری نیست.** `POST /api/v1/user/login` بدون فیلد `altcha` هم
|
||||
توکن میدهد؛ درایور به همین تکیه میکند. اگر روی محیطی که کپچا را اجبار میکند اجرا شود، میشکند.
|
||||
توکن میدهد؛ درایور به همین تکیه میکند و روی محیطی که کپچا را اجبار کند میشکند.
|
||||
|
||||
- **سرت ddev را Node رد میکند** (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). درایور فقط برای
|
||||
هاستهای `*.ddev.site` / `localhost` تأیید TLS را خاموش میکند، نه برای هر مبدأ.
|
||||
`*.ddev.site` / `localhost` تأیید TLS را خاموش میکند، نه برای هر مبدأ.
|
||||
|
||||
- **صفحهٔ نوبتها خودش اسکرول میشود** به ساعت جاری، پس ویوپورت وسط تایملاین میافتد.
|
||||
برای دیدن هدر از `--full` استفاده کن.
|
||||
- **صفحهٔ نوبتها خودش تا ساعت جاری اسکرول میکند**، پس ویوپورت وسط تایملاین میافتد.
|
||||
برای دیدن هدر `--full` بده.
|
||||
|
||||
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` میدهد** — از قبل وجود دارد و جلوی
|
||||
کامپایل JS/TS را نمیگیرد. خطاهای TypeScript همچنان در خروجی `tsc` میآیند.
|
||||
- **منوی تنظیمات دو مصرفکننده دارد** — `settingsMenu.ts` منبع واحد است؛ سایدبار دسکتاپ و
|
||||
فهرست موبایل هر دو از آن میخوانند. در موبایل کل منو **بالای** محتوا مینشیند، پس صفحهٔ
|
||||
تنظیماتی در نمای ۳۹۰px یعنی ۱۵ آیتم منو قبل از رسیدن به محتوا. در نقد موبایل حتماً ببینش.
|
||||
|
||||
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` میدهد** — از قبل هست و جلوی کامپایل
|
||||
JS/TS را نمیگیرد؛ خطاهای TypeScript همچنان در خروجی `tsc` میآیند.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| علامت | علت / راهحل |
|
||||
|---|---|
|
||||
| `login failed for 0912000201: … ERR_AUTH_005` | دیتابیس دوباره seed شده. `ddev exec php bin/console app:seed-scenarios --reset -n` یا `CLINICPRO_USER`/`CLINICPRO_PASS` را ست کن. |
|
||||
| `Chrome did not expose CDP on :9333` | نمونهٔ کروم قبلی زنده مانده. `CDP_PORT=9444` بده یا پروسه را بکش. |
|
||||
| `login failed: … ERR_AUTH_005` | کاربر seed نشده یا رمز فرق دارد. `TEST_USERS.md` را ببین. |
|
||||
| `⚠ redirected to /login` | توکن رد شد؛ معمولاً یعنی JWT منقضی شده — دوباره اجرا کن. |
|
||||
| `⚠ redirected to /login` | توکن رد شد؛ معمولاً JWT منقضی شده — دوباره اجرا کن. |
|
||||
| `⚠ WRONG PAGE` | نقشِ کاربر اجازه ندارد، یا محیط اشتباه است (`--context clinic`). |
|
||||
| `⚠ page text is only N chars` | صفحه خالی رندر شده. `--wait 8000` بده یا کنسول را چک کن. |
|
||||
| اسکرینشات تغییرات را نشان نمیدهد | باندل قدیمی است. `yarn dev` بزن (یا `yarn watch` روشن باشد). |
|
||||
| اسکرینشات تغییرات را نشان نمیدهد | باندل قدیمی است. `ddev exec yarn dev` (یا `yarn watch` روشن). |
|
||||
| صفحهٔ کلینیک خالی است ولی خطا ندارد | محیط روی مطب شخصی است. `--context clinic`. |
|
||||
|
||||
@@ -4,27 +4,39 @@
|
||||
* SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
|
||||
* CDP directly, so there is no playwright/puppeteer install to babysit).
|
||||
*
|
||||
* node .claude/skills/redesign-page/driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
|
||||
* node .claude/skills/redesign-page/driver.mjs inspect <url>
|
||||
* node .claude/skills/redesign-page/driver.mjs audit <file.tsx>
|
||||
* driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
|
||||
* [--theme dark] [--density compact] [--context clinic]
|
||||
* driver.mjs variants <url> [--dir /tmp/review] ← the four shots a review needs
|
||||
* driver.mjs inspect <url>
|
||||
* driver.mjs audit <file.tsx>
|
||||
* driver.mjs ds [components|tokens]
|
||||
*
|
||||
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
|
||||
* navigates and captures. Needed because the admin is a client-side
|
||||
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
|
||||
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
|
||||
* the design-system components it already imports.
|
||||
* `audit` greps one source file for the anti-patterns this project keeps
|
||||
* regrowing (native <select>, hardcoded hex, dead tokens, …).
|
||||
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
|
||||
* navigates and captures. Needed because the admin is a client-side
|
||||
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
|
||||
* `variants` runs `shot` four times — light desktop, dark, compact, 390px mobile.
|
||||
* A redesign judged on one screenshot ships a page that breaks in the
|
||||
* other three; dark mode and compact density are real user settings
|
||||
* here, not hypotheticals.
|
||||
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
|
||||
* the design-system components it already imports.
|
||||
* `audit` greps one source file for the anti-patterns this project keeps
|
||||
* regrowing (native <select>, hardcoded hex, dead tokens, …) plus the
|
||||
* accessibility misses that never fail a build.
|
||||
* `ds` prints the design system — tokens from styles.css and the shared
|
||||
* components — so a redesign starts from what exists.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
|
||||
const USER = process.env.CLINICPRO_USER ?? '09390039833';
|
||||
const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
|
||||
// دو کاربرِ سیدر که رمز دارند: 0912000101 پزشک مستقل، 0912000201 پزشکِ مالک کلینیک.
|
||||
// دومی هر دو محیط را دارد، پس بیشترین صفحه با آن باز میشود.
|
||||
const USER = process.env.CLINICPRO_USER ?? '0912000201';
|
||||
const PASS = process.env.CLINICPRO_PASS ?? 'QaTest@1234';
|
||||
const CHROME = process.env.CHROME_BIN
|
||||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const PORT = Number(process.env.CDP_PORT ?? 9333);
|
||||
@@ -70,6 +82,33 @@ function cdp(ws) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ورود با OTP — در dev کد ثابتِ `12345` است (OtpService::sendCode)، پس کل زنجیرهٔ
|
||||
* send-code → verify-code → otp-login اسکریپتپذیر است.
|
||||
*
|
||||
* راهِ نجاتِ دیتابیسی که seed نشده: کاربر واقعیِ چنین دیتابیسی ممکن است اصلاً
|
||||
* `password_hash` نداشته باشد یا رمزش را ندانیم، ولی شمارهٔ موبایلش کافی است.
|
||||
*/
|
||||
async function otpLogin(mobile) {
|
||||
const post = async (path, body) => {
|
||||
const r = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return r.json();
|
||||
};
|
||||
|
||||
const sent = await post('/api/v1/user/send-code', { mobile });
|
||||
if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 160)}`);
|
||||
|
||||
const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
|
||||
const grant = ver?.data?.grant;
|
||||
if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 160)}`);
|
||||
|
||||
return post('/api/v1/user/otp-login', { grant });
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const r = await fetch(`${BASE}/api/v1/user/login`, {
|
||||
method: 'POST',
|
||||
@@ -77,12 +116,62 @@ async function login() {
|
||||
body: JSON.stringify({ mobile_number: USER, password: PASS }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.access_token) throw new Error(`login failed: ${JSON.stringify(j).slice(0, 200)}`);
|
||||
return j;
|
||||
if (j.access_token) return j;
|
||||
|
||||
// رمز نخورد؛ با OTP امتحان کن. دیتابیسهای واقعی (نه seed) رمزِ مستندشده ندارند و
|
||||
// بدون این، تنها راه یا reset کردن دیتابیس کاربر بود یا نوشتنِ رمز روی حسابش.
|
||||
const otp = await otpLogin(USER).catch((e) => ({ _err: e.message }));
|
||||
if (otp.access_token) return otp;
|
||||
|
||||
// `send-code` سقف ۵ بار در ساعت دارد. وقتی سوخت، بهجای شلکردن یک محدودیتِ واقعیِ
|
||||
// محصول برای تست، توکن را با کامندِ خودِ اپ میسازیم — همان کلید و همان claimها.
|
||||
try {
|
||||
const out = execSync(
|
||||
`ddev exec 'php bin/console lexik:jwt:generate-token ${USER} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
);
|
||||
const minted = out.trim().split('\n').pop().trim();
|
||||
if (minted.startsWith('ey')) return { access_token: minted, refresh_token: null };
|
||||
} catch { /* کامند در دسترس نیست؛ میافتیم روی خطای زیر */ }
|
||||
|
||||
throw new Error(
|
||||
`login failed for ${USER}: ${JSON.stringify(j).slice(0, 160)}\n`
|
||||
+ ` → OTP fallback also failed: ${otp._err ?? JSON.stringify(otp).slice(0, 120)}\n`
|
||||
+ ' → CLINICPRO_USER را روی شمارهٔ یک کاربر واقعیِ همین دیتابیس بگذار،\n'
|
||||
+ ' یا حسابهای تست را بساز: ddev exec php bin/console app:seed-scenarios --reset -n',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط کاری را عوض میکند. کاربری که هم مطب شخصی دارد هم کلینیک، پیشفرض روی مطب
|
||||
* مینشیند و صفحههای کلینیک خالی میآیند — که شبیه باگ است ولی نیست.
|
||||
*/
|
||||
async function switchContext(token, kind) {
|
||||
// فهرست محیطها فقط در `/oauth/userinfo` است. مسیر قبلی (`/api/v1/user/me`) اصلاً
|
||||
// وجود ندارد و ۴۰۴ میداد، پس `--context clinic` همیشه بیصدا نادیده گرفته میشد و
|
||||
// اسکرینشاتِ محیطِ اشتباه گرفته میشد.
|
||||
const me = await (await fetch(`${BASE}/oauth/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})).json().catch(() => ({}));
|
||||
|
||||
const contexts = me?.data?.available_contexts ?? me?.available_contexts ?? [];
|
||||
const want = contexts.find((c) => (c.type ?? c.scope) === kind);
|
||||
if (!want) {
|
||||
console.log(`⚠ no "${kind}" context for this user — staying where we are`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(`${BASE}/api/v1/auth/switch-context`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ db_uuid: want.db_uuid }),
|
||||
});
|
||||
console.log(`context → ${kind} (${want.name ?? want.db_uuid})`);
|
||||
}
|
||||
|
||||
async function shot(url, opts) {
|
||||
const { access_token, refresh_token } = await login();
|
||||
if (opts.context) await switchContext(access_token, opts.context);
|
||||
|
||||
const chrome = spawn(CHROME, [
|
||||
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
||||
@@ -105,6 +194,15 @@ async function shot(url, opts) {
|
||||
await S('Page.enable');
|
||||
await S('Runtime.enable');
|
||||
|
||||
// موبایل بدون این فلگ فقط «پنجرهٔ باریک» است: مدیا کوئریهای pointer: coarse
|
||||
// خاموش میمانند و ارتفاع لمسی ۴۴px که برای موبایل نوشته شده دیده نمیشود.
|
||||
if (opts.w <= 480) {
|
||||
await S('Emulation.setDeviceMetricsOverride', {
|
||||
width: opts.w, height: opts.h, deviceScaleFactor: 2, mobile: true,
|
||||
});
|
||||
await S('Emulation.setTouchEmulationEnabled', { enabled: true });
|
||||
}
|
||||
|
||||
// localStorage is origin-scoped, so the origin must be loaded before seeding.
|
||||
await S('Page.navigate', { url: `${BASE}/admin/login` });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
@@ -115,20 +213,62 @@ async function shot(url, opts) {
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
// تم و تراکم را uiStore در همان localStorage نگه میدارد و روی <html> مینشاند؛
|
||||
// ستکردن مستقیم صفت، بعد از hydrate پس گرفته میشود، پس استور هم نوشته میشود.
|
||||
const ui = { state: { darkMode: opts.theme === 'dark', density: opts.density }, version: 0 };
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `
|
||||
localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
|
||||
localStorage.setItem('clinicpro-ui', ${JSON.stringify(JSON.stringify(ui))});
|
||||
localStorage.setItem('pwa-dismissed', '1');
|
||||
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
|
||||
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
|
||||
`,
|
||||
});
|
||||
|
||||
await S('Page.navigate', { url });
|
||||
await new Promise((r) => setTimeout(r, opts.wait));
|
||||
|
||||
// مودالها فقط با تعامل باز میشوند و بدون این، نقدشان ممکن نیست: --click یک
|
||||
// متنِ دیدنی یا سلکتور میگیرد، اولین تطابق را میزند و منتظر رندر میماند.
|
||||
if (opts.click) {
|
||||
const clicked = await S('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `(() => {
|
||||
const q = ${JSON.stringify(opts.click)};
|
||||
let el = null;
|
||||
try { el = document.querySelector(q); } catch {}
|
||||
if (!el) {
|
||||
// دکمه بر لینک مقدم است: نامِ یکسان معمولاً هم در سایدبار (a) هست هم
|
||||
// روی خودِ صفحه (button)، و منظورِ نقد همیشه دومی است.
|
||||
const hits = [...document.querySelectorAll('button,[role=button],a,td,.slot,.tl-slot')]
|
||||
.filter((n) => (n.innerText || '').trim().includes(q) && n.offsetParent !== null);
|
||||
el = hits.find((n) => n.closest('nav,.sidebar') === null) ?? hits[0];
|
||||
}
|
||||
if (!el) return 'not found: ' + q;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
el.click();
|
||||
return 'clicked: ' + (el.innerText || el.className || el.tagName).slice(0, 60);
|
||||
})()`,
|
||||
});
|
||||
console.log(' CLICK', clicked?.result?.value ?? '—');
|
||||
await new Promise((r) => setTimeout(r, opts.clickWait ?? 1800));
|
||||
}
|
||||
|
||||
// تم بعد از hydrate ممکن است از استور دوباره خوانده شود؛ آخرین کلام با ما.
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `
|
||||
document.documentElement.setAttribute('data-theme', ${JSON.stringify(opts.theme)});
|
||||
document.documentElement.setAttribute('data-density', ${JSON.stringify(opts.density)});
|
||||
`,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
const { data } = await S('Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
captureBeyondViewport: opts.full,
|
||||
});
|
||||
mkdirSync(dirname(resolve(opts.out)), { recursive: true });
|
||||
writeFileSync(opts.out, Buffer.from(data, 'base64'));
|
||||
console.log(`✓ ${opts.out}`);
|
||||
|
||||
@@ -149,12 +289,84 @@ async function shot(url, opts) {
|
||||
console.log(' → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
|
||||
}
|
||||
if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
|
||||
|
||||
if (opts.probe) await probeRuntime(S);
|
||||
|
||||
ws.close();
|
||||
} finally {
|
||||
chrome.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* چیزهایی که فقط در DOMِ رندرشده دیده میشوند و هیچ گرپی رویشان نمیافتد:
|
||||
* سرریز افقی، دکمهٔ بینام، و فیلد بدون لیبل.
|
||||
*/
|
||||
async function probeRuntime(S) {
|
||||
const { result } = await S('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `(() => {
|
||||
const out = [];
|
||||
if (document.documentElement.scrollWidth > window.innerWidth + 2) {
|
||||
out.push('horizontal scroll: page is ' + document.documentElement.scrollWidth
|
||||
+ 'px wide in a ' + window.innerWidth + 'px viewport');
|
||||
}
|
||||
// شمارش تنها میگوید «۲ تا»، نه «کدام دو تا» — و حدس زدنش وقت تلف کردن است.
|
||||
const where = (el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
|
||||
const txt = (el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
||||
const near = el.closest('[class]');
|
||||
return tag + (el.id ? '#' + el.id : '') + (cls.length ? '.' + cls.join('.') : '')
|
||||
+ (txt ? ' «' + txt + '»' : '')
|
||||
+ (near && near !== el && typeof near.className === 'string'
|
||||
? ' ← in .' + near.className.trim().split(/\s+/)[0] : '');
|
||||
};
|
||||
const list = (arr) => arr.map(where).join(' · ');
|
||||
|
||||
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
|
||||
.filter(b => !(b.innerText || '').trim()
|
||||
&& !b.getAttribute('aria-label') && !b.getAttribute('title'));
|
||||
if (nameless.length) out.push(nameless.length + ' icon-only control(s) with no accessible name\n ' + list(nameless));
|
||||
const unlabelled = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
|
||||
.filter(i => !i.getAttribute('aria-label') && !i.getAttribute('aria-labelledby')
|
||||
&& !(i.id && document.querySelector('label[for="' + i.id + '"]'))
|
||||
&& !i.closest('label'));
|
||||
if (unlabelled.length) out.push(unlabelled.length + ' form field(s) with no label\n ' + list(unlabelled));
|
||||
const tiny = [...document.querySelectorAll('button, a')]
|
||||
.filter(b => { const r = b.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && r.height < 32; });
|
||||
if (tiny.length) out.push(tiny.length + ' control(s) under 32px tall (44px is the touch target)\n '
|
||||
+ tiny.map(b => where(b) + ' [' + Math.round(b.getBoundingClientRect().height) + 'px]').join(' · '));
|
||||
return out;
|
||||
})()`,
|
||||
});
|
||||
const findings = result.value ?? [];
|
||||
console.log(findings.length ? 'RUNTIME\n' + findings.map((f) => ' ⚠ ' + f).join('\n')
|
||||
: 'RUNTIME clean');
|
||||
}
|
||||
|
||||
/** چهار نمای اجباریِ هر بازطراحی: روشن، تیره، فشرده، موبایل. */
|
||||
async function variants(url, dir, extra = {}) {
|
||||
const slug = new URL(url).pathname.replace(/^\/admin\/?/, '').replace(/\W+/g, '-') || 'page';
|
||||
const runs = [
|
||||
{ name: 'light', w: 1440, h: 900, theme: 'light', density: 'comfortable' },
|
||||
{ name: 'dark', w: 1440, h: 900, theme: 'dark', density: 'comfortable' },
|
||||
{ name: 'compact', w: 1440, h: 900, theme: 'light', density: 'compact' },
|
||||
{ name: 'mobile', w: 390, h: 844, theme: 'light', density: 'comfortable' },
|
||||
];
|
||||
|
||||
for (const r of runs) {
|
||||
console.log(`\n── ${r.name} ${r.w}×${r.h} ${r.theme}/${r.density}`);
|
||||
await shot(url, {
|
||||
out: `${dir}/${slug}-${r.name}.png`,
|
||||
w: r.w, h: r.h, wait: 5000, full: true, probe: true,
|
||||
theme: r.theme, density: r.density, context: null, ...extra,
|
||||
});
|
||||
}
|
||||
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
|
||||
}
|
||||
|
||||
// ── Static inspection ──────────────────────────────────────────────────────
|
||||
|
||||
/** URL path → the <Route> line in App.tsx → the page component file. */
|
||||
@@ -192,6 +404,8 @@ function inspect(url) {
|
||||
const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
|
||||
console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
|
||||
console.log(`lines ${src.split('\n').length}`);
|
||||
const test = file.replace(/\.tsx$/, '.test.tsx');
|
||||
console.log(`test ${existsSync(`${REPO}/${test}`) ? test : '— none, write one'}`);
|
||||
auditSource(file, src);
|
||||
}
|
||||
}
|
||||
@@ -209,16 +423,56 @@ function auditSource(label, src) {
|
||||
push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
|
||||
push(/className="overlay"/, 'hand-rolled overlay — use the shared <Modal>');
|
||||
push(/className="field"[\s\S]*?<label/, '<label> inside .field — .field is an inline box; use .field-block');
|
||||
push(/new Date\([^)]*\)\.toLocaleDateString\((?!'fa)/, 'Gregorian date — use formatDate() (Jalali)');
|
||||
push(/type="date"/, 'native date input — use PersianDateInput');
|
||||
// آیکون تنها داخل دکمه، بدون aria-label: در تست سبز است و برای screen reader بینام.
|
||||
src.split('\n').forEach((line, i) => {
|
||||
if (/<button(?![^>]*aria-label)/.test(line) && /Icon\b/.test(line) && !/>\s*[^\s<]/.test(line)) {
|
||||
findings.push(`${label}:${i + 1} icon-only <button> with no aria-label`);
|
||||
}
|
||||
});
|
||||
|
||||
// var(--x) references that styles.css never defines (e.g. the dead --error).
|
||||
for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
|
||||
if (!tokens.includes(`${m[1]}:`)) findings.push(`${label} undefined token ${m[1]}`);
|
||||
}
|
||||
|
||||
// .seg فقط کلاس on/active را میشناسد؛ هر چیز دیگری یعنی تب فعال بینشانه.
|
||||
if (/className="seg"/.test(src) && !/'(on|active)'/.test(src)) {
|
||||
findings.push(`${label} .seg without an on/active class — the selected tab has no highlight`);
|
||||
}
|
||||
|
||||
console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
|
||||
: '\nAUDIT clean');
|
||||
}
|
||||
|
||||
/** دیزاینسیستم موجود — قدم اول هر بازطراحی، پیش از نوشتن یک خط JSX. */
|
||||
function designSystem(what) {
|
||||
const css = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
|
||||
|
||||
if (what !== 'components') {
|
||||
const root = css.match(/^:root\s*\{([\s\S]*?)^\}/m)?.[1] ?? '';
|
||||
const vars = [...root.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((m) => ` ${m[1]}: ${m[2].trim()}`);
|
||||
console.log(`TOKENS (${vars.length}) — assets/admin/styles.css`);
|
||||
console.log(vars.join('\n'));
|
||||
|
||||
const classes = [...new Set([...css.matchAll(/^\.([\w-]+)[\s,{:]/gm)].map((m) => m[1]))];
|
||||
console.log(`\nCLASSES (${classes.length})\n ${classes.join(' · ')}`);
|
||||
}
|
||||
|
||||
if (what !== 'tokens') {
|
||||
const dir = `${REPO}/assets/admin/components/ui`;
|
||||
const ui = readdirSync(dir).filter((f) => f.endsWith('.tsx') && !f.endsWith('.test.tsx'));
|
||||
console.log(`\nSHARED COMPONENTS (${ui.length}) — assets/admin/components/ui/`);
|
||||
for (const f of ui) {
|
||||
const src = readFileSync(`${dir}/${f}`, 'utf8');
|
||||
const props = src.match(/interface Props\s*\{([\s\S]*?)\n\}/)?.[1] ?? '';
|
||||
const names = [...props.matchAll(/^\s*\/?\*?\s*(\w+)\??:/gm)].map((m) => m[1]);
|
||||
console.log(` ${f.replace('.tsx', '').padEnd(26)} ${names.slice(0, 8).join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const [cmd, arg, ...rest] = process.argv.slice(2);
|
||||
@@ -231,15 +485,32 @@ if (cmd === 'shot' && arg) {
|
||||
h: Number(flag('h', 900)),
|
||||
wait: Number(flag('wait', 4000)),
|
||||
full: rest.includes('--full'),
|
||||
probe: !rest.includes('--no-probe'),
|
||||
theme: flag('theme', 'light'),
|
||||
density: flag('density', 'comfortable'),
|
||||
context: flag('context', null),
|
||||
click: flag('click', null),
|
||||
clickWait: Number(flag('click-wait', 1800)),
|
||||
});
|
||||
} else if (cmd === 'variants' && arg) {
|
||||
await variants(arg, flag('dir', '/tmp/clinicpro-review'), {
|
||||
click: flag('click', null),
|
||||
clickWait: Number(flag('click-wait', 1800)),
|
||||
});
|
||||
} else if (cmd === 'inspect' && arg) {
|
||||
inspect(arg);
|
||||
} else if (cmd === 'audit' && arg) {
|
||||
auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
|
||||
} else if (cmd === 'ds') {
|
||||
designSystem(arg);
|
||||
} else {
|
||||
console.log(`usage:
|
||||
driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
|
||||
[--theme light|dark] [--density comfortable|compact]
|
||||
[--context clinic|personal] [--no-probe]
|
||||
driver.mjs variants <url> [--dir /tmp/clinicpro-review]
|
||||
driver.mjs inspect <url>
|
||||
driver.mjs audit <path/to/File.tsx>`);
|
||||
driver.mjs audit <path/to/File.tsx>
|
||||
driver.mjs ds [tokens|components]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ name: run-prompt
|
||||
description: اجرای یک فایل پرامپت .md به صورت گامبهگام و ایمن. هر قابلیت را جداگانه پیادهسازی، تست و مستند میکند. استفاده کن وقتی کاربر میگوید "اجرای پرامپت"، "پرامپت را اجرا کن"، "run prompt"، یا مسیر یک فایل .md میدهد.
|
||||
---
|
||||
|
||||
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، todo، تعریف «تمام شد»، مستندات، SOLID، رفتار تحلیلگر، چکلیست پایانی §۷) را تعیین میکند؛ این skill فقط قواعد stack، دستورهای تست و مسیرها را دارد. در تناقض با متن پرامپت، guidelines برنده است — مگر کاربر صریحاً خلافش را بگوید.
|
||||
> **قبل از شروع، فایل `.claude/guidelines.md` را بخوان و همهٔ بخشهای آن را اعمال کن — از جمله `§۰ Grill` که قبل از هر تغییر اجباری است** (اگر روت جلسه workspace است: `clinicpro/.claude/guidelines.md`). آن فایل «چگونه کار کردن» (درک مسئله، todo، تعریف «تمام شد»، مستندات، SOLID، رفتار تحلیلگر، چکلیست پایانی §۷) را تعیین میکند؛ این skill فقط قواعد stack، دستورهای تست و مسیرها را دارد. در تناقض با متن پرامپت، guidelines برنده است — مگر کاربر صریحاً خلافش را بگوید.
|
||||
|
||||
## نحوه دریافت ورودی
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
# مقدارِ ثابت و آشکارا غیرعملیاتی: محیط تست هیچوقت به داده یا کاربر واقعی وصل
|
||||
# نمیشود، و همین شفافیت جلوی این را میگیرد که کسی این فایل را منبع یک secret
|
||||
# واقعی بپندارد. secret واقعی فقط در `.env.local` و خارج از git است.
|
||||
APP_SECRET='not-a-secret-test-env-only'
|
||||
|
||||
# Test DB: doctrine's when@test config appends the `_test` suffix (see
|
||||
# config/packages/doctrine.yaml), so this base name `db` becomes `db_test`.
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
# ClinicPro Backend
|
||||
|
||||
The Symfony API and bundled React admin that owns all clinic data, scheduling and auth for the
|
||||
ClinicPro product. This glossary records the language the backend uses for its own concepts.
|
||||
|
||||
## Language
|
||||
|
||||
### Clinic configuration
|
||||
|
||||
**Practice Domain**:
|
||||
The single field of practice a clinic declares it operates in — beauty, dental, orthopaedics. It is a
|
||||
configuration key: it selects which dashboard, forms and treatment workflows the clinic gets. A
|
||||
clinic has exactly one.
|
||||
_Avoid_: Specialty, clinic type, field, discipline
|
||||
|
||||
**Specialty**:
|
||||
A medical specialty label attached to a Clinic or a Doctor, used by the public booking site for
|
||||
listing and SEO. It is descriptive, not configuration — it never selects behaviour.
|
||||
_Avoid_: Practice Domain, category
|
||||
|
||||
### Treatment
|
||||
|
||||
**Treatment Protocol**:
|
||||
The template attached to a service that says a course of that service runs over several sessions, when
|
||||
each one falls due, which doctor supervises the course, and which staff are allowed to perform it.
|
||||
Defined once by the clinic manager, not per patient.
|
||||
_Avoid_: Treatment plan, course template, session config
|
||||
|
||||
**Supervising Doctor**:
|
||||
The doctor answerable for a Treatment Protocol and for every Treatment Case opened from it. They carry
|
||||
clinical responsibility; they do not necessarily perform the treatment.
|
||||
_Avoid_: Doctor, owner, responsible
|
||||
|
||||
**Operator**:
|
||||
The staff member who actually performs a Treatment Session — runs the device, treats each area, and
|
||||
records what was done. Chosen at booking time from the staff the Treatment Protocol allows.
|
||||
_Avoid_: Technician, performer, staff, nurse
|
||||
|
||||
**Treatment Case**:
|
||||
One patient's run of a Treatment Protocol, from the first booking until the course ends. It owns the
|
||||
sessions and carries the state of the whole course.
|
||||
_Avoid_: Treatment plan, patient file, dossier, episode
|
||||
|
||||
**Treatment Session**:
|
||||
One numbered step of a Treatment Case — session 3 of 8. It exists whether or not it has been booked
|
||||
yet, so its number survives cancellation and rescheduling. It holds the plan and the clinical record,
|
||||
never money.
|
||||
_Avoid_: Appointment, visit, session slot, patient session
|
||||
|
||||
**Visit Record**:
|
||||
The billing side of one attended visit — services rendered, insurance shares, discounts, payments. It
|
||||
comes into existence when an Appointment is confirmed, so a Treatment Session that has not been booked
|
||||
yet has none.
|
||||
_Avoid_: Session, invoice, patient session
|
||||
|
||||
**Appointment**:
|
||||
A reserved slot in a calendar. It is the booking, not the treatment. A Treatment Session may point at
|
||||
one, at a different one after rescheduling, or at none while still unbooked.
|
||||
_Avoid_: Session, booking, reservation
|
||||
|
||||
**Treatment Area**:
|
||||
A region of the body a Treatment Session is performed on — underarms, bikini line. A single session
|
||||
covers several, each completed and recorded on its own.
|
||||
_Avoid_: Zone, body part, region
|
||||
|
||||
**Session Area Record**:
|
||||
What the operator actually did to one Treatment Area in one Treatment Session — the device used, the
|
||||
parameters it was set to, how long it took, and any note.
|
||||
_Avoid_: Treatment log, area result, shot record
|
||||
+124
-97
@@ -1,110 +1,137 @@
|
||||
# کاربران تستی
|
||||
|
||||
**پنل ادمین:** https://clinic-pro.ddev.site/admin
|
||||
**پنل ادمین:** https://clinic-pro.ddev.site/admin — **سایت عمومی:** `yasuj-nobat.localhost:3000`
|
||||
|
||||
> رمز عبور همهٔ پرسوناها: `QaTest@1234`
|
||||
> رمز عبور همهٔ حسابهای کارکنان: `QaTest@1234` · کد OTP در dev همیشه `12345`
|
||||
|
||||
این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را
|
||||
توصیف میکند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل
|
||||
تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است.
|
||||
|
||||
---
|
||||
|
||||
## پرسوناها
|
||||
|
||||
واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR`
|
||||
دارند ولی دادهٔ متفاوتی میبینند.
|
||||
|
||||
| پرسونا | موبایل | نقشها | تمایز |
|
||||
|---|---|---|---|
|
||||
| `admin` | `09120671756` | `ROLE_ADMIN` | — |
|
||||
| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» |
|
||||
| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` |
|
||||
| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک |
|
||||
| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری |
|
||||
| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک |
|
||||
| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» میبیند |
|
||||
| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» |
|
||||
| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک |
|
||||
| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — |
|
||||
| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت |
|
||||
| `importer` | `09129000007` | `ROLE_IMPORTER` | — |
|
||||
|
||||
`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ
|
||||
ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» میگیرند. این باگ نیست:
|
||||
`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد میکند.
|
||||
|
||||
## شناسهها
|
||||
|
||||
| موجودیت | نام | UUID |
|
||||
|---|---|---|
|
||||
| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
|
||||
| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
|
||||
| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
|
||||
| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
|
||||
| پروفایل تصاحبنشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
|
||||
| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` |
|
||||
| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
|
||||
|
||||
هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
|
||||
برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقهای)
|
||||
دارند، پس صفحات نوبتدهیشان خالی نیستند.
|
||||
|
||||
## دادهٔ انبوه
|
||||
|
||||
`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰
|
||||
نوبت میسازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست.
|
||||
|
||||
---
|
||||
|
||||
## بازسازی از صفر
|
||||
|
||||
ترتیب اجباری است — وابستگیها چرخهایاند:
|
||||
کل این محیط با **یک دستور** ساخته میشود:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:schema:drop --full-database --force
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234'
|
||||
# نمایندهها باید قبل از شهرها باشند: data/seed/cities.json به representation_id
|
||||
# های ۱ تا ۳ ارجاع میدهد و app:seed-categories اعتبارسنجیشان میکند.
|
||||
# POST /api/v1/representation ×۳ (با توکن ادمین)
|
||||
ddev exec php bin/console app:seed-categories --no-interaction
|
||||
ddev exec php bin/console app:seed-sms-message-templates --no-interaction
|
||||
ddev exec php bin/console app:seed-demo-data --purge --no-interaction
|
||||
ddev exec php bin/console app:seed-scenarios --reset -n
|
||||
```
|
||||
|
||||
`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نمایندههای مرحلهٔ قبل را
|
||||
purge و بازسازی میکند؛ بعد از آن `cities.representation_id` به شناسههای قدیمی اشاره
|
||||
میکند و باید به شناسههای جدید نگاشت شود.
|
||||
`--reset` دیتابیس را کامل خالی میکند، migrationها را از صفر میزند، دادهٔ پایه
|
||||
(نمایندهها → دستهبندیها → کاتالوگ بیمه → خاموشکردن کپچا) را میسازد و بعد سه سناریو
|
||||
را میچیند. بدون `--reset` فقط سناریوها ساخته میشوند و روی دیتابیسی که قبلاً seed شده
|
||||
با خطا برمیگردد.
|
||||
|
||||
سپس پرسوناها از راه اندپوینتهای خود اپ ساخته میشوند:
|
||||
`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` ·
|
||||
`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` ·
|
||||
`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` ·
|
||||
`send-code → verify-code → register` برای `patient`.
|
||||
> **UUIDها با هر اجرا عوض میشوند.** جدولهای زیر شکل داده را نشان میدهند نه شناسههای
|
||||
> ثابت؛ برای گرفتن uuidهای فعلی از خود API یا دیتابیس بپرسید.
|
||||
|
||||
`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش
|
||||
در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient`
|
||||
را میشناسد، پس این دو با SQL مستقیم ست میشوند.
|
||||
---
|
||||
|
||||
## سناریوی ۱ — پزشک مستقل، نوبتدهی سرویسی
|
||||
|
||||
| نقش | موبایل | توضیح |
|
||||
|---|---|---|
|
||||
| پزشک | `0912000101` | سارا مرادی — مطب شخصی، بدون کلینیک |
|
||||
| منشی | `0912000109` | منشیِ همان پزشک |
|
||||
| بیماران | `09120001100` … `09120001104` | ۵ بیمار با پرونده |
|
||||
|
||||
- **حالت نوبتدهی:** `service` · بافر ۱۰ دقیقه · شنبه تا چهارشنبه ۰۹:۰۰–۱۷:۰۰
|
||||
- **سرویسها:** مشاوره پوست (۲۰د) · لیزر صورت (۳۰د، additional ۲۰) · تزریق ژل (۴۵د) ·
|
||||
میکرونیدلینگ (۶۰د، additional ۴۰)
|
||||
- **بیمه:** تامین اجتماعی، بیمه ایران
|
||||
- **نوبتها:** ۹ نوبت در ۵ وضعیت (انجامشده، لغو کاربر، عدم حضور، تأییدشده، در انتظار پرداخت)
|
||||
که دو تای آنها **امروز** است
|
||||
|
||||
## سناریوی ۲ — پزشکی که مالک کلینیک است
|
||||
|
||||
| نقش | موبایل | توضیح |
|
||||
|---|---|---|
|
||||
| پزشک + مالک کلینیک | `0912000201` | امیر کاظمی — مالک «کلینیک تخصصی مهر»، خودش هم در کلینیک نوبت میدهد |
|
||||
| پزشک عضو (سرویسی) | `0912000202` | نگار سلطانی |
|
||||
| پزشک عضو (اسلاتی) | `0912000203` | بهرام فتحی |
|
||||
| پزشک عضو (اسلاتی) | `0912000204` | الهام قاسمی |
|
||||
| منشی | `0912000209` | روی هر ۴ پزشک |
|
||||
| بیماران | `09120001200` … `09120001207` | ۸ بیمار |
|
||||
|
||||
- **ترکیب حالتها:** ۲ سرویسی + ۲ اسلاتی، همه شنبه تا چهارشنبه ۱۶:۰۰–۲۱:۰۰
|
||||
- **دستگاهها:** ۲ لیزر (الکساندرایت، دایود) با setup/cleanup و تقویم کاری + ۲ اتاق درمان
|
||||
- **سرویسها:** لیزر کامل بدن (۹۰د) · لیزر زیربغل (۲۰د) · ویزیت پوست (۱۵د) · هیدرودرم (۴۵د)
|
||||
- **وابستگی به دستگاه:** دو سرویس لیزری هرکدام یک بخش (`SegmentTemplate`) با نیازمندی
|
||||
انحصاری روی نوع منبع «دستگاه لیزر» دارند
|
||||
- **بیمه:** تامین اجتماعی، سلامت ایرانیان، بیمه آسیا
|
||||
|
||||
این حساب **دو محیط کاری** دارد (مطب شخصی + کلینیک). سرویسها و دستگاهها زیر محیط
|
||||
*کلینیک* اند، پس تا وقتی محیط عوض نشود لیستشان خالی است — این درست است، نه باگ:
|
||||
|
||||
```bash
|
||||
POST /api/v1/auth/switch-context {"db_uuid": "<clinic_uuid>"}
|
||||
```
|
||||
|
||||
## سناریوی ۳ — کلینیک مستقل با مالکِ غیرپزشک
|
||||
|
||||
| نقش | موبایل | توضیح |
|
||||
|---|---|---|
|
||||
| مالک کلینیک | `0912000301` | رضا شریفی — **پزشک نیست**، فقط `ROLE_CLINIC` |
|
||||
| پزشک (اسلاتی) | `0912000302` | پیمان اکبری |
|
||||
| پزشک (سرویسی) | `0912000303` | مینا یوسفی |
|
||||
| پزشک (منبعمحور) | `0912000304` | آرش نوری |
|
||||
| منشی | `0912000309` | روی هر ۳ پزشک |
|
||||
| بیماران | `09120001300` … `09120001307` | ۸ بیمار |
|
||||
|
||||
- **هر سه حالت نوبتدهی** اینجا زندهاند: `slot`، `service` و `resource`
|
||||
- **ساعت کاری:** شنبه تا پنجشنبه ۰۸:۰۰–۱۴:۰۰
|
||||
- **دستگاهها:** ۳ لیزر (CO2 فرکشنال، NdYAG، دایود) + دستگاه RF + دستگاه کرایو
|
||||
- **سرویسها:** ویزیت عمومی (۱۵د) · لیزر CO2 (۴۰د) · کرایوتراپی (۲۵د) · RF فرکشنال (۵۰د)
|
||||
- **بیمه:** تامین اجتماعی، سلامت ایرانیان، بیمه دی
|
||||
|
||||
مالکِ غیرپزشک عمدی است: هر مسیری که فرض کند مالکِ کلینیک یک `Doctor` دارد، باید
|
||||
همینجا بشکند نه در محیط واقعی.
|
||||
|
||||
---
|
||||
|
||||
## موتور نوبتدهی جدید (تسکهای `docs/new_feture`)
|
||||
|
||||
هر سه محیط، علاوه بر دادههای بالا، دادهٔ زندهٔ موتور جدید هم دارند:
|
||||
|
||||
| تسک | چه چیزی seed میشود | کجا دیده میشود |
|
||||
|---|---|---|
|
||||
| ۰۱ شعبه و اتاق | `DoctorAddress` بهعنوان شعبه + منبع اتاق با تقویم | `GET /api/v1/resources` |
|
||||
| ۰۲ مدل منبع | نوع منبع، دستگاه، **مهارت** (`کار با لیزر`) با سطح، **استخر منبع** با اولویت | `GET /api/v1/resources` · `/skills` |
|
||||
| ۰۳ تقویم منبع | تقویم هفتگی هر دستگاه + یک **استثنای تعمیرات** هفتهٔ بعد | جستجوی آزاد آن بازه را رد میکند |
|
||||
| ۰۴ کاتالوگ نسل دوم | دستهٔ درختی، **گروه انتخاب** با بازهٔ ۱ تا ۲، **ناسازگاری** دو سرویس، **override شعبه**، مدت solo/additional | `GET /api/v1/service-items` |
|
||||
| ۰۵ برنامهٔ چندبخشی | سرویس اصلی هر محیط سه بخش دارد: بیحسی ۵د (اتاق انحصاری) → انتظار ۳۰د (اتاق **passive**) → لیزر ۲۰د (اتاق + دستگاه) | `GET /api/v1/service-item/{uuid}/segments` |
|
||||
| ۰۶ موتور دسترسپذیری | همان برنامه با منابع واقعی جستجو میشود | `POST /api/v1/appointment-availability` |
|
||||
| ۰۷ نگهداشتن و ثبت | **۲ نوبت در هر محیط از مسیر واقعی** hold → confirm، با بخش و اشغال منبع | `GET /api/v1/appointment/{uuid}/segments` |
|
||||
| **منبع↔سرویس** | رابطهٔ چندبهچند با مدت و قیمت اختصاصی: دو دستگاه یک سرویس را با اعداد متفاوت میدهند | `GET /api/v1/resource/{uuid}/services` |
|
||||
| **دستهٔ مشترک** | دسته روی سرویس و منبع؛ یال «شامل بودن» بین دستهها | مودال «سرویسها» در صفحهٔ منابع |
|
||||
| ۰۸ عکس قیمت | لیست قیمت فعال + `PriceSnapshot` روی نوبتهای واقعی با بیعانه | `GET /api/v1/price-lists` |
|
||||
|
||||
> **تسکهای ۹ تا ۱۴ (سیاست، پکیج، دوره، لغو/انتظار، رویداد و گزارش) به تصمیم مالک محصول
|
||||
> از محصول حذف شدهاند.** مدل فعلی فقط منبع، سرویس و گزینه است —
|
||||
> [`docs/architecture/resource-first-model.md`](docs/architecture/resource-first-model.md).
|
||||
|
||||
نوبتهای موتور جدید **از `new Appointment(...)` ساخته نمیشوند**؛ از
|
||||
`AppointmentPlanBuilder` → `AvailabilityEngine` → `HoldService` → `BookingService` رد
|
||||
میشوند. برای همین اشغال منبع واقعی است و تقویم دستگاه واقعاً پر است — نه ردیفهایی که
|
||||
با INSERT ساخته شدهاند و هیچوقت از موتور رد نشدهاند.
|
||||
|
||||
## چه چیزی با این داده قابل تست است
|
||||
|
||||
| قابلیت | کجا |
|
||||
|---|---|
|
||||
| نوبتدهی اسلاتی | `0912000203` · `0912000204` · `0912000302` |
|
||||
| نوبتدهی سرویسی | `0912000101` · `0912000201` · `0912000202` · `0912000303` |
|
||||
| نوبتدهی منبعمحور | `0912000304` (با تخصیص واقعی دستگاه) |
|
||||
| مطب شخصی در برابر کلینیک | سناریوی ۲ (یک کاربر، دو محیط) |
|
||||
| مالک پزشک در برابر مالک اداری | سناریوی ۲ در برابر سناریوی ۳ |
|
||||
| دستگاه و وابستگی سرویس به دستگاه | سناریوی ۲ و ۳ |
|
||||
| بیمه (پایه و تکمیلی، فرانشیز، سقف) | هر سه محیط |
|
||||
| پرداخت | ۳۲ پرداخت موفق روی نوبتهای پرداختشده |
|
||||
| پرونده بیمار | ۲۱ پرونده با کد ملی روی پروفایل |
|
||||
|
||||
## نکتهها
|
||||
|
||||
- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و
|
||||
ثبتنام اسکریپتی رد میشود. از پنل ادمین یا
|
||||
`PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن.
|
||||
- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`).
|
||||
- **`send-code` سقف ۵ درخواست در ساعت بهازای هر IP دارد.** برای تستهای انبوه توکن را
|
||||
مستقیم بساز:
|
||||
`ddev exec 'php bin/console lexik:jwt:generate-token <mobile> --user-class="App\\Auth\\Entity\\User"'`
|
||||
- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
|
||||
و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
|
||||
یا `/api/v1/admin/clinic` ساخته میشوند رمز نمیگیرند.
|
||||
|
||||
## نام پزشک بدون عنوان
|
||||
|
||||
نام ذخیرهشدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با
|
||||
`PersianText::stripDoctorTitle()` حذف میکنند. افزودن عنوان کار لایهٔ نمایش است —
|
||||
`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده میکند (idempotent)،
|
||||
و پنل ادمین اصلاً عنوان اضافه نمیکند.
|
||||
|
||||
پاکسازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run`
|
||||
- **کپچا:** روی دیتابیس تازه پیشفرضش **روشن** است و هیچکس نمیتواند وارد شود.
|
||||
سیدر ردیف `site_config.altcha_enabled = '0'` را میگذارد تا محیط قابل ورود بماند.
|
||||
- **لاگین با رمز فقط برای کارکنان است.** فیلد درخواست `mobile_number` است نه `mobile`:
|
||||
`POST /api/v1/user/login {"mobile_number": "...", "password": "..."}`.
|
||||
بیماران (`ROLE_USER`) عمداً فقط با OTP وارد میشوند
|
||||
(`send-code` → `verify-code` با `12345` → `otp-login`).
|
||||
- **`send-code` سقف ۵ درخواست در ساعت بهازای هر IP دارد.**
|
||||
- **نام پزشک بدون عنوان ذخیره میشود** — «دکتر» را لایهٔ نمایش اضافه میکند
|
||||
(`PersianText::stripDoctorTitle`).
|
||||
- **دادهٔ انبوه** جداست: `app:seed-demo-data` حدود ۵۰۰ پزشک و ۲۰هزار نوبت با INSERT خام
|
||||
میسازد و برای تست کارایی و ماژول نمایندگان است، نه برای تست سناریویی.
|
||||
|
||||
+61
-4
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { usePermissions } from './hooks/usePermissions';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
@@ -59,6 +59,11 @@ import MyFinancialPage from './pages/MyFinancialPage';
|
||||
import ClinicFormPage from './pages/ClinicFormPage';
|
||||
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import StaffTreatmentSessionsPage from './pages/StaffTreatmentSessionsPage';
|
||||
import StaffSessionDetailPage from './pages/StaffSessionDetailPage';
|
||||
import TreatmentCasesPage from './pages/TreatmentCasesPage';
|
||||
import PracticeDomainSettingsPage from './pages/PracticeDomainSettingsPage';
|
||||
import AdminPracticeDomainsPage from './pages/AdminPracticeDomainsPage';
|
||||
import SubscriptionPage from './pages/SubscriptionPage';
|
||||
import DiscountsPage from './pages/DiscountsPage';
|
||||
import ClinicServicesPage from './pages/ClinicServicesPage';
|
||||
@@ -69,10 +74,19 @@ import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
|
||||
import SettingsMenuPage from './pages/SettingsMenuPage';
|
||||
import AccountSettingsPage from './pages/AccountSettingsPage';
|
||||
import TagsSettingsPage from './pages/TagsSettingsPage';
|
||||
import RecordNumberSettingsPage from './pages/RecordNumberSettingsPage';
|
||||
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
|
||||
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
|
||||
import PatientsListPage from './pages/PatientsListPage';
|
||||
import InventoryPage from './pages/InventoryPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import ResourceTypesPage from './pages/ResourceTypesPage';
|
||||
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
|
||||
import SkillsPage from './pages/SkillsPage';
|
||||
import ResourcePoolsPage from './pages/ResourcePoolsPage';
|
||||
import ResourceDetailPage from './pages/ResourceDetailPage';
|
||||
import HolidaysSettingsPage from './pages/HolidaysSettingsPage';
|
||||
import NationalHolidaysPage from './pages/NationalHolidaysPage';
|
||||
import PatientRecordFormPage from './pages/PatientRecordFormPage';
|
||||
import PatientDetailPage from './pages/PatientDetailPage';
|
||||
import PaymentSuccessPage from './pages/PaymentSuccessPage';
|
||||
@@ -80,7 +94,7 @@ import PwaInstallBanner from './components/ui/PwaInstallBanner';
|
||||
|
||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'staff', 'representation'] as const;
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe, logout } = useAuthStore();
|
||||
@@ -119,12 +133,18 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** `/resources/{uuid}/calendar` قدیمی → تب «ساعات کاری» صفحهٔ منبع. */
|
||||
function ResourceCalendarRedirect() {
|
||||
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
||||
return <Navigate to={`/admin/resources/${resourceUuid}?tab=hours`} replace />;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||
export function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||
roles: string[];
|
||||
blockClinicScope?: boolean;
|
||||
/**
|
||||
@@ -139,7 +159,23 @@ function RoleRoute({ roles, blockClinicScope, permission, children }: {
|
||||
const context = useAuthStore((s) => s.context);
|
||||
const { can } = usePermissions();
|
||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||
|
||||
/**
|
||||
* پزشکِ عضو در محیط کلینیک برای گِیتِ نقشی همان «clinic» حساب میشود — ولی **فقط**
|
||||
* وقتی صفحه مجوزی اعلام کرده و کلینیک آن مجوز را به او داده باشد.
|
||||
*
|
||||
* بدون این، منو و route با هم نمیخواندند: `settingsMenu` واریانتِ نقشیِ همین پزشک را
|
||||
* با `clinic` تطبیق میداد و آیتم را نشان میداد، ولی اینجا `primaryRole` او `doctor`
|
||||
* بود و کلیک به داشبورد میپرید — یعنی منویی که کار نمیکرد.
|
||||
*
|
||||
* قید «مجوز الزامی» عمدی است: بدون آن، هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز
|
||||
* میشد. با آن، دسترسی همان چیزی میماند که مالک کلینیک صراحتاً داده است.
|
||||
*/
|
||||
const clinicScopedDoctor = primaryRole === 'doctor' && context?.scope === 'clinic';
|
||||
const roleOk = roles.includes(primaryRole)
|
||||
|| (clinicScopedDoctor && roles.includes('clinic') && !!permission && can(permission[0], permission[1]));
|
||||
|
||||
if (!roleOk) return <Navigate to="/admin/dashboard" replace />;
|
||||
// منشیِ بدون مجوزِ این صفحه نباید با ورود مستقیم URL هم بازش کند.
|
||||
if (primaryRole === 'secretary' && permission && !can(permission[0], permission[1])) {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
@@ -262,9 +298,13 @@ export default function App() {
|
||||
|
||||
{/* فاز ۲ — دکتر / کلینیک */}
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
|
||||
{/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */}
|
||||
<Route path="my-sessions" element={<RoleRoute roles={['staff']}><StaffTreatmentSessionsPage /></RoleRoute>} />
|
||||
<Route path="my-sessions/:uuid" element={<RoleRoute roles={['staff']}><StaffSessionDetailPage /></RoleRoute>} />
|
||||
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
|
||||
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
|
||||
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
|
||||
<Route path="record-number-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['patients', 'view']}><RecordNumberSettingsPage /></RoleRoute>} />
|
||||
<Route path="appointment-settings" element={<RoleRoute roles={['doctor', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><AppointmentSettingsPage /></RoleRoute>} />
|
||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><SubscriptionPage /></RoleRoute>} />
|
||||
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['discounts', 'view']}><DiscountsPage /></RoleRoute>} />
|
||||
@@ -272,6 +312,23 @@ export default function App() {
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
|
||||
<Route path="treatment-cases" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} permission={['treatment', 'view']}><TreatmentCasesPage /></RoleRoute>} />
|
||||
{/* مجوزش `clinic_info` است نه `appointment_settings`: صفحه حوزهٔ فعالیت را با
|
||||
PATCH /api/v1/clinic/{uuid} ذخیره میکند و backend همانجا `clinic_info.update`
|
||||
را میسنجد. گِیتِ قبلی منبع دیگری را میپرسید و با اجبارِ واقعی نمیخواند. */}
|
||||
<Route path="settings/practice-domain" element={<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><PracticeDomainSettingsPage /></RoleRoute>} />
|
||||
<Route path="practice-domains" element={<RoleRoute roles={['admin']}><AdminPracticeDomainsPage /></RoleRoute>} />
|
||||
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
<Route path="resources/pools" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourcePoolsPage /></RoleRoute>} />
|
||||
<Route path="resources/:resourceUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['resources', 'view']}><ResourceDetailPage /></RoleRoute>} />
|
||||
{/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینکهای قدیمی نباید بشکنند. */}
|
||||
<Route path="resources/:resourceUuid/calendar" element={<ResourceCalendarRedirect />} />
|
||||
{/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا میزند. */}
|
||||
<Route path="national-holidays" element={<RoleRoute roles={['admin']}><NationalHolidaysPage /></RoleRoute>} />
|
||||
<Route path="holidays" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><HolidaysSettingsPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['sms', 'view']}><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
|
||||
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { renderWithProviders } from './test/utils';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { RoleRoute } from './App';
|
||||
|
||||
/**
|
||||
* گِیتِ نقشیِ صفحات پنل — با تمرکز روی پزشکِ عضو در محیط کلینیک.
|
||||
*
|
||||
* پیش از این `settingsMenu` و `RoleRoute` دو قاعدهٔ متفاوت داشتند: منو واریانتِ
|
||||
* `clinic` را به پزشکِ scope=clinic نشان میداد و route با `primaryRole === 'doctor'`
|
||||
* ردش میکرد. نتیجه یک آیتم منوی مرده بود که به داشبورد میپرید.
|
||||
*/
|
||||
function renderGate(node: React.ReactNode) {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/admin/dashboard" element={<div>داشبورد</div>} />
|
||||
<Route path="/admin/page" element={node} />
|
||||
</Routes>,
|
||||
{ route: '/admin/page' },
|
||||
);
|
||||
}
|
||||
|
||||
function setUser(opts: {
|
||||
role: string;
|
||||
scope?: string | null;
|
||||
permissions?: Record<string, Record<string, boolean>>;
|
||||
}) {
|
||||
useAuthStore.setState({
|
||||
primaryRole: opts.role,
|
||||
context: {
|
||||
type: opts.scope === 'clinic' ? 'clinic' : 'doctor',
|
||||
db_uuid: 'x',
|
||||
name: 'محیط',
|
||||
scope: opts.scope ?? null,
|
||||
permissions: opts.permissions ? { resources: opts.permissions } : null,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
describe('RoleRoute', () => {
|
||||
beforeEach(() => useAuthStore.setState({ primaryRole: null, context: null } as any));
|
||||
|
||||
it('نقشِ مجاز عبور میکند', () => {
|
||||
setUser({ role: 'clinic' });
|
||||
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
|
||||
|
||||
expect(screen.getByText('محتوا')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('نقشِ غیرمجاز به داشبورد میرود', () => {
|
||||
setUser({ role: 'doctor' });
|
||||
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
|
||||
|
||||
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پزشکِ عضو با مجوزِ همان صفحه، به واریانتِ clinic راه دارد', () => {
|
||||
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
|
||||
renderGate(
|
||||
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('محتوا')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پزشکِ عضو بدون آن مجوز، راه ندارد', () => {
|
||||
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: false } } });
|
||||
renderGate(
|
||||
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('صفحهٔ بدون اعلامِ مجوز برای پزشکِ عضو باز نمیشود', () => {
|
||||
// وگرنه هر صفحهٔ مخصوصِ مالک برای هر پزشکِ عضو باز میشد.
|
||||
setUser({ role: 'doctor', scope: 'clinic', permissions: { clinic_info: { view: true } } });
|
||||
renderGate(<RoleRoute roles={['clinic']}><div>محتوا</div></RoleRoute>);
|
||||
|
||||
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پزشکِ مطب شخصی از واریانتِ clinic رد میشود، حتی با مجوزِ باز', () => {
|
||||
// محیط شخصی permissions ندارد و `can` آزاد برمیگردد؛ scope باید جلویش را بگیرد.
|
||||
setUser({ role: 'doctor', scope: null });
|
||||
renderGate(
|
||||
<RoleRoute roles={['clinic']} permission={['clinic_info', 'view']}><div>محتوا</div></RoleRoute>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('داشبورد')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,8 @@ const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
const navigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => ({
|
||||
...(await vi.importActual<typeof import('react-router-dom')>('react-router-dom')),
|
||||
vi.mock('react-router', async () => ({
|
||||
...(await vi.importActual<typeof import('react-router')>('react-router')),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
WalletIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import type { ApiResponse } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
@@ -27,6 +27,10 @@ import Modal from "./ui/Modal";
|
||||
import PersianDateInput from "./ui/PersianDateInput";
|
||||
import PriceInput from "./ui/PriceInput";
|
||||
import SearchableSelect from "./ui/SearchableSelect";
|
||||
import ServiceSlotPicker from "./appointments/ServiceSlotPicker";
|
||||
import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker";
|
||||
import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices";
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
/** Row actions for the appointments table (Figma عملیات menu). */
|
||||
type ModalKind = null | "info" | "move" | "transfer" | "replace";
|
||||
@@ -573,26 +577,79 @@ export function TransferReserveModal({
|
||||
const [date, setDate] = useState(a.appointment_date);
|
||||
const toReserve = !a.is_reserve;
|
||||
|
||||
// روش نوبتدهی از محلِ خودِ نوبت، نه محیط جاری پنل.
|
||||
const { bookingMode, services } = useDoctorBookingServices(
|
||||
toReserve ? undefined : a.doctor_uuid,
|
||||
a.clinic_uuid ?? null,
|
||||
);
|
||||
const serviceMode = !toReserve && bookingMode === "service";
|
||||
|
||||
// بازگشت از رزرو به لیست نوبتها به زمان واقعی نیاز دارد. پیش از این از
|
||||
// appointment_time/end_time خوانده میشد که روی یک رزرو هر دو 00:00 اند — نتیجه،
|
||||
// نوبتی با مدت صفر در نیمهشب بود.
|
||||
const [pick, setPick] = useState<ServicePick | null>(null);
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
|
||||
const initialSelection = useMemo<PickedService[]>(() => {
|
||||
if (!a.service_items?.length || services.length === 0) return [];
|
||||
return a.service_items.flatMap((s) => {
|
||||
const known = services.find((b) => b.uuid === s.uuid);
|
||||
return known
|
||||
? [{
|
||||
uuid: known.uuid,
|
||||
name: known.name,
|
||||
section: known.service_section.name,
|
||||
duration: known.duration_minutes ?? 0,
|
||||
}]
|
||||
: [];
|
||||
});
|
||||
}, [a.service_items, services]);
|
||||
|
||||
const canSubmit = !!date && (toReserve
|
||||
? true
|
||||
: serviceMode
|
||||
? !!pick?.slot && (pick?.serviceUuids.length ?? 0) > 0
|
||||
: !!start && !!end);
|
||||
|
||||
const transfer = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: async () => {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
if (toReserve) {
|
||||
// reserve entries are day-level: midnight-to-midnight, no slot occupation
|
||||
return api.patch(`/api/v1/appointment/${a.uuid}`, {
|
||||
is_reserve: true,
|
||||
slot_start: day,
|
||||
slot_end: day,
|
||||
version: a.version,
|
||||
});
|
||||
}
|
||||
|
||||
if (serviceMode) {
|
||||
// ابتدا زماندار شود (رزرو زمان ندارد و service-reschedule رزرو را رد میکند)،
|
||||
// سپس مدت و سرویسها با endpoint سرویسآگاه تثبیت شوند.
|
||||
await api.patch(`/api/v1/appointment/${a.uuid}`, {
|
||||
is_reserve: false,
|
||||
slot_start: pick!.slot!.start,
|
||||
slot_end: pick!.slot!.end,
|
||||
service_item_uuids: pick!.serviceUuids,
|
||||
durations: pick!.durations,
|
||||
version: a.version,
|
||||
});
|
||||
return api.post(`/api/v1/appointment/${a.uuid}/service-reschedule`, {
|
||||
start: pick!.slot!.start,
|
||||
service_item_uuids: pick!.serviceUuids,
|
||||
durations: pick!.durations,
|
||||
});
|
||||
}
|
||||
|
||||
return api.patch(`/api/v1/appointment/${a.uuid}`, {
|
||||
is_reserve: false,
|
||||
slot_start: toEpoch(date, start),
|
||||
slot_end: toEpoch(date, end),
|
||||
version: a.version,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey });
|
||||
@@ -640,11 +697,60 @@ export function TransferReserveModal({
|
||||
<div style={{ margin: "6px 0 16px" }}>
|
||||
<PersianDateInput value={date} onChange={setDate} />
|
||||
</div>
|
||||
|
||||
{/* بازگشت به لیست نوبتها زمان لازم دارد؛ رزرو زمانی ندارد که ارث ببرد. */}
|
||||
{!toReserve && serviceMode && date && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={a.doctor_uuid}
|
||||
date={date}
|
||||
services={services}
|
||||
clinicUuidOverride={a.clinic_uuid ?? null}
|
||||
excludeAppointmentUuid={a.uuid}
|
||||
initialSelection={initialSelection}
|
||||
onSelect={setPick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!toReserve && !serviceMode && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="btn primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!date || transfer.isPending}
|
||||
disabled={!canSubmit || transfer.isPending}
|
||||
onClick={() => transfer.mutate()}
|
||||
>
|
||||
انتقال و حذف از لیست
|
||||
@@ -904,24 +1010,12 @@ export function ReplaceAppointmentModal({
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Switch
|
||||
inline
|
||||
checked={depositRequired}
|
||||
onChange={setDepositRequired}
|
||||
label="بیعانه مورد نیاز است."
|
||||
/>
|
||||
{depositRequired && (
|
||||
<WalletChargeLink mobile={effectiveMobile} />
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Appointment } from '../types';
|
||||
import Modal from './ui/Modal';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
interface Option { uuid: string; name?: string }
|
||||
|
||||
@@ -120,9 +121,13 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||
{STATUS_OPTIONS.map(([v, l]) => (
|
||||
<label key={v} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.statuses.includes(v)} onChange={() => toggleStatus(v)} /> {l}
|
||||
</label>
|
||||
<Switch
|
||||
key={v}
|
||||
inline
|
||||
checked={f.statuses.includes(v)}
|
||||
onChange={() => toggleStatus(v)}
|
||||
label={l}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, 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 {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import AppointmentInvoiceCard from './AppointmentInvoiceCard';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const invoice = {
|
||||
base_rials: 10_000_000,
|
||||
items_rials: 2_000_000,
|
||||
discount_rials: 1_200_000,
|
||||
insurance_base_rials: 2_160_000,
|
||||
insurance_supplementary_rials: 0,
|
||||
tax_rials: 432_000,
|
||||
final_rials: 9_072_000,
|
||||
deposit_rials: 1_425_600,
|
||||
created_at: 1_700_000_000,
|
||||
breakdown: { discounts: [{ label: 'تخفیف درصدی', rials: 1_200_000 }], sources: {} },
|
||||
};
|
||||
|
||||
describe('AppointmentInvoiceCard', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('lays out the chain down to the final amount', async () => {
|
||||
get.mockResolvedValue({ success: true, data: invoice });
|
||||
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument());
|
||||
expect(screen.getByText('قیمت پایه')).toBeInTheDocument();
|
||||
expect(screen.getByText('مبلغ نهایی')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیعانه')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** ردیف صفر نباید جا بگیرد — فاکتور شلوغ خوانده نمیشود. */
|
||||
it('hides zero rows', async () => {
|
||||
get.mockResolvedValue({ success: true, data: invoice });
|
||||
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument());
|
||||
expect(screen.queryByText('سهم بیمهٔ تکمیلی')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** ⭐ نبودِ فاکتور خطا نیست: نوبتِ ثبتنهایینشده فاکتوری ندارد. */
|
||||
it('treats a missing invoice as a normal state', async () => {
|
||||
get.mockRejectedValue(new Error('not found'));
|
||||
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('برای این نوبت فاکتوری ثبت نشده است.')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('says the snapshot does not follow later price changes', async () => {
|
||||
get.mockResolvedValue({ success: true, data: invoice });
|
||||
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/تغییر بعدی تعرفه این فاکتور را عوض نمیکند/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { formatDate, formatRial } from '../lib/utils';
|
||||
import { useAppointmentInvoice } from '../hooks/useAppointmentInvoice';
|
||||
|
||||
interface Props {
|
||||
appointmentUuid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* فاکتور تفکیکشدهٔ نوبت.
|
||||
*
|
||||
* اعدادش snapshot لحظهٔ ثبتاند، نه محاسبهٔ امروز: تغییر تعرفه هرگز فاکتور صادرشده را
|
||||
* عوض نمیکند (قانون پنجم مستند). همین جمله زیر کارت هم نوشته میشود، چون کاربری که
|
||||
* قیمت را دیروز عوض کرده و امروز عدد قدیمی میبیند وگرنه فکر میکند سیستم خراب است.
|
||||
*/
|
||||
export default function AppointmentInvoiceCard({ appointmentUuid }: Props) {
|
||||
const { invoice, loading, missing } = useAppointmentInvoice(appointmentUuid);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
در حال بارگذاری فاکتور…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// نبودِ فاکتور خطا نیست: نوبتی که هنوز ثبت نهایی نشده، فاکتوری هم ندارد.
|
||||
if (missing || !invoice) {
|
||||
return (
|
||||
<div className="card" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
برای این نوبت فاکتوری ثبت نشده است.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows: { label: string; value: number; muted?: boolean }[] = [
|
||||
{ label: 'قیمت پایه', value: invoice.base_rials },
|
||||
{ label: 'آیتمهای اضافه', value: invoice.items_rials },
|
||||
{ label: 'تخفیف', value: -invoice.discount_rials },
|
||||
{ label: 'سهم بیمهٔ پایه', value: -invoice.insurance_base_rials },
|
||||
{ label: 'سهم بیمهٔ تکمیلی', value: -invoice.insurance_supplementary_rials },
|
||||
{ label: 'مالیات', value: invoice.tax_rials },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h3 style={{ fontSize: 15, margin: 0 }}>فاکتور</h3>
|
||||
{invoice.created_at !== undefined && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
ثبتشده در {formatDate(invoice.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows
|
||||
.filter((row) => row.value !== 0)
|
||||
.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-2)' }}>{row.label}</span>
|
||||
<span style={{ color: row.value < 0 ? 'var(--success)' : undefined }}>
|
||||
{formatRial(Math.abs(row.value))}
|
||||
{row.value < 0 ? ' −' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* فاکتور قدیمی ممکن است ریز تخفیف نداشته باشد؛ نبودنش نباید کل صفحه را ببندد. */}
|
||||
{(invoice.breakdown?.discounts ?? []).map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-3)' }}
|
||||
>
|
||||
<span>{line.label}</span>
|
||||
<span>{formatRial(Math.abs(line.rials))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span>مبلغ نهایی</span>
|
||||
<span>{formatRial(invoice.final_rials)}</span>
|
||||
</div>
|
||||
|
||||
{invoice.deposit_rials > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>بیعانه</span>
|
||||
<span>{formatRial(invoice.deposit_rials)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
قیمتها بر اساس تاریخ همین نوبت محاسبه و ثبت شدهاند؛ تغییر بعدی تعرفه این فاکتور
|
||||
را عوض نمیکند.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, 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 {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import AppointmentSegmentsCard from './AppointmentSegmentsCard';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const segment = (over: Record<string, unknown>) => ({
|
||||
sequence: 1,
|
||||
name: 'ویزیت',
|
||||
starts_at: 1_800_000_000,
|
||||
ends_at: 1_800_001_200,
|
||||
duration_minutes: 20,
|
||||
patient_present: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('AppointmentSegmentsCard', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('sums the recorded segments rather than the service duration', async () => {
|
||||
get.mockResolvedValue({
|
||||
data: [
|
||||
segment({ sequence: 1, duration_minutes: 20 }),
|
||||
segment({ sequence: 2, name: 'انتظار', duration_minutes: 40, patient_present: false }),
|
||||
],
|
||||
});
|
||||
|
||||
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
|
||||
|
||||
// ارقام فارسیاند: ۲۰ + ۴۰ = ۶۰
|
||||
await waitFor(() => expect(screen.getByText('۶۰ دقیقه')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
/** ⭐ مدتی که بیمار روی صندلی نیست باید دیده شود، وگرنه «۹۰ دقیقه» گمراهکننده است. */
|
||||
it('marks the segments the patient is not present for', async () => {
|
||||
get.mockResolvedValue({
|
||||
data: [segment({ sequence: 1, name: 'انتظار', patient_present: false })],
|
||||
});
|
||||
|
||||
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('بدون حضور بیمار')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
/** نوبت اسلاتی بخشی ندارد؛ کارتِ خالی یعنی «چیزی خراب است». */
|
||||
it('renders nothing for an appointment with no segments', async () => {
|
||||
get.mockResolvedValue({ data: [] });
|
||||
|
||||
const { container } = renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
|
||||
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useAppointmentSegments } from '../hooks/useResourceBooking';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
|
||||
const timeOf = (ts: number) =>
|
||||
new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
/**
|
||||
* بخشهای ثبتشدهٔ نوبت — همان چیزی که لحظهٔ رزرو تثبیت شد، نه الگوی امروزِ خدمت.
|
||||
*
|
||||
* بخشی که بیمار در آن حاضر نیست کمرنگ میآید: اپراتوری که «۹۰ دقیقه» را روی نوبت
|
||||
* میبیند باید بفهمد بیمار همهٔ آن مدت روی صندلی نیست.
|
||||
*
|
||||
* نوبت اسلاتی بخشی ندارد و کارت اصلاً رندر نمیشود — جدول خالی یعنی چیزی خراب است.
|
||||
*/
|
||||
export default function AppointmentSegmentsCard({ appointmentUuid }: { appointmentUuid: string }) {
|
||||
const { segments, loading } = useAppointmentSegments(appointmentUuid);
|
||||
|
||||
if (loading || segments.length === 0) return null;
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.duration_minutes, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-[var(--text)]">بخشهای نوبت</h3>
|
||||
<span className="text-sm text-[var(--text-2)]">{formatNumber(total)} دقیقه</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 2, marginBottom: 14 }}>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.sequence}
|
||||
title={`${s.name} — ${formatNumber(s.duration_minutes)} دقیقه`}
|
||||
style={{
|
||||
flex: s.duration_minutes,
|
||||
height: 10,
|
||||
borderRadius: 'var(--r-pill)',
|
||||
background: 'var(--primary)',
|
||||
opacity: s.patient_present ? 1 : 0.35,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ol style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
|
||||
{segments.map((s) => (
|
||||
<li key={s.sequence} style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--text-3)', minWidth: 18 }}>{formatNumber(s.sequence)}</span>
|
||||
<span style={{ flex: 1 }}>{s.name}</span>
|
||||
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
|
||||
{timeOf(s.starts_at)} – {timeOf(s.ends_at)}
|
||||
</span>
|
||||
{!s.patient_present && (
|
||||
<span className="badge" style={{ fontSize: 11 }}>
|
||||
بدون حضور بیمار
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import { useCancelAppointment } from '../hooks/useCancellation';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
appointmentUuid: string;
|
||||
/** چه کسی لغو میکند — وضعیت نهایی نوبت از همین میآید. */
|
||||
by?: 'user' | 'doctor';
|
||||
onClose: () => void;
|
||||
onCancelled?: () => void;
|
||||
}
|
||||
|
||||
/** لغو نوبت با تأیید و دلیل اختیاری. */
|
||||
export default function CancelAppointmentDialog({
|
||||
open,
|
||||
appointmentUuid,
|
||||
by = 'doctor',
|
||||
onClose,
|
||||
onCancelled,
|
||||
}: Props) {
|
||||
const [reason, setReason] = useState('');
|
||||
const cancel = useCancelAppointment();
|
||||
|
||||
const close = () => {
|
||||
setReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
title="لغو نوبت"
|
||||
message="آیا از لغو این نوبت اطمینان دارید؟"
|
||||
confirmLabel="لغو نوبت"
|
||||
danger
|
||||
loading={cancel.isPending}
|
||||
onConfirm={() =>
|
||||
cancel.mutate(
|
||||
{ uuid: appointmentUuid, by, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
close();
|
||||
onCancelled?.();
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
onCancel={close}
|
||||
>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="cp-label mb-2" htmlFor="cancel-reason">
|
||||
دلیل لغو (اختیاری)
|
||||
</label>
|
||||
<textarea
|
||||
id="cancel-reason"
|
||||
className="cp-input"
|
||||
rows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="دلیل لغو نوبت را وارد کنید..."
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
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 ClinicDoctorsManager from './ClinicDoctorsManager';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
|
||||
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
|
||||
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
|
||||
] });
|
||||
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
|
||||
status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
|
||||
responded_at: null, doctor: null },
|
||||
], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
const DOCTOR = {
|
||||
id: '1',
|
||||
uuid: 'doc-1',
|
||||
name: 'پزشک دعوتشده',
|
||||
gender: null,
|
||||
degree: null,
|
||||
img: [],
|
||||
specialties: [],
|
||||
active: false, // نوبتدهی آنلاینِ خودِ پزشک خاموش است
|
||||
};
|
||||
|
||||
/** پاسخها بر اساس مسیر تفکیک میشوند، چون سه کوئری موازی میروند. */
|
||||
function mockApi({ doctors = [DOCTOR], total = 1, permissions = [] as any[], failDoctors = false } = {}) {
|
||||
(api.get as any).mockImplementation((url: string) => {
|
||||
if (url.includes('/doctor-permissions')) return Promise.resolve({ data: permissions });
|
||||
if (url.includes('/invitations')) return Promise.resolve({ data: [] });
|
||||
if (url.includes('/doctor-list/')) {
|
||||
return failDoctors
|
||||
? Promise.reject(new Error('boom'))
|
||||
: Promise.resolve({ data: doctors, meta: { totalRecords: total, totalPages: 1, currentPage: 1 } });
|
||||
}
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('ClinicDoctorsManager', () => {
|
||||
it('lists clinic doctors and shows management controls by default', async () => {
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
|
||||
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
|
||||
expect(screen.getByText('قلب')).toBeInTheDocument();
|
||||
// manager controls
|
||||
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('صفحه و سقف را به سرور میفرستد — وگرنه backend سر ۱۰ پزشک بیصدا میبرد', async () => {
|
||||
mockApi();
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalled());
|
||||
const listCall = (api.get as any).mock.calls.find((c: any[]) => c[0].includes('/doctor-list/'));
|
||||
expect(listCall[0]).toContain('page=1');
|
||||
expect(listCall[0]).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('hides every mutating control when readOnly', async () => {
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
|
||||
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
|
||||
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
|
||||
it('شمارندهٔ تب از meta میآید، نه از طول صفحهٔ جاری', async () => {
|
||||
mockApi({ doctors: [DOCTOR], total: 25 });
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText(/پزشکان \(۲۵\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('«نوبتدهی آنلاین» و «دسترسی در کلینیک» دو ستون جدا هستند', async () => {
|
||||
mockApi();
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText('دسترسی در کلینیک')).toBeInTheDocument();
|
||||
expect(screen.getByText('نوبتدهی آنلاین')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('شمار مجوزها زیر همان بجِ دسترسی میآید، نه در ستون جدا', async () => {
|
||||
mockApi({
|
||||
permissions: [{
|
||||
doctor_uuid: 'doc-1',
|
||||
active: true,
|
||||
permissions: { resources: { appointments: { view: true, create: false } } },
|
||||
}],
|
||||
});
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
|
||||
expect(screen.queryByText('مجوزها')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('پزشکِ بدون ردیف مجوز، «پیشفرض» و دسترسی فعال میگیرد', async () => {
|
||||
mockApi({ permissions: [] });
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText('پیشفرض')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ردیف مجوزِ خاموش، «غیرفعال» نشان میدهد و شمار مجوزها را میآورد', async () => {
|
||||
mockApi({
|
||||
permissions: [{
|
||||
doctor_uuid: 'doc-1',
|
||||
active: false,
|
||||
permissions: { resources: { appointments: { view: true, create: false } } },
|
||||
}],
|
||||
});
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText('۱ از ۲ مجوز')).toBeInTheDocument();
|
||||
// دو «غیرفعال»: یکی دسترسیِ کلینیک، یکی نوبتدهی آنلاین. پیش از این یک بج جای
|
||||
// هر دو مینشست و همین ابهام، پزشکِ سالم را «غیرفعال» نشان میداد.
|
||||
expect(screen.getAllByText('غیرفعال')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('دسترسیِ فعال با نوبتدهیِ خاموش قاطی نمیشود', async () => {
|
||||
mockApi({
|
||||
permissions: [{
|
||||
doctor_uuid: 'doc-1',
|
||||
active: true,
|
||||
permissions: { resources: { appointments: { view: true } } },
|
||||
}],
|
||||
});
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
// دسترسی فعال است…
|
||||
expect(await screen.findByText('فعال')).toBeInTheDocument();
|
||||
// …ولی نوبتدهی آنلاینِ خودِ پزشک همچنان خاموش.
|
||||
expect(screen.getByText('غیرفعال')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('خطای فهرست، پیام خطا میدهد نه حالت خالی', async () => {
|
||||
mockApi({ failDoctors: true });
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText('فهرست پزشکان بارگذاری نشد')).toBeInTheDocument();
|
||||
expect(screen.queryByText('هیچ پزشکی به این کلینیک متصل نیست')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('فهرست خالی، پیام خالی میدهد', async () => {
|
||||
mockApi({ doctors: [], total: 0 });
|
||||
renderWithProviders(<ClinicDoctorsManager clinicUuid="cl-1" />);
|
||||
|
||||
expect(await screen.findByText('هیچ پزشکی به این کلینیک متصل نیست')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
|
||||
@@ -11,8 +11,11 @@ import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import InviteDoctorModal from './ui/InviteDoctorModal';
|
||||
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
|
||||
import DataTable, { type Column } from './ui/DataTable';
|
||||
import Pagination from './ui/Pagination';
|
||||
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export interface ClinicDoctorItem {
|
||||
id: string; uuid: string; name: string;
|
||||
@@ -35,6 +38,29 @@ export interface ClinicInvitation {
|
||||
doctor: { uuid: string; name: string } | null;
|
||||
}
|
||||
|
||||
/** یک ردیف از `GET /api/v1/admin/clinic/{uuid}/doctor-permissions`. */
|
||||
interface DoctorPermissionRow {
|
||||
doctor_uuid: string;
|
||||
active: boolean;
|
||||
permissions: { resources: Record<string, Record<string, boolean>> };
|
||||
}
|
||||
|
||||
/** چند اکشن از کل اکشنهای تعریفشده به این پزشک داده شده. */
|
||||
function grantedCount(row?: DoctorPermissionRow): { granted: number; total: number } {
|
||||
const resources = row?.permissions?.resources ?? {};
|
||||
let granted = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const actions of Object.values(resources)) {
|
||||
for (const allowed of Object.values(actions)) {
|
||||
total += 1;
|
||||
if (allowed) granted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { granted, total };
|
||||
}
|
||||
|
||||
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'در انتظار', cls: 'amber' },
|
||||
accepted: { label: 'پذیرفتهشده', cls: 'green' },
|
||||
@@ -72,10 +98,30 @@ export default function ClinicDoctorsManager({
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
|
||||
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
// فیلدِ جستجو محلی میماند و فقط مقدارِ آرامشده به کوئری میرود؛ وگرنه هر حرف یک
|
||||
// درخواست به سرور میزند.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
/**
|
||||
* صفحه و جستجو به سرور میروند.
|
||||
*
|
||||
* پیش از این هیچکدام فرستاده نمیشد و backend سقف پیشفرضِ ۱۰ را اعمال میکرد
|
||||
* (`DoctorRepository::findByClinicWithFilters`)، پس کلینیکِ یازدهپزشکه بیهیچ نشانهای
|
||||
* یک پزشک را گم میکرد.
|
||||
*/
|
||||
const doctorsQ = useQuery({
|
||||
queryKey: ['clinic-doctors', clinicUuid],
|
||||
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
|
||||
queryKey: ['clinic-doctors', clinicUuid, page, debouncedSearch],
|
||||
queryFn: () => api.get<PaginatedResponse<ClinicDoctorItem>>(
|
||||
`/api/v1/clinic/doctor-list/${clinicUuid}?page=${page}&limit=${PAGE_SIZE}`
|
||||
+ (debouncedSearch ? `&name=${encodeURIComponent(debouncedSearch)}` : ''),
|
||||
),
|
||||
enabled: !!clinicUuid,
|
||||
});
|
||||
|
||||
@@ -85,11 +131,30 @@ export default function ClinicDoctorsManager({
|
||||
enabled: !!clinicUuid,
|
||||
});
|
||||
|
||||
/**
|
||||
* مجوزهای همهٔ پزشکان کلینیک با **یک** درخواست.
|
||||
*
|
||||
* اندپوینت تکپزشکی از قبل بود ولی برای فهرست یعنی N درخواست؛ نسخهٔ گروهی هم از قبل
|
||||
* وجود داشت و فقط مصرف نمیشد.
|
||||
*/
|
||||
const permissionsQ = useQuery({
|
||||
queryKey: ['clinic-doctor-permissions', clinicUuid],
|
||||
queryFn: () => api.get<ApiResponse<DoctorPermissionRow[]>>(`/api/v1/admin/clinic/${clinicUuid}/doctor-permissions`),
|
||||
enabled: !!clinicUuid && canUpdate,
|
||||
});
|
||||
|
||||
const doctorList: ClinicDoctorItem[] = useMemo(() => {
|
||||
const raw = doctorsQ.data?.data;
|
||||
return (raw as any)?.data ?? raw ?? [];
|
||||
}, [doctorsQ.data]);
|
||||
|
||||
const doctorTotal = doctorsQ.data?.meta?.totalRecords ?? doctorList.length;
|
||||
|
||||
const permissionByDoctor = useMemo(() => {
|
||||
const rows = permissionsQ.data?.data ?? [];
|
||||
return new Map((Array.isArray(rows) ? rows : []).map(r => [r.doctor_uuid, r]));
|
||||
}, [permissionsQ.data]);
|
||||
|
||||
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
||||
|
||||
const resendInvMut = useMutation({
|
||||
@@ -114,6 +179,75 @@ export default function ClinicDoctorsManager({
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
/**
|
||||
* ستونها دو «فعال بودن» را از هم جدا میکنند، چون دو چیز متفاوتاند و پیش از این
|
||||
* یکی جای هر دو مینشست:
|
||||
*
|
||||
* • «دسترسی در کلینیک» → `ClinicDoctorPermission::active` — کلیدِ خودِ مالک کلینیک،
|
||||
* و خاموشبودنش یعنی `can()` همهچیز را رد میکند.
|
||||
* • «نوبتدهی آنلاین» → `Doctor::$activeDoctorAppointment && has_schedule` — حالِ
|
||||
* پروفایلِ خودِ پزشک و ربطی به عضویتش ندارد.
|
||||
*
|
||||
* بجِ قبلی دومی را نشان میداد با متنِ «فعال/غیرفعال»، پس پزشکِ سالمِ بدون برنامهٔ
|
||||
* هفتگی «غیرفعال» خوانده میشد و مالک ممکن بود بیدلیل جدایش کند.
|
||||
*/
|
||||
const doctorColumns: Column<ClinicDoctorItem>[] = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'پزشک',
|
||||
render: (doc) => {
|
||||
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
const img = doc.img?.[0]?.url;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
{img
|
||||
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
||||
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
||||
}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||
{doc.specialties?.length > 0 && (
|
||||
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// بج و شمارِ مجوز یک ستوناند نه دو: هر دو یک سؤال را جواب میدهند («این پزشک
|
||||
// چقدر دسترسی دارد؟») و ستونِ کمتر یعنی جدولی که در موبایل هم جا میشود.
|
||||
key: 'clinic_access',
|
||||
header: 'دسترسی در کلینیک',
|
||||
render: (doc) => {
|
||||
// ردیفِ نبوده یعنی هنوز چیزی تنظیم نشده و پیشفرضها برقرارند.
|
||||
const row = permissionByDoctor.get(doc.uuid);
|
||||
const on = row?.active ?? true;
|
||||
const { granted, total } = grantedCount(row);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
|
||||
<span className={`badge ${on ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{on ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: 11 }}>
|
||||
{row ? `${formatNumber(granted)} از ${formatNumber(total)} مجوز` : 'پیشفرض'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'online_booking',
|
||||
header: 'نوبتدهی آنلاین',
|
||||
render: (doc) => (
|
||||
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
], [permissionByDoctor]);
|
||||
|
||||
const detachDoctorMut = useMutation({
|
||||
mutationFn: (doctorUuid: string) =>
|
||||
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
|
||||
@@ -131,8 +265,9 @@ export default function ClinicDoctorsManager({
|
||||
{/* Card header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div className="seg">
|
||||
{/* شمارنده از meta میآید نه از طول آرایه؛ طول آرایه فقط صفحهٔ جاری است. */}
|
||||
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
|
||||
پزشکان ({formatNumber(doctorList.length)})
|
||||
پزشکان ({formatNumber(doctorTotal)})
|
||||
</button>
|
||||
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
|
||||
دعوتنامهها ({formatNumber(invitationList.length)})
|
||||
@@ -147,67 +282,76 @@ export default function ClinicDoctorsManager({
|
||||
|
||||
{/* Doctors tab */}
|
||||
{doctorsTab === 'doctors' && (
|
||||
doctorList.length === 0 ? (
|
||||
doctorsQ.isError ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
||||
<p className="muted">فهرست پزشکان بارگذاری نشد</p>
|
||||
<button className="btn secondary sm" onClick={() => doctorsQ.refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{doctorList.map(doc => {
|
||||
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
const img = doc.img?.[0]?.url;
|
||||
return (
|
||||
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
|
||||
{img
|
||||
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
|
||||
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
|
||||
}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
|
||||
{doc.specialties?.length > 0 && (
|
||||
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
<>
|
||||
<DataTable<ClinicDoctorItem>
|
||||
columns={doctorColumns}
|
||||
data={doctorList}
|
||||
loading={doctorsQ.isLoading}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="جستجوی نام پزشک"
|
||||
emptyMessage={debouncedSearch ? 'پزشکی با این نام پیدا نشد' : 'هیچ پزشکی به این کلینیک متصل نیست'}
|
||||
actions={doc => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مشاهده پروفایل"
|
||||
aria-label={`مشاهده پروفایل ${doc.name}`}
|
||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
||||
>
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && canUpdate && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مشاهده پروفایل"
|
||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
|
||||
title="مدیریت دسترسیها"
|
||||
aria-label={`مدیریت دسترسیهای ${doc.name}`}
|
||||
onClick={() => setPermissionsFor(doc)}
|
||||
>
|
||||
<EyeIcon style={{ width: 14, height: 14 }} />
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{!readOnly && canUpdate && (
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="مدیریت دسترسیها"
|
||||
onClick={() => setPermissionsFor(doc)}
|
||||
>
|
||||
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && canDelete && (
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && canDelete && (
|
||||
<button
|
||||
className="mini-btn danger"
|
||||
title="جداسازی از کلینیک"
|
||||
aria-label={`جداسازی ${doc.name} از کلینیک`}
|
||||
onClick={() => setDetachDoctorConfirm(doc)}
|
||||
>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={doctorTotal}
|
||||
limit={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Invitations tab */}
|
||||
{doctorsTab === 'invitations' && (
|
||||
invitationList.length === 0 ? (
|
||||
invitationsQ.isLoading ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">در حال بارگذاری…</p>
|
||||
</div>
|
||||
) : invitationsQ.isError ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<p className="muted">فهرست دعوتنامهها بارگذاری نشد</p>
|
||||
<button className="btn secondary sm" onClick={() => invitationsQ.refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : invitationList.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
<EnvelopeIcon style={{ width: 30, height: 30 }} />
|
||||
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
|
||||
@@ -305,7 +449,11 @@ export default function ClinicDoctorsManager({
|
||||
clinicUuid={clinicUuid}
|
||||
doctorUuid={permissionsFor.uuid}
|
||||
doctorName={permissionsFor.name}
|
||||
onClose={() => setPermissionsFor(null)}
|
||||
onClose={() => {
|
||||
setPermissionsFor(null);
|
||||
// ستون «دسترسی در کلینیک» و «مجوزها» باید تغییرِ همین مودال را نشان دهند.
|
||||
qc.invalidateQueries({ queryKey: ['clinic-doctor-permissions', clinicUuid] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import PriceInput from './ui/PriceInput';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
const TYPE_LABELS: Record<DiscountRuleType, string> = {
|
||||
patient_tag: 'تگ بیمار',
|
||||
@@ -333,12 +334,8 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.combinable} onChange={(e) => set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.active} onChange={(e) => set('active', e.target.checked)} /> فعال
|
||||
</label>
|
||||
<Switch inline checked={f.combinable} onChange={(v) => set('combinable', v)} label="قابل ترکیب با سایر تخفیفها" />
|
||||
<Switch inline checked={f.active} onChange={(v) => set('active', v)} label="فعال" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
import FieldSchemaEditor from './FieldSchemaEditor';
|
||||
import type { TreatmentFormField } from '../types';
|
||||
|
||||
const selectField = (options?: TreatmentFormField['options']): TreatmentFormField => ({
|
||||
key: 'spot',
|
||||
label: 'اسپات',
|
||||
type: 'select',
|
||||
required: false,
|
||||
sort_order: 0,
|
||||
options,
|
||||
});
|
||||
|
||||
/** والدِ کنترلشده — همان قراردادی که مودال نوع منبع دارد. */
|
||||
function Harness({ initial, onEmit }: {
|
||||
initial: TreatmentFormField[];
|
||||
onEmit?: (fields: TreatmentFormField[]) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<FieldSchemaEditor
|
||||
value={value}
|
||||
onChange={(next) => { setValue(next); onEmit?.(next); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const optionsInput = () =>
|
||||
screen.getByPlaceholderText('7, 8, 9, 10, 12, 14, 16, 18') as HTMLInputElement;
|
||||
|
||||
describe('FieldSchemaEditor — ورودی گزینههای فیلد select', () => {
|
||||
it('کاما در فیلد باقی میماند و پاک نمیشود', () => {
|
||||
renderWithProviders(<Harness initial={[selectField(['7'])]} />);
|
||||
|
||||
fireEvent.change(optionsInput(), { target: { value: '7,' } });
|
||||
|
||||
expect(optionsInput().value).toBe('7,');
|
||||
});
|
||||
|
||||
it('فاصلهٔ بعد از کاما حفظ میشود و گزینهٔ بعدی تایپشدنی است', () => {
|
||||
const onEmit = vi.fn();
|
||||
renderWithProviders(<Harness initial={[selectField(['7'])]} onEmit={onEmit} />);
|
||||
|
||||
fireEvent.change(optionsInput(), { target: { value: '7, ' } });
|
||||
expect(optionsInput().value).toBe('7, ');
|
||||
|
||||
fireEvent.change(optionsInput(), { target: { value: '7, 8' } });
|
||||
expect(optionsInput().value).toBe('7, 8');
|
||||
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]);
|
||||
});
|
||||
|
||||
it('کامای فارسی هم جداکننده است', () => {
|
||||
const onEmit = vi.fn();
|
||||
renderWithProviders(<Harness initial={[selectField([])]} onEmit={onEmit} />);
|
||||
|
||||
fireEvent.change(optionsInput(), { target: { value: 'کم، زیاد' } });
|
||||
|
||||
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['کم', 'زیاد'] })]);
|
||||
});
|
||||
|
||||
it('مقدار اولیه از آرایه ساخته میشود', () => {
|
||||
renderWithProviders(<Harness initial={[selectField(['7', '8', '9'])]} />);
|
||||
|
||||
expect(optionsInput().value).toBe('7, 8, 9');
|
||||
});
|
||||
|
||||
it('گزینهٔ خالی به بکاند فرستاده نمیشود', () => {
|
||||
const onEmit = vi.fn();
|
||||
renderWithProviders(<Harness initial={[selectField([])]} onEmit={onEmit} />);
|
||||
|
||||
fireEvent.change(optionsInput(), { target: { value: '7,, ,8,' } });
|
||||
|
||||
expect(onEmit).toHaveBeenLastCalledWith([expect.objectContaining({ options: ['7', '8'] })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Field from './ui/Field';
|
||||
import Input from './ui/Input';
|
||||
import Switch from './ui/Switch';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { TreatmentFormField } from '../types';
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'select', label: 'انتخاب از فهرست' },
|
||||
{ value: 'number', label: 'عدد' },
|
||||
{ value: 'text', label: 'متن' },
|
||||
];
|
||||
|
||||
const MAX_FIELDS = 20;
|
||||
|
||||
const parseOptions = (raw: string) =>
|
||||
raw.split(/[,،]/).map((o) => o.trim()).filter((o) => o !== '');
|
||||
|
||||
/**
|
||||
* ورودی متنی گزینههای فیلد `select`.
|
||||
*
|
||||
* متن خام را در state خودش نگه میدارد و آرایه را فقط موقع emit میسازد. اگر مقدار
|
||||
* input مستقیم از آرایه ساخته میشد، کاما و فاصلهٔ انتهایی در همان keystroke حذف
|
||||
* میشد (چون عنصر خالی filter میشود) و کاربر اصلاً نمیتوانست کاما تایپ کند.
|
||||
*/
|
||||
function OptionsInput({ id, options, disabled, onChange }: {
|
||||
id: string;
|
||||
options: TreatmentFormField['options'];
|
||||
disabled?: boolean;
|
||||
onChange: (options: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => (options ?? []).join(', '));
|
||||
const emitted = useRef(options);
|
||||
|
||||
// فقط وقتی آرایه از بیرون عوض شود (نه با تایپ خودِ کاربر) متن را همگام کن.
|
||||
useEffect(() => {
|
||||
if (options !== emitted.current) {
|
||||
emitted.current = options;
|
||||
setDraft((options ?? []).join(', '));
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
dir="ltr"
|
||||
placeholder="7, 8, 9, 10, 12, 14, 16, 18"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
const next = parseOptions(e.target.value);
|
||||
emitted.current = next;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* فرمی که اپراتور بعد از درمانِ هر ناحیه با این نوع منبع پر میکند.
|
||||
*
|
||||
* روی نوع منبع تعریف میشود نه روی سرویس، چون خودِ دستگاه تعیین میکند چه چیزی
|
||||
* خواندنی است: لیزر انرژی و پالس و شات دارد، دستگاه RF چیز دیگری. افزودن دستگاه
|
||||
* تازه اینطور تنظیمات است، نه تغییر کد.
|
||||
*/
|
||||
export default function FieldSchemaEditor({ value, onChange, disabled }: {
|
||||
value: TreatmentFormField[];
|
||||
onChange: (fields: TreatmentFormField[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const patch = (index: number, changes: Partial<TreatmentFormField>) => {
|
||||
onChange(value.map((f, i) => (i === index ? { ...f, ...changes } : f)));
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
onChange([...value, { key: '', label: '', type: 'number', required: false, sort_order: value.length }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
اپراتور بعد از درمان هر ناحیه این فیلدها را پر میکند. بدون فیلد، فقط دستگاه و زمان ثبت میشود.
|
||||
</p>
|
||||
|
||||
{value.map((field, index) => (
|
||||
<div key={index} className="card card-pad" style={{ display: 'grid', gap: 10, background: 'var(--surface-2)' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<Field label="کلید (انگلیسی)" htmlFor={`fs-key-${index}`}>
|
||||
<Input
|
||||
id={`fs-key-${index}`}
|
||||
value={field.key}
|
||||
disabled={disabled}
|
||||
dir="ltr"
|
||||
placeholder="energy"
|
||||
onChange={(e) => patch(index, { key: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="برچسب فارسی" htmlFor={`fs-label-${index}`}>
|
||||
<Input
|
||||
id={`fs-label-${index}`}
|
||||
value={field.label}
|
||||
disabled={disabled}
|
||||
placeholder="انرژی"
|
||||
onChange={(e) => patch(index, { label: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="نوع" htmlFor={`fs-type-${index}`}>
|
||||
<SearchableSelect
|
||||
inputId={`fs-type-${index}`}
|
||||
options={TYPE_OPTIONS}
|
||||
value={field.type}
|
||||
isDisabled={disabled}
|
||||
onChange={(v) => patch(index, { type: (v === null ? 'text' : String(v)) as TreatmentFormField['type'] })}
|
||||
ariaLabel="نوع فیلد"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{field.type === 'select' && (
|
||||
<Field label="گزینهها (با کاما جدا کنید)" htmlFor={`fs-options-${index}`}>
|
||||
<OptionsInput
|
||||
id={`fs-options-${index}`}
|
||||
options={field.options}
|
||||
disabled={disabled}
|
||||
onChange={(options) => patch(index, { options })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<Switch
|
||||
inline
|
||||
checked={field.required ?? false}
|
||||
onChange={(v) => patch(index, { required: v })}
|
||||
disabled={disabled}
|
||||
label="الزامی"
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
onClick={() => onChange(value.filter((_, i) => i !== index))}
|
||||
aria-label={`حذف فیلد ${field.label || index + 1}`}
|
||||
>
|
||||
<TrashIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!disabled && value.length < MAX_FIELDS && (
|
||||
<button type="button" className="btn secondary sm" onClick={add} style={{ justifySelf: 'start' }}>
|
||||
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن فیلد
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
// PriceInput صفر را خالی نشان میدهد، پس خالیبودن فیلد نشانهٔ «داده رسید» نیست؛
|
||||
// انتظار روی خودِ فراخوانی بسته میشود تا کلیک قبل از بارگذاری نیفتد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
@@ -40,7 +43,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
// PriceInput صفر را خالی نشان میدهد، پس خالیبودن فیلد نشانهٔ «داده رسید» نیست؛
|
||||
// انتظار روی خودِ فراخوانی بسته میشود تا کلیک قبل از بارگذاری نیفتد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
|
||||
@@ -55,7 +61,10 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ میشود (ارسال مجاز)', async () => {
|
||||
get.mockResolvedValue(pricing(0, false));
|
||||
renderWithProviders(<FreeVisitPrice />);
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
|
||||
// PriceInput صفر را خالی نشان میدهد، پس خالیبودن فیلد نشانهٔ «داده رسید» نیست؛
|
||||
// انتظار روی خودِ فراخوانی بسته میشود تا کلیک قبل از بارگذاری نیفتد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
|
||||
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
@@ -71,6 +80,8 @@ describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () =>
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
|
||||
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
|
||||
expect(screen.getByRole('textbox')).toHaveValue('50000');
|
||||
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(
|
||||
new Intl.NumberFormat('fa-IR').format(50_000),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
|
||||
|
||||
@@ -62,10 +63,11 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600 }}>
|
||||
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
|
||||
aria-invalid={!!error}
|
||||
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
|
||||
<PriceInput
|
||||
className="input"
|
||||
style={{ width: 200 }}
|
||||
value={value === '' ? '' : Number(value)}
|
||||
onChange={(v) => { setValue(String(v)); setError(''); }}
|
||||
/>
|
||||
</div>
|
||||
{value !== '' && (
|
||||
@@ -76,23 +78,14 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '6px 0 0' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 13, cursor: 'pointer', marginTop: 16 }}>
|
||||
<span style={{
|
||||
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
|
||||
background: required ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox" checked={required} role="switch" aria-label="الزامی کردن هزینه ویزیت"
|
||||
onChange={(e) => { setRequired(e.target.checked); setError(''); }}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, insetInlineStart: required ? 22 : 2, width: 18, height: 18,
|
||||
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
||||
}} />
|
||||
</span>
|
||||
الزامی کردن هزینه ویزیت
|
||||
</label>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Switch
|
||||
inline
|
||||
checked={required}
|
||||
onChange={(v) => { setRequired(v); setError(''); }}
|
||||
label="الزامی کردن هزینه ویزیت"
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '6px 0 0', lineHeight: 1.7 }}>
|
||||
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
interface ServiceCategoryRow {
|
||||
key: string;
|
||||
@@ -70,22 +71,14 @@ export default function InsuranceServiceCategoriesCard() {
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 18 }}>
|
||||
{rows.map((row) => (
|
||||
<label
|
||||
<Switch
|
||||
key={row.key}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: canUpdate ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={row.label}
|
||||
checked={row.enabled}
|
||||
disabled={!canUpdate || save.isPending}
|
||||
onChange={() => toggle(row.key)}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13 }}>{row.label}</span>
|
||||
</label>
|
||||
inline
|
||||
checked={row.enabled}
|
||||
disabled={!canUpdate || save.isPending}
|
||||
onChange={() => toggle(row.key)}
|
||||
label={row.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock('../lib/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import InvoiceSummaryModal from './InvoiceSummaryModal';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
@@ -121,6 +122,61 @@ describe('InvoiceSummaryModal', () => {
|
||||
expect(screen.getByText('بیمه ایران')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* ستون «ثبتکننده»: نامِ حلشدهٔ سرور، و لینک فقط وقتی بیننده صفحهٔ آن پروفایل را
|
||||
* میتواند باز کند. عکسِ لحظهٔ ثبت (`created_by_name`) فقط fallback است.
|
||||
*/
|
||||
describe('ثبتکنندهٔ پرداخت', () => {
|
||||
const withRecorder = (recorder: object | null) => ({
|
||||
...baseInvoice,
|
||||
session: {
|
||||
...fullSession,
|
||||
payments: [{
|
||||
uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000,
|
||||
created_by_name: '09120000000', created_by: recorder,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
it('پزشکِ ثبتکننده به پروفایل پزشک لینک میشود', async () => {
|
||||
useAuthStore.setState({ primaryRole: 'clinic' } as any);
|
||||
mockInvoice(withRecorder({ user_uuid: 'u1', name: 'دکتر رضایی', role: 'doctor', doctor_uuid: 'doc-9' }));
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'دکتر رضایی' });
|
||||
expect(link).toHaveAttribute('href', '/admin/doctors/doc-9');
|
||||
// نامِ زنده جای شمارهٔ ذخیرهشده مینشیند.
|
||||
expect(screen.queryByText('09120000000')).toBeNull();
|
||||
});
|
||||
|
||||
it('منشیِ ثبتکننده برای کلینیک به فهرست منشیهای خودش لینک میشود', async () => {
|
||||
useAuthStore.setState({ primaryRole: 'clinic' } as any);
|
||||
mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null }));
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'منشی مدیسا' });
|
||||
expect(link).toHaveAttribute('href', '/admin/my-secretaries');
|
||||
});
|
||||
|
||||
it('بینندهای که آن صفحه را ندارد، فقط نام میبیند نه لینک', async () => {
|
||||
useAuthStore.setState({ primaryRole: 'secretary' } as any);
|
||||
mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null }));
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
expect(await screen.findByText('منشی مدیسا')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'منشی مدیسا' })).toBeNull();
|
||||
});
|
||||
|
||||
it('بدون created_by (کاربر حذفشده): همان عکسِ ذخیرهشده، بدون لینک', async () => {
|
||||
useAuthStore.setState({ primaryRole: 'clinic' } as any);
|
||||
mockInvoice(withRecorder(null));
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
expect(await screen.findByText('09120000000')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '09120000000' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => {
|
||||
mockInvoice(baseInvoice);
|
||||
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import Modal from './ui/Modal';
|
||||
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
||||
import { METHOD_LABELS } from './session/PaymentStep';
|
||||
|
||||
interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }
|
||||
interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null }
|
||||
/** ثبتکنندهٔ پرداخت — نامش زنده از خودِ کاربر حل میشود، نه از عکسِ لحظهٔ ثبت. */
|
||||
interface PaymentRecorder {
|
||||
user_uuid: string;
|
||||
name: string | null;
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user';
|
||||
doctor_uuid: string | null;
|
||||
}
|
||||
interface SessionPayment {
|
||||
uuid: string; method: string; amount_rials: number; paid_at: number;
|
||||
created_by_name: string | null;
|
||||
created_by?: PaymentRecorder | null;
|
||||
}
|
||||
interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number }
|
||||
interface SessionData {
|
||||
session_at: number | null; paid_at: number | null;
|
||||
@@ -60,8 +73,47 @@ function SectionTable({ title, cols, rows }: { title: string; cols: string[]; ro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پروفایلِ ثبتکننده در پنلِ همین بیننده — یا `null` وقتی صفحهای برایش وجود ندارد.
|
||||
*
|
||||
* فقط پزشک صفحهٔ پروفایلِ مستقل دارد؛ منشی و پرسنل صفحهٔ فهرستِ مدیریتشان را دارند
|
||||
* و ادمین فهرستِ خودش را. مسیری که نقشِ بیننده اجازهاش را ندارد لینک نمیشود، وگرنه
|
||||
* کلیک به داشبورد پرت میکرد.
|
||||
*/
|
||||
function recorderProfilePath(recorder: PaymentRecorder, viewerRole: string | null): string | null {
|
||||
if (recorder.role === 'doctor' && recorder.doctor_uuid) {
|
||||
return ['admin', 'doctor', 'clinic', 'representation'].includes(viewerRole ?? '')
|
||||
? `/admin/doctors/${recorder.doctor_uuid}`
|
||||
: null;
|
||||
}
|
||||
if (recorder.role === 'secretary') {
|
||||
if (viewerRole === 'admin') return '/admin/secretaries';
|
||||
return viewerRole === 'clinic' || viewerRole === 'doctor' ? '/admin/my-secretaries' : null;
|
||||
}
|
||||
if (recorder.role === 'staff') {
|
||||
return ['clinic', 'doctor', 'secretary'].includes(viewerRole ?? '') ? '/admin/staff' : null;
|
||||
}
|
||||
if (viewerRole === 'admin') return `/admin/users/${recorder.user_uuid}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** سلولِ «ثبتکننده»: نام، و اگر پروفایلی در دسترسِ بیننده باشد، لینکش. */
|
||||
function RecorderCell({ payment, viewerRole }: { payment: SessionPayment; viewerRole: string | null }) {
|
||||
const recorder = payment.created_by ?? null;
|
||||
const name = recorder?.name ?? payment.created_by_name;
|
||||
if (!name) return <>-</>;
|
||||
|
||||
const path = recorder ? recorderProfilePath(recorder, viewerRole) : null;
|
||||
|
||||
return path
|
||||
? <Link to={path} style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{name}</Link>
|
||||
: <>{name}</>;
|
||||
}
|
||||
|
||||
/** خلاصه فاکتور — invoice summary, ported pixel-for-pixel from tauri InvoiceSummary. */
|
||||
export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceUuid: string | null; onClose: () => void }) {
|
||||
const viewerRole = useAuthStore(s => s.primaryRole);
|
||||
const { data, isLoading } = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['invoice', invoiceUuid],
|
||||
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
|
||||
@@ -156,7 +208,7 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU
|
||||
METHOD_LABELS[p.method] ?? p.method,
|
||||
formatRial(p.amount_rials),
|
||||
p.paid_at ? formatDateTime(p.paid_at) : '-',
|
||||
p.created_by_name ?? '-',
|
||||
<RecorderCell payment={p} viewerRole={viewerRole} />,
|
||||
]),
|
||||
['', <span style={{ fontWeight: 700 }}>مجموع پرداختیها</span>, <span style={{ fontWeight: 700 }}>{formatRial(session.paid_total_rials)}</span>, '', ''],
|
||||
]
|
||||
|
||||
@@ -11,6 +11,7 @@ import PriceInput from './ui/PriceInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { WalletChargeLink } from './AppointmentActions';
|
||||
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||||
@@ -342,10 +343,12 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
<>
|
||||
<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>
|
||||
<Switch
|
||||
inline
|
||||
checked={depositRequired}
|
||||
onChange={setDepositRequired}
|
||||
label="بیعانه مورد نیاز است."
|
||||
/>
|
||||
</div>
|
||||
{depositRequired && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDate } from '../lib/utils';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import BackButton from './ui/BackButton';
|
||||
import {
|
||||
ArrowLeftD, FilesServicePhone, FilesServiceCalendar,
|
||||
@@ -44,14 +44,23 @@ const InfoLine = ({ icon, label, value }: { icon: React.ReactNode; label: string
|
||||
* FileServicesHeader (name + status chip, file number, tags, contact/date,
|
||||
* next appointment, یادداشت button).
|
||||
*/
|
||||
export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, hasDebt, onAddNote }: {
|
||||
export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, nextSession, hasDebt, noShows, onAddNote }: {
|
||||
name: string;
|
||||
recordNumber?: string | null;
|
||||
mobile?: string | null;
|
||||
createdAt?: number;
|
||||
tags?: Tag[];
|
||||
nextAppointment?: number | null;
|
||||
/**
|
||||
* جلسهٔ بعدیِ دوره وقتی نوبتی رزرو نشده.
|
||||
*
|
||||
* جدا از `nextAppointment` است و باید هم باشد: این هنوز نوبت نیست، برنامه است.
|
||||
* یکی کردنشان یعنی بنر چیزی را «نوبت» بنامد که کسی رزروش نکرده.
|
||||
*/
|
||||
nextSession?: number | null;
|
||||
hasDebt?: boolean;
|
||||
/** خلاصهٔ عدم حضور در پنجرهٔ سیاست — `null` یعنی هنوز نیامده. */
|
||||
noShows?: { count: number; threshold: number; window_days: number; at_risk: boolean } | null;
|
||||
onAddNote: () => void;
|
||||
}) {
|
||||
const complete = !hasDebt;
|
||||
@@ -74,6 +83,26 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA
|
||||
<span style={{ fontSize: 16, color: 'var(--text-2)' }}>برچسب ها:</span>
|
||||
<TagDots tags={tags} />
|
||||
</div>
|
||||
|
||||
{/* شمار عدم حضور فقط وقتی میآید که واقعاً اتفاقی افتاده باشد. «۰ غیبت» روی
|
||||
پروندهٔ هر بیمار سالم، اتهام بیجاست. عبور از آستانه فقط رنگش را عوض میکند —
|
||||
مسدودسازی کارِ قانون `eligibility` است، نه این نشان. */}
|
||||
{noShows && noShows.count > 0 && (
|
||||
<span
|
||||
title={`در ${formatNumber(noShows.window_days)} روز گذشته · آستانهٔ سیاست: ${formatNumber(noShows.threshold)}`}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: 13,
|
||||
borderRadius: 8,
|
||||
padding: '4px 10px',
|
||||
background: noShows.at_risk ? 'var(--danger-bg)' : 'var(--warning-bg)',
|
||||
color: noShows.at_risk ? 'var(--danger)' : 'var(--warning)',
|
||||
}}
|
||||
>
|
||||
{formatNumber(noShows.count)} بار عدم حضور
|
||||
{noShows.at_risk && ' — پرریسک'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* middle — contact + file date */}
|
||||
@@ -84,7 +113,15 @@ export default function PatientCaseBanner({ name, recordNumber, mobile, createdA
|
||||
|
||||
{/* left — next appointment + note */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 28 }}>
|
||||
<InfoLine icon={<FilesServiceNotification />} label="نوبت بعدی:" value={nextAppointment ? formatDate(nextAppointment) : '—'} />
|
||||
<InfoLine
|
||||
icon={<FilesServiceNotification />}
|
||||
label={nextAppointment || !nextSession ? 'نوبت بعدی:' : 'جلسهٔ بعدی:'}
|
||||
value={
|
||||
nextAppointment ? formatDate(nextAppointment)
|
||||
: nextSession ? formatDate(nextSession)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button" onClick={onAddNote}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--accent)', color: 'var(--on-primary)', border: 'none', borderRadius: 10, padding: '0 18px', height: 36, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ApiResponse } from '../lib/api';
|
||||
import Modal from './ui/Modal';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
export interface PatientFilters {
|
||||
gender?: string; // male | female
|
||||
@@ -111,11 +112,12 @@ export default function PatientsFilterModal({ open, onClose, value, onApply }: {
|
||||
|
||||
<div>
|
||||
<label style={label}>وضعیت پرونده</label>
|
||||
<label className="switch" title="فقط پروندههای دارای بدهی" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
<input type="checkbox" checked={!!f.has_debt} onChange={(e) => set('has_debt', e.target.checked)} aria-label="فقط پروندههای دارای بدهی" />
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>فقط پروندههای دارای بدهی</span>
|
||||
</label>
|
||||
<Switch
|
||||
inline
|
||||
checked={!!f.has_debt}
|
||||
onChange={(v) => set('has_debt', v)}
|
||||
label="فقط پروندههای دارای بدهی"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { PolicyCategorySchema, PolicyClause } from '../types';
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
equals: 'برابر است با',
|
||||
not_equals: 'برابر نیست با',
|
||||
greater_than: 'بیشتر از',
|
||||
less_than: 'کمتر از',
|
||||
in: 'یکی از',
|
||||
contains: 'شامل',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
schema: PolicyCategorySchema;
|
||||
match: 'all' | 'any';
|
||||
clauses: PolicyClause[];
|
||||
onChange: (match: 'all' | 'any', clauses: PolicyClause[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* شرطساز — کاملاً از `policy-schema` ساخته میشود.
|
||||
*
|
||||
* فیلدها، عملگرهای **مجاز برای همان فیلد**، و نوع ورودی مقدار، همه از سرور میآیند.
|
||||
* اگر اینجا فهرست دستی مینوشتیم، هر فیلد تازه در بکاند نیاز به تغییر فرانت داشت و
|
||||
* بعد از دو ماه دو فهرست ناهمگام میداشتیم.
|
||||
*/
|
||||
export default function PolicyConditionBuilder({ schema, match, clauses, onChange }: Props) {
|
||||
const metaOf = (field: string) => schema.field_meta.find((m) => m.key === field);
|
||||
|
||||
const update = (index: number, patch: Partial<PolicyClause>) => {
|
||||
onChange(
|
||||
match,
|
||||
clauses.map((c, i) => (i === index ? { ...c, ...patch } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const addClause = () => {
|
||||
const first = schema.field_meta[0];
|
||||
if (!first) return;
|
||||
onChange(match, [...clauses, { field: first.key, operator: first.operators[0], value: '' }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>شرطها با هم:</span>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<SearchableSelect
|
||||
value={match}
|
||||
onChange={(v) => onChange((v as 'all' | 'any') ?? 'all', clauses)}
|
||||
options={[
|
||||
{ value: 'all', label: 'همه برقرار باشند' },
|
||||
{ value: 'any', label: 'یکی برقرار باشد' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clauses.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
بدون شرط، این قانون روی همهٔ نوبتهای دامنهاش اعمال میشود.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{clauses.map((clause, index) => {
|
||||
const meta = metaOf(clause.field);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
|
||||
>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<SearchableSelect
|
||||
value={clause.field}
|
||||
onChange={(v) => {
|
||||
const next = metaOf(String(v ?? ''));
|
||||
update(index, {
|
||||
field: String(v ?? ''),
|
||||
// عملگر قبلی ممکن است برای فیلد تازه بیمعنا باشد؛ به اولین
|
||||
// عملگرِ مجاز برمیگردد تا کاربر ۴۲۲ نگیرد.
|
||||
operator: next?.operators[0] ?? 'equals',
|
||||
value: '',
|
||||
});
|
||||
}}
|
||||
options={schema.field_meta.map((m) => ({ value: m.key, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<SearchableSelect
|
||||
value={clause.operator}
|
||||
onChange={(v) => update(index, { operator: String(v ?? 'equals') })}
|
||||
options={(meta?.operators ?? []).map((op) => ({
|
||||
value: op,
|
||||
label: OPERATOR_LABELS[op] ?? op,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 160, flex: 1 }}>
|
||||
{meta?.type === 'enum' ? (
|
||||
<SearchableSelect
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(v) => update(index, { value: String(v ?? '') })}
|
||||
options={(meta.values ?? []).map((val) => ({
|
||||
value: val,
|
||||
label: val === 'male' ? 'آقا' : val === 'female' ? 'خانم' : val,
|
||||
}))}
|
||||
/>
|
||||
) : meta?.type === 'bool' ? (
|
||||
<SearchableSelect
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(v) => update(index, { value: v === 'true' })}
|
||||
options={[
|
||||
{ value: 'true', label: 'بله' },
|
||||
{ value: 'false', label: 'خیر' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={meta?.type === 'int' ? 'number' : 'text'}
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
value: meta?.type === 'int' ? Number(e.target.value) : e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="مقدار"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => onChange(match, clauses.filter((_, i) => i !== index))}
|
||||
aria-label="حذف شرط"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<button type="button" className="btn secondary sm" onClick={addClause}>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن شرط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import PolicyVersionDiff from './PolicyVersionDiff';
|
||||
|
||||
const version = (n: number, snapshot: Record<string, unknown>) => ({
|
||||
version: n,
|
||||
created_at: 1_800_000_000 + n,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
describe('PolicyVersionDiff', () => {
|
||||
/** ⭐ سؤال واقعی «چه چیزی عوض شد؟» است، نه «هر نسخه چه بود». */
|
||||
it('shows only the fields that changed between versions', () => {
|
||||
render(
|
||||
<PolicyVersionDiff
|
||||
versions={[
|
||||
version(1, { name: 'تخفیف پاییز', priority: 0, effects: [{ type: 'discount_percent', value: 10 }] }),
|
||||
version(2, { name: 'تخفیف پاییز', priority: 5, effects: [{ type: 'discount_percent', value: 20 }] }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('اولویت')).toBeInTheDocument();
|
||||
expect(screen.getByText('اثرها')).toBeInTheDocument();
|
||||
// نام عوض نشده، پس نباید ردیف بگیرد.
|
||||
expect(screen.queryByText('نام')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the first version rather than diffing it against nothing', () => {
|
||||
render(<PolicyVersionDiff versions={[version(1, { name: 'قانون' })]} />);
|
||||
|
||||
expect(screen.getByText('نسخهٔ نخست')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says so when a version changed nothing meaningful', () => {
|
||||
render(
|
||||
<PolicyVersionDiff
|
||||
versions={[version(1, { name: 'قانون', priority: 0 }), version(2, { name: 'قانون', priority: 0 })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('بدون تغییرِ معنادار')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { formatDate } from '../lib/utils';
|
||||
|
||||
interface VersionRow {
|
||||
version: number;
|
||||
created_at: number;
|
||||
snapshot: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** فقط فیلدهایی که تغییرشان معنا دارد — `updated_at` و نسخه خودشان همیشه فرق دارند. */
|
||||
const WATCHED: Array<{ key: string; label: string }> = [
|
||||
{ key: 'name', label: 'نام' },
|
||||
{ key: 'priority', label: 'اولویت' },
|
||||
{ key: 'active', label: 'فعال' },
|
||||
{ key: 'condition', label: 'شرط' },
|
||||
{ key: 'effects', label: 'اثرها' },
|
||||
{ key: 'valid_from', label: 'شروع اعتبار' },
|
||||
{ key: 'valid_to', label: 'پایان اعتبار' },
|
||||
{ key: 'service_uuid', label: 'سرویس' },
|
||||
{ key: 'address_uuid', label: 'شعبه' },
|
||||
];
|
||||
|
||||
function show(value: unknown): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (typeof value === 'boolean') return value ? 'بله' : 'خیر';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 0);
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* تفاوت هر نسخه با نسخهٔ پیش از خودش.
|
||||
*
|
||||
* فهرست کامل اثرها روی هر نسخه، سؤال واقعی را جواب نمیدهد: «چه چیزی عوض شد؟». وقتی
|
||||
* نوبتی به نسخهٔ ۳ ارجاع میدهد و کسی میپرسد چرا قیمتش فرق دارد، همین ستون جواب است.
|
||||
*/
|
||||
export default function PolicyVersionDiff({ versions }: { versions: VersionRow[] }) {
|
||||
if (!versions || versions.length === 0) return null;
|
||||
|
||||
const ordered = [...versions].sort((a, b) => a.version - b.version);
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{ordered.map((v, i) => {
|
||||
const previous = i === 0 ? null : ordered[i - 1].snapshot;
|
||||
|
||||
const changes = previous
|
||||
? WATCHED.filter((f) => show(v.snapshot[f.key]) !== show(previous[f.key]))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.version}
|
||||
style={{ borderTop: i === 0 ? undefined : '1px solid var(--border)', paddingTop: i === 0 ? 0 : 10 }}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 10, fontSize: 12, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
|
||||
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
|
||||
{previous === null && (
|
||||
<span style={{ color: 'var(--text-3)' }}>نسخهٔ نخست</span>
|
||||
)}
|
||||
{previous !== null && changes.length === 0 && (
|
||||
<span style={{ color: 'var(--text-3)' }}>بدون تغییرِ معنادار</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{changes.map((f) => (
|
||||
<div
|
||||
key={f.key}
|
||||
style={{ display: 'flex', gap: 8, fontSize: 12, marginTop: 6, flexWrap: 'wrap' }}
|
||||
>
|
||||
<span style={{ minWidth: 90, color: 'var(--text-2)' }}>{f.label}</span>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--danger)',
|
||||
textDecoration: 'line-through',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{show(previous?.[f.key])}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-3)' }}>←</span>
|
||||
<span style={{ color: 'var(--success)', wordBreak: 'break-all' }}>
|
||||
{show(v.snapshot[f.key])}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ResourceUtilizationChart from './ResourceUtilizationChart';
|
||||
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
resource_uuid: 'r-1',
|
||||
resource_name: 'اتاق ۱',
|
||||
role: 'room',
|
||||
available_minutes: 600,
|
||||
occupied_minutes: 300,
|
||||
active_minutes: 200,
|
||||
utilization: 0.5,
|
||||
active_ratio: 0.66,
|
||||
wasted_capacity: false,
|
||||
...over,
|
||||
}) as never;
|
||||
|
||||
describe('ResourceUtilizationChart', () => {
|
||||
it('renders a bar per resource that has a calendar', () => {
|
||||
const { container } = render(
|
||||
<ResourceUtilizationChart rows={[row(), row({ resource_uuid: 'r-2', resource_name: 'اتاق ۲' })]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('بهرهوری منابع')).toBeInTheDocument();
|
||||
expect(container.querySelector('.recharts-responsive-container')).not.toBeNull();
|
||||
});
|
||||
|
||||
/** ⭐ `null` صفر نیست — ستون صفر برای منبعِ بیتقویم دروغ میگوید. */
|
||||
it('leaves out resources with no calendar instead of drawing them at zero', () => {
|
||||
const { container } = render(
|
||||
<ResourceUtilizationChart rows={[row({ utilization: null }), row({ utilization: null, resource_uuid: 'r-2' })]} />,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { UtilizationRow } from '../types';
|
||||
|
||||
/**
|
||||
* بهرهوری هر منبع در یک نگاه.
|
||||
*
|
||||
* جدول شش ستون عدد دارد و برای مقایسه ساخته نشده؛ چشم نمیتواند بگوید کدام منبع
|
||||
* عقب است. نمودار دقیقاً همان یک سؤال را جواب میدهد و بقیهاش زیرش در جدول میماند.
|
||||
*
|
||||
* منبعِ بدون تقویم اینجا **نمیآید**: `null` صفر نیست و ستون صفر دروغ میگوید.
|
||||
*/
|
||||
export default function ResourceUtilizationChart({ rows }: { rows: UtilizationRow[] }) {
|
||||
const data = rows
|
||||
.filter((r) => r.utilization !== null)
|
||||
.map((r) => ({
|
||||
name: r.resource_name,
|
||||
percent: Math.round((r.utilization ?? 0) * 100),
|
||||
wasted: r.wasted_capacity,
|
||||
}));
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>بهرهوری منابع</h3>
|
||||
|
||||
<div style={{ width: '100%', height: Math.max(180, data.length * 42) }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data} layout="vertical" margin={{ right: 16, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
||||
<XAxis type="number" domain={[0, 100]} unit="٪" stroke="var(--text-3)" fontSize={12} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={120}
|
||||
stroke="var(--text-3)"
|
||||
fontSize={12}
|
||||
orientation="right"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Number(v ?? 0)}٪`, 'بهرهوری'] as [string, string]}
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="percent" radius={[0, 6, 6, 0]}>
|
||||
{/* رنگ از توکنها میآید نه از hex — دارکمود همینجا شکسته میشد. */}
|
||||
{data.map((row) => (
|
||||
<Cell
|
||||
key={row.name}
|
||||
fill={row.wasted ? 'var(--danger)' : 'var(--primary)'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '8px 0 0', lineHeight: 1.8 }}>
|
||||
منبعی که تقویم ندارد در نمودار نمیآید — بهرهوریاش صفر نیست، تعریفنشده است.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
import {
|
||||
ClassicEditor,
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
BlockQuote,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Undo,
|
||||
} from 'ckeditor5';
|
||||
import translations from 'ckeditor5/translations/fa.js';
|
||||
import 'ckeditor5/ckeditor5.css';
|
||||
|
||||
/**
|
||||
* ادیتور متن غنی مقالهها.
|
||||
*
|
||||
* تا ۲۰۲۶-۰۸-۰۸ هر دو صفحهٔ مقاله مستقیم `@ckeditor/ckeditor5-build-classic` را
|
||||
* import میکردند. آن پکیج deprecated بود و ۶۲ advisory داشت (یافتهٔ ۴ آدیت
|
||||
* ۲۰۲۶-۰۸-۰۷). جایگزینش پکیج umbrella `ckeditor5` است که در آن، برخلاف build
|
||||
* آماده، فهرست پلاگینها صریح است.
|
||||
*
|
||||
* پیکربندی اینجا متمرکز شد تا مهاجرت بعدی یک فایل باشد نه دو صفحه — و تا نوار
|
||||
* ابزارِ دو صفحه از هم واگرا نشود.
|
||||
*
|
||||
* فهرست پلاگینها دقیقاً همان دکمههای نوار ابزارِ قبلی است، نه بیشتر: هر پلاگین
|
||||
* اضافه یعنی markup تازهای که `html_sanitizer.yaml` هنوز مجازش نکرده و هنگام
|
||||
* ذخیره حذف میشود.
|
||||
*/
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div dir="rtl" className="ck-rtl">
|
||||
<CKEditor
|
||||
editor={ClassicEditor}
|
||||
data={value}
|
||||
onChange={(_evt, editor) => onChange(editor.getData())}
|
||||
config={{
|
||||
licenseKey: 'GPL',
|
||||
language: 'fa',
|
||||
translations: [translations],
|
||||
plugins: [
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
BlockQuote,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Undo,
|
||||
],
|
||||
toolbar: [
|
||||
'heading', '|',
|
||||
'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|',
|
||||
'blockQuote', 'insertTable', '|',
|
||||
'undo', 'redo',
|
||||
],
|
||||
table: {
|
||||
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import Modal from './ui/Modal';
|
||||
import PermissionAccordions from './ui/PermissionAccordions';
|
||||
import { usePermissionCatalog, alignPermissions } from '../hooks/usePermissionCatalog';
|
||||
import type { Secretary, SecretaryPermissions } from '../types';
|
||||
|
||||
/**
|
||||
* دسترسیهای یک منشی — جدا از فرمِ پروفایل.
|
||||
*
|
||||
* تا پیش از این هر دو در یک مودال بودند و افزودنِ یک منشی یعنی تصمیمگیری دربارهٔ
|
||||
* همهٔ منابع در همان لحظه. با رجیستریِ داینامیک تعداد منابع با هر صفحهٔ تازه بیشتر
|
||||
* میشود، پس آن مودال ذاتاً بلندتر میشد.
|
||||
*
|
||||
* ذخیره روی **همهٔ** ردیفهای رابطه اجرا میشود: یک منشی به ازای هر پزشک یک ردیف
|
||||
* دارد و مجوزها باید در همه یکسان بمانند — همان قاعدهای که ویرایش پروفایل دارد.
|
||||
*/
|
||||
export default function SecretaryPermissionsModal({
|
||||
open,
|
||||
secretary,
|
||||
links,
|
||||
isClinic,
|
||||
readOnly,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
secretary: Secretary | null;
|
||||
/** uuid هر رابطهٔ پزشک-منشی. */
|
||||
links: string[];
|
||||
isClinic: boolean;
|
||||
readOnly?: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const catalog = usePermissionCatalog();
|
||||
const [permissions, setPermissions] = useState<SecretaryPermissions>({});
|
||||
|
||||
// شکل را کاتالوگ میدهد؛ تا نیامده مقداردهی نمیشود وگرنه سوییچها از
|
||||
// uncontrolled به controlled میپرند و مقدارِ ذخیرهشده پاک میشود.
|
||||
useEffect(() => {
|
||||
if (!open || catalog.isLoading) return;
|
||||
setPermissions(alignPermissions(secretary?.permissions, catalog.resources));
|
||||
}, [open, secretary, catalog.isLoading, catalog.resources]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
Promise.all(
|
||||
links.map((uuid) =>
|
||||
api.patch(`/api/v1/secretary/${uuid}`, {
|
||||
permissions: { version: 1, resources: permissions },
|
||||
}),
|
||||
),
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('دسترسیهای منشی ذخیره شد');
|
||||
onSaved?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const setPermission = (section: string, item: string, value: boolean) =>
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[section]: { ...prev[section], [item]: value },
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
size="lg"
|
||||
title={`دسترسیهای ${secretary?.user_name ?? 'منشی'}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn ghost sm" onClick={onClose}>
|
||||
{readOnly ? 'بستن' : 'انصراف'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={save.isPending || catalog.isLoading}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{catalog.isLoading ? (
|
||||
<p className="muted">در حال بارگذاری فهرست دسترسیها...</p>
|
||||
) : catalog.isError ? (
|
||||
<p className="muted">فهرست دسترسیها خوانده نشد. مودال را دوباره باز کنید.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted" style={{ fontSize: 12.5, marginBottom: 14, lineHeight: 1.9 }}>
|
||||
هر بخش را باز کنید تا اجزایش را ببینید. عددِ کنار هر بخش میگوید چند مورد از
|
||||
آن روشن است.
|
||||
</p>
|
||||
<PermissionAccordions
|
||||
permissions={permissions}
|
||||
onChange={setPermission}
|
||||
disabled={readOnly}
|
||||
isClinic={isClinic}
|
||||
resources={catalog.resources}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
|
||||
import type { CatalogCategory } from '../types';
|
||||
|
||||
type Flat = { uuid: string; name: string; depth: number };
|
||||
|
||||
function flatten(nodes: CatalogCategory[], depth = 0): Flat[] {
|
||||
return nodes.flatMap((n) => [
|
||||
{ uuid: n.uuid, name: n.name, depth },
|
||||
...flatten(n.children ?? [], depth + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* دستهٔ یک سرویس — فقط **انتخاب** از کاتالوگ سراسری.
|
||||
*
|
||||
* ساخت، ویرایش و حذف دسته عمداً اینجا نیست؛ فقط در «تنظیمات ← دستهبندیها». اگر هر
|
||||
* صفحهای بتواند دسته بسازد، «تمام بدن» چند بار با املاهای مختلف ساخته میشود و یال
|
||||
* «شامل بودن» دیگر چیزی را نمیگیرد.
|
||||
*/
|
||||
export default function ServiceCategoryTab({ serviceUuid, categoryUuid, canEdit }: {
|
||||
serviceUuid: string;
|
||||
categoryUuid: string | null;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { tree, loading } = useCatalogCategories();
|
||||
const [selected, setSelected] = useState<string | null>(categoryUuid);
|
||||
|
||||
useEffect(() => setSelected(categoryUuid), [categoryUuid]);
|
||||
|
||||
const all = useMemo(() => flatten(tree), [tree]);
|
||||
const { includes } = useCategoryIncludes(selected ?? undefined);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (uuid: string | null) =>
|
||||
api.patch<ApiResponse<unknown>>(`/api/v1/service-item/${serviceUuid}`, { catalog_category_uuid: uuid }),
|
||||
onSuccess: () => {
|
||||
toast.success('دستهبندی سرویس ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ دستهبندی ناموفق بود'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 14 }}>
|
||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
دستهبندی سراسری کلینیک است و اینجا فقط انتخاب میشود. ساخت، ویرایش و حذف فقط در{' '}
|
||||
<Link to="/admin/service-categories" style={{ color: 'var(--primary)' }}>تنظیمات ← دستهبندیها</Link>{' '}
|
||||
انجام میشود.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
|
||||
) : all.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
هنوز هیچ دستهبندیای تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 6, maxWidth: 380 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>دستهبندی این سرویس</label>
|
||||
<SearchableSelect
|
||||
options={all.map((c) => ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))}
|
||||
value={selected}
|
||||
onChange={(v) => setSelected(v ? String(v) : null)}
|
||||
placeholder="بدون دستهبندی"
|
||||
isDisabled={!canEdit}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && includes.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>این دسته شامل:</span>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{includes.map((c) => (
|
||||
<span key={c.uuid} className="badge gray" style={{ fontSize: 11 }}>{c.name}</span>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
انتخاب همزمان این سرویس با سرویسی از این زیرمجموعهها هنگام رزرو رد میشود.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={save.isPending || selected === categoryUuid}
|
||||
onClick={() => save.mutate(selected)}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { digitsOnly, parseUserNumberClamped } from '../lib/utils';
|
||||
import Modal from './ui/Modal';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import type { ServiceItem } from '../types';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
interface TenantInsurance {
|
||||
uuid: string;
|
||||
@@ -111,15 +112,12 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
||||
پوشش پیشفرض قرارداد: {contract.coverage_percent}٪
|
||||
</div>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.covered}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, covered: e.target.checked }))}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</label>
|
||||
<Switch
|
||||
checked={draft.covered}
|
||||
disabled={isLoading}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, covered: v }))}
|
||||
ariaLabel="پوشش بیمه برای این سرویس"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* بدنه — فقط وقتی پوشش فعال است */}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
import ServiceItemFormModal from './ServiceItemFormModal';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
const BOOKABLE_LABEL = 'نمایش در نوبتدهی آنلاین';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'get').mockImplementation(async (path: string) => {
|
||||
if (path.startsWith('/api/v1/staff')) return { data: [] } as never;
|
||||
if (path.startsWith('/api/v1/inventory-packages')) return { data: [] } as never;
|
||||
if (path.startsWith('/api/v1/inventory-items')) return { data: { items: [] } } as never;
|
||||
return { data: [] } as never;
|
||||
});
|
||||
});
|
||||
|
||||
const renderModal = () =>
|
||||
renderWithProviders(
|
||||
<ServiceItemFormModal item="create" sectionUuid="section-1" onClose={() => {}} />,
|
||||
);
|
||||
|
||||
describe('ServiceItemFormModal', () => {
|
||||
it('سوییچ رزرو آنلاین برچسب صریح دارد', () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole('switch', { name: BOOKABLE_LABEL })).toBeInTheDocument();
|
||||
// برچسب قدیمی مبهم بود: معلوم نبود «نوبتدهی» یعنی تقویم داخلی یا رزرو بیمار.
|
||||
expect(screen.queryByRole('switch', { name: 'نمایش در نوبتدهی' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('متن راهنما میگوید فعال بودنش یعنی چه', () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
screen.getByText(/بیمار میتواند این سرویس را بهصورت آنلاین رزرو کند/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('راهنما نامِ دسترسیپذیرِ سوییچ را آلوده نمیکند', () => {
|
||||
renderModal();
|
||||
|
||||
// نام باید همان برچسب کوتاه بماند، نه برچسب + یک جملهٔ کامل.
|
||||
expect(screen.getByRole('switch').getAttribute('aria-label')).toBe(BOOKABLE_LABEL);
|
||||
});
|
||||
|
||||
it('قیمت و زمان متوسط یک ردیفاند و پرسنل تمامعرض', () => {
|
||||
renderModal();
|
||||
|
||||
// مودال در portal رندر میشود، پس جستجو از document است نه container.
|
||||
const grids = Array.from(document.querySelectorAll('div'))
|
||||
.filter((el) => (el as HTMLElement).style.gridTemplateColumns === '1fr 1fr');
|
||||
|
||||
// تنها ردیفِ دوستونی همان جفتِ عددی است؛ پرسنل با چیپهایش ردیف را میشکست.
|
||||
expect(grids).toHaveLength(1);
|
||||
expect(within(grids[0] as HTMLElement).getByText('قیمت پایه (تومان) *')).toBeInTheDocument();
|
||||
expect(within(grids[0] as HTMLElement).getByText('زمان متوسط (دقیقه)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('دکمهٔ انصراف واریانت دارد و نامرئی نیست', () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'انصراف' })).toHaveClass('secondary');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { useServiceCategories } from '../hooks/useServiceCategories';
|
||||
import Modal from './ui/Modal';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import Switch from './ui/Switch';
|
||||
|
||||
const itemSchema = z.object({
|
||||
name: z.string().min(1, 'نام سرویس الزامی است'),
|
||||
@@ -159,7 +160,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={onClose}>انصراف</button>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||
<button type="submit" form="service-item-form" className="btn primary" disabled={saving}>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره سرویس'}
|
||||
</button>
|
||||
@@ -182,6 +183,8 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دو عددِ کوتاه کنار هم؛ پیش از این «قیمت» با «پرسنل مسئول» همردیف بود و
|
||||
با افزودن هر پرسنل، چیپها ستون را بلند میکردند و ردیف بههم میریخت. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="field-label">قیمت پایه (تومان) *</label>
|
||||
@@ -199,16 +202,40 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">پرسنل مسئول</label>
|
||||
<SearchableSelect
|
||||
options={staffOptions}
|
||||
value={''}
|
||||
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
|
||||
placeholder="افزودن پرسنل (اختیاری)"
|
||||
noOptionsMessage="پرسنلی باقی نمانده"
|
||||
height={42}
|
||||
/>
|
||||
{selectedStaffUuids.length > 0 && (
|
||||
<label className="field-label">زمان متوسط (دقیقه)</label>
|
||||
<div className="field">
|
||||
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">نوع خدمت *</label>
|
||||
<SearchableSelect
|
||||
options={categories.map((c) => ({ value: c.key, label: c.label }))}
|
||||
value={form.watch('service_category') || null}
|
||||
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
|
||||
placeholder="انتخاب نوع خدمت"
|
||||
noOptionsMessage="نوعی تعریف نشده است"
|
||||
height={42}
|
||||
/>
|
||||
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* تمامعرض: چیپهای پرسنل چند سطر میشوند و در نیمْستون میشکستند. */}
|
||||
<div>
|
||||
<label className="field-label">پرسنل مسئول</label>
|
||||
<SearchableSelect
|
||||
options={staffOptions}
|
||||
value={''}
|
||||
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
|
||||
placeholder="افزودن پرسنل (اختیاری)"
|
||||
noOptionsMessage="پرسنلی باقی نمانده"
|
||||
height={42}
|
||||
/>
|
||||
{selectedStaffUuids.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{selectedStaffUuids.map((uuid) => (
|
||||
<span key={uuid} style={{
|
||||
@@ -228,42 +255,20 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label className="field-label">زمان متوسط (دقیقه)</label>
|
||||
<div className="field">
|
||||
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.watch('bookable') ?? false}
|
||||
onChange={(e) => form.setValue('bookable', e.target.checked)}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">نوع خدمت *</label>
|
||||
<SearchableSelect
|
||||
options={categories.map((c) => ({ value: c.key, label: c.label }))}
|
||||
value={form.watch('service_category') || null}
|
||||
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
|
||||
placeholder="انتخاب نوع خدمت"
|
||||
noOptionsMessage="نوعی تعریف نشده است"
|
||||
height={42}
|
||||
{/* تصمیمِ «آنلاین رزرو بشود یا نه» یک تنظیم است، نه یک فیلد فرم؛ پیش از این
|
||||
در نیمهٔ خالیِ ردیفِ «زمان متوسط» با پدینگ دستی همتراز شده بود. */}
|
||||
<div style={{
|
||||
padding: '12px 13px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface-2)',
|
||||
}}>
|
||||
<Switch
|
||||
checked={form.watch('bookable') ?? false}
|
||||
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
|
||||
label="نمایش در نوبتدهی آنلاین"
|
||||
hint="با فعال بودن این گزینه، بیمار میتواند این سرویس را بهصورت آنلاین رزرو کند."
|
||||
/>
|
||||
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -332,7 +337,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
|
||||
{editing && onManageInsurance && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn sm"
|
||||
className="btn secondary sm"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => { const it = editing; onClose(); onManageInsurance(it); }}
|
||||
>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { formatRial, formatYear, rialToToman, tomanToRial } from '../lib/utils';
|
||||
import Modal from './ui/Modal';
|
||||
import PriceInput from './ui/PriceInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { ServiceItem } from '../types';
|
||||
|
||||
interface TariffRow {
|
||||
uuid: string;
|
||||
year: number;
|
||||
price_rials: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface TariffResponse {
|
||||
current_year: number;
|
||||
default_price_rials: number;
|
||||
data: TariffRow[];
|
||||
}
|
||||
|
||||
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [year, setYear] = useState<number | ''>('');
|
||||
const [price, setPrice] = useState(0);
|
||||
const [editYear, setEditYear] = useState<number | null>(null);
|
||||
const [editPrice, setEditPrice] = useState(0);
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
|
||||
queryKey: ['service-tariffs', item?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`),
|
||||
enabled: !!item,
|
||||
});
|
||||
|
||||
const resp = (data as any)?.data as TariffResponse | undefined;
|
||||
const tariffs = useMemo(() => [...(resp?.data ?? [])].sort((a, b) => b.year - a.year), [resp]);
|
||||
const currentYear = resp?.current_year;
|
||||
|
||||
// سالهای قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سالهای ثبتشده.
|
||||
const yearOptions = useMemo(() => {
|
||||
if (!currentYear) return [];
|
||||
const used = new Set(tariffs.map((t) => t.year));
|
||||
const opts: { value: number; label: string }[] = [];
|
||||
for (let y = currentYear + 2; y >= currentYear - 5; y--) {
|
||||
if (used.has(y)) continue;
|
||||
opts.push({ value: y, label: y === currentYear ? `${formatYear(y)} (سال جاری)` : formatYear(y) });
|
||||
}
|
||||
return opts;
|
||||
}, [currentYear, tariffs]);
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { price_rials: tomanToRial(price) }),
|
||||
onSuccess: () => {
|
||||
toast.success('تعرفه ذخیره شد');
|
||||
setYear('');
|
||||
setPrice(0);
|
||||
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['service-items'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const editMut = useMutation({
|
||||
mutationFn: (vars: { year: number; price: number }) =>
|
||||
api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${vars.year}`, { price_rials: tomanToRial(vars.price) }),
|
||||
onSuccess: () => {
|
||||
toast.success('تعرفه ویرایش شد');
|
||||
setEditYear(null);
|
||||
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['service-items'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const startEdit = (t: TariffRow) => { setEditYear(t.year); setEditPrice(rialToToman(t.price_rials)); };
|
||||
|
||||
return (
|
||||
<Modal open={!!item} onClose={onClose} title={`تعرفههای سالانه — ${item?.name ?? ''}`} size="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{resp && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
|
||||
}}>
|
||||
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* افزودن تعرفهی جدید */}
|
||||
<div style={{ padding: 14, borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 10 }}>افزودن تعرفهی سال</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr auto', gap: 10, alignItems: 'end' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className="field-label">سال (شمسی)</label>
|
||||
<SearchableSelect
|
||||
options={yearOptions}
|
||||
value={year === '' ? '' : year}
|
||||
onChange={(v) => setYear(v != null && v !== '' ? Number(v) : '')}
|
||||
placeholder="انتخاب سال"
|
||||
noOptionsMessage="سالی باقی نمانده"
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className="field-label">تعرفه (تومان)</label>
|
||||
<PriceInput className="input" style={{ height: 40 }} value={price} onChange={setPrice} placeholder="مبلغ" min={0} />
|
||||
</div>
|
||||
<button className="btn primary" style={{ height: 40 }} disabled={year === '' || addMut.isPending} onClick={() => addMut.mutate()}>
|
||||
{addMut.isPending ? '...' : 'افزودن'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* فهرست تعرفهها */}
|
||||
{isLoading ? (
|
||||
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : tariffs.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', textAlign: 'center', padding: '12px 0' }}>هنوز تعرفهای ثبت نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{tariffs.map((t) => {
|
||||
const isCurrent = t.year === currentYear;
|
||||
const isEditing = editYear === t.year;
|
||||
return (
|
||||
<div key={t.uuid} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
|
||||
borderRadius: 9, border: '1px solid var(--border)',
|
||||
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
|
||||
سال {formatYear(t.year)}
|
||||
{isCurrent && <span className="badge green" style={{ fontSize: 9.5, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
|
||||
</span>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PriceInput className="input" style={{ height: 36 }} value={editPrice} onChange={setEditPrice} min={0} />
|
||||
</div>
|
||||
<button className="btn primary sm" disabled={editMut.isPending} onClick={() => editMut.mutate({ year: t.year, price: editPrice })} title="ذخیره">
|
||||
<CheckIcon style={{ width: 14 }} />
|
||||
</button>
|
||||
<button className="btn sm" onClick={() => setEditYear(null)} title="انصراف">
|
||||
<XMarkIcon style={{ width: 14 }} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ flex: 1, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }} dir="ltr">{formatRial(t.price_rials)}</span>
|
||||
<button className="btn sm ghost" onClick={() => startEdit(t)} title="ویرایش">
|
||||
<PencilIcon style={{ width: 14 }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import Modal from './ui/Modal';
|
||||
import Input from './ui/Input';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import type { TreatmentCaseDetail, TreatmentCaseStatus } from '../types';
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [
|
||||
{ value: 'active', label: 'در جریان' },
|
||||
{ value: 'completed', label: 'تمام شده' },
|
||||
{ value: 'abandoned', label: 'رها شده' },
|
||||
];
|
||||
|
||||
interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null }
|
||||
interface StaffRow { uuid: string; full_name: string }
|
||||
|
||||
/**
|
||||
* ویرایش پروندهٔ درمان.
|
||||
*
|
||||
* پرونده بعد از باز شدن سند است نه فرم، پس فقط چیزهایی اینجا هستند که واقعاً وسط دوره
|
||||
* عوض میشوند. سرور جلوی ویرایشی را که سابقه را بازنویسی کند میگیرد؛ فرم آن خطا را
|
||||
* نشان میدهد، تکرارش نمیکند.
|
||||
*/
|
||||
export default function TreatmentCaseEditModal({ caseUuid, onClose }: {
|
||||
caseUuid: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-case', caseUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${caseUuid}`),
|
||||
});
|
||||
|
||||
// همان اندپوینتی که صفحهٔ نوبتها میخواند: فقط پزشکانِ مجازِ همین محیط.
|
||||
// پاسخش دو لایه تو در تو است (`data.data`) — الگوی شناختهشدهٔ همین اندپوینت.
|
||||
const doctorsQ = useQuery<ApiResponse<{ data: DoctorRow[] }>>({
|
||||
queryKey: ['clinic-doctors-lite'],
|
||||
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const staffQ = useQuery<ApiResponse<StaffRow[]>>({
|
||||
queryKey: ['staff-list'],
|
||||
queryFn: () => api.get('/api/v1/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const detail = data?.data;
|
||||
|
||||
return (
|
||||
<Modal open title="ویرایش پروندهٔ درمان" size="sm" onClose={onClose} footer={null}>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
||||
) : isError || !detail ? (
|
||||
<div style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن پرونده ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : (
|
||||
<EditForm
|
||||
detail={detail}
|
||||
doctors={doctorsQ.data?.data?.data ?? []}
|
||||
doctorsLoading={doctorsQ.isLoading}
|
||||
staff={staffQ.data?.data ?? []}
|
||||
staffLoading={staffQ.isLoading}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['treatment-cases'] });
|
||||
qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] });
|
||||
onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({ detail, doctors, doctorsLoading, staff, staffLoading, onSaved, onClose }: {
|
||||
detail: TreatmentCaseDetail;
|
||||
doctors: DoctorRow[];
|
||||
doctorsLoading: boolean;
|
||||
staff: StaffRow[];
|
||||
staffLoading: boolean;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<TreatmentCaseStatus>(detail.status);
|
||||
const [supervisor, setSupervisor] = useState<string | null>(detail.supervisor?.uuid ?? null);
|
||||
const [total, setTotal] = useState(String(detail.total_sessions));
|
||||
const [areas, setAreas] = useState<string[]>(
|
||||
detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null),
|
||||
);
|
||||
const [staffUuids, setStaffUuids] = useState<string[]>(detail.assigned_staff.map((s) => s.uuid));
|
||||
|
||||
// ناحیهای که دستهاش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده
|
||||
// شود، وگرنه کاربر فکر میکند فرم آن را انداخته است.
|
||||
const orphanAreas = useMemo(
|
||||
() => detail.areas.filter((a) => a.category_uuid === null).map((a) => a.name),
|
||||
[detail.areas],
|
||||
);
|
||||
|
||||
const minTotal = detail.completed_sessions;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<TreatmentCaseDetail>>(`/api/v1/treatment-case/${detail.uuid}`, {
|
||||
status,
|
||||
supervisor_doctor_uuid: supervisor,
|
||||
area_uuids: areas,
|
||||
staff_uuids: staffUuids,
|
||||
total_sessions: Number(total) || 0,
|
||||
}),
|
||||
onSuccess: () => { toast.success('پرونده بهروزرسانی شد'); onSaved(); },
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'ویرایش پرونده ناموفق بود'),
|
||||
});
|
||||
|
||||
const toggleArea = (uuid: string) =>
|
||||
setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
|
||||
const toggleStaff = (uuid: string) =>
|
||||
setStaffUuids((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]);
|
||||
|
||||
const totalValid = Number(total) >= 2 && Number(total) <= 60;
|
||||
const valid = areas.length > 0 && totalValid;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{
|
||||
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
||||
display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
|
||||
}}>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
|
||||
<b>{detail.patient.name || 'بدون نام'}</b>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>سرویس: </span>
|
||||
<b>{detail.service.name}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label>وضعیت پرونده</label>
|
||||
<div className="seg" style={{ display: 'flex' }}>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={status === o.value ? 'on' : ''}
|
||||
aria-pressed={status === o.value}
|
||||
onClick={() => setStatus(o.value)}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="case-supervisor">پزشک ناظر</label>
|
||||
<SearchableSelect
|
||||
inputId="case-supervisor"
|
||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name ?? d.full_name ?? '' }))}
|
||||
value={supervisor}
|
||||
onChange={(v) => setSupervisor(v === null ? null : String(v))}
|
||||
placeholder="بدون پزشک ناظر"
|
||||
isLoading={doctorsLoading}
|
||||
isClearable
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label>نواحی درمان</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{detail.available_areas.map((a) => {
|
||||
const on = areas.includes(a.uuid);
|
||||
return (
|
||||
<button
|
||||
key={a.uuid}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
onClick={() => toggleArea(a.uuid)}
|
||||
style={{
|
||||
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
color: on ? 'var(--primary-700)' : 'var(--text-2)',
|
||||
fontWeight: on ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{areas.length === 0 && <span className="field-err">حداقل یک ناحیه لازم است</span>}
|
||||
{orphanAreas.length > 0 && (
|
||||
<span className="field-hint">
|
||||
نواحیِ «{orphanAreas.join('، ')}» در سابقه هستند ولی دستهبندیشان حذف شده و قابل انتخاب نیستند.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label>پرسنل</label>
|
||||
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
|
||||
نیامده بهصورت «پرسنلی ندارید» خوانده میشد. */}
|
||||
{staffLoading ? (
|
||||
<span className="field-hint">در حال خواندن فهرست پرسنل…</span>
|
||||
) : staff.length === 0 ? (
|
||||
<span className="field-hint">پرسنلی در این محیط تعریف نشده است.</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{staff.map((p) => {
|
||||
const on = staffUuids.includes(p.uuid);
|
||||
return (
|
||||
<button
|
||||
key={p.uuid}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
onClick={() => toggleStaff(p.uuid)}
|
||||
style={{
|
||||
minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
border: on ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: on ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
color: on ? 'var(--primary-700)' : 'var(--text-2)',
|
||||
fontWeight: on ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{p.full_name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className="field-hint">
|
||||
{staffUuids.length === 0
|
||||
? 'خالی یعنی هر پرسنلِ مجازِ این سرویس میتواند جلسات را انجام دهد.'
|
||||
: 'جلسات این پرونده فقط در صف همین افراد دیده میشود.'}
|
||||
</span>
|
||||
{detail.performed_by.length > 0 && (
|
||||
<span className="field-hint">
|
||||
تا اینجا انجامدهنده: {detail.performed_by.map((p) => p.name).join('، ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="case-total">تعداد جلسات</label>
|
||||
<div className="field" style={{ maxWidth: 140 }}>
|
||||
<Input
|
||||
id="case-total"
|
||||
numeric
|
||||
className=""
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||
/>
|
||||
</div>
|
||||
<span className={totalValid ? 'field-hint' : 'field-err'}>
|
||||
{totalValid
|
||||
? `${formatNumber(minTotal)} جلسه انجام شده. جلسهای که نوبت دارد یا انجام شده حذف نمیشود.`
|
||||
: 'تعداد جلسات باید بین ۲ و ۶۰ باشد'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-start' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!valid || save.isPending}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره…' : 'ذخیره'}
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { PlusIcon, TrashIcon, XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import Switch from './ui/Switch';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import Input from './ui/Input';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
|
||||
interface ProtocolStep {
|
||||
step_number: number;
|
||||
offset_days: number;
|
||||
}
|
||||
|
||||
interface ProtocolStaff {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TreatmentProtocol {
|
||||
uuid: string;
|
||||
/** آیا هیچ منبعی این سرویس را ارائه میدهد؛ نبودش رزرو را قفل نمیکند ولی باید دیده شود. */
|
||||
service_has_resources?: boolean;
|
||||
active: boolean;
|
||||
total_sessions: number;
|
||||
supervisor: { uuid: string; name: string } | null;
|
||||
steps: ProtocolStep[];
|
||||
staff: ProtocolStaff[];
|
||||
}
|
||||
|
||||
interface StaffRow {
|
||||
uuid: string;
|
||||
full_name: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface DoctorRow {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** پیشفرضِ روشنکردن سوییچ: کوتاهترین دورهای که معنی دارد. */
|
||||
const DEFAULT_STEPS: ProtocolStep[] = [
|
||||
{ step_number: 1, offset_days: 0 },
|
||||
{ step_number: 2, offset_days: 30 },
|
||||
];
|
||||
|
||||
const MIN_STEPS = 2;
|
||||
|
||||
/** خط جداکنندهٔ بخشها — سه بخشِ مستقل نباید بههم چسبیده به نظر برسند. */
|
||||
function Divider() {
|
||||
return <hr style={{ border: 0, borderTop: '1px solid var(--border)', margin: 0 }} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* «طول درمان» یک سرویس.
|
||||
*
|
||||
* وجودِ پروتکل خودش سوییچ است — سرویس بدون پروتکل تکجلسهای است — پس روشنکردن یعنی
|
||||
* ساختن و خاموشکردن یعنی حذف. چون خاموشکردن برگشتناپذیر است و گامها و پرسنل را با
|
||||
* خودش میبرد، با تأیید صریح انجام میشود نه با یک کلیک.
|
||||
*
|
||||
* فاصلهٔ هر گام از **جلسهٔ قبل** است، نه از شروع دوره، چون فاصلهٔ درمان به آخرین جلسه
|
||||
* گره خورده نه به روز باز شدن پرونده.
|
||||
*/
|
||||
export default function TreatmentProtocolTab({ serviceUuid, canEdit }: {
|
||||
serviceUuid: string;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-protocol', serviceUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentProtocol | null>>(
|
||||
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
|
||||
),
|
||||
});
|
||||
|
||||
const { data: staffData } = useQuery({
|
||||
queryKey: ['staff-list-for-protocol'],
|
||||
queryFn: () => api.get<ApiResponse<StaffRow[]>>('/api/v1/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: doctorData } = useQuery({
|
||||
queryKey: ['my-clinic-doctors'],
|
||||
queryFn: () => api.get<ApiResponse<{ data: DoctorRow[] }>>('/api/v1/my/clinic-doctors'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const protocol = data?.data ?? null;
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [steps, setSteps] = useState<ProtocolStep[]>(DEFAULT_STEPS);
|
||||
const [staffUuids, setStaffUuids] = useState<string[]>([]);
|
||||
const [supervisor, setSupervisor] = useState<string | null>(null);
|
||||
const [confirmOff, setConfirmOff] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(protocol !== null);
|
||||
setSteps(protocol?.steps?.length ? protocol.steps : DEFAULT_STEPS);
|
||||
setStaffUuids(protocol?.staff?.map((s) => s.uuid) ?? []);
|
||||
setSupervisor(protocol?.supervisor?.uuid ?? null);
|
||||
}, [protocol]);
|
||||
|
||||
const staffOptions = (staffData?.data ?? [])
|
||||
.filter((s) => s.active !== false)
|
||||
.map((s) => ({ value: s.uuid, label: s.full_name }));
|
||||
|
||||
const doctorOptions = (doctorData?.data?.data ?? []).map((d) => ({ value: d.uuid, label: d.name }));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.put<ApiResponse<TreatmentProtocol>>(
|
||||
`/api/v1/service-item/${serviceUuid}/treatment-protocol`,
|
||||
{
|
||||
steps: steps.map((s, i) => ({ step_number: i + 1, offset_days: s.offset_days })),
|
||||
staff_uuids: staffUuids,
|
||||
supervisor_doctor_uuid: supervisor,
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('طول درمان ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ طول درمان ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/service-item/${serviceUuid}/treatment-protocol`),
|
||||
onSuccess: () => {
|
||||
toast.success('طول درمان خاموش شد');
|
||||
setConfirmOff(false);
|
||||
qc.invalidateQueries({ queryKey: ['treatment-protocol', serviceUuid] });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'خاموشکردن ناموفق بود'),
|
||||
});
|
||||
|
||||
/**
|
||||
* روشنکردن بیخطر است و فوری اعمال میشود؛ خاموشکردن پروتکل را با همهٔ گامها و
|
||||
* پرسنلش پاک میکند، پس تأیید میخواهد.
|
||||
*/
|
||||
const toggle = (on: boolean) => {
|
||||
if (on) {
|
||||
setEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (protocol === null) {
|
||||
setEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmOff(true);
|
||||
};
|
||||
|
||||
const setOffset = (index: number, value: number) => {
|
||||
setSteps((prev) => prev.map((s, i) => (i === index ? { ...s, offset_days: value } : s)));
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
setSteps((prev) => [
|
||||
...prev,
|
||||
{ step_number: prev.length + 1, offset_days: prev[prev.length - 1]?.offset_days || 30 },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
setSteps((prev) => (prev.length <= MIN_STEPS ? prev : prev.filter((_, i) => i !== index)));
|
||||
};
|
||||
|
||||
const addStaff = (uuid: string | number | null) => {
|
||||
const id = uuid === null ? '' : String(uuid);
|
||||
if (id && !staffUuids.includes(id)) setStaffUuids((prev) => [...prev, id]);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* بدون این، شکستِ کوئری `protocol = null` میداد و صفحه سوییچِ خاموش نشان میداد —
|
||||
* یعنی به مدیر میگفت این سرویس تکجلسهای است، در حالی که فقط خواندن شکست خورده.
|
||||
*/
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
|
||||
<strong style={{ fontSize: 13.5 }}>طول درمان این سرویس خوانده نشد</strong>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
تا وقتی خطا برطرف نشده، وضعیت واقعی این سرویس معلوم نیست.
|
||||
</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dirty = enabled && staffUuids.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 18 }}>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={toggle}
|
||||
disabled={!canEdit}
|
||||
label="طول درمان"
|
||||
hint="سرویسهایی که در چند جلسه انجام میشوند — لیزر، بوتاکس، مزوتراپی. خاموش یعنی تکجلسهای."
|
||||
/>
|
||||
|
||||
{enabled && protocol?.service_has_resources === false && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', gap: 10, padding: '12px 14px',
|
||||
background: 'var(--warning-bg)', borderRadius: 'var(--r-sm)',
|
||||
}}
|
||||
>
|
||||
<ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'var(--warning)', flexShrink: 0 }} />
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<strong style={{ fontSize: 13 }}>هیچ دستگاهی به این سرویس وصل نیست</strong>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', lineHeight: 1.9 }}>
|
||||
رزرو قفل نمیشود، ولی منشی میتواند این سرویس را روی هر منبعی ثبت کند و اپراتور فرم
|
||||
دستگاه درست را نمیبیند. در «منابع» مشخص کنید کدام دستگاهها این سرویس را میدهند.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<section style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>
|
||||
جلسات دوره{' '}
|
||||
<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 13 }}>
|
||||
({steps.length} جلسه)
|
||||
</span>
|
||||
</h3>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.9 }}>
|
||||
فاصلهٔ هر جلسه از <b>جلسهٔ قبل</b> حساب میشود، نه از شروع دوره. اگر بیمار دیر بیاید،
|
||||
بقیهٔ دوره هم جابهجا میشود.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||||
paddingBottom: 10,
|
||||
borderBottom: index < steps.length - 1 ? '1px dashed var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 68, fontSize: 13, fontWeight: 600 }}>جلسهٔ {index + 1}</span>
|
||||
|
||||
{index === 0 ? (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1, minWidth: 180 }}>
|
||||
شروع دوره — همان روز اولین نوبت
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
numeric
|
||||
value={String(step.offset_days)}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setOffset(index, Number(e.target.value || 0))}
|
||||
aria-label={`فاصلهٔ جلسهٔ ${index + 1} از جلسهٔ قبل به روز`}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)', flex: 1, minWidth: 140 }}>
|
||||
روز بعد از جلسهٔ قبل
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn danger"
|
||||
onClick={() => removeStep(index)}
|
||||
disabled={steps.length <= MIN_STEPS}
|
||||
title={steps.length <= MIN_STEPS ? 'دوره حداقل دو جلسه دارد' : 'حذف این جلسه'}
|
||||
aria-label={`حذف جلسهٔ ${index + 1}`}
|
||||
>
|
||||
<TrashIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
<button type="button" className="btn secondary sm" onClick={addStep} style={{ justifySelf: 'start' }}>
|
||||
<PlusIcon style={{ width: 16, height: 16 }} /> افزودن جلسه
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Divider />
|
||||
|
||||
<section style={{ display: 'grid', gap: 10 }}>
|
||||
<div>
|
||||
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>پرسنل مجاز</h3>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
منشی هنگام رزرو فقط از میان همینها انتخاب میکند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<SearchableSelect
|
||||
options={staffOptions.filter((o) => !staffUuids.includes(String(o.value)))}
|
||||
value={null}
|
||||
onChange={addStaff}
|
||||
placeholder="افزودن پرسنل..."
|
||||
ariaLabel="افزودن پرسنل مجاز"
|
||||
/>
|
||||
)}
|
||||
|
||||
{staffUuids.length === 0 ? (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>
|
||||
حداقل یک پرسنل الزامی است — بدون آن ذخیره نمیشود.
|
||||
</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{staffUuids.map((uuid) => (
|
||||
<span
|
||||
key={uuid}
|
||||
className="badge"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 2, paddingInlineEnd: 2 }}
|
||||
>
|
||||
{staffOptions.find((o) => String(o.value) === uuid)?.label ?? uuid}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn danger"
|
||||
onClick={() => setStaffUuids((prev) => prev.filter((u) => u !== uuid))}
|
||||
aria-label="حذف این پرسنل"
|
||||
>
|
||||
<XMarkIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Divider />
|
||||
|
||||
<section style={{ display: 'grid', gap: 10 }}>
|
||||
<div>
|
||||
<h3 className="section-title" style={{ margin: 0, fontSize: 15 }}>پزشک ناظر</h3>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
پاسخگوی بالینی دوره. لازم نیست خودش درمان را انجام دهد.
|
||||
</p>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
options={doctorOptions}
|
||||
value={supervisor}
|
||||
onChange={(v) => setSupervisor(v === null ? null : String(v))}
|
||||
placeholder="بدون پزشک ناظر"
|
||||
isClearable
|
||||
isDisabled={!canEdit}
|
||||
ariaLabel="پزشک ناظر دوره"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* نوار ذخیرهٔ چسبان: فرم بلند است و دکمهٔ ته صفحه یعنی اسکرول اجباری، بهویژه در موبایل. */}
|
||||
{enabled && canEdit && (
|
||||
<div className="save-bar">
|
||||
<div className="sb-msg">
|
||||
{staffUuids.length === 0
|
||||
? 'برای ذخیره حداقل یک پرسنل مجاز انتخاب کنید'
|
||||
: `${steps.length} جلسه · ${staffUuids.length} پرسنل مجاز`}
|
||||
</div>
|
||||
<div className="sb-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || !dirty}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ طول درمان'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOff}
|
||||
title="خاموش کردن طول درمان"
|
||||
message="با این کار پروتکل این سرویس همراه با گامها و فهرست پرسنل مجازش حذف میشود و سرویس دوباره تکجلسهای میگردد. پروندههای درمانِ باز دست نمیخورند."
|
||||
confirmLabel="خاموش کن"
|
||||
danger
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
onCancel={() => setConfirmOff(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,11 +34,28 @@ beforeEach(() => {
|
||||
post.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
/** همهٔ فیلدهای مبلغ (تومان) در ردیفهای پرداخت. */
|
||||
/**
|
||||
* فرم ویزارد شد: «بیمه و هزینه» → «پرداخت» → «تأیید».
|
||||
* ردیفهای پرداخت در مرحلهٔ دوماند و وضعیت پرداخت در مرحلهٔ سوم.
|
||||
*/
|
||||
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
|
||||
|
||||
/** همهٔ فیلدهای مبلغ (تومان) در ردیفهای پرداخت — مرحلهٔ «پرداخت». */
|
||||
function amountInputs() {
|
||||
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
|
||||
}
|
||||
|
||||
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» میرود و ردیفهای مبلغ را برمیگرداند. */
|
||||
function goToPayment() {
|
||||
nextStep();
|
||||
return amountInputs();
|
||||
}
|
||||
|
||||
/** تا مرحلهٔ آخر جلو میرود؛ وضعیت پرداخت و دکمهٔ قطعی آنجا هستند. */
|
||||
function goToReview() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
|
||||
}
|
||||
|
||||
describe('ConfirmAppointmentModal', () => {
|
||||
it('بیمار، اقلام هزینه و جمع کل را نشان میدهد', () => {
|
||||
render();
|
||||
@@ -51,29 +68,36 @@ describe('ConfirmAppointmentModal', () => {
|
||||
it('ردیفِ اول پیشفرض برابر کل هزینه است و وضعیت «تسویه کامل» میشود', () => {
|
||||
render();
|
||||
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
|
||||
expect(amountInputs()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
||||
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
||||
goToReview();
|
||||
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» میشود', () => {
|
||||
render();
|
||||
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
|
||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||
goToReview();
|
||||
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال میشود', () => {
|
||||
render();
|
||||
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
|
||||
expect(submit).not.toBeDisabled();
|
||||
const rows = goToPayment();
|
||||
|
||||
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
|
||||
fireEvent.change(rows[0], { target: { value: '9000000' } });
|
||||
expect(screen.getByText('مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
|
||||
expect(submit).toBeDisabled();
|
||||
// پرداختِ بیشتر از مبلغ، حتی جلوی رفتن به مرحلهٔ تأیید را میگیرد.
|
||||
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
|
||||
|
||||
fireEvent.change(rows[0], { target: { value: '100000' } });
|
||||
goToReview();
|
||||
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('پرداخت جزئی مجاز است و همان یک روش را ثبت میکند', async () => {
|
||||
render();
|
||||
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
|
||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||
goToReview();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
||||
@@ -85,7 +109,7 @@ describe('ConfirmAppointmentModal', () => {
|
||||
it('تقسیم پرداخت بین دو روش: مجموع ردیفها بهصورت آرایه ثبت میشود', async () => {
|
||||
render();
|
||||
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم میکنیم
|
||||
fireEvent.change(amountInputs()[0], { target: { value: '200000' } });
|
||||
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
|
||||
// افزودن روش دوم — پیشفرض با باقیماندهٔ ۱۰۰٬۰۰۰ تومان پر میشود
|
||||
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
||||
|
||||
@@ -94,6 +118,7 @@ describe('ConfirmAppointmentModal', () => {
|
||||
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
|
||||
|
||||
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
|
||||
goToReview();
|
||||
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||
@@ -108,6 +133,7 @@ describe('ConfirmAppointmentModal', () => {
|
||||
|
||||
it('حذف ردیف اضافهشده مجموع را دوباره محاسبه میکند', () => {
|
||||
render();
|
||||
goToPayment();
|
||||
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
||||
expect(amountInputs()).toHaveLength(2);
|
||||
|
||||
@@ -220,8 +246,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
|
||||
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
|
||||
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
|
||||
expect(amountInputs()[0]).toHaveValue('۱۷۸٬۵۶۰');
|
||||
expect(goToPayment()[0]).toHaveValue('۱۷۸٬۵۶۰');
|
||||
|
||||
goToReview();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
||||
version: 1,
|
||||
@@ -252,8 +279,10 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
|
||||
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
|
||||
nextStep();
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
|
||||
|
||||
goToReview();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
||||
version: 1,
|
||||
@@ -273,8 +302,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
|
||||
|
||||
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
|
||||
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
|
||||
nextStep();
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
|
||||
});
|
||||
|
||||
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
||||
@@ -288,15 +318,18 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
||||
nextStep();
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
||||
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
|
||||
mockInsurance(BOTH, 9_000_000);
|
||||
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
|
||||
|
||||
await screen.findByText('مبلغ قابل پرداخت');
|
||||
nextStep();
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
||||
});
|
||||
|
||||
@@ -309,6 +342,53 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
||||
await pick('بدون بیمه', 'بیمه ایران');
|
||||
|
||||
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
|
||||
await screen.findByText('سهم بیمار (قابل پرداخت)');
|
||||
nextStep();
|
||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfirmAppointmentModal — ویزارد', () => {
|
||||
const stepLabels = () =>
|
||||
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
|
||||
.map(li => li.textContent?.replace(/^\d+/, '').trim());
|
||||
|
||||
it('سه مرحله دارد و از «بیمه و هزینه» شروع میشود', () => {
|
||||
render();
|
||||
|
||||
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
|
||||
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
|
||||
expect(screen.getByText('جمع کل')).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('0')).toBeNull();
|
||||
});
|
||||
|
||||
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => {
|
||||
render();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('مرحلهٔ قبل مقادیر واردشده را نگه میدارد', () => {
|
||||
render();
|
||||
|
||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
|
||||
expect(screen.getByText('جمع کل')).toBeInTheDocument();
|
||||
|
||||
// برگشت به پرداخت: مبلغ همان است، نه پیشفرضِ کل.
|
||||
nextStep();
|
||||
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
|
||||
});
|
||||
|
||||
it('مرحلهٔ آخر روشهای پرداخت را قبل از ثبت خلاصه میکند', () => {
|
||||
render();
|
||||
|
||||
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||
goToReview();
|
||||
|
||||
expect(screen.getByText('پرداخت نقدی')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
||||
import Modal from '../ui/Modal';
|
||||
import PriceInput from '../ui/PriceInput';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import Stepper from '../ui/Stepper';
|
||||
|
||||
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
||||
const METHOD_OPTIONS = [
|
||||
@@ -49,6 +50,20 @@ interface Props {
|
||||
queryKey?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* مراحلِ قطعی کردن: اول «چقدر»، بعد «چطور»، آخر «تأیید».
|
||||
*
|
||||
* فرم قبلاً یک صفحهٔ بلند بود — بیمه، جدول هزینه، ردیفهای پرداخت و خلاصه با هم — و
|
||||
* با دو روش پرداخت از ارتفاع صفحه بلندتر میشد.
|
||||
*/
|
||||
const CONFIRM_STEPS = [
|
||||
{ key: 'cost', title: 'بیمه و هزینه' },
|
||||
{ key: 'payment', title: 'پرداخت' },
|
||||
{ key: 'review', title: 'تأیید' },
|
||||
] as const;
|
||||
|
||||
type ConfirmStepKey = (typeof CONFIRM_STEPS)[number]['key'];
|
||||
|
||||
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
|
||||
interface PaymentRow {
|
||||
id: number;
|
||||
@@ -99,6 +114,7 @@ export default function ConfirmAppointmentModal({
|
||||
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
|
||||
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر میماند. */
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [stepIdx, setStepIdx] = useState(0);
|
||||
|
||||
// وقتی صفحهی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را میگیریم:
|
||||
// مبلغ ویزیت و قیمت سرویسها فقط در detail هستند.
|
||||
@@ -225,6 +241,7 @@ export default function ConfirmAppointmentModal({
|
||||
nextId.current = 1;
|
||||
setRows([makeRow()]);
|
||||
setTouched(false);
|
||||
setStepIdx(0);
|
||||
}
|
||||
|
||||
function patchRow(id: number, patch: Partial<PaymentRow>) {
|
||||
@@ -254,6 +271,8 @@ export default function ConfirmAppointmentModal({
|
||||
}
|
||||
|
||||
const loading = detailQuery.isLoading && !appointment;
|
||||
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
|
||||
const isLastStep = currentStep === 'review';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -263,17 +282,34 @@ export default function ConfirmAppointmentModal({
|
||||
onClose={handleClose}
|
||||
footer={
|
||||
<>
|
||||
{/* بستنِ فرم همیشه یک کلیک است؛ «مرحلهٔ قبل» جایش را نمیگیرد. */}
|
||||
<button type="button" className="btn ghost" onClick={handleClose}>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={loading || overpaid || confirmMut.isPending}
|
||||
onClick={() => confirmMut.mutate()}
|
||||
>
|
||||
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
||||
</button>
|
||||
{stepIdx > 0 && (
|
||||
<button type="button" className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
|
||||
مرحلهٔ قبل
|
||||
</button>
|
||||
)}
|
||||
{isLastStep ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={loading || overpaid || confirmMut.isPending}
|
||||
onClick={() => confirmMut.mutate()}
|
||||
>
|
||||
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={loading || overpaid}
|
||||
onClick={() => setStepIdx(i => i + 1)}
|
||||
>
|
||||
مرحلهٔ بعد
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -295,6 +331,14 @@ export default function ConfirmAppointmentModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stepper
|
||||
steps={CONFIRM_STEPS.map(st => ({ key: st.key, title: st.title }))}
|
||||
current={currentStep}
|
||||
ariaLabel="مراحل قطعی کردن نوبت"
|
||||
/>
|
||||
|
||||
{currentStep === 'cost' && (
|
||||
<>
|
||||
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{insurance.needsCategoryChoice && (
|
||||
@@ -390,6 +434,11 @@ export default function ConfirmAppointmentModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentStep === 'payment' && (
|
||||
<>
|
||||
{/* پرداختها — تقسیم بین چند روش */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
@@ -508,8 +557,31 @@ export default function ConfirmAppointmentModal({
|
||||
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{currentStep === 'review' && (
|
||||
<>
|
||||
{/* خلاصهٔ همان چیزی که ثبت میشود */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
||||
padding: '4px 14px 10px', marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={rowStyle}>
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(payable)}</strong>
|
||||
</div>
|
||||
{rows.filter(r => tomanToRial(r.amountToman) > 0).map(r => (
|
||||
<div key={r.id} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||
<span>{METHOD_OPTIONS.find(m => m.value === r.method)?.label ?? r.method}</span>
|
||||
<strong style={{ color: 'var(--text)' }}>{formatRial(tomanToRial(r.amountToman))}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* خلاصه */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
@@ -540,6 +612,8 @@ export default function ConfirmAppointmentModal({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
export interface DoctorTab {
|
||||
uuid: string;
|
||||
name: string;
|
||||
/** برچسب کوچکِ کنار نام — مثلاً «بدون ساعت کاری». */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export default function DoctorTabs({
|
||||
@@ -38,6 +40,15 @@ export default function DoctorTabs({
|
||||
}}
|
||||
>
|
||||
{d.name}
|
||||
{d.note && (
|
||||
<span style={{
|
||||
marginRight: 6, fontSize: 10.5, fontWeight: 500, padding: '2px 6px',
|
||||
borderRadius: 'var(--r-pill)', background: 'var(--warning-bg)', color: 'var(--warning)',
|
||||
verticalAlign: 'middle',
|
||||
}}>
|
||||
{d.note}
|
||||
</span>
|
||||
)}
|
||||
{active && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: -1, left: 0, right: 0, height: 3,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
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 NewAppointmentModal from './NewAppointmentModal';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const slot = {
|
||||
start: 1_780_000_000,
|
||||
end: 1_780_001_800,
|
||||
start_time: '۰۹:۰۰',
|
||||
end_time: '۰۹:۳۰',
|
||||
doctor_uuid: 'doc-1',
|
||||
doctor_name: 'دکتر رضایی',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: [] });
|
||||
});
|
||||
|
||||
const props = (overrides: Record<string, unknown> = {}) => ({
|
||||
slot,
|
||||
onClose: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** عنوان مرحلههای نوار بالای مودال. */
|
||||
const stepLabels = () =>
|
||||
Array.from(document.querySelectorAll('ol[aria-label="مراحل ثبت نوبت"] li'))
|
||||
.map(li => li.textContent?.replace(/^\d+/, '').trim());
|
||||
|
||||
const pickerProps = () => props({ serviceMode: true, date: '1405-05-18', services: [] });
|
||||
|
||||
describe('NewAppointmentModal — ویزارد', () => {
|
||||
/**
|
||||
* فرم قبلاً یک صفحهٔ بلند بود: خدمت و زمان، بیمار و هزینه با هم. هر بار یک مرحله
|
||||
* دیده میشود تا مودال از ارتفاع صفحه بلندتر نشود.
|
||||
*/
|
||||
it('حالت انتخابگر سه مرحله دارد و از مرحلهٔ اول شروع میشود', () => {
|
||||
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
|
||||
|
||||
expect(stepLabels()).toEqual(['خدمت و زمان', 'بیمار', 'تأیید و ثبت']);
|
||||
expect(screen.getByRole('heading', { name: 'خدمت و زمان' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'بیمار' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('مرحلهای که دادهاش از قبل معلوم است ساخته نمیشود', () => {
|
||||
// نوبت اسلاتی: زمان از خودِ اسلات میآید، پس مرحلهٔ «خدمت و زمان» بیمعناست.
|
||||
renderWithProviders(<NewAppointmentModal {...props()} />);
|
||||
|
||||
expect(stepLabels()).toEqual(['بیمار', 'تأیید و ثبت']);
|
||||
});
|
||||
|
||||
it('بیمارِ از پیش معلوم، مرحلهٔ بیمار را حذف میکند', () => {
|
||||
renderWithProviders(<NewAppointmentModal {...props({
|
||||
patient: { name: 'علی محمدی', mobile: '09123456789', national_code: '1234567890' },
|
||||
})} />);
|
||||
|
||||
// تنها مرحلهٔ باقیمانده «تأیید و ثبت» است، پس نوار مراحل هم لازم نیست.
|
||||
expect(document.querySelector('ol[aria-label="مراحل ثبت نوبت"]')).toBeNull();
|
||||
expect(screen.getByRole('heading', { name: 'تأیید و ثبت' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('تا وقتی مرحله کامل نشده، دکمهٔ بعدی غیرفعال است و دلیلش را میگوید', () => {
|
||||
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
|
||||
expect(screen.getByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
|
||||
// دکمهٔ ثبت فقط در مرحلهٔ آخر وجود دارد.
|
||||
expect(screen.queryByRole('button', { name: 'ثبت نوبت' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('در مرحلهٔ اول دکمهٔ «مرحلهٔ قبل» نیست، ولی «انصراف» همیشه هست', () => {
|
||||
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('مرحلهٔ آخر خلاصهٔ نوبت را نشان میدهد', () => {
|
||||
renderWithProviders(<NewAppointmentModal {...props({
|
||||
patient: { name: 'علی محمدی', mobile: '09123456789', national_code: '1234567890' },
|
||||
})} />);
|
||||
|
||||
expect(screen.getByText('علی محمدی')).toBeInTheDocument();
|
||||
expect(screen.getByText('09123456789')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('دکمهٔ اصلی در فوتر ثابت است، نه داخل ناحیهٔ اسکرول', () => {
|
||||
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
|
||||
|
||||
const next = screen.getByRole('button', { name: 'مرحلهٔ بعد' });
|
||||
|
||||
expect(next.closest('.modal-foot')).not.toBeNull();
|
||||
expect(next.closest('.modal-body')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,692 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial, formatDate } from '../../lib/utils';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import Modal from '../ui/Modal';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import PriceInput from '../ui/PriceInput';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import Stepper from '../ui/Stepper';
|
||||
import ServiceSlotPicker from './ServiceSlotPicker';
|
||||
import type { ServicePick } from './ServiceSlotPicker';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
|
||||
export interface BookingSlot {
|
||||
start: number;
|
||||
end: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
doctor_uuid: string;
|
||||
doctor_name: string;
|
||||
}
|
||||
|
||||
type StepKey = 'service' | 'patient' | 'confirm';
|
||||
|
||||
const STEP_TITLES: Record<StepKey, string> = {
|
||||
service: 'خدمت و زمان',
|
||||
patient: 'بیمار',
|
||||
confirm: 'تأیید و ثبت',
|
||||
};
|
||||
|
||||
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
|
||||
|
||||
/** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش مینشیند. */
|
||||
export interface BookingResource {
|
||||
uuid: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ثبت نوبت از روی یک اسلات/منبع: جستجوی بیمار، انتخاب سرویسها و زمان، هزینهٔ ویزیت.
|
||||
*
|
||||
* سه حالت دارد و هر سه یک فرماند — سه مودال یعنی سه رفتار:
|
||||
* - اسلاتی: زمان همان اسلاتِ کلیکشده است.
|
||||
* - سرویسی (پزشک): زمان از `ServiceSlotPicker` و برنامهٔ هفتگیِ پزشک میآید.
|
||||
* - منبع: همان انتخابگر، ولی زمانها از تقویم خودِ منبع و ثبت با `resource_uuid`.
|
||||
*/
|
||||
export default function NewAppointmentModal({
|
||||
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
|
||||
treatmentSessionUuid = null, patient = null,
|
||||
}: {
|
||||
slot: BookingSlot;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
serviceMode?: boolean;
|
||||
services?: BookingService[];
|
||||
date?: string;
|
||||
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت میدهد. */
|
||||
clinicUuid?: string | null;
|
||||
resource?: BookingResource | null;
|
||||
/**
|
||||
* رزروِ صریحِ یک جلسهٔ درمان. بیمار میتواند چند دورهٔ باز داشته باشد، پس بدون این،
|
||||
* اتصال از روی سرویس حدس زده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
||||
*/
|
||||
treatmentSessionUuid?: string | null;
|
||||
/**
|
||||
* بیمارِ از پیش معلوم — وقتی مودال از پروندهٔ خودِ بیمار باز میشود.
|
||||
*
|
||||
* مرحلهٔ جستجو رد میشود: کسی که پروندهٔ بیمار را باز کرده نباید همان بیمار را
|
||||
* دوباره با کد ملی پیدا کند.
|
||||
*/
|
||||
patient?: { name: string | null; mobile: string; national_code: string | null } | null;
|
||||
}) {
|
||||
const [mobile, setMobile] = useState(patient?.mobile ?? '');
|
||||
const [lookup, setLookup] = useState<PatientLookup | null>(
|
||||
patient === null ? null : { found: true, name: patient.name, mobile: patient.mobile, national_code: patient.national_code },
|
||||
);
|
||||
const [patientName, setPatientName] = useState(patient?.name ?? '');
|
||||
const [nationalCode, setNationalCode] = useState(patient?.national_code ?? '');
|
||||
// معیار جستجوی بیمار: کد ملی (پیشفرض) یا موبایل.
|
||||
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
|
||||
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
|
||||
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز میگذارد،
|
||||
// پر کردنش آن را از قبل به یک نفر میدهد.
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
/**
|
||||
* تاریخ داخل خودِ مودال قابل ویرایش است.
|
||||
*
|
||||
* prop فقط نقطهٔ شروع را میدهد — تاریخِ کارتِ جلسه یا روزِ انتخابشدهٔ صفحه. ولی
|
||||
* وقت پیشنهادی همیشه در دسترس نیست و کاربر باید بتواند همانجا روز دیگری بگیرد،
|
||||
* بدون بستن فرمی که بیمار و سرویسش را پر کرده.
|
||||
*/
|
||||
const [activeDate, setActiveDate] = useState(date ?? '');
|
||||
useEffect(() => { setActiveDate(date ?? ''); }, [date]);
|
||||
// پیشفرضِ پروتکل فقط تا وقتی اعمال میشود که کاربر دست نزده باشد؛ وگرنه انتخاب
|
||||
// دستیِ منشی با هر تغییرِ سرویس پاک میشد.
|
||||
const [staffTouched, setStaffTouched] = useState(false);
|
||||
|
||||
const role = useAuthStore(s => s.primaryRole);
|
||||
// نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمیشناسد.
|
||||
const createEndpoint = role === 'admin' && resource === null
|
||||
? '/api/v1/admin/appointment'
|
||||
: '/api/v1/my/appointment';
|
||||
|
||||
// منبع تقویم خودش را دارد، پس نوبتش همیشه سرویسی است — اسلات ثابتی وجود ندارد
|
||||
// که بشود رویش نشست.
|
||||
const pickerMode = serviceMode || resource !== null;
|
||||
|
||||
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
|
||||
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ میدهد.
|
||||
//
|
||||
// قیمت باید از تنظیمات نوبتدهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
|
||||
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر میماند. اگر دسترسی به تنظیمات آن
|
||||
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمیگردیم تا فلگ الزامیبودن از دست نرود.
|
||||
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
|
||||
const doctorPricingQ = useQuery<Pricing>({
|
||||
queryKey: ['insurance-pricing', slot.doctor_uuid],
|
||||
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
|
||||
enabled: !!slot.doctor_uuid,
|
||||
retry: false,
|
||||
});
|
||||
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
|
||||
queryKey: ['staff-list'],
|
||||
queryFn: () => api.get('/api/v1/staff'),
|
||||
enabled: pickerMode,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* پرسنلِ مجازِ «طول درمان» همان سرویس.
|
||||
*
|
||||
* منشی نباید چیزی را دوباره انتخاب کند که در تنظیمات سرویس یک بار تعریف شده؛
|
||||
* پیشفرض همان است و تغییرش آزاد.
|
||||
*/
|
||||
const primaryService = pick.serviceUuids[0] ?? '';
|
||||
const protocolQ = useQuery<ApiResponse<{ staff: { uuid: string; name: string }[] } | null>>({
|
||||
queryKey: ['service-protocol', primaryService],
|
||||
queryFn: () => api.get(`/api/v1/service-item/${primaryService}/treatment-protocol`),
|
||||
enabled: primaryService !== '',
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const protocolStaff = protocolQ.data?.data?.staff ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (staffTouched || protocolStaff.length === 0) return;
|
||||
|
||||
setStaffUuid(protocolStaff[0].uuid);
|
||||
}, [protocolStaff, staffTouched]);
|
||||
|
||||
const selfPricingQ = useQuery<Pricing>({
|
||||
queryKey: ['insurance-pricing'],
|
||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||
enabled: doctorPricingQ.isError || !slot.doctor_uuid,
|
||||
});
|
||||
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
|
||||
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
|
||||
const requireVisit = pricing?.data?.require_visit_price ?? false;
|
||||
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
|
||||
const [visitPriceToman, setVisitPriceToman] = useState(0);
|
||||
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
||||
}, [freeVisit, visitPriceTouched]);
|
||||
// هزینه ویزیت اختیاری داخل کلپسِ بسته مینشیند؛ وقتی الزامی است کلپس همیشه باز است.
|
||||
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
|
||||
const visitPriceExpanded = requireVisit || visitPriceOpen;
|
||||
const [stepIdx, setStepIdx] = useState(0);
|
||||
|
||||
const mobileValid = /^09\d{9}$/.test(mobile);
|
||||
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
|
||||
// اعتبار کلید جستجو بسته به معیار انتخابشده.
|
||||
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
|
||||
const serviceTimingValid = !pickerMode || (pick.serviceUuids.length > 0 && !!pick.slot);
|
||||
// یک بیمارِ یافتشده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
|
||||
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
|
||||
const needsDetails = lookup !== null && !foundWithNationalCode; // یافتنشده، یا یافتشده بدون کد ملی
|
||||
|
||||
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
|
||||
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
|
||||
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
|
||||
const visitPriceValid = !requireVisit || visitPriceToman > 0;
|
||||
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
|
||||
|
||||
const search = useMutation({
|
||||
mutationFn: () => {
|
||||
const q = searchBy === 'mobile'
|
||||
? `mobile=${encodeURIComponent(mobile)}`
|
||||
: `national_code=${encodeURIComponent(nationalCode)}`;
|
||||
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const data: PatientLookup = res?.data ?? { found: false };
|
||||
setLookup(data);
|
||||
setPatientName(data.found ? (data.name ?? '') : '');
|
||||
// موبایل و کد ملیِ بیمارِ یافتشده را پر میکنیم تا ثبت مستقل از معیار جستجو کار کند.
|
||||
if (data.found) {
|
||||
if (data.mobile) setMobile(data.mobile);
|
||||
setNationalCode(data.national_code ?? '');
|
||||
} else if (searchBy === 'mobile') {
|
||||
setNationalCode('');
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.post(createEndpoint, {
|
||||
...(slot.doctor_uuid ? { doctor_uuid: slot.doctor_uuid } : {}),
|
||||
slot_start: pickerMode ? pick.slot!.start : slot.start,
|
||||
slot_end: pickerMode ? pick.slot!.end : slot.end,
|
||||
patient_mobile: mobile,
|
||||
patient_name: effectiveName,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||||
...(treatmentSessionUuid ? { treatment_session_uuid: treatmentSessionUuid } : {}),
|
||||
...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}),
|
||||
// منبع: مدت را سرور از سرویسهای همین منبع میسازد، پس ساعت پایان حدس نیست.
|
||||
...(resource ? {
|
||||
resource_uuid: resource.uuid,
|
||||
duration_from_services: true,
|
||||
service_durations: pick.durations,
|
||||
} : {}),
|
||||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوبت با موفقیت ثبت شد');
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
// تغییر کلید جستجو نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
||||
function invalidateLookup() {
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); }
|
||||
}
|
||||
function onMobileChange(v: string) {
|
||||
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
||||
setMobile(sanitizeMobileInput(v));
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
|
||||
}
|
||||
function onNationalSearchChange(v: string) {
|
||||
setNationalCode(digitsOnly(v, 10));
|
||||
invalidateLookup();
|
||||
}
|
||||
// جابهجایی معیار جستجو همهچیز را از نو شروع میکند.
|
||||
function onSwitchSearchBy(mode: 'mobile' | 'national') {
|
||||
setSearchBy(mode);
|
||||
setLookup(null); setPatientName('');
|
||||
setMobile(''); setNationalCode('');
|
||||
}
|
||||
|
||||
// هدفِ نوبت بهصورت «برچسب: مقدار» — دو اسمِ لخت کنار هم معلوم نمیکند کدام دستگاه
|
||||
// است و کدام پزشک. تاریخ هم اینجاست چون تنها جای مودال بود که اصلاً دیده نمیشد.
|
||||
const targetFacts: { label: string; value: string }[] = [
|
||||
resource
|
||||
? { label: 'دستگاه', value: resource.name }
|
||||
: pickerMode
|
||||
? { label: 'پزشک', value: slot.doctor_name }
|
||||
: { label: 'ساعت', value: `${slot.start_time} تا ${slot.end_time}` },
|
||||
...((resource || !pickerMode) && slot.doctor_name
|
||||
? [{ label: resource ? 'پزشک ناظر' : 'پزشک', value: slot.doctor_name }]
|
||||
: []),
|
||||
/* در حالت انتخابگر، خودِ فیلدِ تاریخ همین را میگوید و تکرارش در هدر نویز است. */
|
||||
...(activeDate && !pickerMode ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
|
||||
];
|
||||
|
||||
/**
|
||||
* مراحلِ ویزارد — فقط مراحلی که واقعاً تصمیمی دارند.
|
||||
*
|
||||
* فرم قبلاً یک صفحهٔ بلند بود: انتخاب سرویس و زمان، جستجوی بیمار و هزینه همه با هم.
|
||||
* مرحلهای که دادهاش از قبل معلوم است ساخته نمیشود — نوبتِ اسلاتی زمان دارد و
|
||||
* فرمِ بازشده از پروندهٔ بیمار، بیمار.
|
||||
*/
|
||||
const stepKeys: StepKey[] = [
|
||||
...(pickerMode && activeDate ? (['service'] as StepKey[]) : []),
|
||||
...(patient === null ? (['patient'] as StepKey[]) : []),
|
||||
'confirm',
|
||||
];
|
||||
const currentStep = stepKeys[Math.min(stepIdx, stepKeys.length - 1)];
|
||||
const isLastStep = currentStep === 'confirm';
|
||||
|
||||
// اولین چیزی که جلوی رفتن به مرحلهٔ بعد (یا ثبت) را گرفته. دکمهٔ خاکستریِ
|
||||
// بیتوضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
|
||||
const stepBlockReason: Record<StepKey, string | null> = {
|
||||
service: !serviceTimingValid
|
||||
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
|
||||
: null,
|
||||
patient: lookup === null
|
||||
? 'ابتدا بیمار را جستجو کنید'
|
||||
: needsDetails && !detailsValid
|
||||
? 'مشخصات بیمار را کامل کنید'
|
||||
: !mobileValid
|
||||
? 'شماره موبایل بیمار معتبر نیست'
|
||||
: null,
|
||||
confirm: !visitPriceValid ? 'هزینه ویزیت الزامی است' : null,
|
||||
};
|
||||
const blockReason = stepBlockReason[currentStep];
|
||||
|
||||
// خلاصهٔ مرحلهٔ آخر: همان چیزی که ثبت میشود، پیش از ثبت.
|
||||
const pickedServiceNames = pick.serviceUuids
|
||||
.map(uuid => services.find(sv => sv.uuid === uuid)?.name ?? uuid);
|
||||
const staffName = (staffQ.data?.data ?? []).find(st => st.uuid === staffUuid)?.full_name ?? null;
|
||||
const summaryRows: { label: string; value: string }[] = [
|
||||
...targetFacts.map(f => ({ label: f.label, value: f.value })),
|
||||
...(pickerMode && activeDate ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []),
|
||||
...(pickerMode && pick.slot ? [{ label: 'ساعت', value: pick.slot.start_time }] : []),
|
||||
...(pickedServiceNames.length > 0 ? [{ label: 'سرویس', value: pickedServiceNames.join('، ') }] : []),
|
||||
...(staffName ? [{ label: 'پرسنل', value: staffName }] : []),
|
||||
{ label: 'بیمار', value: effectiveName || (patient?.name ?? '—') },
|
||||
{ label: 'موبایل', value: mobile || (patient?.mobile ?? '—') },
|
||||
];
|
||||
|
||||
const priceHint = pricingLoading
|
||||
? 'در حال خواندن تعرفهٔ پزشک…'
|
||||
: freeVisit > 0
|
||||
? `تعرفهٔ نوبتدهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
|
||||
: 'برای این پزشک تعرفهای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="ثبت نوبت"
|
||||
// هر بار یک مرحله دیده میشود، پس ستون تکی کافی است؛ `md` عرضِ راحتِ
|
||||
// انتخابگر سرویس و اسلات است بدون فضای خالی.
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{/* «قبلی» جای «انصراف» را نمیگیرد: بستنِ فرم همیشه باید یک کلیک باشد. */}
|
||||
<button className="btn ghost" onClick={onClose}>انصراف</button>
|
||||
{stepIdx > 0 && (
|
||||
<button className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
|
||||
مرحلهٔ قبل
|
||||
</button>
|
||||
)}
|
||||
{isLastStep ? (
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!isValid || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => setStepIdx(i => i + 1)}
|
||||
disabled={blockReason !== null}
|
||||
>
|
||||
مرحلهٔ بعد
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* هدف نوبت: منبع، یا اسلات/پزشک */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18,
|
||||
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
||||
}}>
|
||||
{resource
|
||||
? <CpuChipIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
|
||||
: <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />}
|
||||
{targetFacts.map(f => (
|
||||
<span key={f.label} style={{ fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-3)' }}>{f.label}: </span>
|
||||
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{f.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stepKeys.length > 1 && (
|
||||
<Stepper
|
||||
steps={stepKeys.map(key => ({ key, title: STEP_TITLES[key] }))}
|
||||
current={currentStep}
|
||||
ariaLabel="مراحل ثبت نوبت"
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'service' && (
|
||||
<Step title={STEP_TITLES.service}>
|
||||
<div className="field-block" style={{ marginBottom: 12, maxWidth: 220 }}>
|
||||
<label htmlFor="appt-date">تاریخ نوبت</label>
|
||||
<PersianDateInput
|
||||
value={activeDate}
|
||||
onChange={setActiveDate}
|
||||
ariaLabel="تاریخ نوبت"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={slot.doctor_uuid}
|
||||
resourceUuid={resource?.uuid}
|
||||
date={activeDate}
|
||||
services={services}
|
||||
onSelect={setPick}
|
||||
clinicUuidOverride={clinicUuid}
|
||||
/>
|
||||
|
||||
{/* اختیاری بهعمد: پیشفرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت
|
||||
میکند که بیمار اپراتور مشخصی خواسته باشد. */}
|
||||
<div className="field-block" style={{ marginTop: 12 }}>
|
||||
<label htmlFor="appt-operator">پرسنل <span className="opt">(اختیاری)</span></label>
|
||||
<SearchableSelect
|
||||
inputId="appt-operator"
|
||||
options={(staffQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.full_name }))}
|
||||
value={staffUuid || null}
|
||||
onChange={v => { setStaffUuid(v ? String(v) : ''); setStaffTouched(true); }}
|
||||
placeholder={protocolQ.isLoading ? 'در حال خواندن پرسنل سرویس…' : 'در صف مشترک پرسنل بماند'}
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{currentStep === 'patient' && (
|
||||
<Step title={STEP_TITLES.patient}>
|
||||
{/* بیمارِ از پیش معلوم: فقط تأیید میشود، جستجو لازم نیست. */}
|
||||
{patient !== null && (
|
||||
<div style={{
|
||||
marginBottom: 14, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--success-bg)', display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
|
||||
}}>
|
||||
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{patient.name || 'بدون نام'}</div>
|
||||
<div style={{ color: 'var(--text-2)', fontSize: 12, direction: 'ltr', textAlign: 'start' }}>
|
||||
{patient.mobile}{patient.national_code ? ` · ${patient.national_code}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-block" style={{ marginBottom: 14, display: patient === null ? undefined : 'none' }}>
|
||||
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
|
||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
{(['national', 'mobile'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={searchBy === mode ? 'on' : ''}
|
||||
aria-pressed={searchBy === mode}
|
||||
onClick={() => onSwitchSearchBy(mode)}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
{searchBy === 'mobile' ? (
|
||||
<input
|
||||
id="appt-patient-search"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => onMobileChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus={!pickerMode}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id="appt-patient-search"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => onNationalSearchChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
// در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار
|
||||
// اینجا کاربر را از مرحلهٔ یک رد میکرد.
|
||||
autoFocus={!pickerMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn soft"
|
||||
onClick={() => search.mutate()}
|
||||
disabled={!searchValid || search.isPending}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
||||
{search.isPending ? '...' : 'جستجو'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* وقتی بیمار از قبل معلوم است، کارت بالا همین را میگوید؛ دو بار گفتنش نویز است. */}
|
||||
{patient === null && foundWithNationalCode && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--success-bg)', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
|
||||
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsDetails && (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
|
||||
}}>
|
||||
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
|
||||
</div>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label htmlFor="appt-patient-name">نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
id="appt-patient-name"
|
||||
type="text"
|
||||
value={patientName}
|
||||
onChange={e => setPatientName(e.target.value)}
|
||||
placeholder="مثال: علی محمدی"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
||||
{searchBy === 'national' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label htmlFor="appt-patient-mobile">شماره موبایل بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
id="appt-patient-mobile"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده میشود و فیلد تکراری لازم نیست. */}
|
||||
{searchBy === 'mobile' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label htmlFor="appt-patient-national">کد ملی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
id="appt-patient-national"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{currentStep === 'confirm' && (
|
||||
<Step title={STEP_TITLES.confirm}>
|
||||
{/* خلاصهٔ همان چیزی که ثبت میشود — تنها جایی که کاربر پیش از ثبت، انتخاب
|
||||
سرویس و زمان و بیمار را با هم میبیند. */}
|
||||
<dl style={{
|
||||
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 14px', margin: '0 0 18px',
|
||||
padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{summaryRows.map(row => (
|
||||
<React.Fragment key={row.label}>
|
||||
<dt style={{ color: 'var(--text-3)' }}>{row.label}</dt>
|
||||
<dd style={{ margin: 0, color: 'var(--text)', fontWeight: 600, minWidth: 0, overflowWrap: 'anywhere' }}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
|
||||
<div className="field-block">
|
||||
{requireVisit ? (
|
||||
<label>
|
||||
هزینه ویزیت (تومان)<span className="req"> *</span>
|
||||
</label>
|
||||
) : (
|
||||
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisitPriceOpen(o => !o)}
|
||||
aria-expanded={visitPriceExpanded}
|
||||
style={{
|
||||
// سرِ کلپس تمام عرض و ۳۶px است: با padding صفر ارتفاعش اندازهٔ یک خط متن
|
||||
// میشد و روی موبایل عملاً قابل زدن نبود.
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', minHeight: 36,
|
||||
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
||||
padding: 0, color: 'var(--text)', textAlign: 'start',
|
||||
}}
|
||||
>
|
||||
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)',
|
||||
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{visitPriceExpanded && (
|
||||
<>
|
||||
<div
|
||||
className="field"
|
||||
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
||||
>
|
||||
<PriceInput
|
||||
value={visitPriceToman}
|
||||
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
||||
suffix="تومان"
|
||||
/>
|
||||
</div>
|
||||
{requireVisit && visitPriceToman <= 0
|
||||
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
||||
: <span className="field-hint">{priceHint}</span>}
|
||||
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
||||
>
|
||||
استفاده از تعرفهٔ پزشک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{blockReason && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, marginTop: 16,
|
||||
fontSize: 12.5, color: 'var(--text-3)',
|
||||
}}>
|
||||
<ExclamationCircleIcon style={{ width: 15, height: 15, flexShrink: 0 }} />
|
||||
{isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */
|
||||
function Step({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 18 }}>
|
||||
<h3 style={{
|
||||
margin: '0 0 10px', fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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 { useAuthStore } from '../../stores/authStore';
|
||||
import NewAppointmentModal from './NewAppointmentModal';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
const START = 1_900_000_000;
|
||||
|
||||
const slot = {
|
||||
start: 0, end: 0, start_time: '', end_time: '',
|
||||
doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی',
|
||||
};
|
||||
|
||||
const resource = { uuid: 'r-2', name: 'لیزر CO2' };
|
||||
|
||||
const services = [
|
||||
{ uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
|
||||
{ uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
|
||||
];
|
||||
|
||||
/** بیمارِ یافتشده تا فرم معتبر شود؛ زمانهای خالی از تقویم منبع. */
|
||||
function mockApi() {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('patient-lookup')) {
|
||||
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
||||
}
|
||||
if (url === '/api/v1/staff') {
|
||||
return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] });
|
||||
}
|
||||
if (url.includes('/treatment-protocol')) {
|
||||
return Promise.resolve({ success: true, data: { staff: [{ uuid: 'st-1', name: 'پرسنل یک' }] } });
|
||||
}
|
||||
if (url.includes('/service-slots')) {
|
||||
return Promise.resolve({ success: true, data: {
|
||||
total_duration_minutes: 25,
|
||||
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
||||
} });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */
|
||||
async function pickServiceAndTime() {
|
||||
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش میرویم.
|
||||
fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
||||
// ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
|
||||
// «انتخابشده/نشده» اعلام شود.
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
||||
}
|
||||
|
||||
/** فرم ویزارد شد: هر «مرحلهٔ بعد» یک گام جلو میبرد. */
|
||||
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
|
||||
|
||||
/** خدمت و زمان را انتخاب میکند و به مرحلهٔ «بیمار» میرود. */
|
||||
async function pickServiceAndTimeThenNext() {
|
||||
await pickServiceAndTime();
|
||||
nextStep();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'clinic' } as any);
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
|
||||
mockApi();
|
||||
});
|
||||
|
||||
describe('مودال ثبت نوبتِ منبع', () => {
|
||||
it('نام منبع و پزشک ناظر بالای فرم میآید و زمانها از تقویم منبع خوانده میشوند', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('لیزر CO2')).toBeInTheDocument();
|
||||
expect(screen.getByText('مینا یوسفی')).toBeInTheDocument();
|
||||
|
||||
await pickServiceAndTime();
|
||||
|
||||
await waitFor(() => expect(
|
||||
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')),
|
||||
).toBe(true));
|
||||
});
|
||||
|
||||
it('ثبت، نوبت را با resource_uuid و مدتِ سرویسها میفرستد', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
await pickServiceAndTimeThenNext();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||
await screen.findByText('بیمار یافت شد');
|
||||
|
||||
nextStep();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
const [url, body] = post.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/my/appointment');
|
||||
expect(body).toMatchObject({
|
||||
resource_uuid: 'r-2',
|
||||
duration_from_services: true,
|
||||
service_item_uuids: ['s-1'],
|
||||
service_durations: { 's-1': 25 },
|
||||
slot_start: START,
|
||||
patient_national_code: '0012345678',
|
||||
});
|
||||
});
|
||||
|
||||
it('بدون انتخاب سرویس و زمان، از مرحلهٔ اول رد نمیشود', () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
// مرحلهٔ بیمار هنوز ساخته نشده، پس فیلد جستجو در DOM نیست.
|
||||
expect(screen.queryByPlaceholderText('کد ملی ۱۰ رقمی')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('دلیلِ غیرفعال بودنِ ثبت را میگوید و با پیشرفتِ فرم عوض میشود', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
|
||||
|
||||
// مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش میرویم.
|
||||
fireEvent.keyDown(screen.getByLabelText('بخش'), { 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' }));
|
||||
nextStep();
|
||||
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** تاریخ در حالت انتخابگر یک فیلدِ قابل ویرایش است، نه متنِ ثابتِ هدر. */
|
||||
it('تاریخ نوبت بهصورت جلالی و قابل ویرایش میآید', () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
|
||||
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
|
||||
// چیپ هدر دیگر تاریخ را تکرار نمیکند.
|
||||
expect(screen.queryByText('تاریخ:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('معیار جستجو یک seg با حالتِ اعلامشده است', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
await pickServiceAndTimeThenNext();
|
||||
|
||||
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('پرسنلِ پروتکل سرویس را پیشفرض میفرستد', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
await pickServiceAndTime();
|
||||
// پرسنل در همان مرحلهٔ «خدمت و زمان» است.
|
||||
await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument());
|
||||
nextStep();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||
await screen.findByText('بیمار یافت شد');
|
||||
|
||||
nextStep();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1]).toMatchObject({ staff_uuid: 'st-1' });
|
||||
});
|
||||
|
||||
/** پروتکل بدون پرسنل نباید فرم را قفل کند. */
|
||||
it('پروتکل بدون پرسنل، فیلد را خالی میگذارد و ثبت همچنان ممکن است', async () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/treatment-protocol')) return Promise.resolve({ success: true, data: null });
|
||||
if (url.includes('patient-lookup')) {
|
||||
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
||||
}
|
||||
if (url.includes('/service-slots')) {
|
||||
return Promise.resolve({ success: true, data: {
|
||||
total_duration_minutes: 25,
|
||||
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
||||
} });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
await pickServiceAndTimeThenNext();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||
await screen.findByText('بیمار یافت شد');
|
||||
|
||||
nextStep();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
|
||||
});
|
||||
|
||||
/**
|
||||
* وقتِ پیشنهادی همیشه در دسترس نیست؛ کاربر باید بتواند بدون بستن فرم روز دیگری
|
||||
* بگیرد و زمانها برای همان روز دوباره خوانده شوند.
|
||||
*/
|
||||
it('تغییر تاریخ، زمانهای خالی را برای روز تازه میخواند', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
await pickServiceAndTime();
|
||||
await waitFor(() => expect(
|
||||
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('date=2026-08-04')),
|
||||
).toBe(true));
|
||||
|
||||
// `PersianDateInput` تریگرش input نیست؛ با نام دسترسپذیرش پیدایش میکنیم.
|
||||
expect(screen.getByLabelText('تاریخ نوبت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('منبعِ بدون سرویس، پیام راهنما میدهد نه فهرست خالی', () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } 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 ResourceDayPanel from './ResourceDayPanel';
|
||||
import type { Appointment, ClinicResource } from '../../types';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const DAY = Math.floor(Date.now() / 1000) + 7 * 86400;
|
||||
|
||||
const resource = {
|
||||
uuid: 'r-2', name: 'لیزر CO2',
|
||||
supervisor: { uuid: 'd1', name: 'مینا یوسفی' },
|
||||
} as unknown as ClinicResource;
|
||||
|
||||
const appointment = {
|
||||
uuid: 'a-1', patient_name: 'رضا رحیمی', patient_mobile: '09120001307',
|
||||
doctor_uuid: 'd1', doctor_name: 'مینا یوسفی',
|
||||
slot_start: DAY + 3600, slot_end: DAY + 3600 + 3000,
|
||||
appointment_date: '', appointment_time: '10:35', end_time: '11:25',
|
||||
status: 'pending', version: 1, created_at: '',
|
||||
service_item: { uuid: 's-2', name: 'RF فرکشنال' },
|
||||
} as unknown as Appointment;
|
||||
|
||||
function render(appointments: Appointment[] = []) {
|
||||
return renderWithProviders(
|
||||
<ResourceDayPanel
|
||||
resource={resource}
|
||||
date="2026-08-04"
|
||||
appointments={appointments}
|
||||
loading={false}
|
||||
canCreate
|
||||
queryKey={['appointments']}
|
||||
onBook={() => {}}
|
||||
onView={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => get.mockReset());
|
||||
|
||||
describe('ResourceDayPanel', () => {
|
||||
it('بازهٔ کاری منبع و ردیفهای خالیِ قابل رزرو را نشان میدهد', async () => {
|
||||
get.mockResolvedValue({ success: true, data: {
|
||||
windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }],
|
||||
empty_reason: null,
|
||||
} });
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByText(/ساعت کاری: 08:00 - 14:00/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/نوبتها بر اساس مدت سرویس چیده میشوند/)).toBeInTheDocument();
|
||||
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('نوبتِ رزروشده کارت خودش را میگیرد و خالیها دو طرفش میمانند', async () => {
|
||||
get.mockResolvedValue({ success: true, data: {
|
||||
windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }],
|
||||
empty_reason: null,
|
||||
} });
|
||||
|
||||
render([appointment]);
|
||||
|
||||
expect(await screen.findByText('رضا رحیمی')).toBeInTheDocument();
|
||||
expect(screen.getByText('سرویس: RF فرکشنال')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('افزودن نوبت سریع')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('روزِ بدون شیفت، دلیلش را میگوید نه فهرست خالی', async () => {
|
||||
get.mockResolvedValue({ success: true, data: { windows: [], empty_reason: 'no_shift' } });
|
||||
|
||||
render();
|
||||
|
||||
expect(await screen.findByText('این روز شیفت کاری ندارد')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import TurnsTimeline from './TurnsTimeline';
|
||||
import { buildServiceTimeline } from './serviceTimeline';
|
||||
import type { TimelineSlot } from './types';
|
||||
import type { Appointment, ClinicResource } from '../../types';
|
||||
|
||||
interface DaySlotsData {
|
||||
windows: { start: number; end: number; start_time: string; end_time: string }[];
|
||||
empty_reason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای روزِ یک منبع — همان تایملاینِ نوبتدهی سرویسیِ پزشک، با تقویم خودِ منبع.
|
||||
*
|
||||
* منبع اسلاتِ ثابت ندارد: بازهٔ کاری از شیفت خودش میآید و طول هر نوبت از سرویسهایش،
|
||||
* پس ردیفها «نوبتهای رزروشده + بازههای خالیِ بینشان»اند. کامپوننت تایملاین یکی
|
||||
* است تا کارت، وضعیت و عملیاتِ نوبت در هر دو نما یک چیز باشند.
|
||||
*/
|
||||
export default function ResourceDayPanel({
|
||||
resource, date, appointments, loading, canCreate, queryKey, onBook, onView,
|
||||
}: {
|
||||
resource: ClinicResource;
|
||||
/** روزِ نمایش، ISO `Y-m-d`. */
|
||||
date: string;
|
||||
appointments: Appointment[];
|
||||
loading: boolean;
|
||||
canCreate: boolean;
|
||||
queryKey: unknown[];
|
||||
onBook: (slot: TimelineSlot | null) => void;
|
||||
onView: (appointment: Appointment) => void;
|
||||
}) {
|
||||
const dayQuery = useQuery<ApiResponse<DaySlotsData>>({
|
||||
queryKey: ['resource-day-slots', resource.uuid, date],
|
||||
queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/day-slots?date=${date}`),
|
||||
});
|
||||
|
||||
const data = dayQuery.data?.data;
|
||||
const windows = data?.windows ?? [];
|
||||
|
||||
const slots = React.useMemo(
|
||||
() => buildServiceTimeline(windows, appointments),
|
||||
[windows, appointments],
|
||||
);
|
||||
|
||||
const workingRange = windows.length > 0
|
||||
? { start: windows[0].start_time, end: windows[windows.length - 1].end_time }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
fontSize: 12.5, color: 'var(--text-2)',
|
||||
}}>
|
||||
<span>نوبتدهی سرویسی — نوبتها بر اساس مدت سرویس چیده میشوند.</span>
|
||||
{workingRange && (
|
||||
<span dir="ltr" style={{ color: 'var(--text-3)', flexShrink: 0 }}>
|
||||
ساعت کاری: {workingRange.start} - {workingRange.end}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TurnsTimeline
|
||||
slots={canCreate ? slots : slots.filter(s => s.appointment !== null)}
|
||||
loading={loading || dayQuery.isLoading}
|
||||
queryKey={queryKey}
|
||||
onView={onView}
|
||||
onBook={onBook}
|
||||
emptyReason={data?.empty_reason ?? null}
|
||||
errorMessage={dayQuery.isError ? ((dayQuery.error as Error)?.message || 'خطای نامشخص') : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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';
|
||||
|
||||
@@ -18,20 +19,35 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
|
||||
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
||||
*/
|
||||
export default function ServiceSlotPicker({
|
||||
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
||||
doctorUuid, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
|
||||
excludeAppointmentUuid, initialSelection,
|
||||
}: {
|
||||
doctorUuid: string;
|
||||
/**
|
||||
* نوبتدهی روی یک منبع: زمانها از تقویم خودِ منبع میآیند، نه از برنامهٔ پزشک.
|
||||
*
|
||||
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش → سرویس → مدت → زمان)، پس فقط
|
||||
* منبعِ زمان عوض میشود نه کامپوننت — دو نسخه یعنی دو رفتار.
|
||||
*/
|
||||
resourceUuid?: string;
|
||||
date: string;
|
||||
services: BookingService[];
|
||||
onSelect: (v: ServicePick) => void;
|
||||
editableDuration?: boolean;
|
||||
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخابشده خارج از context */
|
||||
clinicUuidOverride?: string | null;
|
||||
/**
|
||||
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلیاش در فهرست
|
||||
* نمیآید و کاربر نمیتواند «همان ساعت، سرویس متفاوت» را ثبت کند.
|
||||
*/
|
||||
excludeAppointmentUuid?: string;
|
||||
/** سرویسهای از قبل انتخابشده (ویرایش نوبت موجود). فقط یک بار هیدریت میشود. */
|
||||
initialSelection?: PickedService[];
|
||||
}) {
|
||||
const contextClinicUuid = useClinicContext();
|
||||
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [selected, setSelected] = useState<PickedService[]>([]);
|
||||
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
|
||||
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
||||
|
||||
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
||||
@@ -45,9 +61,14 @@ export default function ServiceSlotPicker({
|
||||
[services, sectionUuid],
|
||||
);
|
||||
|
||||
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند.
|
||||
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
|
||||
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
|
||||
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند. اجرای نخست معاف است،
|
||||
// وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک میشد.
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!mounted.current) { mounted.current = true; return; }
|
||||
setSelected([]); setSectionUuid('');
|
||||
}, [doctorUuid, resourceUuid]);
|
||||
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]);
|
||||
|
||||
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
||||
const durations = useMemo(
|
||||
@@ -58,15 +79,19 @@ export default function ServiceSlotPicker({
|
||||
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
|
||||
|
||||
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
|
||||
const servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('');
|
||||
const slotsQ = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
|
||||
queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
|
||||
queryFn: () => api.get(
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
||||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||||
+ durationsQs
|
||||
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
|
||||
resourceUuid
|
||||
? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs
|
||||
: `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
||||
+ servicesQs
|
||||
+ durationsQs
|
||||
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
||||
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
|
||||
),
|
||||
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
|
||||
enabled: (!!resourceUuid || !!doctorUuid) && !!date && serviceUuids.length > 0,
|
||||
});
|
||||
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
|
||||
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
|
||||
@@ -84,7 +109,9 @@ export default function ServiceSlotPicker({
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||||
{resourceUuid
|
||||
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویسها»ی همین منبع اضافه کنید.'
|
||||
: 'سرویسی با «نمایش در نوبتدهی آنلاین» برای این پزشک تعریف نشده است.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +119,7 @@ export default function ServiceSlotPicker({
|
||||
return (
|
||||
<div>
|
||||
{/* انتخاب بخش */}
|
||||
<label style={label}>بخش</label>
|
||||
<label style={label} htmlFor="service-mode-section-select">بخش</label>
|
||||
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
|
||||
<SearchableSelect
|
||||
inputId="service-mode-section-select"
|
||||
@@ -117,9 +144,10 @@ export default function ServiceSlotPicker({
|
||||
const active = selected.some(p => p.uuid === s.uuid);
|
||||
return (
|
||||
<button key={s.uuid} type="button" onClick={() => toggle(s)}
|
||||
role="checkbox" aria-checked={active}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||||
minHeight: 40, padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
@@ -164,7 +192,7 @@ export default function ServiceSlotPicker({
|
||||
{editableDuration ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
|
||||
height: 32, padding: '0 8px', borderRadius: 'var(--r-sm)',
|
||||
height: 36, padding: '0 8px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface)', border: '1px solid var(--border-2)',
|
||||
}}>
|
||||
<DigitInput
|
||||
@@ -179,12 +207,12 @@ export default function ServiceSlotPicker({
|
||||
) : (
|
||||
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
|
||||
)}
|
||||
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||||
style={{
|
||||
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, flexShrink: 0,
|
||||
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: 'var(--on-primary)',
|
||||
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
|
||||
}}>×</button>
|
||||
{/* هدف کلیک ۳۲px است نه ۱۸px — این دکمه انتخابِ کاربر را پاک میکند و
|
||||
خطا زدنش روی موبایل یعنی حذف ناخواستهٔ سرویس. */}
|
||||
<button type="button" className="mini-btn" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||||
style={{ flexShrink: 0, color: 'var(--primary-700)' }}>
|
||||
<XMarkIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -197,6 +225,13 @@ export default function ServiceSlotPicker({
|
||||
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
|
||||
{slotsQ.isLoading ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
|
||||
) : slotsQ.isError ? (
|
||||
/* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه میشد — یعنی کاربر
|
||||
روز درست را کنار میگذاشت، چون پاسخ دروغ بود. */
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '6px 0' }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن زمانهای خالی ناموفق بود.</span>
|
||||
<button type="button" className="btn ghost sm" onClick={() => slotsQ.refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : startTimes.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
|
||||
@@ -207,9 +242,10 @@ export default function ServiceSlotPicker({
|
||||
const active = pickedSlot?.start === s.start;
|
||||
return (
|
||||
<button key={s.start} type="button" dir="ltr"
|
||||
aria-pressed={active}
|
||||
onClick={() => 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)',
|
||||
}}>
|
||||
|
||||
@@ -187,6 +187,12 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
|
||||
holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' },
|
||||
day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' },
|
||||
outside_window: { title: 'خارج از بازهٔ نوبتدهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبتدهی آنلاین خاموش است' },
|
||||
// دلایلِ تقویمِ منبع — `resource/{uuid}/day-slots`.
|
||||
no_shift: { title: 'این روز شیفت کاری ندارد', hint: 'در تقویم این منبع، برای این روز شیفتی تعریف نشده است' },
|
||||
national_holiday: { title: 'تعطیل رسمی', hint: 'این روز در تقویم رسمی تعطیل است' },
|
||||
tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' },
|
||||
exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' },
|
||||
resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبتدهی، منبع را از صفحهٔ «منابع» فعال کنید' },
|
||||
};
|
||||
|
||||
export default function TurnsTimeline({
|
||||
|
||||
@@ -1,51 +1,64 @@
|
||||
/**
|
||||
* سوییچ کشویی «نمایش جدولی / زمانبندی» — بازسازی `TurnsViewModeToggle.jsx` طرح
|
||||
* tauri (کادر ۱۹۳×۴۸، پسزمینهٔ لغزنده، متن فعال #5559ce).
|
||||
* سوییچ کشویی نمای نوبتها — بازسازی `TurnsViewModeToggle.jsx` طرح tauri
|
||||
* (پسزمینهٔ لغزنده، متن فعال #5559ce).
|
||||
*
|
||||
* منابع نمای جدا ندارند: ردیفشان درون همان نمای «زمانبندی» میآید، چون پرشدن یک ساعت
|
||||
* را دستگاه و اتاق تعیین میکنند نه فقط برنامهٔ پزشک — و دیدنشان جدا از هم یعنی کاربر
|
||||
* باید دو نما را با چشم تطبیق دهد.
|
||||
*/
|
||||
export type TurnsViewMode = 'table' | 'timeline';
|
||||
|
||||
const MODES: { id: TurnsViewMode; label: string }[] = [
|
||||
{ id: 'table', label: 'نمایش جدولی' },
|
||||
{ id: 'timeline', label: 'زمانبندی' },
|
||||
];
|
||||
|
||||
export default function TurnsViewToggle({
|
||||
viewMode, onChange,
|
||||
}: {
|
||||
viewMode: TurnsViewMode;
|
||||
onChange: (m: TurnsViewMode) => void;
|
||||
}) {
|
||||
const activeText = 'var(--primary)';
|
||||
const idleText = 'var(--text-3)';
|
||||
const index = Math.max(MODES.findIndex((m) => m.id === viewMode), 0);
|
||||
const width = 100 / MODES.length;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
|
||||
}}>
|
||||
{/* پسزمینهٔ لغزنده */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="نمای نوبتها"
|
||||
style={{
|
||||
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
{/* پسزمینهٔ لغزنده — RTL، پس اولین حالت سمت راست است */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0, height: '100%', width: '50%',
|
||||
position: 'absolute', top: 0, right: 0, height: '100%', width: `${width}%`,
|
||||
background: 'var(--primary-soft)', transition: 'transform .3s var(--ease)',
|
||||
transform: viewMode === 'table' ? 'translateX(100%)' : 'translateX(0%)',
|
||||
transform: `translateX(${index * -100}%)`,
|
||||
}} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('table')}
|
||||
style={{
|
||||
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 500, color: viewMode === 'table' ? activeText : idleText,
|
||||
}}
|
||||
>
|
||||
نمایش جدولی
|
||||
</button>
|
||||
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('timeline')}
|
||||
style={{
|
||||
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 500, color: viewMode === 'timeline' ? activeText : idleText,
|
||||
}}
|
||||
>
|
||||
زمانبندی
|
||||
</button>
|
||||
|
||||
{MODES.map((m, i) => (
|
||||
<div key={m.id} style={{ display: 'contents' }}>
|
||||
{i > 0 && (
|
||||
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === m.id}
|
||||
onClick={() => onChange(m.id)}
|
||||
style={{
|
||||
position: 'relative', zIndex: 1, flex: 1, height: '100%', border: 'none',
|
||||
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 500,
|
||||
color: viewMode === m.id ? 'var(--primary)' : 'var(--text-3)',
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildServiceTimeline } from './serviceTimeline';
|
||||
import type { Appointment } from '../../types';
|
||||
|
||||
const DAY = 1_800_000_000; // نیمهشبِ فرضی
|
||||
const at = (h: number) => DAY + h * 3600;
|
||||
|
||||
function appointment(from: number, to: number, over: Partial<Appointment> = {}): Appointment {
|
||||
return {
|
||||
uuid: `a-${from}`, patient_name: 'بیمار', patient_mobile: '09120000000',
|
||||
doctor_uuid: 'd1', doctor_name: 'دکتر', slot_start: from, slot_end: to,
|
||||
appointment_date: '', appointment_time: '', end_time: '',
|
||||
status: 'confirmed', version: 1, created_at: '',
|
||||
...over,
|
||||
} as Appointment;
|
||||
}
|
||||
|
||||
describe('buildServiceTimeline', () => {
|
||||
const window = [{ start: at(8), end: at(14) }];
|
||||
|
||||
it('یک نوبت، بازهٔ کاری را به «خالی — نوبت — خالی» میشکند', () => {
|
||||
const rows = buildServiceTimeline(window, [appointment(at(10), at(11))], at(0));
|
||||
|
||||
expect(rows.map(r => [r.start, r.end, r.appointment !== null])).toEqual([
|
||||
[at(8), at(10), false],
|
||||
[at(10), at(11), true],
|
||||
[at(11), at(14), false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('بدون نوبت، کل بازهٔ کاری یک ردیفِ خالی است', () => {
|
||||
const rows = buildServiceTimeline(window, [], at(0));
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].is_available).toBe(true);
|
||||
});
|
||||
|
||||
it('نوبت لغوشده جای خالی را نمیگیرد', () => {
|
||||
const rows = buildServiceTimeline(
|
||||
window,
|
||||
[appointment(at(10), at(11), { status: 'cancelled_by_doctor' })],
|
||||
at(0),
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].appointment).toBeNull();
|
||||
});
|
||||
|
||||
it('نوبتِ رزرو (روزانه) بازهای اشغال نمیکند', () => {
|
||||
const rows = buildServiceTimeline(window, [appointment(at(10), at(11), { is_reserve: true })], at(0));
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].appointment).toBeNull();
|
||||
});
|
||||
|
||||
it('بازهٔ خالیِ گذشته به «اکنون» بریده میشود و ردیفِ تمامگذشته حذف', () => {
|
||||
const rows = buildServiceTimeline([{ start: at(8), end: at(9) }, { start: at(10), end: at(14) }], [], at(11));
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].start).toBe(at(11));
|
||||
expect(rows[0].end).toBe(at(14));
|
||||
});
|
||||
|
||||
it('نوبتِ خارج از بازهٔ کاری، ردیف نمیسازد', () => {
|
||||
const rows = buildServiceTimeline(window, [appointment(at(20), at(21))], at(0));
|
||||
|
||||
expect(rows.every(r => r.appointment === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { formatTime } from '../../lib/utils';
|
||||
import { CANCELLED_STATUSES } from './turnStatus';
|
||||
import type { Appointment } from '../../types';
|
||||
import type { TimelineSlot } from './types';
|
||||
|
||||
/** یک بازهٔ کاری: شیفتِ منبع یا سشنِ برنامهٔ هفتگیِ پزشک. */
|
||||
export interface WorkingWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* تایملاینِ نوبتدهی **سرویسی**: نوبتهای رزروشده + بازههای خالیِ بینشان.
|
||||
*
|
||||
* حالت سرویسی اسلاتِ ثابت ندارد — طول هر نوبت از سرویسهایش میآید — پس ردیفها از
|
||||
* روی بازهٔ کاری و نوبتهای واقعی ساخته میشوند، نه از شبکهٔ اسلات.
|
||||
*
|
||||
* پزشک و منبع همین یک الگوریتم را دارند: بازهٔ کاری یکی از برنامهٔ هفتگی میآید و
|
||||
* دیگری از تقویم منبع، ولی چیدنِ کارتها فرقی نمیکند و دو نسخهاش یعنی دو رفتار.
|
||||
*/
|
||||
export function buildServiceTimeline(
|
||||
windows: WorkingWindow[],
|
||||
appointments: Appointment[],
|
||||
now: number = Math.floor(Date.now() / 1000),
|
||||
): TimelineSlot[] {
|
||||
const booked = appointments
|
||||
.filter(a => !CANCELLED_STATUSES.has(a.status) && !a.is_reserve)
|
||||
.map(a => ({
|
||||
appointment: a,
|
||||
start: Number(a.slot_start),
|
||||
end: Number(a.slot_end),
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
const out: TimelineSlot[] = [];
|
||||
|
||||
/** بازهٔ خالی؛ تکهٔ گذشتهاش بریده میشود چون قابل رزرو نیست. */
|
||||
const pushFree = (start: number, end: number) => {
|
||||
const from = start < now ? now : start;
|
||||
if (end <= from) return;
|
||||
out.push({
|
||||
start: from, end,
|
||||
start_time: formatTime(from), end_time: formatTime(end),
|
||||
is_available: true, appointment: null, cancelled_appointment: null,
|
||||
});
|
||||
};
|
||||
|
||||
windows.forEach(({ start: winStart, end: winEnd }) => {
|
||||
let cursor = winStart;
|
||||
|
||||
booked
|
||||
.filter(b => b.start >= winStart && b.start < winEnd)
|
||||
.forEach(({ appointment, start, end }) => {
|
||||
if (start > cursor) pushFree(cursor, start);
|
||||
out.push({
|
||||
start, end,
|
||||
start_time: formatTime(start), end_time: formatTime(end),
|
||||
is_available: false, appointment, cancelled_appointment: null,
|
||||
});
|
||||
cursor = Math.max(cursor, end);
|
||||
});
|
||||
|
||||
if (cursor < winEnd) pushFree(cursor, winEnd);
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* and the parent passes the query key to invalidate.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
|
||||
import { findRecordUuid } from '../AppointmentActions';
|
||||
|
||||
@@ -11,6 +11,40 @@ const real: ChartPoint[] = [
|
||||
|
||||
const EMPTY = 'دادهای برای نمایش نیست';
|
||||
|
||||
/**
|
||||
* صفحه RTL است، پس در یک ردیفِ flex «اولین فرزند» سمت راست رندر میشود. ستون مقادیر
|
||||
* باید سمت چپ بنشیند، یعنی باید فرزند **آخر** باشد و فاصلهٔ ردیف برچسبها هم از چپ
|
||||
* گرفته شود. جابهجا شدن این ترتیب، اعداد را بیصدا به سمت راست برمیگرداند.
|
||||
*/
|
||||
describe('ChartFrame — جای محور مقادیر', () => {
|
||||
const frameOf = (container: HTMLElement) => {
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
return {
|
||||
plotRow: root.firstElementChild as HTMLElement,
|
||||
labelRow: root.lastElementChild as HTMLElement,
|
||||
};
|
||||
};
|
||||
|
||||
it('ستون مقادیر آخرین فرزند ردیف است تا در RTL سمت چپ بیفتد', () => {
|
||||
const { container } = render(<TauriLineChart data={real} />);
|
||||
const { plotRow } = frameOf(container);
|
||||
|
||||
const ticks = plotRow.lastElementChild as HTMLElement;
|
||||
expect(ticks.textContent).toContain('۰');
|
||||
expect(ticks.className).toContain('flex-col');
|
||||
// نمودار خودش فرزند اول است، پس سمت راست میماند.
|
||||
expect((plotRow.firstElementChild as HTMLElement).className).toContain('overflow-hidden');
|
||||
});
|
||||
|
||||
it('ردیف برچسبها به اندازهٔ ستون مقادیر از چپ فاصله میگیرد', () => {
|
||||
const { container } = render(<TauriLineChart data={real} />);
|
||||
const { labelRow } = frameOf(container);
|
||||
|
||||
expect(labelRow.style.paddingLeft).not.toBe('');
|
||||
expect(labelRow.style.paddingRight).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TauriBarChart', () => {
|
||||
it('shows the empty state when every value is zero', () => {
|
||||
render(<TauriBarChart data={zero} />);
|
||||
@@ -165,3 +199,59 @@ describe('TauriLineChart — ادامهٔ پیشبینی', () => {
|
||||
expect(container.querySelector('.td-forecast')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* محور زمان راستبهچپ است: برچسبها در جریان RTL چیده میشوند، پس فروردین سمت راست
|
||||
* مینشیند. SVG جریان RTL ندارد و اگر اندیس ۰ به `x=0` برود، خط نسبت به برچسبهایش
|
||||
* آینه میشود — دقیقاً باگی که دادهٔ واقعیِ ابتدای سال را روی ماههای پایانی میانداخت.
|
||||
*/
|
||||
describe('TauriLineChart — جهت محور زمان', () => {
|
||||
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `ماه ${i + 1}`,
|
||||
value: i < 4 ? (i + 1) * 10 : 0,
|
||||
}));
|
||||
|
||||
/** ستونِ هاورِ هر نقطه دقیقاً حول همان نقطه مینشیند، پس `left`ش جای نقطه را میگوید. */
|
||||
const hitCenters = (container: HTMLElement): number[] =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('.td-hit')).map((el) => {
|
||||
const left = parseFloat(el.style.left);
|
||||
const width = parseFloat(el.style.width);
|
||||
return left + width / 2;
|
||||
});
|
||||
|
||||
it('اولین نقطه سمت راست و آخرین نقطه سمت چپ مینشیند', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
const centers = hitCenters(container);
|
||||
|
||||
expect(centers[0]).toBeCloseTo(100, 5);
|
||||
expect(centers[centers.length - 1]).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('نقطهها به ترتیب زمان از راست به چپ پیش میروند', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
const centers = hitCenters(container);
|
||||
|
||||
for (let i = 1; i < centers.length; i++) {
|
||||
expect(centers[i]).toBeLessThan(centers[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
/** بخش واقعی سمت راست است و پیشبینی سمت چپ، نه برعکس. */
|
||||
it('بخش واقعی راستتر از بخش پیشبینی است', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
const centers = hitCenters(container);
|
||||
|
||||
const lastActual = centers[3];
|
||||
const firstForecast = centers[4];
|
||||
expect(lastActual).toBeGreaterThan(firstForecast);
|
||||
});
|
||||
|
||||
/** نقطه باید روی خط بنشیند، نه داخل ستونِ خودش سُر بخورد. */
|
||||
it('نقطه در مرکز ستون هاورِ خودش است', () => {
|
||||
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
|
||||
|
||||
for (const dot of Array.from(container.querySelectorAll<HTMLElement>('.td-dot'))) {
|
||||
expect(dot.style.left).toBe('50%');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,18 +88,11 @@ function ChartFrame({
|
||||
// column to zero height. Inline `alignItems` is the only reliable override.
|
||||
return (
|
||||
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]" style={{ alignItems: 'stretch' }}>
|
||||
{/* ستونِ مقادیر سمت چپ مینشیند و نمودار سمت راست. جریان صفحه RTL است، پس
|
||||
«اول در JSX» یعنی «راست روی صفحه» — ترتیب عمداً برعکسِ ترتیب دیداری است.
|
||||
با `direction: ltr` هم میشد، ولی آن به میلههای نمودار ستونی ارث میرسید و
|
||||
ترتیبشان را نسبت به برچسبها وارونه میکرد. */}
|
||||
<div className="flex-1 flex min-h-0" style={{ alignItems: 'stretch' }}>
|
||||
{/* y-axis ticks, aligned to gridlines */}
|
||||
<div
|
||||
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-left pl-[4px] shrink-0"
|
||||
style={{ width: yWidth, alignItems: 'flex-start' }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
{faNum.format(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* plot area */}
|
||||
{/* overflow-hidden keeps a dense series (a 31-day month) inside the card */}
|
||||
<div className="relative flex-1 min-w-0 overflow-hidden">
|
||||
@@ -112,9 +105,20 @@ function ChartFrame({
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
{/* y-axis ticks, aligned to gridlines — چسبیده به لبهٔ نمودار، پس راستچین */}
|
||||
<div
|
||||
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-right pr-[4px] shrink-0"
|
||||
style={{ width: yWidth, alignItems: 'flex-end' }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
{faNum.format(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* x-axis labels */}
|
||||
<div className="flex pt-[8px]" style={{ paddingRight: yWidth, alignItems: 'flex-start' }}>
|
||||
<div className="flex pt-[8px]" style={{ paddingLeft: yWidth, alignItems: 'flex-start' }}>
|
||||
{labels.map((l, i) => (
|
||||
<span key={i} className="flex-1 min-w-0 overflow-hidden text-center text-[10px] font-normal text-[var(--text-2)] whitespace-nowrap">
|
||||
{l}
|
||||
@@ -189,7 +193,11 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
|
||||
const W = 100;
|
||||
const H = 100;
|
||||
const stepX = data.length > 1 ? W / (data.length - 1) : W;
|
||||
const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
|
||||
// محور زمان راستبهچپ است — `ChartFrame` برچسبها را در جریان RTL میچیند، پس
|
||||
// فروردین سمت راست مینشیند. SVG جریان RTL ندارد، بنابراین اندیس ۰ باید صریحاً به
|
||||
// لبهٔ راست نگاشت شود؛ وگرنه خط نسبت به برچسبهایش آینه میشود.
|
||||
const xOf = (i: number) => W - i * stepX;
|
||||
const pts = values.map((v, i) => [xOf(i), H - (v / top) * H] as [number, number]);
|
||||
// خط پیشبینی از آخرین نقطهٔ واقعی شروع میشود تا وصلهی دو بخش دیده نشود.
|
||||
const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts);
|
||||
const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : '';
|
||||
@@ -204,8 +212,9 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
{/* رنگِ خط در طول محور افقی از برند به اکسنت میرود (سبک دموی apex) */}
|
||||
<linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
|
||||
{/* رنگِ خط در طول زمان از برند به اکسنت میرود (سبک دموی apex). چون زمان
|
||||
راستبهچپ میرود، گرادیان هم از راست شروع میشود. */}
|
||||
<linearGradient id="tdIncomeStroke" x1="1" y1="0" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="var(--primary)" />
|
||||
<stop offset="55%" stopColor="var(--primary-600)" />
|
||||
<stop offset="100%" stopColor="var(--accent)" />
|
||||
@@ -254,28 +263,38 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS) */}
|
||||
<div className="absolute inset-0 flex" style={{ alignItems: 'stretch' }}>
|
||||
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور میکند (بدون state، فقط CSS).
|
||||
ستونها با `left` فیزیکی و دقیقاً حولِ نقطهٔ خودشان مینشینند، نه با flex:
|
||||
در flex، هم ترتیبشان به جهت متن گره میخورد و هم `left`ِ نقطه نسبت به ستون
|
||||
حساب میشد نه نسبت به نمودار — یعنی نقطه داخل ستون سُر میخورد. */}
|
||||
<div className="absolute inset-0">
|
||||
{data.map((p, i) => {
|
||||
const isForecast = hasForecast && i >= nActual;
|
||||
const colW = 100 / data.length;
|
||||
const xPct = (pts[i][0] / W) * 100;
|
||||
const yPct = (pts[i][1] / H) * 100;
|
||||
return (
|
||||
<div key={i} className="td-hit relative flex-1 min-w-0">
|
||||
<div
|
||||
key={i}
|
||||
className="td-hit absolute top-0 bottom-0"
|
||||
style={{ left: `${xPct - colW / 2}%`, width: `${colW}%` }}
|
||||
>
|
||||
<span
|
||||
className="td-dot absolute block rounded-full border-2 border-[var(--surface)]"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: isForecast ? 'var(--accent)' : 'var(--primary)',
|
||||
left: `${(i * stepX / W) * 100}%`,
|
||||
top: `${(pts[i][1] / H) * 100}%`,
|
||||
left: '50%',
|
||||
top: `${yPct}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="td-tip absolute whitespace-nowrap rounded-[var(--r-xs)] px-2 py-1 text-[11px] font-bold"
|
||||
style={{
|
||||
left: `${(i * stepX / W) * 100}%`,
|
||||
top: `${(pts[i][1] / H) * 100}%`,
|
||||
left: '50%',
|
||||
top: `${yPct}%`,
|
||||
transform: 'translate(-50%, calc(-100% - 12px))',
|
||||
background: isForecast ? 'var(--accent)' : 'var(--text)',
|
||||
color: 'var(--on-primary)',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* DoctorDashboard feed it their (real-API) data.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { TauriStatCards, type DashboardStats } from './TauriStatCards';
|
||||
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
|
||||
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const setOverride = { mutate: vi.fn(), isPending: false };
|
||||
const removeOverride = { mutate: vi.fn(), isPending: false };
|
||||
|
||||
let holidays: Array<{ uuid: string; date: number; title: string }> = [];
|
||||
let overrides: Array<{ uuid: string; date: number; is_working: boolean }> = [];
|
||||
|
||||
vi.mock('../../hooks/useResourceCalendar', () => ({
|
||||
useHolidays: () => ({ holidays, overrides, loading: false, setOverride, removeOverride }),
|
||||
}));
|
||||
|
||||
import NationalHolidaysCard from './NationalHolidaysCard';
|
||||
|
||||
const NOWRUZ = { uuid: 'h-1', date: 1774040400, title: 'نوروز' };
|
||||
|
||||
describe('NationalHolidaysCard', () => {
|
||||
beforeEach(() => {
|
||||
holidays = [NOWRUZ];
|
||||
overrides = [];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('تعطیلات رسمی سال را نشان میدهد', () => {
|
||||
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
|
||||
|
||||
expect(screen.getByText(/نوروز/)).toBeInTheDocument();
|
||||
expect(screen.getByText('تعطیل')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** محیطی که آن روز کار میکند، تعطیلیِ سراسری را برای خودش خنثی میکند. */
|
||||
it('با یک کلیک روز را برای این محیط باز میکند', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'این روز باز است' }));
|
||||
|
||||
expect(setOverride.mutate).toHaveBeenCalledWith({ date: NOWRUZ.date, is_working: true });
|
||||
});
|
||||
|
||||
it('روزی که استثنا خورده، «باز» است و میشود دوباره تعطیلش کرد', async () => {
|
||||
overrides = [{ uuid: 'o-1', date: NOWRUZ.date, is_working: true }];
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
|
||||
|
||||
expect(screen.getByText('باز')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'تعطیل کن' }));
|
||||
|
||||
expect(removeOverride.mutate).toHaveBeenCalledWith('o-1');
|
||||
});
|
||||
|
||||
/** ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است؛ اینجا فقط استثنا زده میشود. */
|
||||
it('بدون مجوز ویرایش، هیچ دکمهای نمیدهد', () => {
|
||||
renderWithProviders(<NationalHolidaysCard canUpdate={false} year={1405} />);
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('تنظیمات ← تعطیلات رسمی')).toHaveAttribute('href', '/admin/holidays');
|
||||
});
|
||||
|
||||
it('سالِ بدون تعطیلی را صریح میگوید', async () => {
|
||||
holidays = [];
|
||||
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useHolidays } from '../../hooks/useResourceCalendar';
|
||||
import { formatDate, currentJalaliYear } from '../../lib/utils';
|
||||
|
||||
/**
|
||||
* تعطیلات رسمیِ سال، همانجایی که تعطیلی اختصاصی تعریف میشود.
|
||||
*
|
||||
* تقویم رسمی یک بار مرکزی ثبت میشود و هر پزشک و هر منبع از آن ارث میبرد؛ اینجا فقط
|
||||
* دیده میشود و — اگر این محیط آن روز باز باشد — با یک سوییچ خنثی میشود. ساخت و حذفِ
|
||||
* خودِ تعطیلی کارِ مدیر سیستم است، نه کلینیک.
|
||||
*
|
||||
* یک کامپوننت برای دو مصرفکننده: تب تعطیلات پزشک و تب تعطیلات منبع. دو نسخه یعنی دو
|
||||
* رفتار که با هم واگرا میشوند.
|
||||
*/
|
||||
export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYear() }: {
|
||||
canUpdate: boolean;
|
||||
year?: number;
|
||||
}) {
|
||||
const { holidays, overrides, loading, setOverride, removeOverride } = useHolidays(year);
|
||||
|
||||
const overrideByDate = useMemo(
|
||||
() => new Map(overrides.map((o) => [o.date, o])),
|
||||
[overrides],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<h2 className="section-title" style={{ margin: '0 0 4px' }}>تعطیلات رسمی {year.toLocaleString('fa-IR', { useGrouping: false })}</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px', lineHeight: 1.9 }}>
|
||||
اینها برای همهٔ پزشکان و منابع اعمال میشوند. اگر این محیط روزی را باز است،
|
||||
همینجا استثنا بزنید — مدیریت کاملشان در{' '}
|
||||
<Link to="/admin/holidays" style={{ color: 'var(--primary)' }}>تنظیمات ← تعطیلات رسمی</Link>.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
|
||||
) : holidays.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
برای سال {year.toLocaleString('fa-IR', { useGrouping: false })} تعطیلی رسمی ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{holidays.map((h) => {
|
||||
const override = overrideByDate.get(h.date);
|
||||
const open = override?.is_working === true;
|
||||
|
||||
return (
|
||||
<div key={h.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<span className={`badge ${open ? 'gray' : 'red'}`} style={{ fontSize: 11 }}>
|
||||
{open ? 'باز' : 'تعطیل'}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--text-2)' }}>
|
||||
{formatDate(h.date)} · {h.title}
|
||||
</span>
|
||||
{canUpdate && (
|
||||
open ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={removeOverride.isPending}
|
||||
onClick={() => override && removeOverride.mutate(override.uuid)}
|
||||
>
|
||||
تعطیل کن
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={setOverride.isPending}
|
||||
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
|
||||
>
|
||||
این روز باز است
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet } from 'react-router';
|
||||
import Sidebar from './Sidebar';
|
||||
import Topbar from './Topbar';
|
||||
import { useUiStore, applyBrand } from '../../stores/uiStore';
|
||||
|
||||
@@ -3,8 +3,8 @@ import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router')>('react-router');
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
UserCircleIcon,
|
||||
|
||||
@@ -1,75 +1,48 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { SearchHeaderP } from '../../pages/subscriptionIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { groupMenu, menuForRole, type SettingsMenuItem } from './settingsMenu';
|
||||
|
||||
/**
|
||||
* Settings sub-navigation for the subscription page — item list and order copied
|
||||
* verbatim from clinic-pro-tauri's `PurchaseSubscription.jsx` navItems, minus
|
||||
* "مدیریت پزشک" (that lives in the main nav / doctor profile, not here). Shown
|
||||
* ungated to mirror the tauri source. Each entry maps to a real admin route; the
|
||||
* "تنظیمات" (security) section has no doctor/clinic route yet → disabled.
|
||||
* سایدبار تنظیمات — همان فهرستی که `SettingsMenuPage` روی موبایل نشان میدهد
|
||||
* (`settingsMenu.ts`)، با دو تفاوتِ عمدی:
|
||||
*
|
||||
* This list is intentionally separate from SETTINGS_MENU (SettingsLayout) so the
|
||||
* other settings pages are not affected.
|
||||
* ۱. «مدیریت پزشک» اینجا نیست؛ جایش منوی اصلی/پروفایل پزشک است.
|
||||
* ۲. یک آیتم «تنظیمات» غیرفعال ته فهرست است — بخش امنیت هنوز مسیر پزشک/کلینیک ندارد.
|
||||
*
|
||||
* قبلاً این فهرست کپی جدا بود و هر آیتم تازه باید دو جا اضافه میشد؛ «منابع» و
|
||||
* «دستهبندیها» فقط در موبایل ظاهر شدند و در سایدبار غایب بودند.
|
||||
*/
|
||||
/**
|
||||
* `roles`: when set, the item is only shown to those roles (omit = every role).
|
||||
* `perm`: [resource, action] — منشی فقط با داشتن این مجوز آیتم را میبیند.
|
||||
* `alwaysOpen`: برای منشی همیشه نمایش داده میشود (حساب/تنظیمات پایه).
|
||||
* آیتمهای owner-only (خرید اشتراک، مدیریت منشی) نه `perm` دارند نه `alwaysOpen`
|
||||
* → برای منشی پنهان میشوند.
|
||||
*/
|
||||
type NavItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
to?: string;
|
||||
roles?: string[];
|
||||
perm?: [string, string];
|
||||
alwaysOpen?: boolean;
|
||||
};
|
||||
const HIDDEN_KEYS = new Set(['doctor']);
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription', perm: ['subscription', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
|
||||
{ key: 'discounts', label: 'مدیریت تخفیفها', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
|
||||
{ key: 'tags', label: 'تگ ها', to: '/admin/tags-settings', perm: ['tags', 'view'] },
|
||||
{ key: 'sms', label: 'پیامک ها', to: '/admin/sms-wallet', perm: ['sms', 'view'] },
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
{ key: 'account', label: 'حساب کاربری', to: '/admin/account-settings', alwaysOpen: true },
|
||||
{ key: 'security', label: 'تنظیمات', alwaysOpen: true },
|
||||
];
|
||||
const SECURITY_ITEM: SettingsMenuItem = {
|
||||
key: 'security',
|
||||
label: 'تنظیمات',
|
||||
icon: () => null,
|
||||
group: 'account',
|
||||
alwaysOpen: true,
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||
const scope = useAuthStore((s) => s.context?.scope);
|
||||
const { can } = usePermissions();
|
||||
// منشی، و پزشکِ عضوِ کلینیک (scope=clinic) هر دو محدود-به-مجوزند؛ پزشکِ مستقل و
|
||||
// مالک آزادند و با فیلترِ نقشیِ معمول کار میکنند.
|
||||
const permissionRestricted = primaryRole === 'secretary' || (primaryRole === 'doctor' && scope === 'clinic');
|
||||
const items = useMemo(
|
||||
() => NAV_ITEMS
|
||||
// آیتمِ بدونِ perm/alwaysOpen برای کاربرِ محدود پنهان است. برای آیتمهایی که
|
||||
// واریانتِ نقشی دارند (مثلِ «نوبتدهی» با doctor vs clinic)، با scope تطبیق داده میشود.
|
||||
.filter((i) => {
|
||||
if (!permissionRestricted) {
|
||||
return !i.roles || (primaryRole != null && i.roles.includes(primaryRole));
|
||||
}
|
||||
if (i.alwaysOpen) return true;
|
||||
if (!i.perm || !can(i.perm[0], i.perm[1])) return false;
|
||||
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
|
||||
return true;
|
||||
})
|
||||
.filter((i) => i.label.includes(query.trim())),
|
||||
// گروهها بعد از فیلترِ جستجو ساخته میشوند تا تیترِ دستهای که هیچ نتیجهای ندارد
|
||||
// بالای فضای خالی نماند.
|
||||
const groups = useMemo(
|
||||
() => groupMenu(
|
||||
[
|
||||
...menuForRole(primaryRole, can, scope).filter((i) => !HIDDEN_KEYS.has(i.key)),
|
||||
SECURITY_ITEM,
|
||||
].filter((i) => i.label.includes(query.trim())),
|
||||
),
|
||||
[query, primaryRole, scope, can],
|
||||
);
|
||||
const itemCount = groups.reduce((sum, g) => sum + g.items.length, 0);
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -107,36 +80,46 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
{items.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
const rowStyle: React.CSSProperties = {
|
||||
borderRadius: 12, height: 44, marginBottom: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
||||
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
|
||||
lineHeight: 1, textAlign: 'right',
|
||||
background: isActive ? 'var(--accent)' : 'transparent',
|
||||
color: isActive ? 'var(--on-primary)' : 'var(--text-2)',
|
||||
cursor: item.to ? 'pointer' : 'not-allowed',
|
||||
transition: 'background .14s',
|
||||
};
|
||||
const inner = <span style={{ flex: 1 }}>{item.label}</span>;
|
||||
if (!item.to) {
|
||||
return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
style={rowStyle}
|
||||
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
{groups.map((group, groupIdx) => (
|
||||
<section key={group.key} aria-label={group.label}>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--text-3)', textAlign: 'right',
|
||||
padding: '0 14px', marginTop: groupIdx === 0 ? 0 : 14, marginBottom: 6,
|
||||
}}>
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
const rowStyle: React.CSSProperties = {
|
||||
borderRadius: 12, height: 44, marginBottom: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
||||
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
|
||||
lineHeight: 1, textAlign: 'right',
|
||||
background: isActive ? 'var(--accent)' : 'transparent',
|
||||
color: isActive ? 'var(--on-primary)' : 'var(--text-2)',
|
||||
cursor: item.to ? 'pointer' : 'not-allowed',
|
||||
transition: 'background .14s',
|
||||
};
|
||||
const inner = <span style={{ flex: 1 }}>{item.label}</span>;
|
||||
if (!item.to || item.disabled) {
|
||||
return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
style={rowStyle}
|
||||
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
{itemCount === 0 && (
|
||||
<div style={{ padding: '12px 4px', fontSize: 13, color: 'var(--text-3)', textAlign: 'center' }}>
|
||||
موردی یافت نشد
|
||||
</div>
|
||||
|
||||
@@ -26,14 +26,34 @@ describe('SettingsLayout', () => {
|
||||
expect(active).toHaveAttribute('href', '/admin/subscription');
|
||||
});
|
||||
|
||||
it('renders the tauri-sourced items as navigable links', () => {
|
||||
it('renders the shared menu items as navigable links', () => {
|
||||
renderWithProviders(<SettingsLayout active="subscription"><div /></SettingsLayout>);
|
||||
expect(screen.getByText('مدیریت نوبت دهی').closest('a')).toHaveAttribute('href', '/admin/appointment-settings');
|
||||
expect(screen.getByText('تگ ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings');
|
||||
expect(screen.getByText('برچسبها').closest('a')).toHaveAttribute('href', '/admin/tags-settings');
|
||||
// 'مدیریت پزشک' lives in the main nav, not the settings menu
|
||||
expect(screen.queryByText('مدیریت پزشک')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* سایدبار و فهرست موبایل یک منبع دارند؛ آیتمی که به منو اضافه میشود باید در هر دو
|
||||
* دیده شود. قبلاً دو کپی بود و «دستهبندیها» فقط در موبایل ظاهر شد.
|
||||
*/
|
||||
it('shows the resource-first entries in the desktop sidebar too', () => {
|
||||
useAuthStore.setState({ primaryRole: 'clinic' });
|
||||
renderWithProviders(<SettingsLayout active="service-categories"><div /></SettingsLayout>);
|
||||
|
||||
expect(screen.getByText('دستهبندیها').closest('a')).toHaveAttribute('href', '/admin/service-categories');
|
||||
expect(screen.getByText('دستهبندیها').closest('a')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
/** «منابع» کارِ روزمره است و به سایدبار اصلی رفت؛ نباید در منوی تنظیمات تکرار شود. */
|
||||
it('does not list منابع in the settings menu any more', () => {
|
||||
useAuthStore.setState({ primaryRole: 'clinic' });
|
||||
renderWithProviders(<SettingsLayout active="service-categories"><div /></SettingsLayout>);
|
||||
|
||||
expect(screen.queryByText('منابع')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('role-gates the desktop sidebar: a doctor does not see the clinic-doctors tab', () => {
|
||||
renderWithProviders(<SettingsLayout active="subscription"><div /></SettingsLayout>);
|
||||
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,66 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
|
||||
|
||||
// ── Settings menu configuration ─────────────────────────────────────────────
|
||||
// Source of truth for the *mobile* settings list (SettingsMenuPage). The desktop
|
||||
// shell renders the shared PurchaseSubscriptionSidebar instead, so there is a
|
||||
// single settings sidebar across all settings pages (no duplicate menu).
|
||||
export type SettingsMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
to?: string;
|
||||
/** when set, the item is only shown to these roles (omit = every role) */
|
||||
roles?: string[];
|
||||
/** [resource, action] — منشی فقط با داشتن این مجوز آیتم را میبیند. */
|
||||
perm?: [string, string];
|
||||
/** برای منشی همیشه نمایش داده میشود (حساب کاربری). */
|
||||
alwaysOpen?: boolean;
|
||||
};
|
||||
|
||||
export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription', perm: ['subscription', 'view'] },
|
||||
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
|
||||
{ key: 'discounts', label: 'مدیریت تخفیفها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
|
||||
{ key: 'tags', label: 'برچسبها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] },
|
||||
{ key: 'sms', label: 'پیامکها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] },
|
||||
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر میشود
|
||||
* (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقشها با roles.
|
||||
*/
|
||||
export function menuForRole(
|
||||
role: string | null | undefined,
|
||||
can?: (resource: string, action: string) => boolean,
|
||||
scope?: string | null,
|
||||
): SettingsMenuItem[] {
|
||||
// منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی.
|
||||
const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic');
|
||||
return SETTINGS_MENU.filter((i) => {
|
||||
if (permissionRestricted) {
|
||||
if (i.alwaysOpen) return true;
|
||||
if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false;
|
||||
// واریانتِ نقشی (نوبتدهی/پزشکان کلینیک) را با scope تطبیق بده.
|
||||
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
|
||||
return true;
|
||||
}
|
||||
return !i.roles || (role != null && i.roles.includes(role));
|
||||
});
|
||||
}
|
||||
// فهرست منو به `settingsMenu.ts` منتقل شد تا سایدبار دسکتاپ و فهرست موبایل یک منبع
|
||||
// داشته باشند. این re-export برای مصرفکنندههای موجود میماند.
|
||||
export { SETTINGS_MENU, menuForRole, type SettingsMenuItem } from './settingsMenu';
|
||||
|
||||
/**
|
||||
* SettingsLayout — presentational shell for the doctor/clinic settings area.
|
||||
|
||||
@@ -141,6 +141,22 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () =>
|
||||
expect(screen.queryByText("تنظیمات نوبتدهی")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* «منابع» از منوی تنظیمات به سایدبار اصلی منتقل شد. گِیتش عوض نشده — همان
|
||||
* `appointment_settings.view` که در منوی تنظیمات داشت.
|
||||
*/
|
||||
it("«منابع» در سایدبار اصلی میآید و به مجوز appointment_settings گِیت است", () => {
|
||||
setSecretary({ appointment_settings: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.getByText("منابع").closest("a")).toHaveAttribute("href", "/admin/resources");
|
||||
});
|
||||
|
||||
it("منشیِ بدون مجوز، «منابع» را نمیبیند", () => {
|
||||
setSecretary({ appointments: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.queryByText("منابع")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar — گِیت منوی پزشکِ عضوِ کلینیک بر اساس مجوز", () => {
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ChevronDownIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
Cog6ToothIcon,
|
||||
CreditCardIcon,
|
||||
CubeIcon,
|
||||
CurrencyDollarIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
DocumentTextIcon,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
UserCircleIcon,
|
||||
@@ -28,10 +31,10 @@ import {
|
||||
WrenchScrewdriverIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useSubscription } from "../../hooks/useSubscription";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router";
|
||||
import { usePermissions } from "../../hooks/usePermissions";
|
||||
import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings";
|
||||
import { useSubscription } from "../../hooks/useSubscription";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
@@ -59,7 +62,11 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [
|
||||
*/
|
||||
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
|
||||
...APPOINTMENTS_CHILDREN,
|
||||
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon },
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
label: "رزرو نوبت",
|
||||
icon: ArchiveBoxIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function buildSections(
|
||||
@@ -91,19 +98,55 @@ function buildSections(
|
||||
});
|
||||
}
|
||||
if (can("payments", "view")) {
|
||||
items.push({ to: "/admin/my-payments", icon: CreditCardIcon, label: "پرداختها" });
|
||||
items.push({
|
||||
to: "/admin/my-payments",
|
||||
icon: CreditCardIcon,
|
||||
label: "پرداختها",
|
||||
});
|
||||
}
|
||||
if (can("insurances", "view")) {
|
||||
items.push(
|
||||
{ to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمتگذاری بیمه", feature: "insurance" },
|
||||
{ to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", feature: "insurance" },
|
||||
{
|
||||
to: "/admin/insurance-pricing",
|
||||
icon: ShieldCheckIcon,
|
||||
label: "قیمتگذاری بیمه",
|
||||
feature: "insurance",
|
||||
},
|
||||
{
|
||||
to: "/admin/claims",
|
||||
icon: DocumentTextIcon,
|
||||
label: "مطالبات بیمه",
|
||||
feature: "insurance",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (can("services", "view")) {
|
||||
items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" });
|
||||
items.push({
|
||||
to: "/admin/clinic-services",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویس ها",
|
||||
});
|
||||
}
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "دورههای درمان",
|
||||
});
|
||||
}
|
||||
if (can("appointment_settings", "view")) {
|
||||
items.push({
|
||||
to: "/admin/resources",
|
||||
icon: CubeIcon,
|
||||
label: "منابع",
|
||||
});
|
||||
}
|
||||
if (can("inventory", "view")) {
|
||||
items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" });
|
||||
items.push({
|
||||
to: "/admin/inventory",
|
||||
icon: ArchiveBoxIcon,
|
||||
label: "انبارداری",
|
||||
});
|
||||
}
|
||||
|
||||
// زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings)
|
||||
@@ -112,12 +155,24 @@ function buildSections(
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
||||
items: [
|
||||
{
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
label: "داشبورد",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: "مدیریت", items },
|
||||
{
|
||||
label: "تنظیمات",
|
||||
items: [{ to: "/admin/account-settings", icon: Cog6ToothIcon, label: "تنظیمات" }],
|
||||
items: [
|
||||
{
|
||||
to: "/admin/account-settings",
|
||||
icon: Cog6ToothIcon,
|
||||
label: "تنظیمات",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -235,6 +290,16 @@ function buildSections(
|
||||
icon: CreditCardIcon,
|
||||
label: "اشتراکها",
|
||||
},
|
||||
{
|
||||
to: "/admin/national-holidays",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "تعطیلات رسمی",
|
||||
},
|
||||
{
|
||||
to: "/admin/practice-domains",
|
||||
icon: SparklesIcon,
|
||||
label: "حوزههای فعالیت",
|
||||
},
|
||||
{
|
||||
to: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
@@ -301,6 +366,16 @@ function buildSections(
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویس ها",
|
||||
},
|
||||
{
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "دورههای درمان",
|
||||
},
|
||||
{
|
||||
to: "/admin/resources",
|
||||
icon: CubeIcon,
|
||||
label: "منابع",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -376,6 +451,16 @@ function buildSections(
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویس ها",
|
||||
},
|
||||
{
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "دورههای درمان",
|
||||
},
|
||||
{
|
||||
to: "/admin/resources",
|
||||
icon: CubeIcon,
|
||||
label: "منابع",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -443,6 +528,20 @@ function buildSections(
|
||||
label: "سرویس ها",
|
||||
});
|
||||
}
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/treatment-cases",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "دورههای درمان",
|
||||
});
|
||||
}
|
||||
if (can("appointment_settings", "view")) {
|
||||
items.push({
|
||||
to: "/admin/resources",
|
||||
icon: CubeIcon,
|
||||
label: "منابع",
|
||||
});
|
||||
}
|
||||
if (can("inventory", "view")) {
|
||||
items.push({
|
||||
to: "/admin/inventory",
|
||||
@@ -472,7 +571,13 @@ function buildSections(
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
||||
items: [
|
||||
{
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
label: "داشبورد",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: "مدیریت", items },
|
||||
{
|
||||
@@ -490,6 +595,33 @@ function buildSections(
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "staff") {
|
||||
// پرسنل فقط داشبورد خودش و سرویسهای تخصیصیافته را دارد؛ بقیهٔ مسیرها
|
||||
// سمت API هم برایش بسته است (StaffRouteGuardSubscriber).
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/dashboard",
|
||||
icon: ChartBarIcon,
|
||||
label: "داشبورد",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "مدیریت",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/my-sessions",
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "جلسات امروز من",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "representation") {
|
||||
return [
|
||||
{
|
||||
@@ -570,6 +702,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
clinic: "مالک کلینیک",
|
||||
doctor: "پزشک",
|
||||
secretary: "منشی",
|
||||
staff: "پرسنل",
|
||||
representation: "نماینده",
|
||||
user: "کاربر",
|
||||
};
|
||||
@@ -724,7 +857,9 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { can } = usePermissions();
|
||||
const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary");
|
||||
const { summary: secretaryEarnings } = useSecretaryEarnings(
|
||||
primaryRole === "secretary",
|
||||
);
|
||||
const sections = buildSections(
|
||||
primaryRole,
|
||||
dbUuid,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../test/utils";
|
||||
|
||||
vi.mock("../../hooks/useSubscription", () => ({
|
||||
useSubscription: () => ({ hasFeature: () => true }),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
/**
|
||||
* پرسنل فقط داشبورد و سرویسهای خودش را دارد؛ هیچ آیتم مدیریتی نباید در منویش
|
||||
* ظاهر شود — قرینهٔ enforcement سمت API (StaffRouteGuardSubscriber).
|
||||
*/
|
||||
describe("Sidebar — نقش پرسنل", () => {
|
||||
const asStaff = () =>
|
||||
useAuthStore.setState({
|
||||
primaryRole: "staff",
|
||||
dbUuid: "d1",
|
||||
userName: "زهرا احمدی",
|
||||
availableContexts: [],
|
||||
context: {
|
||||
type: "doctor",
|
||||
db_uuid: "d1",
|
||||
name: "مطب تست",
|
||||
role: "staff",
|
||||
scope: "doctor",
|
||||
permissions: { version: 1, resources: { services: { view: true }, appointments: { view: true } } },
|
||||
},
|
||||
} as any);
|
||||
|
||||
it("shows only داشبورد and جلسات امروز من", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
|
||||
expect(screen.getByText("داشبورد").closest("a")).toHaveAttribute("href", "/admin/dashboard");
|
||||
expect(screen.getByText("جلسات امروز من").closest("a")).toHaveAttribute("href", "/admin/my-sessions");
|
||||
expect(screen.getByText("پرسنل")).toBeInTheDocument(); // برچسب نقش در فوتر
|
||||
|
||||
// صفحهٔ «سرویسهای من» حذف شد؛ فهرست سرویسها روی خودِ داشبورد است.
|
||||
expect(screen.queryByText("سرویسهای من")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides management entries", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
|
||||
for (const label of ["پرونده بیماران", "تنظیمات", "نوبتها", "پرداختها"]) {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@ import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router')>('react-router');
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
BellIcon, SunIcon, MoonIcon, Bars3Icon, Cog6ToothIcon,
|
||||
MagnifyingGlassIcon, XMarkIcon, CheckIcon,
|
||||
@@ -38,7 +38,7 @@ export default function Topbar({ onMobileMenuOpen }: { onMobileMenuOpen?: () =>
|
||||
{/* Search bar — placeholder «جستجو» مطابق clinic-pro-tauri */}
|
||||
<div className="topbar-search">
|
||||
<MagnifyingGlassIcon style={{ width: 17, height: 17, flexShrink: 0 }} />
|
||||
<input placeholder="جستجو" readOnly />
|
||||
<input placeholder="جستجو" aria-label="جستجو" readOnly />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SETTINGS_GROUPS, SETTINGS_MENU, groupMenu, groupedMenuForRole, menuForRole,
|
||||
} from './settingsMenu';
|
||||
|
||||
/**
|
||||
* منو یک فهرست تختِ ۱۶ ردیفی بود که ترتیبش از تاریخِ اضافهشدنِ آیتمها میآمد:
|
||||
* «خرید اشتراک» — کممصرفترین — ردیف اول بود و «مدیریت پرداخت» وسط آیتمهای
|
||||
* نوبتدهی. این تستها همان چیدمان تازه را قفل میکنند.
|
||||
*/
|
||||
describe('گروهبندی منوی تنظیمات', () => {
|
||||
it('هر آیتم منو دقیقاً به یکی از گروههای تعریفشده تعلق دارد', () => {
|
||||
const known = new Set(SETTINGS_GROUPS.map((g) => g.key));
|
||||
|
||||
for (const item of SETTINGS_MENU) {
|
||||
expect(known.has(item.group), `${item.key} گروه نامعتبر دارد`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('گروهها به ترتیب تعریفشده برمیگردند', () => {
|
||||
const keys = groupedMenuForRole('clinic').map((g) => g.key);
|
||||
|
||||
expect(keys).toEqual(['practice', 'scheduling', 'patients', 'finance', 'account']);
|
||||
});
|
||||
|
||||
it('خرید اشتراک در گروه آخر است، نه ردیف اول', () => {
|
||||
const groups = groupedMenuForRole('doctor');
|
||||
const last = groups[groups.length - 1];
|
||||
|
||||
expect(last.key).toBe('account');
|
||||
expect(last.items.map((i) => i.key)).toEqual(['subscription', 'account']);
|
||||
expect(menuForRole('doctor')[0].key).not.toBe('subscription');
|
||||
});
|
||||
|
||||
it('مدیریت پرداخت کنار بیمه و تخفیف است، نه وسط نوبتدهی', () => {
|
||||
const finance = groupedMenuForRole('doctor').find((g) => g.key === 'finance');
|
||||
|
||||
expect(finance?.items.map((i) => i.key)).toEqual(['payment', 'insurance', 'discounts', 'sms']);
|
||||
});
|
||||
|
||||
it('گروه خالی برنمیگردد', () => {
|
||||
// منشیِ فقط-با-مجوزِ پرداخت: نباید تیتر «نوبتدهی» بالای فضای خالی ببیند.
|
||||
const can = (resource: string) => resource === 'payments';
|
||||
const groups = groupedMenuForRole('secretary', can);
|
||||
|
||||
expect(groups.map((g) => g.key)).toEqual(['finance', 'account']);
|
||||
for (const group of groups) {
|
||||
expect(group.items.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('فیلترِ جستجو گروه بینتیجه را حذف میکند', () => {
|
||||
const filtered = groupMenu(menuForRole('doctor').filter((i) => i.label.includes('بیمه')));
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].key).toBe('finance');
|
||||
expect(filtered[0].items.map((i) => i.key)).toEqual(['insurance']);
|
||||
});
|
||||
|
||||
it('گروهبندی هیچ آیتمی را نمیاندازد و تکراری نمیسازد', () => {
|
||||
for (const role of ['doctor', 'clinic']) {
|
||||
const flat = menuForRole(role);
|
||||
const grouped = groupedMenuForRole(role).flatMap((g) => g.items);
|
||||
|
||||
expect(grouped).toHaveLength(flat.length);
|
||||
expect(new Set(grouped.map((i) => i.to)).size).toBe(flat.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('گِیت نقشی بعد از گروهبندی هم برقرار است', () => {
|
||||
const practice = groupedMenuForRole('doctor').find((g) => g.key === 'practice');
|
||||
|
||||
expect(practice?.items.map((i) => i.key)).not.toContain('clinic-doctors');
|
||||
expect(practice?.items.map((i) => i.key)).toContain('doctor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
|
||||
RectangleStackIcon, HashtagIcon, SparklesIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
/**
|
||||
* تنها منبعِ حقیقتِ منوی تنظیمات — هم فهرست موبایل (`SettingsMenuPage`) و هم سایدبار
|
||||
* دسکتاپ (`PurchaseSubscriptionSidebar`) از همین میخوانند.
|
||||
*
|
||||
* قبلاً هرکدام فهرست خودش را داشت و آیتم تازه فقط در یکی ظاهر میشد؛ «منابع» و
|
||||
* «دستهبندیها» در موبایل بودند و در سایدبار نبودند.
|
||||
*/
|
||||
/**
|
||||
* دستههای منو، به ترتیبِ نمایش.
|
||||
*
|
||||
* ترتیب از «چقدر به کارِ روزمره نزدیک است» میآید، نه از تاریخِ اضافهشدنِ آیتم:
|
||||
* اول ساختارِ مطب، بعد نوبتدهی، بعد بیماران، بعد مالی، و ته فهرست حساب و اشتراک
|
||||
* که ماهی یکبار سراغش میروند. پیش از این فهرست تخت بود و «خرید اشتراک» — کممصرفترین
|
||||
* آیتم — اولین ردیف بود، در حالی که «مدیریت پرداخت» وسط آیتمهای نوبتدهی افتاده بود.
|
||||
*/
|
||||
export const SETTINGS_GROUPS = [
|
||||
{ key: 'practice', label: 'مطب و کلینیک' },
|
||||
{ key: 'scheduling', label: 'نوبتدهی' },
|
||||
{ key: 'patients', label: 'بیماران' },
|
||||
{ key: 'finance', label: 'مالی' },
|
||||
{ key: 'account', label: 'حساب و اشتراک' },
|
||||
] as const;
|
||||
|
||||
export type SettingsGroupKey = (typeof SETTINGS_GROUPS)[number]['key'];
|
||||
|
||||
export type SettingsMenuGroup = {
|
||||
key: SettingsGroupKey;
|
||||
label: string;
|
||||
items: SettingsMenuItem[];
|
||||
};
|
||||
|
||||
export type SettingsMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
group: SettingsGroupKey;
|
||||
to?: string;
|
||||
/** when set, the item is only shown to these roles (omit = every role) */
|
||||
roles?: string[];
|
||||
/** [resource, action] — منشی فقط با داشتن این مجوز آیتم را میبیند. */
|
||||
perm?: [string, string];
|
||||
/** برای منشی همیشه نمایش داده میشود (حساب کاربری). */
|
||||
alwaysOpen?: boolean;
|
||||
/** مقصدی ندارد — در سایدبار خاکستری و غیرقابل کلیک نشان داده میشود. */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// ترتیب همین آرایه ترتیبِ نمایش است؛ آیتمهای هر دسته پشت سر هم میآیند.
|
||||
export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
// ── مطب و کلینیک — چه کسی اینجا کار میکند و چه چیزی ارائه میشود ───────────
|
||||
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, group: 'practice', to: '/admin/profile', roles: ['doctor'] },
|
||||
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, group: 'practice', to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] },
|
||||
// مجوزش `clinic_info` است چون ذخیرهاش روی PATCH /api/v1/clinic/{uuid} مینشیند.
|
||||
{ key: 'practice-domain', label: 'حوزهٔ فعالیت', icon: SparklesIcon, group: 'practice', to: '/admin/settings/practice-domain', roles: ['clinic'], perm: ['clinic_info', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, group: 'practice', to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, group: 'practice', to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
// ── نوبتدهی — قواعدی که تقویم را میسازند ────────────────────────────────
|
||||
// «منابع» به سایدبار اصلی («مدیریت») منتقل شد — کارِ روزمره است، نه تنظیمات.
|
||||
// گِیتش آنجا همین است: `appointment_settings.view` در هر چهار نقشی که اینجا میدیدندش.
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'service-categories', label: 'دستهبندیها', icon: RectangleStackIcon, group: 'scheduling', to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, group: 'scheduling', to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
|
||||
// ── بیماران — قواعدی که روی پروندهٔ بیمار مینشینند ────────────────────────
|
||||
{ key: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, group: 'patients', to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] },
|
||||
{ key: 'tags', label: 'برچسبها', icon: TagIcon, group: 'patients', to: '/admin/tags-settings', perm: ['tags', 'view'] },
|
||||
|
||||
// ── مالی — هر چیزی که به پول یا اعتبار وصل است ────────────────────────────
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, group: 'finance', to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, group: 'finance', to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
|
||||
{ key: 'discounts', label: 'مدیریت تخفیفها', icon: ReceiptPercentIcon, group: 'finance', to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
|
||||
{ key: 'sms', label: 'پیامکها', icon: ChatBubbleLeftRightIcon, group: 'finance', to: '/admin/sms-wallet', perm: ['sms', 'view'] },
|
||||
|
||||
// ── حساب و اشتراک — کممصرفترینها، ته فهرست ─────────────────────────────
|
||||
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, group: 'account', to: '/admin/subscription', perm: ['subscription', 'view'] },
|
||||
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, group: 'account', to: '/admin/account-settings', alwaysOpen: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Menu items visible to the given role. برای منشی بر اساس مجوز فیلتر میشود
|
||||
* (آیتمِ بدونِ perm/alwaysOpen پنهان است)؛ سایر نقشها با roles.
|
||||
*/
|
||||
export function menuForRole(
|
||||
role: string | null | undefined,
|
||||
can?: (resource: string, action: string) => boolean,
|
||||
scope?: string | null,
|
||||
): SettingsMenuItem[] {
|
||||
// منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی.
|
||||
const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic');
|
||||
return SETTINGS_MENU.filter((i) => {
|
||||
if (permissionRestricted) {
|
||||
if (i.alwaysOpen) return true;
|
||||
if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false;
|
||||
// واریانتِ نقشی (نوبتدهی/پزشکان کلینیک) را با scope تطبیق بده.
|
||||
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
|
||||
return true;
|
||||
}
|
||||
return !i.roles || (role != null && i.roles.includes(role));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* همان خروجی `menuForRole`، دستهبندیشده و به ترتیبِ `SETTINGS_GROUPS`.
|
||||
*
|
||||
* دستهٔ خالی برنمیگردد: منشیای که مجوز مالی ندارد نباید تیترِ «مالی» را ببیند و
|
||||
* زیرش هیچ. هر دو مصرفکنندهٔ منو از همین میخوانند تا ترتیب دسکتاپ و موبایل واگرا نشود.
|
||||
*/
|
||||
export function groupMenu(items: SettingsMenuItem[]): SettingsMenuGroup[] {
|
||||
return SETTINGS_GROUPS
|
||||
.map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
items: items.filter((i) => i.group === group.key),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
export function groupedMenuForRole(
|
||||
role: string | null | undefined,
|
||||
can?: (resource: string, action: string) => boolean,
|
||||
scope?: string | null,
|
||||
): SettingsMenuGroup[] {
|
||||
return groupMenu(menuForRole(role, can, scope));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
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 { toast } from 'sonner';
|
||||
import PatientTreatmentTab from './PatientTreatmentTab';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const CASE_UUID = 'case-1';
|
||||
|
||||
const summary = (over: Record<string, unknown> = {}) => ({
|
||||
uuid: CASE_UUID,
|
||||
status: 'active',
|
||||
total_sessions: 5,
|
||||
completed_sessions: 3,
|
||||
service: { uuid: 'svc-1', name: 'لیزر ناحیه ۱' },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** فهرست دورهها، تقویم دوره، و بقیهٔ GETها. */
|
||||
function mockApi(caseOver: Record<string, unknown> = {}) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/treatment-cases')) {
|
||||
return Promise.resolve({ success: true, data: [summary(caseOver)] });
|
||||
}
|
||||
if (url.includes('/plan')) {
|
||||
return Promise.resolve({ success: true, data: {
|
||||
case: { ...summary(caseOver), patient_national_code: null },
|
||||
resource: null,
|
||||
sessions: [],
|
||||
} });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
patch.mockReset();
|
||||
patch.mockResolvedValue({ success: true, data: {} });
|
||||
mockApi();
|
||||
});
|
||||
|
||||
/** دورهٔ انتخابشده در URL مینشیند؛ تست هم از همان مسیر شروع میکند. */
|
||||
const render = () => renderWithProviders(
|
||||
<PatientTreatmentTab recordUuid="rec-1" />,
|
||||
{ route: `/admin/patients/p-1?case=${CASE_UUID}` },
|
||||
);
|
||||
|
||||
const plusBtn = () => screen.getByRole('button', { name: 'افزودن یک جلسه' });
|
||||
const minusBtn = () => screen.getByRole('button', { name: 'کم کردن یک جلسه' });
|
||||
|
||||
/**
|
||||
* تعداد جلسات سرِ پروندهٔ بیمار تصمیم گرفته میشود، ولی تا این تغییر فقط از صفحهٔ
|
||||
* «دورههای درمان» و پشت یک مودال قابل تغییر بود.
|
||||
*/
|
||||
describe('PatientTreatmentTab — تعداد جلسات', () => {
|
||||
it('یک جلسه اضافه میکند', async () => {
|
||||
render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'افزودن یک جلسه' }));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 6 },
|
||||
));
|
||||
});
|
||||
|
||||
it('یک جلسه کم میکند', async () => {
|
||||
render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 4 },
|
||||
));
|
||||
});
|
||||
|
||||
it('وقتی همهٔ جلسهها انجام شده، کم کردن قفل است', async () => {
|
||||
mockApi({ total_sessions: 4, completed_sessions: 4 });
|
||||
render();
|
||||
|
||||
await waitFor(() => expect(minusBtn()).toBeDisabled());
|
||||
expect(screen.getByText(/همهٔ جلسهها انجام شده/)).toBeInTheDocument();
|
||||
// افزودن همچنان آزاد است.
|
||||
expect(plusBtn()).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('کف دو جلسه رعایت میشود', async () => {
|
||||
mockApi({ total_sessions: 2, completed_sessions: 0 });
|
||||
render();
|
||||
|
||||
await waitFor(() => expect(minusBtn()).toBeDisabled());
|
||||
expect(screen.getByText(/کمتر از ۲ جلسه ممکن نیست/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('سقف شصت جلسه رعایت میشود', async () => {
|
||||
mockApi({ total_sessions: 60, completed_sessions: 0 });
|
||||
render();
|
||||
|
||||
await waitFor(() => expect(plusBtn()).toBeDisabled());
|
||||
expect(minusBtn()).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('خطای سرور را همانطور که آمده نشان میدهد', async () => {
|
||||
// سرور دقیقتر از هر متن ثابتی میگوید چند جلسه قفل است.
|
||||
patch.mockRejectedValue(new Error('۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست'));
|
||||
render();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
|
||||
'۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست',
|
||||
));
|
||||
});
|
||||
|
||||
it('پیشرفت دوره با نسبت واقعی اعلام میشود', async () => {
|
||||
render();
|
||||
|
||||
const bar = await screen.findByRole('progressbar', { name: /پیشرفت دوره/ });
|
||||
|
||||
expect(bar).toHaveAttribute('aria-valuenow', '3');
|
||||
expect(bar).toHaveAttribute('aria-valuemax', '5');
|
||||
});
|
||||
|
||||
it('دکمهٔ ویرایش دوره برای بقیهٔ فیلدها هست', async () => {
|
||||
render();
|
||||
|
||||
expect(await screen.findByRole('button', { name: /ویرایش دوره/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
|
||||
CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import StatusBadge from '../ui/StatusBadge';
|
||||
import TreatmentCaseEditModal from '../TreatmentCaseEditModal';
|
||||
import Pagination from '../ui/Pagination';
|
||||
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
||||
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
||||
import { useClinicContext } from '../../hooks/useClinicContext';
|
||||
import { useUrlState } from '../../hooks/useUrlState';
|
||||
import { formatDate, formatDateTime, formatNumber } from '../../lib/utils';
|
||||
import type { TreatmentCaseSummary, SessionAreaRecord } from '../../types';
|
||||
|
||||
interface PlanSession {
|
||||
uuid: string;
|
||||
session_number: number;
|
||||
total_sessions: number;
|
||||
status: string;
|
||||
planned_at: number | null;
|
||||
is_estimate: boolean;
|
||||
appointment: { uuid: string; slot_start: number } | null;
|
||||
performed_by: { uuid: string; name: string } | null;
|
||||
areas?: SessionAreaRecord[];
|
||||
}
|
||||
|
||||
interface PlanResponse {
|
||||
case: TreatmentCaseSummary & { patient_national_code: string | null };
|
||||
resource: { uuid: string; name: string } | null;
|
||||
sessions: PlanSession[];
|
||||
}
|
||||
|
||||
/**
|
||||
* آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بکاند.
|
||||
*
|
||||
* قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را میگیرد که جوابش از پیش معلوم
|
||||
* است — دکمهای که میدانیم ۴۲۲ میگیرد نباید فعال بماند.
|
||||
*/
|
||||
const MIN_SESSIONS = 2;
|
||||
const MAX_SESSIONS = 60;
|
||||
|
||||
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||
active: 'در جریان',
|
||||
completed: 'تمام شده',
|
||||
abandoned: 'رها شده',
|
||||
};
|
||||
|
||||
const SESSION_STATUS_TEXT: Record<string, string> = {
|
||||
planned: 'برنامهریزی شده',
|
||||
booked: 'زمانبندی شده',
|
||||
in_progress: 'در حال انجام',
|
||||
done: 'انجام شد',
|
||||
cancelled: 'لغو شده',
|
||||
no_show: 'غیبت',
|
||||
};
|
||||
|
||||
const SETTLED = ['done', 'cancelled', 'no_show'];
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
/** `planned_at` (ثانیه) → `YYYY-MM-DD` میلادی، همان چیزی که مودال میخواهد. */
|
||||
function isoDay(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** جستجو روی همان چیزهایی که در کارت دیده میشوند، نه فیلدهای پنهان. */
|
||||
function matches(s: PlanSession, serviceName: string, term: string): boolean {
|
||||
if (term === '') return true;
|
||||
|
||||
return [
|
||||
serviceName,
|
||||
s.performed_by?.name ?? '',
|
||||
SESSION_STATUS_TEXT[s.status] ?? s.status,
|
||||
String(s.session_number),
|
||||
formatNumber(s.session_number),
|
||||
...(s.areas ?? []).map((a) => a.area.name),
|
||||
].join(' ').includes(term);
|
||||
}
|
||||
|
||||
/**
|
||||
* دورههای درمانِ همین بیمار.
|
||||
*
|
||||
* دو نما دارد و نه یکی: فهرستِ کارتِ دورهها، و با کلیک، جزئیاتِ همان دوره. یک دورهٔ
|
||||
* شصتجلسهای بازشده کنار بقیه، صفحه را غیرقابلخواندن میکرد.
|
||||
*
|
||||
* انتخاب در URL مینشیند تا «بازگشت» مرورگر همان دوره را برگرداند.
|
||||
*/
|
||||
export default function PatientTreatmentTab({ recordUuid }: { recordUuid: string }) {
|
||||
const [urlState, setUrlState] = useUrlState({ case: '', view: 'sessions' });
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['patient-treatment-cases', recordUuid],
|
||||
queryFn: () => api.get<ApiResponse<TreatmentCaseSummary[]>>(
|
||||
`/api/v1/treatment-cases?record=${encodeURIComponent(recordUuid)}`,
|
||||
),
|
||||
enabled: recordUuid !== '',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const cases = data?.data ?? [];
|
||||
const selected = cases.find((c) => c.uuid === urlState.case) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12, padding: '16px 0' }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 108 }} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'center', padding: 32 }}>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--danger)' }}>خواندن دورههای این بیمار ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cases.length === 0) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 8, justifyItems: 'center', padding: 40 }}>
|
||||
<ClipboardDocumentListIcon style={{ width: 40, height: 40, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontSize: 13.5, color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.9 }}>
|
||||
این بیمار دورهٔ درمان ندارد.
|
||||
<br />
|
||||
دوره وقتی ساخته میشود که نوبتِ سرویسی با «طول درمان» قطعی شود.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selected !== null) {
|
||||
return (
|
||||
<CaseDetail
|
||||
summary={selected}
|
||||
view={urlState.view === 'upcoming' ? 'upcoming' : 'sessions'}
|
||||
onView={(v) => setUrlState({ view: v })}
|
||||
onBack={() => setUrlState({ case: '', view: 'sessions' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gap: 12, padding: '16px 0',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
}}>
|
||||
{cases.map((c) => (
|
||||
<CaseCard key={c.uuid} summary={c} onOpen={() => setUrlState({ case: c.uuid, view: 'sessions' })} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** کارتِ خلاصهٔ یک دوره — کل کارت دکمه است، نه لینکی در گوشهاش. */
|
||||
function CaseCard({ summary: c, onOpen }: { summary: TreatmentCaseSummary; onOpen: () => void }) {
|
||||
const percent = c.total_sessions > 0
|
||||
? Math.round((c.completed_sessions / c.total_sessions) * 100)
|
||||
: 0;
|
||||
|
||||
const operator = c.performed_by.length > 0
|
||||
? c.performed_by.map((s) => s.name).join('، ')
|
||||
: c.assigned_staff.map((s) => s.name).join('، ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="card card-pad"
|
||||
style={{
|
||||
display: 'grid', gap: 10, textAlign: 'start', cursor: 'pointer',
|
||||
font: 'inherit', color: 'var(--text)', width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 14 }}>{c.service.name}</strong>
|
||||
<span className={`badge ${c.status === 'active' ? 'blue' : c.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[c.status]}
|
||||
</span>
|
||||
<ChevronLeftIcon style={{ width: 16, height: 16, marginInlineStart: 'auto', color: 'var(--text-3)' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 4, fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
<span>{formatNumber(c.completed_sessions)} از {formatNumber(c.total_sessions)} جلسه انجام شده</span>
|
||||
<span>شروع: {formatDate(c.opened_at)}</span>
|
||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||
{operator !== '' && <span>پرسنل: {operator}</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={c.completed_sessions}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={c.total_sessions}
|
||||
aria-label={`پیشرفت دوره: ${c.completed_sessions} از ${c.total_sessions}`}
|
||||
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{
|
||||
width: `${percent}%`, height: '100%', borderRadius: 999,
|
||||
background: c.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||
}} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CaseDetail({ summary, view, onView, onBack }: {
|
||||
summary: TreatmentCaseSummary;
|
||||
view: 'sessions' | 'upcoming';
|
||||
onView: (v: 'sessions' | 'upcoming') => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [term, setTerm] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => setPage(1), [term, view]);
|
||||
|
||||
/**
|
||||
* کم/زیاد کردن جلسات همین دوره.
|
||||
*
|
||||
* پیش از این فقط از صفحهٔ «دورههای درمان» و پشت یک مودال ممکن بود، در حالی که
|
||||
* تصمیمش سرِ پروندهٔ بیمار گرفته میشود. سرور جلسهٔ انجامشده یا نوبتدار را حذف
|
||||
* نمیکند و ۴۰۹ برمیگرداند؛ پیام همان خطا نشان داده میشود چون دلیلش را دقیقتر
|
||||
* از هر متن ثابتی میگوید (چند جلسه قفل است).
|
||||
*/
|
||||
const setTotalSessions = useMutation({
|
||||
mutationFn: (total: number) =>
|
||||
api.patch<ApiResponse<unknown>>(`/api/v1/treatment-case/${summary.uuid}`, { total_sessions: total }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
||||
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message || 'تغییر تعداد جلسات ناموفق بود'),
|
||||
});
|
||||
|
||||
const total = summary.total_sessions;
|
||||
const completed = summary.completed_sessions;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
const busy = setTotalSessions.isPending;
|
||||
// کفِ واقعی، جلسات قفلشده است؛ سرور همان را با ۴۰۹ میگوید ولی دکمهٔ فعالِ
|
||||
// بیاثر بدتر از دکمهٔ خاموش است.
|
||||
const minAllowed = Math.max(MIN_SESSIONS, completed);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['treatment-case-plan', summary.uuid],
|
||||
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const all = data?.data?.sessions ?? [];
|
||||
const scoped = view === 'upcoming' ? all.filter((s) => !SETTLED.includes(s.status)) : all;
|
||||
const sessions = scoped.filter((s) => matches(s, summary.service.name, term.trim()));
|
||||
const paged = sessions.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const upcomingCount = all.filter((s) => !SETTLED.includes(s.status)).length;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 14, padding: '16px 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn secondary sm" onClick={onBack}>
|
||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||
همهٔ دورهها
|
||||
</button>
|
||||
<strong style={{ fontSize: 14 }}>{summary.service.name}</strong>
|
||||
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
style={{ marginInlineStart: 'auto' }}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<PencilSquareIcon style={{ width: 15, height: 15 }} />
|
||||
ویرایش دوره
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته میشود، پس همینجا هم تغییر میکند. */}
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700 }}>تعداد جلسات</span>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn"
|
||||
aria-label="کم کردن یک جلسه"
|
||||
disabled={busy || total <= minAllowed}
|
||||
onClick={() => setTotalSessions.mutate(total - 1)}
|
||||
>
|
||||
<MinusIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
style={{ minWidth: 32, textAlign: 'center', fontSize: 15, fontWeight: 700 }}
|
||||
>
|
||||
{formatNumber(total)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn"
|
||||
aria-label="افزودن یک جلسه"
|
||||
disabled={busy || total >= MAX_SESSIONS}
|
||||
onClick={() => setTotalSessions.mutate(total + 1)}
|
||||
>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||
{formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-label={`پیشرفت دوره: ${completed} از ${total}`}
|
||||
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{
|
||||
width: `${percent}%`, height: '100%',
|
||||
background: summary.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{total <= minAllowed && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||
{completed >= total
|
||||
? 'همهٔ جلسهها انجام شدهاند؛ کم کردن ممکن نیست.'
|
||||
: `کمتر از ${formatNumber(MIN_SESSIONS)} جلسه ممکن نیست.`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="seg" style={{ alignSelf: 'start' }}>
|
||||
{([['sessions', 'جزئیات دوره'], ['upcoming', `نوبتهای آینده (${formatNumber(upcomingCount)})`]] as const).map(
|
||||
([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={view === v ? 'on' : ''}
|
||||
aria-pressed={view === v}
|
||||
onClick={() => onView(v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ maxWidth: 420 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
||||
<input
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
placeholder="پرسنل، وضعیت، ناحیه یا شمارهٔ جلسه"
|
||||
aria-label="جستجوی جلسات"
|
||||
/>
|
||||
{term !== '' && (
|
||||
<button type="button" className="mini-btn" aria-label="پاک کردن جستجو" onClick={() => setTerm('')}>
|
||||
<XMarkIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{[0, 1].map((i) => <div key={i} className="card" style={{ height: 72 }} />)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 10, justifyItems: 'start' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger)' }}>خواندن تقویم این دوره ناموفق بود.</span>
|
||||
<button type="button" className="btn secondary sm" onClick={() => refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="card card-pad" style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
{term !== ''
|
||||
? `جلسهای با «${term}» پیدا نشد.`
|
||||
: view === 'upcoming'
|
||||
? 'جلسهٔ باقیماندهای در این دوره نیست.'
|
||||
: 'این دوره جلسهای ندارد.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{paged.map((s) => (
|
||||
<SessionCard
|
||||
key={s.uuid}
|
||||
session={s}
|
||||
summary={summary}
|
||||
resource={data?.data?.resource ?? null}
|
||||
nationalCode={data?.data?.case.patient_national_code ?? null}
|
||||
/>
|
||||
))}
|
||||
<Pagination page={page} total={sessions.length} limit={PAGE_SIZE} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<TreatmentCaseEditModal caseUuid={summary.uuid} onClose={() => setEditing(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCard({ session: s, summary, resource, nationalCode }: {
|
||||
session: PlanSession;
|
||||
summary: TreatmentCaseSummary;
|
||||
resource: { uuid: string; name: string } | null;
|
||||
nationalCode: string | null;
|
||||
}) {
|
||||
const [booking, setBooking] = useState(false);
|
||||
/**
|
||||
* سابقه بسته میآید و کارِ پیشِ رو باز.
|
||||
*
|
||||
* جلسهٔ انجامشده سه ناحیه با خواندههایشان دارد و بازِ پیشفرض، فهرست را دفن
|
||||
* میکند؛ ولی جلسهای که باید رزرو شود، دکمهاش دلیلِ وجودش است و پشت یک کلیک
|
||||
* اضافه نباید بماند.
|
||||
*/
|
||||
const [open, setOpen] = useState(!SETTLED.includes(s.status));
|
||||
const qc = useQueryClient();
|
||||
const clinicUuid = useClinicContext();
|
||||
|
||||
const supervisor = summary.supervisor;
|
||||
// سرویسها از تقویم منبع میآیند نه از برنامهٔ پزشک — دورهٔ لیزر روی دستگاه رزرو
|
||||
// میشود و برنامهٔ پزشکِ ناظر سرویسی برای انتخاب ندارد.
|
||||
const { services } = useResourceBookingServices(booking ? resource?.uuid : null);
|
||||
|
||||
const bookable = !SETTLED.includes(s.status) && s.appointment === null && summary.status === 'active';
|
||||
const areas = s.areas ?? [];
|
||||
// جلسهای که نه ناحیهای ثبت کرده و نه کاری برای انجام دارد، بدنهای ندارد که باز
|
||||
// شود؛ کلپسِ خالی فقط یک کلیک بینتیجه است.
|
||||
const hasBody = areas.length > 0 || bookable;
|
||||
|
||||
const head = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', width: '100%' }}>
|
||||
<strong style={{ fontSize: 13.5 }}>
|
||||
جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
|
||||
</strong>
|
||||
<StatusBadge type="treatment-session" value={s.status} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||
{s.planned_at === null ? '—' : formatDateTime(s.planned_at)}
|
||||
</span>
|
||||
{/* تخمین با واقعیت یکی نیست و باید در خودِ کارت معلوم باشد. */}
|
||||
{s.is_estimate && <span className="badge gray" style={{ fontSize: 11 }}>تخمینی</span>}
|
||||
{s.performed_by && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پرسنل: {s.performed_by.name}</span>
|
||||
)}
|
||||
{/* شمارهٔ نواحی در سرِ بسته میماند تا باز کردن، حدس نباشد. */}
|
||||
{areas.length > 0 && !open && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>{formatNumber(areas.length)} ناحیه</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 16, height: 16, marginInlineStart: 'auto', flexShrink: 0, color: 'var(--text-3)',
|
||||
transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ display: 'grid', gap: 8 }}>
|
||||
{hasBody ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', width: '100%', padding: 0, background: 'none', border: 'none',
|
||||
cursor: 'pointer', font: 'inherit', color: 'var(--text)', textAlign: 'start',
|
||||
}}
|
||||
>
|
||||
{head}
|
||||
</button>
|
||||
) : head}
|
||||
|
||||
{/* آنچه واقعاً انجام شد: ناحیه، دستگاه و خواندههایش. */}
|
||||
{open && areas.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{areas.map((a) => (
|
||||
<div key={a.uuid} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5 }}>
|
||||
<span style={{ fontWeight: 600 }}>{a.area.name}</span>
|
||||
<StatusBadge type="treatment-area" value={a.status} />
|
||||
{a.resource && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-3)' }}>
|
||||
<CpuChipIcon style={{ width: 13, height: 13 }} />
|
||||
{a.resource.name}
|
||||
</span>
|
||||
)}
|
||||
{a.parameters && Object.entries(a.parameters).map(([k, v]) => (
|
||||
<span key={k} style={{ color: 'var(--text-3)' }}>
|
||||
{k}: <b style={{ color: 'var(--text-2)' }}>{String(v)}</b>
|
||||
</span>
|
||||
))}
|
||||
{a.note && <span style={{ color: 'var(--text-3)' }}>یادداشت: {a.note}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && bookable && (
|
||||
/* بدون دستگاه، فرم سرویسی برای انتخاب ندارد؛ دکمهٔ خاموشِ بیتوضیح بدتر از
|
||||
نبودنش است، پس دلیلش نوشته میشود. */
|
||||
resource === null ? (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--warning)' }}>
|
||||
هنوز هیچ جلسهای از این دوره روی دستگاهی انجام نشده — نوبت را از صفحهٔ نوبتها ثبت کنید.
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
style={{ justifySelf: 'start' }}
|
||||
onClick={() => setBooking(true)}
|
||||
>
|
||||
ثبت نوبت این جلسه
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{booking && resource && (
|
||||
<NewAppointmentModal
|
||||
slot={{
|
||||
start: 0, end: 0, start_time: '', end_time: '',
|
||||
doctor_uuid: supervisor?.uuid ?? '', doctor_name: supervisor?.name ?? '',
|
||||
}}
|
||||
resource={resource}
|
||||
services={services}
|
||||
date={s.planned_at === null ? undefined : isoDay(s.planned_at)}
|
||||
clinicUuid={clinicUuid}
|
||||
treatmentSessionUuid={s.uuid}
|
||||
patient={{
|
||||
name: summary.patient.name,
|
||||
mobile: summary.patient.mobile,
|
||||
national_code: nationalCode,
|
||||
}}
|
||||
onClose={() => setBooking(false)}
|
||||
onSuccess={() => {
|
||||
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
||||
qc.invalidateQueries({ queryKey: ['patient-appointments'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import Switch from '../ui/Switch';
|
||||
|
||||
/**
|
||||
* سوییچ وضعیت فعال/غیرفعال — معادل MUI Switch مبدأ با دیزاینسیستم مقصد.
|
||||
@@ -16,16 +17,9 @@ export default function StatusToggle({
|
||||
const label = active ? 'فعال' : 'غیرفعال';
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<label className="switch" title={label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={onToggle}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</label>
|
||||
<span title={label}>
|
||||
<Switch checked={active} onChange={onToggle} disabled={disabled} ariaLabel={label} />
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { api, ApiError, type ApiResponse } from '../../lib/api';
|
||||
import { formatDate, isoToUnix, unixToIso } from '../../lib/utils';
|
||||
|
||||
interface Block {
|
||||
uuid: string;
|
||||
starts_at: number;
|
||||
ends_at: number;
|
||||
segment_name: string | null;
|
||||
}
|
||||
|
||||
const HOUR = 3600;
|
||||
|
||||
/**
|
||||
* غیرفعالسازی موقتِ یک منبع — بستن یک بازهٔ زمانی مشخص.
|
||||
*
|
||||
* عمداً از «استثنای تقویم» جداست و همینجا هم گفته میشود: آن الگوی کاری منبع را عوض
|
||||
* میکند و ماندگار است، این فقط یک بازهٔ مشخص را میبندد. اپراتوری که این تفاوت را
|
||||
* نداند، تعطیلی یک بعدازظهر را برای همیشه در تقویم ثبت میکند.
|
||||
*
|
||||
* پیشتر مودالی بود که از ردیف جدولِ منابع باز میشد؛ حالا یکی از تبهای خودِ منبع
|
||||
* است، کنار بقیهٔ تنظیماتش.
|
||||
*/
|
||||
export default function ResourceBlocksPanel({ resourceUuid, canUpdate }: {
|
||||
resourceUuid?: string;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['resource-blocks', resourceUuid];
|
||||
|
||||
const [day, setDay] = useState(() => unixToIso(Math.floor(Date.now() / 1000) + 86400));
|
||||
const [fromHour, setFromHour] = useState(9);
|
||||
const [toHour, setToHour] = useState(13);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<Block[]>>(`/api/v1/resource/${resourceUuid}/blocks`),
|
||||
enabled: !!resourceUuid,
|
||||
});
|
||||
|
||||
const blocks = data?.data ?? [];
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: { starts_at: number; ends_at: number; reason: string }) =>
|
||||
api.post<ApiResponse<Block>>(`/api/v1/resource/${resourceUuid}/blocks`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('بازه بسته شد');
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
setReason('');
|
||||
},
|
||||
// ۴۰۹ یعنی آن بازه نوبت دارد؛ پیام سرور دقیقاً میگوید اول چه باید کرد.
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'بستن بازه ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-block/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('بازه دوباره باز شد');
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'حذف ناموفق بود'),
|
||||
});
|
||||
|
||||
const dayStart = isoToUnix(day) ?? 0;
|
||||
const rangeInvalid = toHour <= fromHour;
|
||||
|
||||
return (
|
||||
<div className="card card-pad">
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>غیرفعالسازی موقت</h2>
|
||||
<p className="field-hint" style={{ marginTop: 4, marginBottom: 16 }}>
|
||||
یک بازهٔ مشخص را میبندد و تا وقتی حذفش نکنید میماند. برای تغییر
|
||||
<strong> الگوی کاری </strong>
|
||||
منبع (مثلاً «پنجشنبهها تعطیل») از تب «تعطیلات و استثنا» استفاده کنید، نه از اینجا.
|
||||
</p>
|
||||
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'grid', gap: 12, marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div className="field-block" style={{ minWidth: 180 }}>
|
||||
<label>روز</label>
|
||||
<PersianDateInput value={day} onChange={setDay} ariaLabel="روزِ بازهٔ بستهشده" />
|
||||
</div>
|
||||
|
||||
<div className="field-block" style={{ width: 110 }}>
|
||||
<label htmlFor="block-from">از ساعت</label>
|
||||
<label className="field">
|
||||
<input
|
||||
id="block-from"
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={fromHour}
|
||||
onChange={(e) => setFromHour(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field-block" style={{ width: 110 }}>
|
||||
<label htmlFor="block-to">تا ساعت</label>
|
||||
<label className="field">
|
||||
<input
|
||||
id="block-to"
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
value={toHour}
|
||||
onChange={(e) => setToHour(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="block-reason">دلیل <span className="opt">(اختیاری)</span></label>
|
||||
<label className="field">
|
||||
<input
|
||||
id="block-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="مثلاً سرویس دورهای دستگاه"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{rangeInvalid && (
|
||||
<p className="field-err" role="alert" style={{ marginTop: 0 }}>
|
||||
ساعت پایان باید بعد از ساعت شروع باشد.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={rangeInvalid || dayStart === 0 || create.isPending}
|
||||
onClick={() =>
|
||||
create.mutate({
|
||||
starts_at: dayStart + fromHour * HOUR,
|
||||
ends_at: dayStart + toHour * HOUR,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
>
|
||||
{create.isPending ? 'در حال ثبت...' : 'بستن این بازه'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 10px' }}>بازههای بستهشده</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="skeleton" style={{ height: 60, borderRadius: 'var(--r-sm)' }} />
|
||||
) : blocks.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
این منبع هیچ بازهٔ بستهشدهای ندارد.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{blocks.map((block) => (
|
||||
<div
|
||||
key={block.uuid}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
|
||||
padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>{formatDate(block.starts_at)}</span>
|
||||
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
|
||||
{new Date(block.starts_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' – '}
|
||||
{new Date(block.ends_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 12, flex: 1, minWidth: 0 }}>
|
||||
{block.segment_name ?? '—'}
|
||||
</span>
|
||||
{canUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn danger"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(block.uuid)}
|
||||
aria-label={`باز کردن بازهٔ ${formatDate(block.starts_at)}`}
|
||||
>
|
||||
<TrashIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useId, useMemo, useState } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import ConfirmDialog from '../ui/ConfirmDialog';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { useResourceExceptions } from '../../hooks/useResourceCalendar';
|
||||
import { formatDate } from '../../lib/utils';
|
||||
import type { ResourceException } from '../../types';
|
||||
|
||||
const EXCEPTION_TYPES = [
|
||||
{ value: 'leave', label: 'مرخصی' },
|
||||
{ value: 'absence', label: 'غیبت' },
|
||||
{ value: 'maintenance', label: 'سرویس دورهای' },
|
||||
{ value: 'closure', label: 'تعطیلی موردی' },
|
||||
];
|
||||
|
||||
/**
|
||||
* مرخصی و سرویسِ یک منبع — فهرست استثناهای ثبتشده و فرم ثبت استثنای تازه.
|
||||
*
|
||||
* کارتِ مستقل است چون دو ترکیب متفاوت دارد: در صفحهٔ جزئیات منبع کنار تعطیلات رسمی
|
||||
* و پیشنمایش مینشیند (`ResourceExceptionsPanel`)، و در تنظیمات نوبتدهی یکی از دو
|
||||
* تبِ زیر شیفت هفتگی است. حذفِ دوبارهنویسی، نه انتزاعِ زودرس.
|
||||
*/
|
||||
export default function ResourceExceptionsCard({ resourceUuid, canUpdate }: {
|
||||
resourceUuid?: string;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
const { exceptions, create, remove } = useResourceExceptions(resourceUuid);
|
||||
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
|
||||
|
||||
const [type, setType] = useState<string>('leave');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
// id یکتا لازم است: این کارت در صفحهٔ منبع و صفحهٔ تنظیمات نوبتدهی هر دو رندر میشود.
|
||||
const uid = useId();
|
||||
|
||||
const toTimestamp = (value: string): number | null => {
|
||||
if (value === '') return null;
|
||||
const ms = new Date(`${value}T00:00:00`).getTime();
|
||||
return Number.isNaN(ms) ? null : Math.floor(ms / 1000);
|
||||
};
|
||||
|
||||
const start = toTimestamp(startDate);
|
||||
const end = toTimestamp(endDate);
|
||||
// پایان روزِ انتخابشده، نه آغازش: مرخصیِ «تا سهشنبه» شامل خودِ سهشنبه است.
|
||||
const endExclusive = end === null ? null : end + 86400;
|
||||
const invalid = start === null || endExclusive === null || endExclusive <= start;
|
||||
|
||||
const typeLabel = useMemo(
|
||||
() => EXCEPTION_TYPES.find((t) => t.value === type)?.label ?? '',
|
||||
[type],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card card-pad">
|
||||
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>
|
||||
مرخصی و سرویس
|
||||
</h2>
|
||||
|
||||
{exceptions.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 14px' }}>
|
||||
استثنایی ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
||||
{exceptions.map((e) => (
|
||||
<div
|
||||
key={e.uuid}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13,
|
||||
padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
||||
}}
|
||||
>
|
||||
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
|
||||
<span style={{ flex: 1, color: 'var(--text-2)', minWidth: 0 }}>
|
||||
{formatDate(e.starts_at)} تا {formatDate(e.ends_at)}
|
||||
{e.reason ? ` · ${e.reason}` : ''}
|
||||
</span>
|
||||
{canUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="mini-btn danger"
|
||||
onClick={() => setToDelete(e)}
|
||||
aria-label={`حذف ${e.type_label} از ${formatDate(e.starts_at)}`}
|
||||
>
|
||||
<XMarkIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUpdate ? (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div className="field-block">
|
||||
<label id={`${uid}-type-label`} htmlFor={`${uid}-type`}>نوع استثنا</label>
|
||||
<SearchableSelect
|
||||
inputId={`${uid}-type`}
|
||||
ariaLabelledBy={`${uid}-type-label`}
|
||||
options={EXCEPTION_TYPES}
|
||||
value={type}
|
||||
onChange={(v) => setType(v ? String(v) : 'leave')}
|
||||
placeholder="نوع استثنا"
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
|
||||
ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="field-block">
|
||||
<label>از تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder="از تاریخ"
|
||||
ariaLabel={`تاریخ شروع ${typeLabel}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-block">
|
||||
<label>تا تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="تا تاریخ"
|
||||
ariaLabel={`تاریخ پایان ${typeLabel}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor={`${uid}-reason`}>توضیح <span className="opt">(اختیاری)</span></label>
|
||||
<label className="field">
|
||||
<input
|
||||
id={`${uid}-reason`}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="مثلاً سرویس سالانهٔ دستگاه"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={create.isPending || invalid}
|
||||
onClick={() => {
|
||||
create.mutate({
|
||||
type,
|
||||
starts_at: start!,
|
||||
ends_at: endExclusive!,
|
||||
reason: reason.trim() === '' ? null : reason.trim(),
|
||||
});
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setReason('');
|
||||
}}
|
||||
>
|
||||
{create.isPending ? 'در حال ثبت...' : 'ثبت استثنا'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: 0 }}>
|
||||
برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف استثنا"
|
||||
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
danger
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
||||
import ResourceExceptionsCard from './ResourceExceptionsCard';
|
||||
|
||||
/**
|
||||
* تقویمِ استثناهای یک منبع: تعطیلات رسمی، و مرخصی و سرویس.
|
||||
*
|
||||
* دو ورودیِ همخانواده که کنار هم میمانند — هر دو روزی را از تقویم منبع کم میکنند،
|
||||
* یکی سراسری و یکی مخصوص همین منبع.
|
||||
*/
|
||||
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||
resourceUuid?: string;
|
||||
canUpdate: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="wh-two-col">
|
||||
<NationalHolidaysCard canUpdate={canUpdate} />
|
||||
<ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user