diff --git a/.claude/prompt/admin-jalali-date-input.md b/.claude/prompt/admin-jalali-date-input.md new file mode 100644 index 00000000..4168afe2 --- /dev/null +++ b/.claude/prompt/admin-jalali-date-input.md @@ -0,0 +1,156 @@ +# همه‌ی تقویم‌های پنل ادمین باید شمسی باشند (رفع تقویم میلادی PersianDateInput) + +## پروژه + +`clinicpro` (React 19 admin SPA داخل Symfony — `assets/admin/`) + +## زمینه + +کل محصول فارسی/RTL و تاریخ‌ها Jalali (شمسی) است. تقویم شمسی قبلاً نوشته شده: +`PersianCalendar.tsx` (پیکر ماه/سال/روز شمسی، خروجی `YYYY-MM-DD` میلادی) و روکش آن +`PersianDatePicker.tsx`. اما یک کامپوننت دیگر به‌نام `PersianDateInput.tsx` هنوز از +`` بومی مرورگر استفاده می‌کند که **تقویم میلادی** مرورگر را باز می‌کند. +هرجای پنل که این کامپوننت استفاده شده، کاربر تقویم میلادی می‌بیند — خلاف قاعده‌ی محصول. + +## مشکل / هدف + +**هدف:** هرجای پنل ادمین که از انتخاب تاریخ (تقویم) استفاده می‌شود، تقویم شمسی نمایش دهد. + +**تنها منبع میلادی در کل ادمین:** `assets/admin/components/ui/PersianDateInput.tsx` خط ۵۶ +(`type="date"`). با اصلاح همین یک فایل، همه‌ی call siteهای زیر یک‌جا شمسی می‌شوند +(بدون تغییر در آن‌ها، چون امضای Props ثابت می‌ماند). + +بررسی انجام‌شده: +- `PersianCalendar` / `PersianDatePicker` قبلاً شمسی‌اند — نیازی به بازنویسی ندارند. +- در کل `assets/admin` فقط **یک** `type="date"` وجود دارد (همین فایل). هیچ + `datetime-local` / `month` / `week` بومی دیگری نیست. +- تقویم ماهانه‌ی درون `DoctorDetailPage.tsx` و `PersianDateInput` محلیِ همان فایل + از قبل با `jalaali-js` شمسی‌اند — دست نزن. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/components/ui/PersianDateInput.tsx` | **تنها فایلی که تغییر می‌کند** — حذف input بومی، استفاده از PersianCalendar | +| `assets/admin/components/ui/PersianCalendar.tsx` | پیکر شمسی موجود (Props: `value`, `onChange`, `onClose`, `enableYearPicker`) — مرجع | +| `assets/admin/components/ui/PersianDatePicker.tsx` | الگوی درستِ استفاده از PersianCalendar (کپی همین ساختار) | +| `assets/admin/lib/utils.ts` → `formatDate()` | نمایش شمسیِ مقدار انتخاب‌شده (از قبل استفاده می‌شود) | + +### call siteهای فعلی PersianDateInput (نباید تغییر کنند — فقط برای اطمینان از سازگاری Props) + +``` +components/AppointmentActions.tsx:482,613 value/onChange +components/InsuranceModal.tsx:153,157 value/onChange/placeholder +components/NewAppointmentDrawer.tsx:257 value/onChange +components/PatientsFilterModal.tsx:84,86 value/onChange/placeholder +pages/AppointmentEditPage.tsx:150 value/onChange +pages/MyPaymentsPage.tsx:108,111 value/onChange/placeholder +pages/AppointmentCreatePage.tsx:254 value/onChange +pages/PatientDetailPage.tsx:373,510 value/onChange +pages/PatientRecordFormPage.tsx:132 value/onChange ← تاریخ تولد (نیاز به enableYearPicker) +pages/DoctorDetailPage.tsx:1687,1914,1918 این‌ها به PersianDateInput محلیِ همان فایل وصل‌اند، نه فایل مشترک — دست نزن +``` + +## وضعیت فعلی (کد مشکل‌دار) + +`assets/admin/components/ui/PersianDateInput.tsx` — لایه‌ی متنی شمسی است ولی پیکر بومی میلادی: + +```tsx +{/* hidden native input — opens picker on click */} + onChange(e.target.value)} + style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 1, height: 1, top: 0, left: 0 }} + tabIndex={-1} +/> +``` + +`Props` فعلی: `value, onChange, placeholder?, min?, max?, style?, className?`. +نکته: `min`/`max` در هیچ call siteی پاس داده نمی‌شوند (Prop مرده‌اند). + +## وظایف + +### ۱. بازنویسی `PersianDateInput.tsx` روی پیکر شمسی + +`` را حذف کن و همان الگوی `PersianDatePicker.tsx` را به کار ببر: +state باز/بسته + رندر شرطی ``. لایه‌ی متنی visible و `formatDate(value)` +و دکمه‌ی پاک‌کردن (X) و امضای Props فعلی را **حفظ کن** تا هیچ call siteی نشکند. + +```tsx +import { useState } from 'react'; +import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { formatDate } from '../../lib/utils'; +import PersianCalendar from './PersianCalendar'; + +interface Props { + value: string; // YYYY-MM-DD میلادی + onChange: (v: string) => void; + placeholder?: string; + enableYearPicker?: boolean; // برای تاریخ تولد + style?: React.CSSProperties; + className?: string; +} + +export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', enableYearPicker = false, style, className }: Props) { + const [open, setOpen] = useState(false); + return ( +
+
setOpen(o => !o)} style={{ /* همان استایل visible فعلی: height 36, border/surface, icon, placeholder color */ }}> + + {value ? formatDate(value) : placeholder} + {value && { e.stopPropagation(); onChange(''); }}>} +
+ {open && ( + { onChange(v); setOpen(false); }} + onClose={() => setOpen(false)} + enableYearPicker={enableYearPicker} + /> + )} +
+ ); +} +``` + +- `min`/`max` را حذف کن (استفاده‌ای ندارند). اگر خواستی امن‌تر باشی، نگه‌شان دار ولی + بدون اثر — ترجیح: حذف، مطابق قاعده‌ی «کد مرده ننویس». +- استایل لایه‌ی visible دقیقاً همان مقادیر فعلی فایل بماند (height 36، `--border`، + `--surface`، `minWidth 148`، fontSize 13، رنگ placeholder با `--text-3`). + +### ۲. فعال‌کردن انتخاب سال برای تاریخ تولد + +در `pages/PatientRecordFormPage.tsx:132` (فیلد `birth_date`) پراپ `enableYearPicker` را بده +تا کاربر بتواند سریع سال تولد را انتخاب کند: + +```tsx + form.setValue('birth_date', v)} enableYearPicker /> +``` + +(الگوی مشابه از قبل در `PatientRecordInfoForm.tsx:133` با `PersianDatePicker … enableYearPicker` هست.) + +### ۳. رفع هم‌پوشانی SOLID (اختیاری ولی توصیه‌شده) + +بعد از این تغییر، `PersianDateInput` و `PersianDatePicker` تقریباً یکی می‌شوند +(هر دو = لایه‌ی متنی + PersianCalendar). برای پرهیز از دوگانگی: +- گزینه‌ی ساده: `PersianDateInput` را یک روکش نازک روی `PersianDatePicker` کن + (`return `)، یا +- در همین تسک فقط رفتار را یکی کن و در کامنت بالای فایل اشاره کن که این دو باید + در آینده ادغام شوند. حذف کامل یکی از آن‌ها → نیازمند به‌روزرسانی همه‌ی importها است؛ + اگر انجامش می‌دهی، همه‌ی call siteها را هم اصلاح کن و tscرا سبز نگه دار. + +## نکات مهم + +- بعد از تغییر، حتماً تایپ‌چک: `ddev exec npx tsc --noEmit --project tsconfig.json` باید سبز شود. +- خروجی `PersianCalendar.onChange` همان `YYYY-MM-DD` میلادی است؛ قرارداد داده‌ی ارسالی + به API تغییر نمی‌کند — فقط UI تقویم شمسی می‌شود. رفتار submit/فیلترها نباید عوض شود. +- `DoctorDetailPage.tsx` یک `PersianDateInput` **محلیِ درون‌فایل** دارد (تعریف حدود خط ۱۷۷) + که با `jalaali-js` از قبل شمسی است و پراپ `minDate` دارد؛ به فایل مشترک ربطی ندارد — دست نزن. +- تست‌ها: اگر تستی برای `PersianDateInput` هست، آپدیت کن؛ در غیر این صورت یک تست کوتاه + Vitest اضافه کن که کلیک روی input، پیکر شمسی را باز می‌کند و انتخاب روز، `onChange` + با `YYYY-MM-DD` را صدا می‌زند (حالت موفق + پاک‌کردن مقدار). +- تغییرِ فقط-UI است؛ backend و `docs/api/*` نیاز به تغییر ندارند. diff --git a/.claude/prompt/admin-native-select-to-searchableselect.md b/.claude/prompt/admin-native-select-to-searchableselect.md new file mode 100644 index 00000000..614ed610 --- /dev/null +++ b/.claude/prompt/admin-native-select-to-searchableselect.md @@ -0,0 +1,180 @@ +# جایگزینی همه‌ی `` بومی HTML استفاده شده (استایل درون‌خطی تکراری، +بدون جست‌وجو، ظاهر ناهماهنگ با طراحی‌سیستم، بدون RTL/dark درست). طراحی‌سیستم یک +کامپوننت مشترک دارد: `assets/admin/components/ui/SearchableSelect.tsx` (روی `react-select`، +قابل جست‌وجو، RTL، هماهنگ با توکن‌های CSS و dark mode، منو portal با `zIndex 9999`). + +**هدف:** همه‌ی ``: +- `onChange` مقدار خام می‌دهد (`string|number|null`) نه `e.target.value`. +- `options` باید `{ value, label }` باشد — نه ``) حذف و به `placeholder` منتقل شود؛ + اگر خالی‌کردن مجاز است `isClearable` بده. +- `disabled` → `isDisabled`. + +## فایل‌های دارای ` onChange(e.target.value)} style={sel}> + + {options.map(s => )} + +``` + +### الگوی B — گزینه‌های وابسته / disabled +```tsx +// AppointmentFiltersModal.tsx:100 + +``` + +### الگوی C — React Hook Form با `register` (خاص — نیازمند Controller) +```tsx +// PatientRecordFormPage.tsx:119 +
+``` + +## وظایف + +> **یک فایل در هر مرحله.** بعد از هر فایل: `tsc` سبز شود، بعد فایل بعدی. ترتیب: از فایل‌های کم‌مورد به پرمورد، یا هر ترتیبی، ولی هر فایل مستقل تست/تایپ‌چک شود. + +### ۱. تبدیل الگوی A (controlled) + +هر `` بومیِ جدیدی در پنل ادمین نساز؛ + همیشه `SearchableSelect`. diff --git a/.claude/prompt/admin-ui-match-tauri.md b/.claude/prompt/admin-ui-match-tauri.md new file mode 100644 index 00000000..b6c15bc4 --- /dev/null +++ b/.claude/prompt/admin-ui-match-tauri.md @@ -0,0 +1,179 @@ +# همسان‌سازی کامل UI پنل ادمین clinicpro با فیگما (زبان طراحی clinic-pro-tauri) + +## پروژه + +`clinicpro` — فقط لایه‌ی frontend پنل ادمین (`assets/admin/`). بدون تغییر backend/API. تسک صرفاً بصری است: توکن‌های طراحی + کامپوننت‌های مشترک + layout هر صفحه. + +## هدف کلی + +**همه‌ی صفحات پنل ادمین clinicpro باید ظاهر و زبان طراحیِ این فیگما را داشته باشند** — همان رنگ‌ها، تایپوگرافی، کارت‌ها، جدول‌ها، فرم‌ها، badgeها، سایدبار/هدر، و الگوهای موبایل (bottom sheet، منوی پایین). clinic-pro-tauri پیاده‌سازی مرجعِ همین طراحی است؛ هرجا فیگما و tauri هم‌خوان بودند از همان مقادیر استفاده کن. + +**فیگما:** `https://www.figma.com/design/76Z5FkibRdsMRisqPTIXKU/nobat724?node-id=3598-18842` +- `fileKey = 76Z5FkibRdsMRisqPTIXKU` — page: `Cover` — board: `3598:18842` (name «new version»، کل صفحات اینجاست) +- ابزار: Figma MCP → `get_screenshot`, `get_variable_defs`, `get_metadata` روی **node-id هر فریم جداگانه** (بورد کامل ۴۹۸۳۳×۱۶۰۰۰ است، یک‌جا نگیر). + +## معماری فعلی (مهم — قبل از شروع بخوان) + +پنل ادمین clinicpro **از قبل یک design-system سفارشی کامل دارد** و تا حدی به سمت tauri رفته (کامنت‌های `styles.css` صراحتاً «دقیقاً مطابق clinic-pro-tauri»): +- استک: **Tailwind v4 + CSS variables**، نه MUI. **MUI را وارد نکن؛ معماری را عوض نکن.** فقط مقدار متغیرها و کلاس‌های موجود را به مقادیر فیگما/tauri برسان. +- فایل توکن/کامپوننت: `assets/admin/styles.css` (۸۳۱ خط) — کلاس‌های `.cp-*` (کارت/input/button/stat/badge)، `.app/.sidebar/.topbar/.nav-item`، `.seg`, `.avatar`, `.modal`. +- dark mode: کلاس `.dark` + `data-theme="dark"` روی `` (در `AdminLayout.tsx` خطوط ۱۶–۲۲). عیناً مثل tauri؛ نگه‌دار. +- RTL + فونت Vazirmatn از قبل درست است؛ دست نزن. + +پس این تسک «بازنویسی از صفر» نیست؛ دو بخش است: **(الف)** یک‌بار توکن/کامپوننت‌های مشترک را دقیق مطابق فیگما کن، **(ب)** بعد تک‌تک صفحات را با فریم فیگمای متناظر مقایسه و اصلاح کن. + +--- + +## بخش الف — سیستم طراحی مشترک (یک‌بار، پایه‌ی همه‌ی صفحات) + +فایل: `assets/admin/styles.css` + `stores/uiStore.ts` + `components/layout/*`. + +### الف-۱. توکن‌های رنگ (light + dark) + +اختلاف فعلی با فیگما/tauri: + +| توکن | فعلی clinicpro | هدف (tauri/فیگما) | +|------|----------------|-------------------| +| primary | oklch پویا `~#5457dd` | بنفش ثابت **`#5559CE`** (hover `#494CB3`، dark selected `#6B6FD6`) | +| bg light | `#eef2f8` | **`#fafafa`** | +| bg dark (body) | `#080d16` | **`#1f1d2b`** | +| surface dark (سایدبار/هدر/کارت) | `#111a29` | **`#222433`** | +| border dark | `#243042` | **`#343645`** (اغلب فریم‌ها transparent) | +| text dark | `#e9eef7` / `#9eb0c6` | **`#D7D8ED`** / **`#A1A1A1`** | + +```css +:root { + --brand-h: 277; --brand-c: 0.14; /* تأیید با نمونه‌گیری #5559CE از get_variable_defs */ + --primary: #5559CE; --primary-600: #494CB3; --primary-700: #3E41A0; + --bg: #fafafa; --bg-2: #f2f2f5; --surface: #ffffff; +} +[data-theme="dark"] { + --bg: #1f1d2b; --bg-2: #1a1826; + --surface: #222433; --surface-2: #2a2c3d; --surface-3: #313349; + --border: #343645; --text: #D7D8ED; --text-2: #A1A1A1; + --primary: #6B6FD6; +} +``` +> بلوک `@supports (color: color-mix(in oklch …))` (خطوط ۱۱۳–۱۳۹) هم primary/dark را از نو می‌سازد؛ **هم sRGB و هم oklch را هماهنگ کن** وگرنه مرورگر مدرن رنگ متفاوت می‌دهد. رنگ‌های status/stat-card را با `get_variable_defs` فیگما تأیید کن (accent نارنجی `#F17732`، سبز `#009D79`، amber `#FFC051` از قبل کپی شده‌اند). + +### الف-۲. سلکتور رنگ کاربر + +primary فعلاً «قابل‌تعویض» است (`brandHue` در `uiStore` + سلکتور در `Topbar.tsx`). فیگما این را ندارد. **پیش‌فرض `brandHue` را روی بنفش بگذار**؛ سلکتور رنگ بماند اما default بنفش (اگر کارفرما تک‌رنگ می‌خواهد، بلوک سلکتور رنگ در `Topbar.tsx` خطوط ۱۱۶–۱۳۶ را حذف کن). + +### الف-۳. ابعاد و رفتار layout + +- عرض سایدبار: `--sidebar-w: 243px`، حالت جمع **`90px`** (بجای `252/76`). همه‌ی `76px` هاردکد‌شده (`styles.css` خطوط ۳۱۳، ۳۲۶، ۷۱۵) → `90px`. +- **hover-expand** سایدبار مثل tauri: در `data-collapsed="true"` روی `:hover` عرض به `243px` و برچسب‌ها/`brand-text` دوباره ظاهر شوند (مرجع: `clinic-pro-tauri/src/components/layout/sidebar/index.jsx`، کلاس `hover-mode-sidebar`). +- ارتفاع هدر ریسپانسیو مثل tauri (`56 → 67 → 79 → 90px`)؛ مقدار دقیق دسکتاپ را از فریم `header` فیگما بگیر. +- border سایدبار/هدر در dark عملاً محو (`dark:border-transparent`). + +### الف-۴. شعاع‌ها و input/button + +- input/select/textarea شعاع **8px** (tauri `mui.js`)؛ `--r-sm` را به `8px` ببر یا `--r-input: 8px` بساز و در `.cp-input/.cp-select/.cp-textarea` (خطوط ۲۰۱–۲۲۸) استفاده کن. focus بنفش (بعد از تغییر primary خودکار). +- ارتفاع دکمه‌ها را با فریم button فیگما مقایسه کن (اگر tauri 40px است، `.cp-btn-*` از 42px به 40px). + +### الف-۵. کامپوننت‌های مشترک مطابق فیگما + +این کلاس‌ها/کامپوننت‌ها پایه‌ی همه‌ی صفحات‌اند؛ هرکدام را با کامپوننت متناظر فیگما یک‌به‌یک تطبیق بده (شعاع، سایه، padding، رنگ، حالت hover/active): + +| کامپوننت clinicpro | فایل | فریم مرجع فیگما | +|---|---|---| +| کارت | `.cp-card` (styles.css) | کارت‌های `dashboard` (`4836:5435`) | +| stat card | `components/ui/StatCard.tsx` + `.cp-stat` | کارت‌های بالای `dashboard` | +| جدول | `components/ui/DataTable.tsx` | `appointments-table` (`4840:7773`)، `patients-grid` (`5265:8540`) | +| صفحه‌بندی | `components/ui/Pagination.tsx` | پایین جدول‌ها | +| مودال | `components/ui/Modal.tsx` + `.modal` | `appointments-info` (`5635:11700`) | +| badge وضعیت | `components/ui/StatusBadge.tsx`, `AppointmentStatusDropdown.tsx` | ستون status جدول‌ها، `status` (`6070:35666`) | +| هدر صفحه | `components/ui/PageHeader.tsx` | نوار عنوان فریم‌ها | +| فرم/input | `.cp-input/.cp-label/...` + `MobileInput.tsx`, `PriceInput.tsx` | `add patients` (`5287:32314`)، `add services` | +| تقویم/تاریخ شمسی | `PersianDatePicker/Calendar/DateInput.tsx` | `calendar-mobile` (`6070:36125`)، فریم‌های reserve | +| آواتار | `.avatar` | آواتار هدر/کارت بیمار | +| سایدبار ناوبری | `components/layout/Sidebar.tsx` | فریم `menu`/سایدبار | +| هدر بالا | `components/layout/Topbar.tsx` | instance `header` (`7788:39703`) | + +--- + +## بخش ب — تطبیق تک‌تک صفحات با فریم فیگما + +### ایندکس فریم‌های فیگما (۴ section، node-id دسکتاپ) + +**section `edited-appointment` (نوبت‌ها) — `4836:5434`:** +| فریم | node-id | +|---|---| +| dashboard | `4836:5435` | +| appointments-table | `4840:7773` | +| appointments-info | `5635:11700` | +| appointments-replace | `5635:14347` | +| appointments-change | `5126:7712` | +| transfer | `6026:23601` / `6030:12460` | +| reserve-table | `5484:10961` | +| add reserve / add patients | `5760:34891` | +| edit | `6026:23229` | + +**section `patients` (پرونده/بیماران) — `5265:8539`:** +| فریم | node-id | | فریم | node-id | +|---|---|---|---|---| +| patients-grid | `5265:8540` | | detail | `6020:22415` | +| patients-card | `5642:11515` | | invoice | `5892:15788` | +| add patients | `5287:32314` | | wallet | `6046:13257` | +| services | `5339:9489` | | call center | `6328:9693` | +| add services | `5892:12711` | | document | `6419:10086` | +| booked | `6214:9519` | | patients-filter | `5272:31373` | +| payment / payment1–4 | `5892:13200` … `5892:14976` | | | | + +**section `inventory` (انبار) — `7492:16182`:** +| inventory | `7492:15558` | inventory-package | `7501:15827` | + +**section `setting` (تنظیمات) — `6838:34246`:** ~۲۵ فریم `seetting` (تب‌های مختلف). شروع‌ها: `6923:34358`, `6945:14186`, `7043:24804`, `7091:14411`, `7291:33902`. هر تب را با `get_screenshot` جدا بگیر. + +> **موبایل:** هر فریم دسکتاپ نسخه‌ی `*-mobile` (عرض ۳۶۰) دارد + الگوهای مشترک: `menu` (نوار پایین، `360x56`)، `bottom sheet`، `filters-mobile`، `status`. این الگوها را در نسخه‌ی responsive صفحات clinicpro پیاده کن (بویژه bottom-sheet بجای مودال روی موبایل، و منوی پایین موبایل). + +### mapping صفحات clinicpro → فریم فیگما + +پنل clinicpro علاوه بر صفحات پزشک/کلینیک، صفحات super-admin دارد که معادل مستقیم در فیگما ندارند. قانون: +- **معادل مستقیم دارد** → دقیقاً از فریم فیگما پیروی کن (layout، اجزا، رنگ). +- **معادل ندارد** (super-admin) → همان **کامپوننت‌لایبرری و استایل مشترک** (بخش الف) را اعمال کن تا هم‌خانواده‌ی فیگما به‌نظر برسد؛ ساختار جدول/فرم/کارت را از نزدیک‌ترین الگوی فیگما (جدول = `patients-grid`، فرم = `add patients`، تنظیمات = `seetting`) وام بگیر. + +| صفحه clinicpro | فریم فیگمای مرجع | نوع | +|---|---|---| +| `DashboardPage.tsx` | dashboard `4836:5435` | مستقیم | +| `AppointmentsPage.tsx` | appointments-table `4840:7773` | مستقیم | +| `AppointmentDetailPage.tsx` | appointments-info `5635:11700` | مستقیم | +| `NewSessionPage.tsx` | add reserve `5760:34891` | مستقیم | +| `MyPatientsPage.tsx` | patients-grid `5265:8540` | مستقیم | +| `PaymentsPage.tsx` / `PaymentDetailPage.tsx` | payment `5892:13200`, invoice `5892:15788` | مستقیم | +| `SettingsPage.tsx` / `MyClinicPage.tsx` / `DoctorProfilePage.tsx` | seetting `6923:34358`… | مستقیم | +| `MyFinancialPage.tsx` / `FinancialReportPage.tsx` | wallet `6046:13257` | مستقیم | +| `ClinicServicesPage.tsx` / `InsurancePricingPage.tsx` | services `5339:9489` | مستقیم | +| `SmsPage.tsx` / `SmsWalletPage.tsx` | wallet `6046:13257` | نیمه‌مستقیم | +| `MySecretariesPage.tsx` / `SecretariesPage.tsx` / `StaffPage.tsx` | patients-grid (جدول) + add patients (فرم) | الگو | +| `DoctorsPage/ClinicsPage/UsersPage/RepresentationsPage/BlogsPage/CommentsPage/RatingsPage/ClaimsPage/PreRegistrationsPage/LogsPage/SettlementsPage/SubscriptionPage` و صفحات `*DetailPage`/`*FormPage` | جدول = `patients-grid` `5265:8540`، فرم = `add patients` `5287:32314`، جزئیات = `detail` `6020:22415` | الگو | +| `CategoriesPage.tsx` | seetting (تب‌دار) | الگو | +| `LoginPage.tsx` / `SelectContextPage.tsx` | — (اگر فریم auth در فیگما نبود، استایل مشترک) | الگو | + +> صفحات با تست (`BlogFormPage.test.tsx`, `BlogsPage.test.tsx`, `LoginPage.test.tsx`) — بعد از تغییر UI، تست‌ها را اجرا کن و اگر selectorها شکستند به‌روز کن. + +--- + +## روش کار پیشنهادی (گام‌به‌گام و ایمن) + +1. **بخش الف** را کامل کن (توکن + کامپوننت مشترک)، سپس با یک صفحه‌ی نمونه (Dashboard) صحت پایه را تأیید کن. +2. صفحات را **گروه‌به‌گروه** جلو ببر (اول «مستقیم»ها: dashboard → appointments → patients → payments → settings؛ بعد «الگو»ها). +3. برای هر صفحه: `get_screenshot` فریم فیگما → مقایسه با اجرای محلی → اصلاح. **رنگ/شعاع را در توکن‌های `styles.css` عوض کن، نه inline در کامپوننت** (مگر جایی که tauri هم inline دارد). +4. موبایل: bottom-sheet و منوی پایین را طبق فریم‌های `*-mobile` اضافه کن. + +## نکات مهم + +- **فقط CSS/توکن/layout و JSX ظاهری؛ منطق داده/API/route را دست نزن.** هیچ فایل backend یا `docs/api/*` تغییر نمی‌کند. +- MUI وارد نکن؛ Tailwind v4 + CSS variables را نگه‌دار. tauri فقط «مرجع ظاهری» است. +- fallback مرورگر قدیمی (بلوک `@supports oklch`) را حفظ کن؛ sRGB و oklch را هماهنگ به‌روز کن. +- بعد از هر گروه: `ddev exec yarn dev` (خطای native lightningcss داخل ddev بی‌ربط است؛ فقط JS/TS مهم) و `ddev exec npx tsc --noEmit`. تست‌ها: `ddev exec yarn test` یا vitest. +- پایان کار: `graphify update .` در `clinicpro/`. +- به‌خاطر بزرگی تسک، حتماً incremental commit بزن (هر گروه صفحه یک commit). +``` + +## دستور اجرا + +``` +/run-prompt clinicpro/.claude/prompt/admin-ui-match-tauri.md +``` diff --git a/.claude/prompt/appointment-book-national-code.md b/.claude/prompt/appointment-book-national-code.md new file mode 100644 index 00000000..f71124be --- /dev/null +++ b/.claude/prompt/appointment-book-national-code.md @@ -0,0 +1,154 @@ +# نوبت‌دهی ادمین بر اساس کد ملی + موبایل (پرونده یکتا با کد ملی) + +## پروژه + +`clinicpro` (backend Symfony + پنل ادمین React). تک‌ریپو — نیازی به تغییر `nobat724_front` نیست. + +> توجه: endpoint عمومی سایت (`POST /api/v1/appointment` → `AppointmentController::book`) **از قبل** کد ملی را الزامی و اعتبارسنجی می‌کند. این تسک فقط شکافِ مسیر **ادمین/کلینیک/منشی/پزشک** را می‌بندد که هنوز بیمار را فقط با موبایل resolve می‌کند. + +## زمینه + +پرونده‌ی بیمار (`PatientRecord`) روی `user_id` کلید می‌خورد (UniqueConstraint: `entity_type + entity_id + user_id`) و در `PatientService::autoCreateForEntity` از `$appointment->getUser()` ساخته می‌شود. یعنی هویت پرونده = رکورد `User`. اما رکورد `User` در مسیر ثبت نوبتِ ادمین فقط با **موبایل** پیدا/ساخته می‌شود: + +- `src/Appointment/Controller/MyAppointmentsController.php` خط ۸۱: `findOneBy(['mobileNumber' => $mobile])` +- `src/Admin/Controller/AdminApiController.php` خط ۸۷۰: `findOneBy(['mobileNumber' => $mobile])` + +نتیجه: یک شخص با دو موبایل مختلف → دو `User` مجزا → دو پرونده‌ی مجزا. در حالی که کد ملی یکتاست (`User.national_code` هم‌اکنون `unique: true, nullable: true`). پس هویت درستِ بیمار = **کد ملی**، و موبایل صرفاً یک راه تماس است. + +## هدف + +در ثبت نوبتِ ادمین، بیمار باید با **کد ملی + موبایل** شناسایی شود: + +1. کد ملی در فرم و در هر دو endpoint ادمین **الزامی و معتبر** شود. +2. رکورد `User` بیمار **اول با کد ملی** resolve شود (نه صرفاً موبایل)، تا پرونده برای یک کد ملی یکتا بماند حتی اگر موبایل عوض شود. +3. `patient_national_code` روی `Appointment` ذخیره شود (فیلد و setter از قبل موجود است: `Appointment::setPatientNationalCode`). + +## فایل‌های مرتبط + +| فایل | نقش | تغییر | +|------|-----|-------| +| `src/Appointment/Controller/MyAppointmentsController.php` | endpoint `POST /api/v1/my/appointment` (doctor/clinic/secretary/admin) | الزام + resolve با کد ملی | +| `src/Admin/Controller/AdminApiController.php` | endpoint `POST /api/v1/admin/appointment` (فقط admin) | الزام + resolve با کد ملی | +| `src/Auth/Repository/UserRepository.php` | فقط `findByMobile` دارد | افزودن `findByNationalCode` | +| `src/Patient/Service/PatientService.php` | `resolvePatientUser` مشترک (اختیاری، ضدتکرار) | استخراج منطق resolve | +| `assets/admin/pages/AppointmentCreatePage.tsx` | فرم ثبت نوبت | افزودن فیلد کد ملی + ارسال در payload | +| `src/Shared/…/InputValidator.php` | `toEnglishDigits` + `isValidIranNationalCode` (استفاده‌شده در `book`) | فقط استفاده | +| `docs/api/appointment.md` + `docs/api/admin.md` | مستندات endpoint | به‌روزرسانی | + +## وضعیت فعلی (کد واقعی) + +### `book()` عمومی — الگوی درستِ موجود (کپی از `AppointmentController::book`, خط ۲۴۲–۲۶۳) + +```php +$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); +if ($nationalCode === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code'); +} +if (!InputValidator::isValidIranNationalCode($nationalCode)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code'); +} +$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); +$appointment->setPatientNationalCode($nationalCode); +``` + +### مسیر ادمین — بیمار فقط با موبایل (کپی از `MyAppointmentsController::createAppointment`, خط ۸۱–۸۷) + +```php +$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); +if (!$patient) { + $patient = new User($mobile); + $patient->setRealName($patientName); + $patient->setRoles(['ROLE_USER']); + $this->em->persist($patient); +} +$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); +``` +(`AdminApiController::createAppointment` خط ۸۷۰–۸۷۶ دقیقاً همین است.) + +### فرم — بدون فیلد کد ملی (کپی از `AppointmentCreatePage.tsx`) + +```tsx +const [name, setName] = useState(''); +const [mobile, setMobile] = useState(''); +// ... +const effectiveName = picked?.user_name || name.trim(); +const effectiveMobile = picked?.user_mobile || mobile.trim(); +const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && !!start && !!end; +// payload: +patient_name: effectiveName, +patient_mobile: effectiveMobile, +``` +> نکته: ردیف‌های جستجوی بیمار (`PatientRow`) فقط `user_name` و `user_mobile` دارند؛ برای پرکردن خودکارِ کد ملیِ بیمارِ انتخاب‌شده باید `user_national_code` هم از endpoint جستجو (`GET /api/v1/patient`) بیاید — بررسی کن آیا برمی‌گردد؛ اگر نه، آن را هم به خروجی اضافه کن (این فیلد در `PatientRecord::toArray` خط ۱۱۶ موجود است). + +## وظایف + +### ۱. `UserRepository::findByNationalCode` + +در `src/Auth/Repository/UserRepository.php` کنار `findByMobile` اضافه کن: + +```php +public function findByNationalCode(string $nationalCode): ?User +{ + return $this->findOneBy(['nationalCode' => $nationalCode]); +} +``` + +### ۲. منطق resolve بیمار با کد ملی (اولویت با کد ملی، سپس موبایل) + +یک متد مشترک بساز تا در هر دو endpoint استفاده شود (DRY + SOLID). مکان پیشنهادی: `PatientService::resolvePatientUser` (یا یک سرویس کوچک اختصاصی اگر تزریق `PatientService` سنگین بود — تصمیم را در کد بنویس). + +قاعده‌ی resolve: + +``` +nationalCode معتبر ورودی + mobile + name داریم: +1) user = userRepo.findByNationalCode(nationalCode) +2) اگر نبود: user = userRepo.findByMobile(mobile) + - اگر پیدا شد و nationalCode او خالی است → user.setNationalCode(nationalCode) + - اگر پیدا شد و nationalCode او با ورودی فرق دارد → خطای 422 + «این شماره موبایل به کد ملی دیگری تعلق دارد» (تعارض هویت) +3) اگر هیچ‌کدام نبود: user جدید با mobile، setRealName(name)، setNationalCode(nationalCode)، ROLE_USER، persist +4) اگر user با کد ملی پیدا شد ولی mobileنش با ورودی فرق دارد → موبایل را به‌روز نکن + (کد ملی مرجع است؛ یک کد ملی می‌تواند چند موبایل داشته باشد — فقط پرونده یکتا بماند). + نامِ خالیِ user را با name پر کن. +``` + +> چرا اولویت با کد ملی: خواسته‌ی صریح — «یک کاربر ممکن است با چند موبایل باشد و پرونده برای یک کد ملی یکتا». چون `PatientRecord` روی `user_id` است، تا وقتی برای یک کد ملی همان `User` برگردد، پرونده یکتا می‌ماند. + +### ۳. الزام + اعتبارسنجی کد ملی در دو endpoint ادمین + +در **هر دو** `MyAppointmentsController::createAppointment` و `AdminApiController::createAppointment`: + +- بعد از خواندن `$mobile`/`$patientName`، `patient_national_code` را با همان الگوی `book()` بخوان، `toEnglishDigits` کن، خالی‌بودن و `isValidIranNationalCode` را چک کن (خطای 422 با فیلد `patient_national_code`). +- `$patient` را با متد resolve وظیفه‌ی ۲ بگیر (به‌جای `findOneBy(['mobileNumber' => $mobile])`). +- `$appointment->setPatientNationalCode($nationalCode)` را ست کن (مثل `book`). +- `patient_gender` را **الزامی نکن** مگر اینکه قبلاً در این مسیر الزامی بوده باشد — `book` عمومی جنسیت را الزامی می‌کند ولی مسیر ادمین تاکنون نمی‌کرده؛ رفتار فعلی را حفظ کن و فقط کد ملی را اضافه کن (اسکوپ حداقلی). +- به `ErrorCodes` مسیر ادمین دقت کن: این کنترلرها از ثابت‌های کوتاه (`ErrorCodes::VALIDATION`, `ErrorCodes::DOCTOR_NOT_FOUND` …) استفاده می‌کنند، نه `ERR_VALIDATION_001`. از همان سبکِ همان فایل استفاده کن. + +### ۴. فرم `AppointmentCreatePage.tsx` + +- state جدید: `const [nationalCode, setNationalCode] = useState('')`. +- در بلوک «مراجعه کننده جدید» (`picked === null`) یک فیلد ورودی کد ملی اضافه کن (کنار نام/موبایل). ورودی فارسی/انگلیسی را بپذیر ولی فقط رقم؛ maxLength=10، `dir="ltr"`. +- `effectiveNationalCode = picked?.user_national_code || nationalCode.trim()`. +- `valid` را گسترش بده: کد ملی باید ۱۰ رقم باشد (اعتبارسنجی کاملِ کد ملی سمت بک‌اند است؛ سمت فرانت فقط طول/رقم). +- در `payload`: `patient_national_code: effectiveNationalCode`. +- `PatientRow` را با `user_national_code?: string` گسترش بده و اگر endpoint جستجو آن را برنگرداند، در وظیفه‌ی مرتبط بک‌اند اضافه‌اش کن تا انتخاب بیمارِ موجود، فیلد را پر کند. + +### ۵. مستندات + +`docs/api/appointment.md` (برای `/api/v1/my/appointment`) و `docs/api/admin.md` (برای `/api/v1/admin/appointment`) را به‌روز کن: افزوده‌شدن فیلد الزامی `patient_national_code`، خطای 422 تعارض موبایل/کد ملی، و رفتار «resolve با کد ملی». + +## نکات مهم + +- **SOLID/DRY:** منطق resolve بیمار را یک‌جا بنویس؛ در دو کنترلر کپی‌پیست نکن. دلیلِ محلِ قرارگیری را در کامنت بنویس. +- **یکتایی DB:** `User.national_code` هم‌اکنون `unique: true` است — نیازی به migration نیست مگر تغییری در entity بدهی. اگر تغییری ندادی، migration نساز. +- **تعارض هویت (edge مهم):** موبایلی که قبلاً با کد ملیِ X ثبت شده، حالا با کد ملیِ Y بیاید → باید خطای روشن بدهی، نه اینکه کد ملی را عوض کنی (چون verify قبلی را باطل و داده را خراب می‌کند؛ `User::setNationalCode` خط ۹۵ خودش `nationalCodeVerified=false` می‌کند). +- **`for_self` نداریم اینجا:** مسیر ادمین همیشه برای «دیگری» است؛ برخلاف `book`، `$user` جاری پزشک/منشی است نه بیمار. بیمار همیشه از موبایل/کد ملیِ ورودی resolve می‌شود. +- **ارقام فارسی:** همیشه `InputValidator::toEnglishDigits` روی کد ملی و موبایل قبل از جستجو/ذخیره (منشی معمولاً فارسی تایپ می‌کند). +- **تست (الزامی — موفق/خطا/مرزی):** + - موفق: بیمار جدید با کد ملی → `User` با `national_code` ساخته شد + نوبت ثبت شد. + - موفق (یکتایی پرونده): همان کد ملی با موبایلِ متفاوت در نوبت دوم → همان `User` برگردد (نه User جدید)؛ پس از confirm، `PatientRecord` یکتا بماند. + - خطا: کد ملی خالی → 422 `patient_national_code`. + - خطا: کد ملی نامعتبر (checksum) → 422. + - مرزی/تعارض: موبایلِ موجود با کد ملیِ متفاوت → 422 تعارض هویت. +- تست‌ها را با `ddev exec php bin/phpunit` و type-check فرانت را با `npx tsc --noEmit` اجرا کن. بدون سبز شدن، تسک تمام نیست. +- بعد از تغییر کد: `graphify update .` (اول commit طبق قاعده‌ی پروژه). diff --git a/.claude/prompt/appointment-confirm-flow.md b/.claude/prompt/appointment-confirm-flow.md new file mode 100644 index 00000000..adb4c0f5 --- /dev/null +++ b/.claude/prompt/appointment-confirm-flow.md @@ -0,0 +1,104 @@ +# فرآیند ثبت و قطعی کردن نوبت (مودال پرداخت + پرونده) + +## پروژه + +`clinicpro` (backend + پنل ادمین) — **پیش‌نیاز:** `clinic-appointment-operations-fix.md` اجرا شده باشد. + +## زمینه + +وضعیت‌ها همین حالا وجود دارند: `pending` = «ثبت شده»، `confirmed` = «قطعی شده» (`turnStatus.ts`). زیرساخت پرونده هم هست: با confirm شدن نوبت، `AppointmentConfirmationService::onConfirmed` → `PatientService::autoCreateOnAppointmentConfirm` پرونده را بر اساس محیط (`clinic` اگر `appointment.getClinic()!==null` وگرنه `doctor`) **پیدا یا ایجاد** می‌کند و session با قیمت ویزیت + سرویس‌ها می‌سازد — یعنی الزام «پرونده موجود استفاده شود / نبود ساخته شود» از قبل پیاده است. پرداخت چندبخشی هم روی session موجود است (`SessionPayment`، متدهای `wallet/pos/cash/card`). + +آنچه کم است: (۱) نوبت پنلی الان مستقیم `confirmed` ساخته می‌شود؛ (۲) دکمه/مودال «قطعی کردن نوبت» با نمایش هزینه‌ها و پرداخت کامل/جزئی وجود ندارد؛ (۳) ثبت پرداخت‌ها هنگام قطعی شدن در پرونده انجام نمی‌شود. + +## مشکل / هدف + +1. هر نوبت (آنلاین، سریع، عادی) با وضعیت اولیه «ثبت‌شده» (`pending`) ایجاد شود. +2. روی کارت نوبت‌های `pending` در Timeline دکمه «قطعی کردن نوبت» باشد. +3. کلیک → مودال: مبلغ ویزیت + هزینه سرویس‌های انتخاب‌شده، پرداخت کامل یا جزئی، نمایش شفاف پرداخت‌شده/باقی‌مانده/وضعیت پرداخت. +4. تأیید مودال → وضعیت `confirmed` + ثبت سرویس‌ها و پرداخت‌ها در پرونده (موجود یا جدید) نزد همان محیط. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Entity/Appointment.php` | وضعیت‌ها (~25-33)، `ALLOWED_TRANSITIONS` (~37-42)، `visitPriceRials`، `serviceItems` | +| `src/Appointment/Controller/MyAppointmentsController.php` | ساخت پنلی — الان `confirmed` می‌گذارد (~192) | +| `src/Appointment/Controller/AppointmentController.php` | `PATCH .../status` (~850)؛ endpoint جدید confirm اینجا یا کنارش | +| `src/Appointment/Repository/AppointmentRepository.php` | `expireLapsedPending` (~154) — TTL پانزده‌دقیقه‌ای pending | +| `src/Appointment/Service/AppointmentConfirmationService.php` | `onConfirmed` (~30) — نقطه واحد confirm | +| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm` (~133)، `addSessionPayment` (~552) | +| `src/Payment/Service/PaymentManager.php` | مسیر آنلاین: بعد از پرداخت درگاه → `confirmed` (~306-315) — دست نزن | +| `assets/admin/components/appointments/TurnsTimeline.tsx` | کارت‌ها (`OccupiedCard` ~100) | +| `assets/admin/components/appointments/turnStatus.ts` | لیبل‌ها (pending=«ثبت شده») | +| `assets/admin/components/ui/AppointmentStatusDropdown.tsx` | `TRANSITIONS` + `PATCH status {status, version}` | +| `assets/admin/components/session/PaymentStep.tsx` | الگوی پرداخت جزئی (`METHODS`, `METHOD_LABELS`, `PriceInput`, toman→rial) | +| `assets/admin/components/ui/Modal.tsx`, `ConfirmDialog.tsx` | پایه مودال | +| `assets/admin/pages/AppointmentCreatePage.tsx` | گزینه‌های status هنگام ساخت (~496) | + +## وضعیت فعلی + +```php +// MyAppointmentsController (~192): نوبت پنلی بلافاصله confirmed +$appointment->setStatus(Appointment::STATUS_CONFIRMED); + +// PaymentManager (~313): نوبت سایت بعد از پرداخت درگاه confirmed می‌شود (درست است، حفظ شود) +// AppointmentRepository::expireLapsedPending: pending های کهنه را expire می‌کند (TTL رزرو آنلاین ۱۵ دقیقه) +``` + +```tsx +// AppointmentStatusDropdown (~74): تنها مسیر فعلی قطعی‌کردن — بدون پرداخت/پرونده +api.patch(`/api/v1/appointment/${uuid}/status`, { status: newStatus, version }) +``` + +## وظایف + +### ۱. Backend — ساخت پنلی با وضعیت `pending` بدون انقضا + +- در `MyAppointmentsController::create` وضعیت اولیه را `STATUS_PENDING` کن (نوبت سریع و عادی). +- **حیاتی:** `expireLapsedPending` نباید نوبت‌های پنلی را بعد از ۱۵ دقیقه منقضی کند. مکانیزم تفکیک اضافه کن — مثلاً فیلد/فلگ `source`/`hold_expires_at` روی Appointment (migration) یا شرط «pending فقط وقتی expire شود که از مسیر رزرو آنلاین با TTL ساخته شده». مسیر آنلاین (POST `/api/v1/appointment` عمومی) رفتار فعلی‌اش (pending با TTL تا پرداخت درگاه) را حفظ کند. +- گذار `pending → confirmed` از قبل در `ALLOWED_TRANSITIONS` مجاز است — دست نزن. + +### ۲. Backend — endpoint قطعی‌کردن اتمیک + +`POST /api/v1/appointment/{uuid}/confirm` بساز (در `AppointmentController`، با `canManage` از checker پرامپت قبلی): + +```php +// Request: +// { "version": 3, "payments": [ { "method": "cash|pos|card|wallet", "amount_rials": 500000 } ], "discount"?: ... } +// در یک تراکنش: +// 1) transitionTo(STATUS_CONFIRMED) → از canTransitionTo عبور کند +// 2) AppointmentConfirmationService::onConfirmed($appointment) → record/session (منطق موجود reuse/create) +// 3) session ساخته/یافته‌شده را بگیر و هر payment را با PatientService::addSessionPayment ثبت کن +// Response: success + { appointment: {...}, session: { uuid, final_price_rials, paid_total_rials, remaining_rials, is_paid } } +``` + +- `payments` می‌تواند خالی باشد (قطعی بدون پرداخت) یا جزئی — جمع نباید از مبلغ قابل‌پرداخت بیشتر شود (خطای موجود `ERR_SESSION_PAYMENT_EXCEEDS` reuse شود). +- `autoCreateOnAppointmentConfirm` الان خطا را قورت می‌دهد (log-only). برای این endpoint نباید silent باشد: اگر پرونده/سرویس‌ها ساخته نشد (مثلاً feature اشتراک `patient_records` فعال نیست)، پاسخ باید صریح بگوید (confirm موفق ولی `session: null` + پیام، یا خطای کامل — تصمیم را مستند کن). +- endpoint یک GET پیش‌نمایش هم لازم دارد یا همان detail کافی است: مودال باید مبلغ ویزیت (`visit_price_rials`) + سرویس‌های نوبت (`serviceItems` با قیمت) را قبل از تأیید نشان دهد — اگر detail فعلی قیمت آیتم‌ها را نمی‌دهد، به پاسخ detail اضافه کن. + +### ۳. Frontend — دکمه و مودال «قطعی کردن نوبت» + +- در `TurnsTimeline.tsx` روی `OccupiedCard` وقتی `a.status === 'pending'` دکمه «قطعی کردن نوبت» اضافه کن (کنار کلاستر dropdown/menu، با `stopPropagation`). +- مودال جدید `components/appointments/ConfirmAppointmentModal.tsx` بر پایه `Modal` (نه ConfirmDialog — فرم دارد): + - بخش هزینه‌ها: ردیف «ویزیت» + ردیف هر سرویس انتخاب‌شده + جمع کل (`formatRial`، نمایش تومان مثل `PaymentStep`). + - بخش پرداخت: همان الگوی `PaymentStep` — روش‌ها (`METHODS`/`METHOD_LABELS`)، `PriceInput` تومان، امکان چند ردیف پرداخت یا یک ردیف با مبلغ دلخواه؛ دکمه میان‌بر «پرداخت کامل». + - خلاصه شفاف: پرداخت‌شده / باقی‌مانده / وضعیت (تسویه کامل، پرداخت جزئی، بدون پرداخت). + - تأیید → `POST /api/v1/appointment/${uuid}/confirm` با `version`؛ بعد `invalidateQueries({ queryKey })`؛ toast موفقیت با sonner؛ خطای 409 نسخه با پیام فارسی. +- همین دکمه/مودال را در `AppointmentDetailPage`، `ReserveAppointmentsPage` (ردیف‌های pending) و `AppointmentInfoModal` هم در دسترس بگذار. +- در `AppointmentStatusDropdown`، انتخاب مستقیم `confirmed` از dropdown باید همین مودال را باز کند (نه PATCH خام) تا مسیر دورزدن پرداخت/پرونده نماند — یا حداقل بعد از PATCH خام هم `onConfirmed` سمت سرور اجرا می‌شود (الان می‌شود؛ ولی بدون پرداخت). تصمیم UX: dropdown → مودال. مستند کن. +- `AppointmentCreatePage` (~496): پیش‌فرض ساخت را «ثبت شده» بگذار؛ گزینه ساخت مستقیم confirmed را بردار یا به مودال وصل کن. + +### ۴. تست و مستندات + +- سناریوها: قطعی با پرداخت کامل / جزئی / بدون پرداخت؛ بیمار با پرونده قبلی نزد همان پزشک (reuse — session جدید در همان پرونده) و بیمار بدون پرونده (create)؛ همین دو حالت در محیط کلینیک (`entityType=clinic`) با کاربر `09024206041` و در مطب شخصی با کاربر پزشک از `TEST_USERS.md`. +- رزرو آنلاین سایت: بدون رگرسیون — pending تا پرداخت درگاه، بعد confirmed + پرونده (مسیر `PaymentManager` دست‌نخورده). +- نوبت‌های `is_reserve` مثل قبل از `onConfirmed` رد می‌شوند (خط ~33) — دکمه قطعی‌کردن برای ردیف رزرو روزانه بعد از انتقال به slot معنا پیدا می‌کند. +- `docs/api/*`: endpoint جدید confirm + تغییر رفتار create مستند شود. + +## نکات مهم + +- تاریخ‌ها Unix timestamp؛ نمایش شمسی با `formatDate()`. مبالغ backend ریال، ورودی UI تومان (`tomanToRial`). +- Optimistic lock: هر mutation نوبت `version` می‌خواهد؛ فراموشش نکن (AppointmentDetailPage الان status را بدون version می‌فرستد — همان‌جا هم اصلاح کن). +- envelope پاسخ: single ممکن است double-nested باشد (`data?.data?.data`) — الگوی صفحات موجود را نگاه کن. +- لیبل‌های فارسی موجود را تغییر نده: `pending`=«ثبت شده»، `confirmed`=«قطعی شده». دو map وضعیت موازی هست (`turnStatus.ts` و `AppointmentStatusDropdown.STATUS_META`) — اگر دست زدی هر دو را همگام نگه دار. +- کامپوننت انتخاب‌ها فقط `SearchableSelect`؛ طراحی مودال با تم/کلاس‌های موجود پنل، بدون طراحی جدید. diff --git a/.claude/prompt/appointment-service-mode-section-picker.md b/.claude/prompt/appointment-service-mode-section-picker.md new file mode 100644 index 00000000..b1426631 --- /dev/null +++ b/.claude/prompt/appointment-service-mode-section-picker.md @@ -0,0 +1,192 @@ +# انتخاب سرویس بر اساس بخش + ویرایش مدت توسط منشی — حالت نوبت‌دهی سرویسی + +## زمینه + +صفحهٔ ایجاد نوبت پنل ادمین (`assets/admin/pages/AppointmentCreatePage.tsx`) دو حالت دارد که با `booking_mode` پزشک تعیین می‌شود (`useDoctorBookingServices`): + +- **اسلاتی (`slot`)**: کاربر بخش را انتخاب می‌کند، سپس سرویس‌های همان بخش به‌صورت چک‌باکس نشان داده می‌شوند، انتخاب‌ها در یک لیستِ انباشته (chip قابل حذف) جمع می‌شوند و بین چند بخش انباشته می‌مانند. تاریخ/ساعت شروع/پایان دستی است. (این الگو **قبلاً پیاده شده** — state `selectedServices: {uuid,name}[]`، endpointهای `GET /api/v1/service-sections` و `GET /api/v1/service-items/{sectionUuid}`.) +- **سرویسی (`service`)**: از کامپوننت `assets/admin/components/appointments/ServiceSlotPicker.tsx` استفاده می‌شود که سرویس‌ها را **تخت** (بدون بخش) از `GET /api/v1/appointment-booking-services/{doctorUuid}` می‌گیرد؛ کاربر یک/چند سرویس را تیک می‌زند، مدت کل = مجموع `duration_minutes` سرویس‌ها، و زمان‌های خالیِ پیشنهادی از `GET /api/v1/appointment-service-slots` می‌آید. + +پزشک نمونه: `ab747d75-2114-42b8-9e6d-abdaa338edbe` (سرویس‌ها در بخش‌های «زیبایی»، «لیزر»). + +## مشکل / هدف + +۱. در حالت **سرویسی**، انتخاب سرویس هم باید مثل حالت اسلاتی «بخش → سرویس» شود (نه لیست تخت): +- انتخاب بخش (Select/Autocomplete) → نمایش فقط سرویس‌های همان بخش → افزودن به لیست انباشته → انباشت بین چند بخش → حذف هر سرویس. +- در chip سرویس انتخاب‌شده، **نام بخش کنار نام سرویس** نشان داده شود (مثل «زیبایی → بوتاکس»). +- محاسبهٔ مدت/اسلات سرویسی باید **حفظ** شود (`appointment-service-slots`). + +۲. **زمان متوسط سرویس (duration) قابل ویرایش توسط منشی، فقط برای همان نوبت**: +- هر سرویس `duration_minutes` پیش‌فرض از تنظیمات سرویس دارد. +- نوبت‌دهی آنلاین (سایت عمومی، بیمار): غیرقابل تغییر. +- نوبت‌دهی پنل (منشی/کلینیک/پزشک): منشی بتواند مدت هر سرویس را **فقط برای این نوبت** ویرایش کند؛ مقدار پیش‌فرض سرویس در تنظیمات (`ServiceItem.durationMinutes`) **نباید** تغییر کند. +- کنار هر سرویس انتخاب‌شده مدتش نمایش داده شود و در پنل قابل ویرایش باشد. مجموع مدت (و در نتیجه اسلات‌های پیشنهادی + `slot_end` نهایی) باید بر اساس مقدارِ override محاسبه شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Controller/AppointmentController.php` | `bookingServices` (خط ۲۰۲) — افزودن `service_section` به هر سرویس؛ `serviceSlots` (خط ۱۴۵) — پذیرش override مدت | +| `src/Appointment/Controller/MyAppointmentsController.php` | `createAppointment` (`POST /api/v1/my/appointment`) — پذیرش مدتِ override هنگام محاسبهٔ `slot_end` سرویسی | +| `src/Admin/Controller/AdminApiController.php` | `createAppointment` (`POST /api/v1/admin/appointment`) — همان منطق override | +| `assets/admin/hooks/useDoctorBookingServices.ts` | type `BookingService` + استخراج `section` | +| `assets/admin/components/appointments/ServiceSlotPicker.tsx` | بازطراحی UI انتخاب سرویس به «بخش → سرویس + مدتِ قابل‌ویرایش + chip» | +| `assets/admin/pages/AppointmentCreatePage.tsx` | اتصال payload (مدت override) در `create` mutation | +| `docs/api/appointment.md` | مستند تغییرات `appointment-booking-services`، `appointment-service-slots`، `my/appointment` | +| `tests/Appointment/*` | تست backend (section در پاسخ، override مدت در اسلات و ثبت) | +| `assets/admin/components/appointments/ServiceSlotPicker.test.tsx` + `assets/admin/pages/AppointmentCreatePage.test.tsx` | تست frontend | + +## وضعیت فعلی + +### backend: `bookingServices` — سرویس تخت، بدون بخش +```php +// src/Appointment/Controller/AppointmentController.php:213 +$services = array_map(fn(\App\ClinicService\Entity\ServiceItem $i) => [ + 'uuid' => $i->getUuid(), + 'name' => $i->getName(), + 'duration_minutes' => $i->getDurationMinutes(), + 'price_rials' => $i->getPriceRials(), +], $this->itemRepo->findBookableByEntity('doctor', $doctor->getId())); +``` +`ServiceItem::getSection(): ServiceSection` موجود است (`getUuid()`, `getName()`). + +### backend: `serviceSlots` — مدت کل فقط از duration پیش‌فرض +```php +// src/Appointment/Controller/AppointmentController.php:170 +$totalMinutes = 0; +foreach ($uuids as $u) { + $item = $this->itemRepo->findByUuid($u); + // ... اعتبارسنجی bookable/duration ... + $totalMinutes += (int) $item->getDurationMinutes(); +} +// ... +'total_duration_minutes' => $totalMinutes, +'start_times' => $this->slotCalculator->getServiceStartTimes($doctor, $date, $totalMinutes), +``` + +### backend: `my/appointment` — بازمحاسبهٔ slot_end از duration پیش‌فرض +```php +// src/Appointment/Controller/MyAppointmentsController.php (createAppointment) +$computeDuration = (bool) ($data['duration_from_services'] ?? false); +if (!empty($serviceUuids) && !$isReserve) { + $totalMinutes = 0; + foreach ($serviceUuids as $u) { + $item = $this->itemRepo->findByUuid($u); + if ($computeDuration) { + // ... اعتبارسنجی ... + $totalMinutes += (int) $item->getDurationMinutes(); + } + $serviceItems[] = $item; + } + if ($computeDuration) { $slotEnd = $slotStart + $totalMinutes * 60; } +} +``` + +### frontend: `BookingService` type — بدون section +```ts +// assets/admin/hooks/useDoctorBookingServices.ts +export interface BookingService { + uuid: string; + name: string; + duration_minutes: number | null; + price_rials: number; +} +``` + +### frontend: `ServiceSlotPicker` — لیست تخت با تیک، بدون بخش، مدت غیرقابل‌ویرایش +```tsx +// assets/admin/components/appointments/ServiceSlotPicker.tsx (خلاصه) +const [serviceUuids, setServiceUuids] = useState([]); +// slotsQ: GET /api/v1/appointment-service-slots?doctor_uuid=..&date=..&service_item_uuids[]=.. +// services.map(...) → دکمهٔ تیک‌دار؛ چیدنِ start_times؛ onSelect({serviceUuids, slot}) +``` + +## وظایف + +### ۱. backend — افزودن بخش به پاسخ `appointment-booking-services` + +در `bookingServices`، هر سرویس `service_section` بگیرد: +```php +$services = array_map(function (\App\ClinicService\Entity\ServiceItem $i) { + $section = $i->getSection(); + return [ + 'uuid' => $i->getUuid(), + 'name' => $i->getName(), + 'duration_minutes' => $i->getDurationMinutes(), + 'price_rials' => $i->getPriceRials(), + 'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()], + ]; +}, $this->itemRepo->findBookableByEntity('doctor', $doctor->getId())); +``` +- سازگاری عقب‌رو: افزودنِ فیلد است، مصرف‌کنندهٔ سایت عمومی (`nobat724_front`) نمی‌شکند. + +### ۲. backend — پذیرش override مدت در `serviceSlots` + +`serviceSlots` باید علاوه بر مدت پیش‌فرض، یک override اختیاری بپذیرد تا اسلات‌ها بر اساس مدتِ ویرایش‌شدهٔ منشی چیده شوند. الگوی پیشنهادی: پارامتر `durations[]=` (map) یا `total_duration_minutes` مستقیم. +```php +// اگر durations[uuid] آمده و > 0 بود، به‌جای getDurationMinutes همان استفاده شود +$overrides = (array) $request->query->all('durations'); // uuid => minutes +// در حلقه: +$dur = isset($overrides[$u]) && (int)$overrides[$u] > 0 + ? (int) $overrides[$u] + : (int) $item->getDurationMinutes(); +if ($dur <= 0) { /* 422 مدت تعریف نشده */ } +$totalMinutes += $dur; +``` +- اعتبارسنجی: override باید عدد مثبت باشد؛ مقدار نامعتبر ⇒ `422`. +- **مقدار پیش‌فرض سرویس تغییر نکند** — override فقط در محاسبهٔ همین درخواست استفاده شود (هیچ `set`/`save` روی `ServiceItem`). + +### ۳. backend — اعمال override مدت هنگام ثبت نوبت + +در `MyAppointmentsController::createAppointment` و `AdminApiController::createAppointment`، وقتی `duration_from_services=true`، بازمحاسبهٔ `slot_end` باید مدتِ override را لحاظ کند تا با اسلاتی که منشی انتخاب کرده هم‌خوان بماند. یک فیلد جدید در payload، مثلاً `service_durations: { "": }`: +```php +$durations = (array) ($data['service_durations'] ?? []); // uuid => minutes +// در حلقهٔ محاسبهٔ مدت: +$dur = isset($durations[$u]) && (int)$durations[$u] > 0 + ? (int) $durations[$u] + : (int) $item->getDurationMinutes(); +$totalMinutes += $dur; +``` +- منطق پیوستِ چند سرویس (`addServiceItem`) و پرچم `duration_from_services` که قبلاً پیاده شده، حفظ شود. +- **مهم — سازگاری قیمت/گزارش**: بررسی شود آیا مدتِ override باید روی خودِ نوبت ذخیره شود (برای نمایش/گزارش بعدی). اگر بله، به Entity `Appointment` یک ستون/فیلد برای مدتِ مؤثر یا map مدت‌ها اضافه شود (⇒ **migration**). اگر ذخیره لازم نیست و فقط `slot_end` کافی است، ذخیرهٔ اضافه لازم نیست — این تصمیم را در زمان اجرا بر اساس نیاز گزارش‌گیری مشخص کن و در پرامپت‌کننده تأیید بگیر. + +### ۴. frontend — type و hook + +`BookingService` را با بخش گسترش بده: +```ts +export interface BookingService { + uuid: string; + name: string; + duration_minutes: number | null; + price_rials: number; + service_section: { uuid: string; name: string }; +} +``` + +### ۵. frontend — بازطراحی `ServiceSlotPicker` به «بخش → سرویس» + +منطق slot/مدت را نگه دار، فقط UIِ انتخاب سرویس را عوض کن — از همان الگوی حالت اسلاتیِ `AppointmentCreatePage.tsx` تقلید کن: +- گروه‌بندی `services` بر اساس `service_section.uuid` (client-side؛ نیازی به endpoint جدید نیست چون همهٔ سرویس‌های bookable یکجا آمده‌اند). +- Select/Autocomplete بخش (`SearchableSelect`) → نمایش سرویس‌های همان بخش به‌صورت چک‌باکس → افزودن به `selected: { uuid; name; section: string; duration: number }[]` (انباشته، بین چند بخش). +- chip قابل حذف با نمایش «بخش → سرویس» و مدت؛ در حالت پنل (منشی) مدت با `DigitInput`/عدد قابل ویرایش. +- مجموع مدت از `selected` (با override) محاسبه و در query `appointment-service-slots` به‌صورت `durations[uuid]=minutes` ارسال شود تا `start_times` هماهنگ بماند. +- `onSelect` باید `serviceUuids` + `durations` map + `slot` را بالا بفرستد. + +### ۶. frontend — payload در `AppointmentCreatePage` + +در `create` mutation، حالت سرویسی علاوه بر `service_item_uuids` و `duration_from_services:true`، در صورت override منشی `service_durations: { uuid: minutes }` هم بفرستد. +- تشخیص «منشی/پنل بودن» برای فعال‌کردن ویرایش مدت: از نقش کاربر (`useAuthStore().primaryRole`) — همهٔ نقش‌های پنل (admin/clinic/doctor/secretary) مجازند؛ این صفحه اصلاً پنل است، پس ویرایش مدت همیشه در این صفحه فعال است (محدودیت «غیرقابل‌تغییر» فقط مربوط به سایت عمومی `nobat724_front` است، نه این صفحه). + +## نکات مهم + +- **عدم تغییر پیش‌فرض سرویس**: override مدت هرگز نباید `ServiceItem.durationMinutes` را در دیتابیس تغییر دهد — نه در `serviceSlots`، نه در ثبت نوبت. فقط در محاسبهٔ همان درخواست/نوبت. +- **حفظ منطق موجود**: پرچم `duration_from_services`, تابع `addServiceItem` (چند سرویس)، و جریان اسلاتیِ فعلی نباید بشکنند. حالت اسلاتی دست‌نخورده بماند. +- **سازگاری مصرف‌کننده‌ها**: `appointment-booking-services` و `appointment-service-slots` توسط `nobat724_front` هم مصرف می‌شوند (`services/response.js`). افزودن فیلد (`service_section`) و پارامتر اختیاری (`durations`) عقب‌رو-سازگار است؛ سایت عمومی نباید override را فعال کند (بیمار مجاز به تغییر مدت نیست). +- **پاسخ‌ها**: با `$this->success(...)` / `$this->error(ErrorCodes::..., msg, status, field)` مطابق `BaseController`. +- **الگوی frontend**: `SearchableSelect` (نه `` هستند و از این کامپوننت‌ها استفاده نمی‌کنند (مثلاً `NewAppointmentModal` L215-273، `NewAppointmentDrawer` L165-199/303-313). `Input.tsx` پایه design-system است ولی **هیچ** `inputMode`/`lang`/تبدیل رقم ندارد و adoption ناقص است. تبدیل رقم در سه جای تکراری است (`toEnglishDigits`، `PriceInput.toLatinDigits`، regex inline در AppointmentsPage L176-180). + +### هدف + +هر فیلدی که فقط عدد می‌گیرد، هنگام تایپ رقم لاتین وارد شود (نه فارسی)، بدون شکستن فیلدهای غیرعددی. + +### وظایف + +1. **`Input.tsx` را ارتقا بده** تا یک prop اختیاری `numeric?: boolean` بگیرد. وقتی `numeric` است: + - `inputMode="numeric"`, `dir="ltr"`, `lang="en"` روی input ست شود. + - در `onChange`، مقدار با `toEnglishDigits` نرمال شود قبل از فراخوانی `onChange` والد (رقم فارسی/عربی تایپ‌شده بلافاصله به لاتین تبدیل شود). از همان `toEnglishDigits` مشترک `utils.ts` استفاده کن — تبدیل‌های تکراری (`PriceInput.toLatinDigits`، regex inline) را با import از `utils.ts` یکدست کن. +2. **حذف تکرار**: `PriceInput.tsx` و `onMobileChange` در `AppointmentsPage.tsx` (L176-180) به‌جای map/regex محلی از `toEnglishDigits` مشترک استفاده کنند. +3. **پوشش inputهای خام عددی**: فیلدهای عددیِ خام موجود در مودال/drawer نوبت و سایر فرم‌ها (کدملی، موبایل، مبالغ، تعداد) که از `Input`/`DigitInput`/`MobileInput`/`PriceInput` استفاده نمی‌کنند را یا به این کامپوننت‌ها مهاجرت بده یا حداقل `inputMode="numeric"` + `dir="ltr"` + نرمال‌سازی `toEnglishDigits` در onChange اضافه کن. حداقل این نقاط: `NewAppointmentModal` (کدملی/موبایل)، `NewAppointmentDrawer`. + +### نکات + +- فیلدهای متنی (نام، آدرس، توضیحات) نباید عددی شوند — فقط فیلدهایی که «فقط عدد» می‌گیرند. +- `inputMode="numeric"` صفحه‌کلید موبایل را عددی می‌کند؛ `dir="ltr"` + نرمال‌سازی `toEnglishDigits` تضمین می‌کند رقم فارسی paste/تایپ‌شده هم لاتین ذخیره شود. هر دو لازم است. +- تبدیل باید در **onChange** انجام شود نه فقط onBlur، تا کاربر بلافاصله رقم لاتین ببیند. + +--- + +## تسک ۴ — رفع باگ: ثبت نوبت هنگام الزامی بودن هزینه ویزیت (۴۲۲) + +### وضعیت فعلی + +- backend درست است: `MyAppointmentsController::createAppointment` (L132-135) وقتی `isRequiredForDoctor` و `visit_price_rials <= 0` → `422 "هزینه ویزیت الزامی است"`. +- **باگ در frontend**: `NewAppointmentModal` (`AppointmentsPage.tsx:102`) — payload آن (L143-152) **اصلاً `visit_price_rials` ندارد**، هیچ فیلد قیمت ویزیت رندر نمی‌کند و تنظیم `insurance-pricing`/`require_visit_price` را نمی‌خواند: +```tsx +mutationFn: () => api.post(createEndpoint, { + doctor_uuid: slot.doctor_uuid, + slot_start: serviceMode ? pick.slot!.start : slot.start, + slot_end: serviceMode ? pick.slot!.end : slot.end, + patient_mobile: mobile, + patient_name: effectiveName, + patient_national_code: effectiveNationalCode, + ...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}), +}), +``` +- مرجع درست: `AppointmentCreatePage.tsx` که همین را دارد — خواندن تنظیم (L109-114)، state + prefill از `freeVisit` (L116-120)، گیت اعتبارسنجی (L129)، فیلد ورودی (L477-488)، و ارسال شرطی (L149): +```tsx +...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), +``` +- `NewAppointmentDrawer.tsx` (L126-140) هم همین باگ را دارد. + +### هدف + +مودال (و drawer) ثبت نوبت مثل `AppointmentCreatePage` هزینه ویزیت را بگیرد و ارسال کند تا ۴۲۲ رخ ندهد. + +### وظایف + +1. در `NewAppointmentModal`: + - تنظیم را بخوان: `useQuery(['insurance-pricing'])` → `requireVisit` و `freeVisit` (دقیقاً مثل `AppointmentCreatePage.tsx:109-114`). `doctor_uuid` مودال از `slot.doctor_uuid`. + - state `visitPriceToman` با prefill از `freeVisit` (مثل L116-120). + - یک فیلد ورودی «هزینه ویزیت (تومان)» با `` اضافه کن؛ اگر `requireVisit` است ستاره `*` روی label و پیام خطای «هزینه ویزیت الزامی است» زیر فیلد وقتی `visitPriceToman <= 0`. + - گیت submit: دکمه «ثبت نوبت» (L285) وقتی `requireVisit && visitPriceToman <= 0` غیرفعال شود. + - در payload (L143-152) خط شرطی اضافه کن: `...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {})`. +2. همین اصلاح را در `NewAppointmentDrawer.tsx` (L126-140) اعمال کن. + +### نکات + +- `visit_price_rials` بر حسب **ریال** ارسال می‌شود؛ ورودی UI تومان است → `tomanToRial()` از `utils.ts`. +- وقتی `requireVisit` غیرفعال است رفتار فعلی حفظ شود (فیلد اختیاری، بدون مقدار → فیلد در payload نیاید). +- فیلد قیمت باید عددی/لاتین باشد (با تسک ۳ سازگار — `PriceInput` این را دارد). + +--- + +## تسک ۵ — ثبت لاگ و رویداد Timeline هنگام لغو نوبت + +### وضعیت فعلی (مهم — سیستم Timeline وجود ندارد) + +- لغو نوبت از طریق `AppointmentController::updateStatus` (L634-669) با گذار وضعیت به `cancelled_by_doctor` / `cancelled_by_user` انجام می‌شود (و نیز `update` L677-776, transition L752-764). `transitionTo()` (Entity L299-314) فقط status/updatedAt را ست می‌کند، **هیچ لاگ یا reason ندارد**. +- **هیچ فیلد `cancel_reason`** در Entity یا بدنه request وجود ندارد (grep صفر). +- **هیچ سیستم Timeline/ActivityLog/رویدادِ per-appointment** در backend یا پنل ادمین clinicpro وجود ندارد. `TurnsTimeline.tsx` صرفاً نمای روزانهٔ نوبت‌هاست، نه تاریخچهٔ رویدادهای یک نوبت. پس این تسک **اولین** سیستم رویداد نوبت را می‌سازد. +- زیرساخت لاگ موجود: `DbLogger` → جدول `app_log`، اما فقط سطح WARNING به بالا persist می‌شود. + +### هدف + +هر بار یک نوبت لغو می‌شود: (الف) یک Log ثبت شود، (ب) یک رویداد جدید با عنوان «نوبت لغو شد» شامل زمان لغو، کاربرِ لغوکننده و دلیل لغو (در صورت وجود) در Timeline نوبت نمایش داده شود. + +### وظایف + +1. **Entity رویداد نوبت (جدید)** — `src/Appointment/Entity/AppointmentEvent.php`: + - ستون‌ها: `id`, `uuid`, `appointment_id` (FK/int به نوبت), `type` string (مثل `cancelled`), `title` string («نوبت لغو شد»), `actor_user_id` (nullable int — کاربر لغوکننده), `actor_name` string (nullable — کش نام برای نمایش), `reason` text nullable, `created_at` int (Unix timestamp صحیح — نه DateTime). + - migration لازم است: `ddev exec php bin/console make:migration` سپس `ddev exec php bin/console doctrine:migrations:migrate -n`. +2. **repository جدید** `AppointmentEventRepository` با متد لیستِ رویدادهای یک نوبت به‌صورت **DQL array hydration** (`getArrayResult()`)، مرتب بر `created_at`. +3. **ثبت رویداد در نقطه لغو** — در `AppointmentController::updateStatus` (بعد از `transitionTo`, حدود L656) و نیز مسیر `update` (L760): اگر `$newStatus` یکی از `STATUS_CANCELLED_BY_DOCTOR` / `STATUS_CANCELLED_BY_USER` بود: + - `reason` را از بدنه request بخوان: `$data['cancel_reason'] ?? null` (اختیاری). + - یک `AppointmentEvent` با `type='cancelled'`, `title='نوبت لغو شد'`, `actor_user_id`/`actor_name` از `$user`, `reason`, `created_at=time()` بساز و persist کن. + - همزمان `LoggerInterface` را با فرمت غنی پروژه (الگوی `project-logging`) صدا بزن، سطح `warning` تا در `app_log` هم persist شود: + ```php + $this->logger->warning(sprintf( + 'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s', + $appointment->getUuid(), $newStatus, $user->getId(), $user->getName() ?? '-', $reason ?? '-' + )); + ``` + - سرویس لاگ/EntityManager را در constructor کنترلر inject کن (الان هیچ‌کدام inject نشده — L29-39). +4. **خروجی رویدادها در API**: یک endpoint `GET /api/v1/appointment/{uuid}/events` (یا رویدادها را داخل پاسخ جزئیات نوبت `toArray()` اضافه کن) که آرایه رویدادها را برمی‌گرداند: `{ type, title, actor_name, reason, created_at }`. envelope با `$this->success()`. +5. **نمایش Timeline در پنل ادمین**: در نمای جزئیات نوبت (مودال/بخش جزئیات که از `AppointmentsPage`/`TurnsTable` باز می‌شود) یک بخش «تاریخچه/Timeline» اضافه کن که رویدادها را از endpoint بالا می‌خواند و هر رویداد را نشان می‌دهد: عنوان («نوبت لغو شد»)، نامِ لغوکننده، زمان لغو (شمسی با `formatDate`)، و دلیل در صورت وجود. اگر نمای جزئیات نوبت مستقل وجود ندارد، یک بخش timeline ساده در همان مودال/سطر گسترش‌یافته اضافه کن. + +### نکات + +- تاریخ‌ها Unix timestamp صحیح ذخیره شوند؛ نمایش با `formatDate()` شمسی در فرانت. +- لیست‌های admin طبق قانون پروژه با DQL array hydration. +- `cancel_reason` فیلد اختیاری است — اگر فرانت دلیل نفرستد، رویداد بدون reason ثبت شود ولی همچنان «نوبت لغو شد» ثبت گردد. +- (اختیاری، بهبود) در UIِ لغو نوبت یک ورودی «دلیل لغو» اضافه کن تا `cancel_reason` پر شود؛ اگر خارج از scope است، backend همچنان باید null-safe باشد. +- این ساختار قابل‌گسترش است: در آینده رویدادهای دیگر (ایجاد/تأیید/تغییر) هم می‌توانند از همین `AppointmentEvent` استفاده کنند — ولی در این تسک فقط لغو کافی است. + +--- + +## قوانین عمومی پروژه (برای همه تسک‌ها) + +- کنترلرها از `BaseController` ارث می‌برند؛ پاسخ‌ها با `$this->success()` / `$this->error()` / `$this->paginated()`. +- تغییر Entity → migration لازم. +- بعد از تغییر API، فایل مربوط در `docs/api/` همان session به‌روز شود (`docs/api/appointment.md`, `docs/api/insurance.md`). +- Admin frontend: JWT در `localStorage['clinicpro-auth']`؛ paginated → items از `data?.data`, total از `data?.meta?.totalRecords`؛ single → `data?.data`. +- select‌ها: همیشه `SearchableSelect`، نه `` بومی). + +### ۶. مستندات + +`clinicpro/docs/api/blog.md`: فیلد `city` در پاسخ لیست و جزئیات، پارامتر `city_id`، و معنای `null` = سراسری. + +## نکات مهم + +- معنای `null` را در مستندات صریح بنویس — سایت عمومی بر پایهٔ همین تصمیم می‌گیرد پست را روی دامنهٔ اصلی canonical کند یا روی دامنهٔ شهر. +- پستی که شهر دارد نباید روی دامنه‌های دیگر از لیست حذف شود؛ فقط **canonical** و **sitemap** آن به دامنهٔ شهر می‌رود. تصمیم «چه چیزی در کدام لیست دیده شود» با پارامتر `city_id` است و اختیاری. +- منبع شهر در سایت عمومی `data/city.json` است (۳۵ رکورد با `domain`) و `id` آن با `id` شهر در دیتابیس یکی است (مثلاً یاسوج = ۱۲۳). اگر این تطابق برقرار نیست، **قبل از هر کاری این را گزارش کن** — کل نگاشت شهر→دامنه به آن وابسته است. + + diff --git a/.claude/prompt/claims-dashboard-redesign.md b/.claude/prompt/claims-dashboard-redesign.md new file mode 100644 index 00000000..e087fbea --- /dev/null +++ b/.claude/prompt/claims-dashboard-redesign.md @@ -0,0 +1,210 @@ +# بازطراحی صفحه Claims به داشبورد مدیریتی بیمار-محور + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React) + +پیش‌نیاز: `clinicpro/.claude/prompt/insurance-shared-calculation.md` باید **قبل** از این پرامپت اجرا شود — ستون‌های سهم بیمه/بیمار روی session و منطق واحد محاسبه از آنجا می‌آید. + +## زمینه + +صفحه `/admin/claims` امروز یک لیست کارتی از رکوردهای Claim است: هر Claim یک کارت، و داخل هر کارت یک `` تودرتو از اقلام. این نه DataTable استاندارد پروژه است و نه برای پیگیری پرونده‌های بیمه‌ی یک بیمار کاربردی — کاربر نمی‌تواند ببیند «بیمار X مجموعاً چقدر ادعا دارد و چقدر وصول شده». + +## هدف + +تبدیل صفحه به داشبورد دو سطحی: +- **سطح ۱ — لیست بیماران:** هر ردیف یک بیمار با تجمیع مبالغ و وضعیت کلی. +- **سطح ۲ — جزئیات یک بیمار:** همه‌ی درخواست‌های بیمه‌ی آن بیمار با جزئیات کامل و لاگ تغییرات. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/pages/ClaimsPage.tsx` | صفحه فعلی (۳۶۶ خط) — بازنویسی می‌شود | +| `assets/admin/App.tsx:237` | ثبت route `claims`؛ نقش‌های `['doctor','clinic']` + `blockClinicScope` + `` | +| `src/Billing/Controller/BillingController.php:252` | `GET /api/v1/billing/claims` | +| `src/Billing/Controller/BillingController.php:286` | `POST /api/v1/billing/claims/{uuid}/{submit\|approve\|reject\|pay}` | +| `src/Billing/Controller/BillingController.php:332` | `GET /api/v1/billing/reports/insurance-debt` | +| `src/Billing/Entity/Claim.php` | `insuranceId`, `insuranceKind`, `totalClaimedRials`, `totalApprovedRials`, `totalPaidRials`, `status`, `TRANSITIONS` (`:26`), `rejectReason`, `submittedAt`, `settledAt` | +| `src/Billing/Entity/ClaimItem.php` | `invoiceItemId`, `claimedRials` | +| `src/Billing/Entity/Invoice.php` / `InvoiceItem.php` | مبالغ اصلی و سهم‌ها | +| `src/Billing/Service/ClaimService.php` | `buildClaim()` و انتقال وضعیت | +| `assets/admin/components/ui/` | `DataTable`, `Pagination`, `SearchableSelect`, `PageHeader`, `Modal`, `StatCard`, `StatusBadge`, `PersianDatePicker`, `FeatureGate` | + +## وضعیت فعلی + +`ClaimsPage.tsx` — کارت به‌ازای هر Claim، totalها به‌صورت یک رشته متنی درهم (`:271-276`): + +``` +ادعا … · تأیید … · پرداخت … · دلیل رد: … +``` + +و جدول داخلی (`:302-337`): + +```tsx + + + + + + + + + +``` + +مشکلات موجود که باید در بازطراحی رفع شوند: +- `GET /api/v1/billing/reports/insurance-debt` **دو بار** با دو query key جدا fetch می‌شود (`:116`, `:122`) — یکی برای پنل بدهی، یکی برای پر کردن dropdown بیمه. +- فیلترها با URL همگام نیستند (همه `useState`)؛ رفرش صفحه همه فیلترها را پاک می‌کند. +- وضعیت‌ها در `STATUS_META` محلی تعریف شده‌اند (`:69-75`) به‌جای `StatusBadge`. +- بدون `DoctorClaimsPage.tsx` اشتباه گرفته نشود — آن صفحه (`/admin/doctor-claims`) مربوط به ادعای مالکیت پروفایل پزشک است، نه بیمه. + +## وظایف + +### ۱. Backend — endpoint تجمیع بیمار-محور + +endpoint جدید: + +``` +GET /api/v1/billing/claims/by-patient +``` + +پارامترها: `page`, `limit`, `search` (نام/موبایل/کد ملی), `insurance_id`, `doctor_id`, `clinic_id`, `status`, `payment_status`, `from`, `to` (Unix), `sort`, `dir`. + +پاسخ paginated (`$this->paginated()`) با هر آیتم: + +```json +{ + "patient_uuid": "...", + "full_name": "...", + "mobile": "...", + "national_code": "...", + "claims_count": 0, + "total_services_rials": 0, + "total_insurance_rials": 0, + "total_patient_rials": 0, + "total_approved_rials": 0, + "total_paid_rials": 0, + "overall_status": "pending|submitted|approved|rejected|paid|mixed", + "last_activity_at": 0 +} +``` + +قاعده `overall_status`: اگر همه Claimهای بیمار یک وضعیت دارند همان؛ در غیر این صورت `mixed`. + +پیاده‌سازی با DQL و array hydration (`->getArrayResult()`)، تجمیع در SQL (`GROUP BY patient`) — نه در PHP روی کل رکوردها. + +### ۲. Backend — endpoint جزئیات یک بیمار + +``` +GET /api/v1/billing/claims/by-patient/{patientUuid} +``` + +لیست همه Claimهای آن بیمار (paginated)، هر رکورد شامل: + +```json +{ + "uuid": "...", + "visit_date": 0, + "doctor_name": "...", + "clinic_name": "...", + "service_title": "...", + "service_base_rials": 0, + "coverage_percent": 0, + "coverage_rials": 0, + "patient_share_rials": 0, + "insurance_share_rials": 0, + "insurance_title": "...", + "insurance_kind": "base|supplementary", + "status": "...", + "submitted_at": 0, + "settled_at": 0, + "tracking_number": null, + "reject_reason": null, + "description": null, + "allowed_transitions": ["submit"], + "logs": [ + { "at": 0, "from": "pending", "to": "submitted", "by": "نام کاربر", "note": null } + ] +} +``` + +`allowed_transitions` از `Claim::TRANSITIONS` بیاید تا فرانت دکمه‌های مجاز را خودش hardcode نکند. + +### ۳. Backend — شماره پیگیری و لاگ تغییرات + +دو مورد در دیتابیس وجود ندارند و باید اضافه شوند: + +**الف) `trackingNumber`** روی `Claim` (string nullable) — شماره پرونده/پیگیری بیمه. هنگام `submit` قابل ورود باشد و در `POST /api/v1/billing/claims/{uuid}/submit` به‌عنوان فیلد اختیاری بدنه پذیرفته شود. + +**ب) `ClaimStatusLog`** — entity جدید: + +| فیلد | نوع | +|------|-----| +| `claimId` | int | +| `fromStatus` | string nullable | +| `toStatus` | string | +| `note` | text nullable | +| `createdBy` | userId | +| `createdAt` | Unix int | + +در `ClaimService` هر جا انتقال وضعیت انجام می‌شود یک رکورد لاگ نوشته شود. migration لازم است: + +```bash +ddev exec php bin/console make:migration +ddev exec php bin/console doctrine:migrations:migrate -n +``` + +**Backfill:** برای Claimهای موجود از `submittedAt` / `settledAt` رکوردهای لاگ تقریبی ساخته شود تا صفحه جزئیات برای داده قدیمی خالی نباشد. + +### ۴. Frontend — سطح ۱: DataTable بیماران + +`ClaimsPage.tsx` بازنویسی شود با `DataTable` استاندارد پروژه (نه کارت): + +ستون‌ها: نام بیمار | موبایل | کد ملی | تعداد درخواست | مجموع خدمات | مجموع سهم بیمه | مجموع سهم بیمار | وضعیت کلی (`StatusBadge`) + +بالای جدول: ردیف `StatCard` با مجموع کل خدمات / کل سهم بیمه / وصول‌شده / مانده وصول‌نشده — از `GET /api/v1/billing/reports/insurance-debt` که باید **فقط یک بار** fetch شود (یک `useQuery`، خروجی‌اش هم پنل و هم options فیلتر بیمه را بدهد). + +کلیک روی ردیف → سطح ۲. + +### ۵. Frontend — سطح ۲: جزئیات بیمار + +مسیر جدید `/admin/claims/:patientUuid` در `App.tsx` با همان roleها و ``. + +- `PageHeader` با breadcrumb: پرونده‌های بیمه ← نام بیمار. +- `DataTable` از درخواست‌های بیمه با ستون‌های: تاریخ مراجعه | پزشک | کلینیک | سرویس | مبلغ اصلی | پوشش بیمه (درصد + مبلغ) | سهم بیمار | سهم بیمه | وضعیت | تاریخ ارسال | تاریخ پرداخت | شماره پیگیری. +- `actions?(row)` → دکمه‌های انتقال وضعیت بر اساس `allowed_transitions`. +- کلیک روی ردیف → `Modal` (size `lg`) با توضیحات کامل + **لاگ کامل تغییرات** به‌صورت timeline. +- عملیات `reject` باید `reject_reason` بگیرد؛ `submit` باید `tracking_number` اختیاری بگیرد. + +### ۶. Frontend — فیلتر، جستجو، مرتب‌سازی، URL sync + +فیلترها در هر دو سطح: بیمار (فقط سطح ۱) | بیمه | پزشک | کلینیک | وضعیت Claim | بازه زمانی (`PersianDatePicker` + میان‌بر یک ماه/یک سال اخیر) | وضعیت پرداخت. + +همه‌ی فیلترها + `page` + `sort`/`dir` باید در **query string آدرس** ذخیره شوند (`useSearchParams`) تا رفرش و اشتراک‌گذاری لینک کار کند. این رفع مشکل فعلی است. + +مرتب‌سازی سرور-ساید از طریق `sort`/`dir` روی: نام بیمار، تعداد درخواست، مجموع خدمات، مجموع سهم بیمه، آخرین فعالیت. + +## نکات مهم + +- **الزامی:** صفحه جدید با تم، Layout و کامپوننت‌های موجود ساخته شود — بدون طراحی جدید. از `components/ui/` استفاده شود، نه جدول دست‌ساز. +- برای dropdownها **همیشه** `SearchableSelect`، هرگز `` بومی. +- CSS: از کلاس‌های موجود (`seg`، `card`، `btn primary sm`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL. +- بعد از انتقال کامپوننت‌ها حتماً `ddev exec npx tsc --noEmit` و `ddev exec yarn dev` را اجرا کن — این refactor حجم زیادی import جابه‌جا می‌کند. diff --git a/.claude/prompt/clinic-doctor-permissions.md b/.claude/prompt/clinic-doctor-permissions.md new file mode 100644 index 00000000..23af0de9 --- /dev/null +++ b/.claude/prompt/clinic-doctor-permissions.md @@ -0,0 +1,241 @@ +# مدیریت دسترسی پزشکان کلینیک (Per-doctor permissions) + +## زمینه + +پس از پذیرش دعوت‌نامه، پزشک صرفاً یک سطر در جدول join `clinic_doctors` می‌گیرد و هیچ‌جا نمی‌توان تعیین کرد که این پزشک در آن کلینیک به چه بخش‌هایی دسترسی دارد. امروز نقش او hardcode است: در `buildAvailableContexts()` پزشکِ غیرمالک، context کلینیک را با `role: 'doctor'` و `scope: 'clinic'` می‌گیرد و همین `scope` باعث می‌شود Sidebar فقط داشبورد و «نوبت‌های من» را نشان دهد — بدون هیچ امکان تنظیم. + +هدف: مدیر کلینیک بتواند از `/admin/settings/clinic-doctors` برای هر پزشک سطح دسترسی تعیین کند، و این دسترسی هم در بک‌اند اعمال شود هم منوی پنل را بسازد. + +الگوی مرجع در پروژه: سیستم مجوز منشی (`DoctorSecretary`). عیناً همان envelope و همان الگوی UI را تکرار کن، **ولی سه ضعف آن را تکرار نکن** (در «نکات مهم» توضیح داده شده). + +> این پرامپت پیش‌نیاز `clinic-appointment-settings-tabs.md` است. اول این را اجرا کن. + +## مشکل / هدف + +۱. جایی برای ذخیره‌ی مجوزِ «پزشک X در کلینیک Y» وجود ندارد. +۲. مجوزها به کلاینت ارسال نمی‌شوند (context پزشکِ عضو کلینیک فیلد `permissions` ندارد). +۳. هیچ primitive سمت فرانت برای gate کردن منو/صفحه بر اساس مجوز وجود ندارد (`FeatureGate` فقط اشتراک را چک می‌کند). +۴. صفحه `/admin/settings/clinic-doctors` برای هر پزشک فقط دو اکشن دارد: مشاهده پروفایل و جداسازی. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Clinic/Entity/Clinic.php:88-95` | ManyToMany `clinic_doctors` — پیوند فعلی، بدون ستون اضافی | +| `src/Secretary/Entity/DoctorSecretary.php:20-30, 104-122, 140` | الگوی مرجع: envelope مجوز، `mergePermissions()`، `toArray()` | +| `src/Secretary/Security/SecretaryPermissionChecker.php` | چکر موجود — **کد مرده، هیچ call site ندارد** | +| `src/Auth/Controller/AuthController.php:694-765` | `buildAvailableContexts()` — جایی که باید `permissions` اضافه شود | +| `src/Clinic/Controller/ClinicController.php` | `GET /api/v1/clinic/doctor-list/{clinicUuid}`، `DELETE /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}` | +| `assets/admin/components/ClinicDoctorsManager.tsx` | UI ردیف هر پزشک — محل دکمه مجوزها | +| `assets/admin/pages/SecretariesPage.tsx:15-22, 26+, 82-140` | الگوی مرجع `PermissionsMatrix` و `PERMISSION_LABELS` | +| `assets/admin/stores/authStore.ts:4-12` | `ContextItem.permissions?: Record` — تعریف شده ولی هیچ مصرف‌کننده‌ای ندارد | +| `assets/admin/components/layout/Sidebar.tsx:52-76` | `buildSections(primaryRole, dbUuid, scope)` — منوی پزشکِ در scope کلینیک | +| `assets/admin/App.tsx:117-128` | `RoleRoute` + `blockClinicScope` | +| `docs/api/clinic.md` | مستند API کلینیک | + +## وضعیت فعلی + +پیوند کلینیک↔پزشک هیچ ستون اضافی ندارد — `src/Clinic/Entity/Clinic.php:88-95`: + +```php +#[ORM\ManyToMany(targetEntity: Doctor::class)] +#[ORM\JoinTable( + name: 'clinic_doctors', + joinColumns: [new ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', onDelete: 'CASCADE')], + inverseJoinColumns: [new ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', onDelete: 'CASCADE')] +)] +private Collection $doctors; +``` + +context پزشکِ عضو کلینیک هیچ مجوزی حمل نمی‌کند — `src/Auth/Controller/AuthController.php:711-718`: + +```php +$isOwner = $clinic->getUser()->getId() === $user->getId(); +$contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => $isOwner ? 'clinic' : 'doctor', + 'scope' => $isOwner ? null : 'clinic', + 'doctor_uuid' => $doctor->getUuid(), +]; +``` + +منوی پزشکِ در scope کلینیک hardcode است — `assets/admin/components/layout/Sidebar.tsx:52-76`: + +```tsx +if (primaryRole === "doctor" && scope === "clinic") { + // فقط داشبورد و «نوبت‌های من» +``` + +ردیف هر پزشک فقط دو اکشن دارد — `assets/admin/components/ClinicDoctorsManager.tsx`: + +```tsx + +{!readOnly && ( + +)} +``` + +## وظایف + +### ۱. Entity جدید `ClinicDoctorPermission` + +**جدول `clinic_doctors` را به entity تبدیل نکن.** شش نقطه در کد به `Clinic::$doctors` (`getDoctors`/`hasDoctor`/`findByDoctor`/`isDoctorInClinic`/detach endpoint) وابسته‌اند و mapping هم‌زمانِ ManyToMany و entity روی یک جدول، schema tool را دچار تعارض می‌کند. به‌جایش یک جدول موازی بساز: + +`src/Clinic/Entity/ClinicDoctorPermission.php` — جدول `clinic_doctor_permissions`: + +| ستون | نوع | توضیح | +|---|---|---| +| `id` | int, auto | | +| `uuid` | string(36) unique | `Uuid::v4()->toRfc4122()` در constructor | +| `clinic_id` | ManyToOne Clinic, `nullable: false`, `onDelete: CASCADE` | | +| `doctor_id` | ManyToOne Doctor, `nullable: false`, `onDelete: CASCADE` | | +| `permission` | json | envelope `{version, resources}` | +| `active` | bool, default true | | +| `created_at` / `updated_at` | int (Unix) | | + +`UniqueConstraint(['clinic_id','doctor_id'])`. + +envelope پیش‌فرض — دقیقاً هم‌شکل `DoctorSecretary::DEFAULT_PERMISSIONS` ولی با منابعِ مربوط به پزشک: + +```php +public const DEFAULT_PERMISSIONS = [ + 'version' => 1, + 'resources' => [ + 'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true], + 'appointment_settings' => ['view' => true, 'update' => true], + 'patients' => ['view' => true, 'create' => true, 'update' => true, 'delete' => false], + 'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'services' => ['view' => true, 'update' => false], + 'clinic_info' => ['view' => true, 'update' => false], + ], +]; +``` + +متدها: `mergePermissions(array $partial)` (deep-merge با cast به bool — عیناً از `DoctorSecretary.php:104-122` الگو بگیر)، `getPermissions()`، `toArray()`. + +**`toArray()` نباید envelope را flatten کند** — همان `{version, resources}` را برگردان تا کلاینت با دو شکل مختلف روبه‌رو نشود (ضعف فعلی سیستم منشی). + +Migration بساز و اجرا کن. برای هر سطر موجود `clinic_doctors` یک سطر با مجوز پیش‌فرض seed کن (در همان migration یا با یک command). + +### ۲. Repository + Service + +`src/Clinic/Repository/ClinicDoctorPermissionRepository.php`: +- `findOneFor(Clinic $clinic, Doctor $doctor): ?ClinicDoctorPermission` +- `findByClinic(Clinic $clinic): array` +- `getOrCreate(Clinic $clinic, Doctor $doctor): ClinicDoctorPermission` — پزشکی که قبل از این feature عضو شده، سطر ندارد؛ در اولین دسترسی با مجوز پیش‌فرض ساخته شود. + +### ۳. چکر مجوز — با call site واقعی + +`src/Clinic/Security/ClinicDoctorPermissionChecker.php`: + +```php +public function can(User $user, Clinic $clinic, string $resource, string $action): bool +``` + +- مالک کلینیک و `ROLE_ADMIN` → همیشه `true`. +- در غیر این‌صورت: پروفایل پزشکِ `$user` را بگیر، سطر مجوز را پیدا کن، `active` و `resources.$resource.$action` را برگردان. سطر نبود یا `active=false` → `false`. +- یک `assert(...)` هم داشته باشد که در صورت false، `AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403)` پرتاب کند. + +**این کلاس باید واقعاً استفاده شود.** حداقل در endpointهای زیر آن را صدا بزن (نه فقط تعریف کن): +- `GET /api/v1/clinic/doctor-list/{clinicUuid}` → `clinic_info.view` +- تنظیمات نوبت‌دهی (در پرامپت دوم) → `appointment_settings.view` / `.update` + +اگر منبعی هنوز endpoint متناظر ندارد، آن کلید را از `DEFAULT_PERMISSIONS` حذف کن — کلید ذخیره‌شده‌ای که هرگز چک نمی‌شود، همان اشتباه سیستم منشی است. + +### ۴. Endpointهای مدیریت مجوز + +در `src/Clinic/Controller/ClinicController.php` (یا کنترلر جدید `ClinicDoctorPermissionController` اگر تمیزتر بود): + +| Method | Path | دسترسی | +|---|---|---| +| `GET` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` | +| `PATCH` | `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions` | مالک کلینیک یا `ROLE_ADMIN` | + +بدنه PATCH: `{ "permissions": { "appointments": { "cancel": false } } }` — deep-merge، نه جایگزینی کامل. `active` هم قابل تغییر باشد: `{ "active": false }`. + +پاسخ‌ها با `$this->success($perm->toArray())`. اگر پزشک عضو این کلینیک نیست → `404` با `ERR_NOT_FOUND_001`. + +بررسی دسترسی مالک: همان الگوی `assertClinicAccess()` در `src/ClinicInvitation/Controller/ClinicInvitationController.php:196-205`. + +### ۵. انتشار مجوز در context + +در `src/Auth/Controller/AuthController.php:711-718`، برای پزشکِ غیرمالک فیلد `permissions` را اضافه کن: + +```php +$contexts[] = [ + 'type' => 'clinic', + 'db_uuid' => $clinic->getUuid(), + 'name' => $clinic->getName() ?? '', + 'role' => $isOwner ? 'clinic' : 'doctor', + 'scope' => $isOwner ? null : 'clinic', + 'doctor_uuid' => $doctor->getUuid(), + 'permissions' => $isOwner ? null : $this->clinicDoctorPermRepo->getOrCreate($clinic, $doctor)->getPermissions(), +]; +``` + +مراقب N+1 باش: `buildAvailableContexts` روی همه کلینیک‌های پزشک حلقه می‌زند — مجوزها را با یک کوئری برای همه کلینیک‌ها بگیر و در آرایه نگاشت کن. + +### ۶. `usePermissions` سمت فرانت + +`assets/admin/hooks/usePermissions.ts` — primitive تازه (امروز اصلاً وجود ندارد): + +```ts +export function usePermissions() { + const context = useAuthStore(s => s.context); + const primaryRole = useAuthStore(s => s.primaryRole); + + const can = useCallback((resource: string, action: string): boolean => { + if (primaryRole === 'admin' || primaryRole === 'clinic') return true; + const res = (context?.permissions as any)?.resources; + if (!res) return true; // context بدون مجوز = پزشک در مطب شخصی خودش + return Boolean(res?.[resource]?.[action]); + }, [context, primaryRole]); + + return { can }; +} +``` + +نکته مهم: نبودِ `permissions` یعنی «مطب شخصی، محدودیتی نیست» — نه «هیچ دسترسی». اگر برعکس پیاده شود، پزشک مستقل کل پنلش را از دست می‌دهد. + +سپس `Sidebar.buildSections` (`:52-76`) را از حالت hardcode خارج کن: به‌جای «فقط داشبورد و نوبت‌های من» برای `scope === 'clinic'`، آیتم‌ها را با `can(resource, 'view')` فیلتر کن. رفتار پیش‌فرض باید معادل امروز بماند برای مجوز پیش‌فرضِ محدود، ولی با روشن کردن یک مجوز، آیتم مربوطه ظاهر شود. + +### ۷. UI مدیریت مجوز در صفحه پزشکان کلینیک + +- `ClinicDoctorItem` (در `ClinicDoctorsManager.tsx`) فیلد `permissions` و `permission_active` بگیرد (از `doctor-list` برگردانده شود، یا با کوئری جدا). +- یک `mini-btn` سوم با `ShieldCheckIcon` بین «مشاهده پروفایل» و «جداسازی»، داخل بلوک `{!readOnly && …}`. +- کامپوننت جدید `assets/admin/components/ui/DoctorPermissionsModal.tsx` — ماتریس چک‌باکس، عیناً از `SecretariesPage.tsx:82-140` الگو بگیر، با `PERMISSION_LABELS` فارسی برای شش منبع بالا و یک سوییچ «فعال/غیرفعال» برای `active`. +- ذخیره با `api.patch('/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}/permissions', { permissions })` و `invalidateQueries(['clinic-doctors', clinicUuid])`. +- از `Modal` موجود در `components/ui/` استفاده کن، نه modal دستی. برای هر select احتمالی از `SearchableSelect` استفاده کن، نه `` بومی). + - create mutation به‌جای `doctor_uuid` تکی، `doctor_uuids: string[]` بفرستد. + - برای منشیِ موجود، امکان ویرایش مجموعه‌ی پزشکان (فراخوانی endpoint sync وظیفه‌ی ۱). نمایش پزشکانِ فعلیِ هر منشی (از `listByClinic` که ردیف‌ها را per پزشک می‌دهد — گروه‌بندی بر اساس منشی/موبایل). + - نمایش پیام برای پزشکانِ skip‌شده به‌خاطر سقف پلن. +- تایپ `Secretary` در `types/index.ts`: افزودن `doctors?: { uuid: string; name: string }[]` یا `doctor_uuids`. +- تست vitest: رندر multi-select، ارسال آرایه در mutation، گروه‌بندی منشی با چند پزشک. (تست‌ها روی host اجرا شوند: `npx vitest --run ` — node_modules داخل ddev برای esbuild لینوکسی نیست.) + +## نکات مهم + +- **بدون تغییر schema.** فقط ردیف‌های `DoctorSecretary` اضافه/حذف می‌شوند. اگر لازم شد ردیف حذف شود، هماهنگ با رفتار فعلی `deactivate` (soft `setActive(false)`) تصمیم بگیر — برای sync، حذف واقعی یا غیرفعال‌سازی را یکدست انتخاب کن و مستند کن. +- **سازگاری قدیمی:** مسیر تک‌پزشکیِ `doctor_uuid` و جریان منشیِ owner=doctor نباید بشکند. +- **envelope:** پاسخ‌ها با `$this->success(...)`/`$this->paginated(...)`؛ لیست‌های admin با array hydration. سمت فرانت single = `data?.data` (ممکن double-nested)، paginated items = `data?.data`. +- **context منشی:** ورود منشی یک context per کلینیک می‌سازد (`AuthController.php:~737`)؛ scope در هر request از `UserActiveContext` خوانده می‌شود. تغییرات وظیفه‌ی ۲ در همان لایه‌ی resolve اعمال شود، نه در ساخت context. +- **permissionها:** ماتریس دسترسی منشی (`DEFAULT_PERMISSIONS`) دست‌نخورده؛ scopeِ پزشک یک لایه‌ی مستقل و مقدم بر permission است. +- **RTL/فارسی، تاریخ‌ها Unix + شمسی.** +- بعد از اتمام: `graphify update .` (طبق قانون؛ اول commit). diff --git a/.claude/prompt/context-separation-clinic-vs-doctor-booking.md b/.claude/prompt/context-separation-clinic-vs-doctor-booking.md new file mode 100644 index 00000000..ef94e221 --- /dev/null +++ b/.claude/prompt/context-separation-clinic-vs-doctor-booking.md @@ -0,0 +1,445 @@ +# جداسازی Context کلینیک و محیط شخصی پزشک در تنظیمات نوبت‌دهی + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React) + +## زمینه + +در مسیر `admin/doctors/{doctorUuid}` (پنل کلینیک) هنگام ذخیرهٔ تنظیمات نوبت‌دهی برای پزشک عضو کلینیک (دکتر تست، موبایل `09100652121`، کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`) با انتخاب حالت «نوبت‌دهی سرویسی» خطای زیر برمی‌گردد: + +> برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است + +در حالی که کلینیک سرویس‌های bookable دارد. علت: شمارش سرویس‌ها همیشه با `entity_type='doctor'` انجام می‌شود و هیچ‌وقت سرویس‌های کلینیک را نمی‌بیند. + +اما این فقط علامتِ یک مشکل معماری بزرگ‌تر است: **کل مدل تنظیمات نوبت‌دهی، context ندارد.** `WeeklySchedule` یک رابطهٔ `OneToOne` با `doctor` دارد و یک unique constraint روی `doctor_id`؛ یعنی یک پزشک که هم مطب شخصی دارد و هم عضو یک یا چند کلینیک است، فقط **یک** برنامهٔ نوبت‌دهی در کل سیستم دارد. سرویس‌ها ولی polymorphic هستند (`service_sections.entity_type` = `doctor|clinic`) و کاملاً از هم جدا. + +## مشکل / هدف + +دو Context باید کاملاً از هم جدا شوند: + +| Context | مالک تنظیمات | سرویس‌های قابل استفاده | آدرس‌های قابل انتخاب | +|---|---|---|---| +| محیط شخصی پزشک | `doctor` | فقط `entity_type='doctor', entity_id=doctor.id` | فقط `DoctorAddress` با `type=personal` (یا `clinic_id IS NULL`) | +| محیط مدیریت کلینیک | `(doctor, clinic)` | فقط `entity_type='clinic', entity_id=clinic.id` | فقط آدرس‌های همان کلینیک | + +قوانین: + +1. پزشک در محیط شخصی **نباید** به سرویس‌ها، آدرس‌ها یا تنظیمات کلینیک دسترسی داشته باشد. +2. کلینیک در محیط خودش برای پزشک عضو، **باید** بتواند از سرویس‌های کلینیک استفاده کند. +3. یک پزشک باید بتواند برای مطب شخصی و برای هر کلینیک، برنامهٔ نوبت‌دهی مستقل داشته باشد. +4. `booking_mode` (slot/service) در هر context مستقل قفل می‌شود، نه سراسری. + +## فایل‌های مرتبط + +| فایل | نقش | +|---|---| +| `src/Appointment/Entity/WeeklySchedule.php` | Entity تنظیمات نوبت‌دهی — `OneToOne` با doctor، بدون clinic | +| `src/Appointment/Controller/AppointmentSettingsController.php` | همهٔ endpointهای تنظیمات؛ محل خطا و محل authorization | +| `src/ClinicService/Repository/ServiceItemRepository.php` | `countBookableByEntity()` / `findBookableByEntity()` | +| `src/ClinicService/Entity/ServiceSection.php` | مالکیت polymorphic سرویس (`entityType`/`entityId`) | +| `src/ClinicService/Entity/ServiceItem.php` | فلگ `bookable` | +| `src/ClinicService/Controller/ClinicServiceController.php` | `resolveEntity()` — تشخیص context از روی role | +| `src/Doctor/Entity/DoctorAddress.php` | آدرس با `clinicId` و `type` | +| `src/Appointment/Controller/AppointmentController.php:234-262` | لیست عمومی سرویس‌های bookable پزشک | +| `src/Auth/Entity/UserActiveContext.php` | context فعال کاربر (فقط `db_uuid`) | +| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحهٔ شخصی پزشک | +| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | صفحهٔ کلینیک، تب به ازای هر پزشک | +| `assets/admin/components/schedule/ScheduleSection.tsx` | کامپوننت مشترک هر دو صفحه | +| `assets/admin/stores/authStore.ts` | `context: {type: 'doctor'|'clinic'}` | + +## وضعیت فعلی + +### ۱. شمارش سرویس با `doctor` هاردکد + +`src/Appointment/Controller/AppointmentSettingsController.php:57-61`: + +```php + private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool + { + return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE + && $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0; + } +``` + +فراخوانی در `:101-103` (POST) و `:144-146` (PATCH): + +```php + if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است', 422, 'booking_mode'); + } +``` + +### ۲. Entity بدون clinic + +`src/Appointment/Entity/WeeklySchedule.php:13-49`: + +```php +#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] +#[ORM\Table(name: 'weekly_schedules')] +#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])] +class WeeklySchedule +{ + public const MODE_SLOT = 'slot'; + public const MODE_SERVICE = 'service'; + ... + #[ORM\OneToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(name: 'doctor_id', onDelete: 'CASCADE')] + private Doctor $doctor; + + #[ORM\Column(type: 'json')] + private array $setting = []; +``` + +### ۳. تشخیص context فقط از روی role (و doctor برنده است) + +`src/ClinicService/Controller/ClinicServiceController.php:492-505` — این متد در ۹+ کنترلر تکرار شده: + +```php + private function resolveEntity(User $user): array + { + if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; + } + + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; + } + + return ['unknown', null]; + } +``` + +کاربری که هر دو role را دارد، همیشه به‌عنوان doctor حل می‌شود و هرگز سرویس‌های کلینیکش را نمی‌بیند. + +### ۴. Authorization از کلینیک عبور می‌کند ولی context را حمل نمی‌کند + +`src/Appointment/Controller/AppointmentSettingsController.php:437-450`: + +```php + private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse + { + if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) { + return null; + } + + foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) { + return null; + } + } + + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } +``` + +مالک کلینیک مجاز است بنویسد، اما هیچ‌جا مشخص نمی‌شود که این نوشتن «در context کلینیک» است. + +### ۵. فرانت context را ارسال نمی‌کند + +`assets/admin/components/schedule/ScheduleSection.tsx:546-563`: + +```tsx + ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta }) + : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta }); +``` + +هر دو صفحهٔ شخصی و کلینیک دقیقاً همین `ScheduleSection` را رندر می‌کنند و هیچ تفاوتی در payload ندارند. + +## وظایف + +### ۱. مدل‌سازی Context در `WeeklySchedule` + +ستون `clinic_id` (nullable) به `weekly_schedules` اضافه شود: + +- `clinic_id IS NULL` → context شخصی پزشک +- `clinic_id = X` → context کلینیک X برای همین پزشک + +تغییرات لازم در `src/Appointment/Entity/WeeklySchedule.php`: + +```php +#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] +#[ORM\Table(name: 'weekly_schedules')] +#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_id'])] +class WeeklySchedule +{ + // OneToOne → ManyToOne (یک پزشک چند برنامه دارد: شخصی + هر کلینیک) + #[ORM\ManyToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')] + private Doctor $doctor; + + #[ORM\ManyToOne(targetEntity: Clinic::class)] + #[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'CASCADE')] + private ?Clinic $clinic = null; +``` + +نکته دربارهٔ unique در MySQL/MariaDB: `NULL` در unique index تکراری مجاز است، پس `(doctor_id, NULL)` چند بار می‌تواند ثبت شود. برای جلوگیری، یا در سطح Repository قبل از insert چک کن، یا به‌جای NULL از `clinic_id = 0` استفاده کن. **گزینهٔ توصیه‌شده: nullable نگه‌دار و یکتایی را در سرویس/Repository تضمین کن** (سازگارتر با FK). + +Migration بنویس. برای رکوردهای موجود `clinic_id = NULL` بگذار (همه به‌عنوان تنظیمات شخصی تفسیر می‌شوند) — و در توضیح migration این تصمیم را ذکر کن. + +#### تصمیم قطعی دربارهٔ `DateOverride` و `Holiday` + +این دو **معنای متفاوتی** دارند و رفتارشان یکسان نیست: + +**`DateOverride` → همیشه per-context (`clinic_id` مطابق schedule).** +یک override یعنی «ساعت کاری این روزِ خاص با برنامهٔ عادی فرق دارد». ساعت کاری خودش per-context است، پس استثنای آن هم per-context است. اگر پزشک در کلینیک پنجشنبه را تا ۱۲ کار کند، هیچ ربطی به مطب شخصی‌اش ندارد. ستون `clinic_id` nullable اضافه شود و **همیشه با `clinic_id` همان `WeeklySchedule` مقداردهی شود** (NULL = context شخصی). عملاً بهتر است `DateOverride` به `WeeklySchedule` رفرنس بدهد نه به `Doctor`، ولی برای کم‌کردن ریسک migration، `(doctor_id, clinic_id)` کافی است. + +**`Holiday` → پیش‌فرض سراسری (doctor-level)، با امکان محدودسازی به یک context.** +تعطیلی یعنی «پزشک آن روز نیست» — یک واقعیت فیزیکی است. پزشکی که در سفر یا مرخصی است، هم‌زمان در مطب شخصی و در کلینیک غایب است؛ اگر per-context باشد، پزشک باید یک مرخصی را N بار ثبت کند و فراموش‌کردن یکی از آن‌ها = نوبت‌گرفتن بیمار برای روزی که پزشک نیست. این بدترین خطای ممکن در این دامنه است. + +پس `clinic_id` nullable با این معنا: + +| `clinic_id` | معنی | +|---|---| +| `NULL` | پزشک آن روز در **هیچ** محلی نیست — روی همهٔ contextها اثر می‌گذارد | +| `X` | پزشک آن روز فقط در کلینیک X نیست (مطب شخصی و بقیه کلینیک‌ها باز) | + +محاسبهٔ تعطیلی مؤثر برای یک context، **اجتماع** دو مجموعه است: + +```php +// در HolidayRepository +->where('h.doctor = :doctor') +->andWhere('h.clinic IS NULL OR h.clinic = :clinic') +``` + +قواعد نوشتن (اجباری، در سرویس اعمال شود): + +- مالک/کارمند کلینیک فقط می‌تواند `Holiday` با `clinic_id = <کلینیک خودش>` بسازد یا حذف کند. تلاش برای ساخت تعطیلی سراسری (`clinic_id = NULL`) → 403. دلیل: کلینیک نباید بتواند مطب شخصی پزشک را تعطیل کند. +- خودِ پزشک در context شخصی می‌تواند هر دو نوع را بسازد، ولی UI باید صریح بپرسد. یک انتخاب دوتایی در فرم ثبت تعطیلی: + - «در همهٔ محل‌ها نیستم» → `clinic_id = NULL` (پیش‌فرض) + - «فقط در …» → انتخاب یک محل +- تعطیلی سراسریِ ساخته‌شده توسط پزشک، در پنل کلینیک **فقط-خواندنی** نمایش داده شود (کلینیک باید ببیند پزشک نیست، ولی نتواند حذفش کند). + +Migration: همهٔ رکوردهای موجود `Holiday` و `DateOverride` با `clinic_id = NULL` بمانند — برای `Holiday` معنایش دقیقاً همان رفتار فعلی است (سراسری)، برای `DateOverride` یعنی به context شخصی نسبت داده می‌شوند که با تصمیم بند ۱ سازگار است. + +نکتهٔ مرزی: «کلینیک کلاً تعطیل است» (برای همهٔ پزشکان) با این مدل بیان نمی‌شود و نیاز به یک `ClinicHoliday` جدا دارد. **خارج از scope این تسک** — فقط در `docs/` به‌عنوان کار بعدی ثبت شود. + +### ۲. یک سرویس مرکزی برای حل Context + +به‌جای تکرار `resolveEntity()` در ۹ کنترلر، یک سرویس بساز: + +`src/Common/Service/EntityContextResolver.php` (یا محل مناسب مطابق ساختار موجود): + +```php +final class EntityContextResolver +{ + /** + * context مؤثر را برمی‌گرداند: ['doctor'|'clinic', id, ?Clinic] + * اولویت: clinic_uuid صریح در request > UserActiveContext > role + */ + public function resolve(User $user, ?string $clinicUuid = null): EntityContext; + + /** آیا این کاربر مجاز است در context کلینیک داده‌شده عمل کند؟ */ + public function assertCanActAs(User $user, EntityContext $ctx): void; +} +``` + +قواعد: + +- اگر `clinic_uuid` در درخواست آمد → context کلینیک، **مشروط به** اینکه `permChecker->can($user, $clinic, ...)` مجاز باشد؛ در غیر این صورت 403. +- اگر نیامد → از `UserActiveContext` بخوان (`src/Auth/Entity/UserActiveContext.php`). +- اگر آن هم نبود → fallback به منطق فعلی مبتنی بر role. + +**مهم:** اولویت فعلی که `ROLE_DOCTOR` را بر `ROLE_CLINIC` مقدم می‌کند، برای کاربر دو-نقشی اشتباه است. با این سرویس، `UserActiveContext` باید تعیین‌کننده باشد. + +سپس `resolveEntity()` را در کنترلرهای موجود (ClinicService, Inventory, Patient, Staff, Billing, Insurance, Subscription, Tag, Sms) با این سرویس جایگزین کن. اگر ریسک این refactor بزرگ بود، **حداقل `ClinicServiceController` و `AppointmentSettingsController` را مهاجرت بده** و بقیه را در یک TODO مستند کن. + +### ۳. اصلاح validation سرویس bookable بر اساس Context + +در `AppointmentSettingsController`: + +```php + private function serviceModeHasNoBookable(array $meta, EntityContext $ctx): bool + { + if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) { + return false; + } + + return $this->itemRepo->countBookableByEntity($ctx->type, $ctx->id) === 0; + } +``` + +و پیام خطا بسته به context، دقیق‌تر شود: + +```php +$msg = $ctx->type === 'clinic' + ? 'برای نوبت‌دهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبت‌دهی» داشته باشد' + : 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است'; +``` + +### ۴. محدودسازی آدرس‌ها بر اساس Context + +`GET /api/v1/appointment-settings/available-locations/{doctorUuid}` (`:399`) الان همهٔ آدرس‌های شخصی + همهٔ کلینیک‌ها را union می‌کند: + +```php + $clinics = $this->clinicRepo->findByDoctor($doctor); + $clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics); + $addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds); +``` + +باید پارامتر `?clinic_uuid=` بپذیرد: + +- با `clinic_uuid` → فقط آدرس‌های همان کلینیک +- بدون آن (context شخصی) → فقط `DoctorAddress` با `type = TYPE_PERSONAL` / `clinicId IS NULL` + +همچنین در `validateSessionsHaveLocation()` (`:456-466`) اضافه کن که `location_id` انتخاب‌شده حتماً متعلق به همان context باشد؛ الان هر آدرسی پذیرفته می‌شود. + +### ۵. لیست سرویس‌ها برای context + +الان هیچ endpointای برای «سرویس‌های bookable یک پزشک در یک کلینیک» وجود ندارد؛ `GET /api/v1/service-items` (`ClinicServiceController:217`) owner را از کاربر لاگین‌شده می‌گیرد. + +- `GET /api/v1/service-items` باید `?clinic_uuid=` بپذیرد و از `EntityContextResolver` استفاده کند. +- `AppointmentController.php:234-262` که `findBookableByEntity('doctor', ...)` را هاردکد کرده، باید context را از `WeeklySchedule` مربوطه (که حالا `clinic` دارد) استخراج کند — نه از role. این مسیر عمومی است و `nobat724_front` مصرف‌کنندهٔ آن است. + +### ۶. تغییرات endpointهای تنظیمات نوبت‌دهی + +همهٔ endpointهای `AppointmentSettingsController` باید context بپذیرند: + +- POST `/api/v1/appointment-settings/weekly-schedule` → بدنه `clinic_uuid` اختیاری +- GET/PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}` → query `?clinic_uuid=` +- `WeeklyScheduleRepository` متد `findOneByDoctorAndClinic(Doctor $d, ?Clinic $c)` بگیرد؛ همهٔ `findOneBy(['doctor' => ...])`ها به‌روز شوند. +- `assertModeImmutable()` باید mode را از schedule همان context بخواند، نه از تنها schedule پزشک. + +پاسخ‌ها طبق `BaseController` با `$this->success()` / `$this->error()` بمانند. + +### ۷. پنل ادمین React + +- `assets/admin/components/schedule/ScheduleSection.tsx` یک prop جدید `clinicUuid?: string` بگیرد و در هر دو فراخوانی POST/PATCH و در query key و در fetch آدرس‌ها آن را ارسال کند. +- `AppointmentSettingsPage.tsx` (شخصی) → `clinicUuid` ندهد. +- `ClinicAppointmentSettingsPage.tsx` → `clinicUuid={clinicUuid}` بدهد. +- query keyهای React Query حتماً شامل `clinicUuid` شوند، وگرنه cache بین دو context نشت می‌کند. +- متن راهنمای `ScheduleSection.tsx:715` بسته به context متفاوت شود: در کلینیک به بخش سرویس‌های کلینیک ارجاع دهد. + +### ۸. مستندات و تست + +- فایل‌های `docs/api/` مربوط به appointment-settings و service-items با پارامتر جدید `clinic_uuid` به‌روز شوند (قانون ثابت پروژه). +- تست موجود `tests/Appointment/AppointmentSettingsListOwnershipTest.php` را گسترش بده؛ حداقل این سناریوها: + 1. پزشک عضو کلینیک، در context شخصی، mode=service با صفر سرویس شخصی → 422. + 2. همان پزشک در context کلینیک که کلینیک سرویس bookable دارد → 200. + 3. پزشک در context شخصی نمی‌تواند `location_id` متعلق به کلینیک را انتخاب کند → 422. + 4. دو schedule مستقل برای یک پزشک (شخصی + کلینیک) هم‌زمان ذخیره می‌شوند و mode مستقل قفل می‌شود. + 5. کاربری بدون permission روی کلینیک، با `clinic_uuid` آن کلینیک → 403. + +### ۹. داشبورد پزشک دعوت‌شده در context کلینیک + +**مشکل مشاهده‌شده:** پزشک دعوت‌شده («دکتر دعوت تست ۲») وقتی داخل محیط کلینیک «علی بهروزی» است، داشبورد کاملِ پزشک را می‌بیند: «میزان درآمد»، «کل پرداختی‌ها»، «پرداختی‌های امروز»، «تعداد کل مراجعین» و کارت «کلینیک‌های من». این داده‌ها به context شخصی پزشک تعلق دارند و نباید در محیط کلینیک نمایش داده شوند. علاوه بر این، پزشک دعوت‌شده اصلاً نباید اطلاعات مالی ببیند. + +**ریشه:** انتخاب داشبورد فقط بر اساس `primaryRole` است و `context.scope` نادیده گرفته می‌شود. + +`assets/admin/pages/DashboardPage.tsx:1116`: + +```tsx +export default function DashboardPage() { + const primaryRole = useAuthStore(s => s.primaryRole); + if (!primaryRole) return ; + if (primaryRole === 'admin') return ; + if (primaryRole === 'clinic') return ; + if (primaryRole === 'doctor') return ; + ... +``` + +در حالی که Sidebar **دقیقاً همین تمایز را می‌شناسد** — `assets/admin/components/layout/Sidebar.tsx:60-83`: + +```tsx +if (primaryRole === "doctor" && scope === "clinic") { + const items: SectionItem[] = [ + { to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }, + ]; + if (can("appointments", "view")) { ... } + if (can("patients", "view")) { ... } + return [{ label: "عمومی", items }]; +} +``` + +منبع `scope`: `src/Auth/Controller/AuthController.php:700-729` — پزشک دعوت‌شده `role='doctor'`, `scope='clinic'`, `permissions` از `ClinicDoctorPermission`؛ مالک کلینیک `role='clinic'`, `scope=null`, `permissions=null`. + +**وظایف:** + +1. در `DashboardPage.tsx` قبل از dispatch، `scope` را هم بخوان و یک شاخهٔ جدید اضافه کن: + +```tsx +const primaryRole = useAuthStore(s => s.primaryRole); +const scope = useAuthStore(s => s.context?.scope ?? null); +... +if (primaryRole === 'doctor' && scope === 'clinic') return ; +if (primaryRole === 'doctor') return ; +``` + +2. `InvitedDoctorDashboard` فقط این‌ها را نشان دهد: + - «تعداد نوبت‌های امروز» (محدود به نوبت‌های همین پزشک در همین کلینیک) + - «لیست نوبت‌های جدید» همین پزشک در همین کلینیک + - در صورت داشتن `can('patients','view')`، «تعداد مراجعین» همین context + + و این‌ها **حذف** شوند: «میزان درآمد»، «کل پرداختی‌ها»، «پرداختی‌های امروز»، «نمودار درآمد»، کارت «کلینیک‌های من» (`DashboardPage.tsx:851`)، و کارت دعوت‌های کلینیک (`DoctorClinicInvitationsCard`, `:709`) — دعوت‌ها فقط در context شخصی معنا دارند. + + کارت‌ها بر اساس `permissions` همان context نمایش داده شوند (همان `usePermissions()` که Sidebar استفاده می‌کند)، نه صرفاً hardcode. + +3. **Backend مهم‌تر است — مخفی‌کردن در UI کافی نیست.** `src/Dashboard/Controller/DashboardController.php:180-182` (`GET /api/v1/dashboard/doctor`) داده را از `doctorRepo->findByUser($user)` می‌گیرد و روی **همهٔ کلینیک‌ها + مطب شخصی** جمع می‌زند؛ `UserActiveContextRepository` تزریق شده (`:34`) ولی مصرف نمی‌شود. پزشک دعوت‌شده الان می‌تواند مستقیماً این endpoint را صدا بزند و درآمد شخصی‌اش را بگیرد. + + - `?clinic_uuid=` بپذیرد و از `EntityContextResolver` (وظیفهٔ ۲) استفاده کند. + - وقتی context کلینیک است: فیلدهای مالی (`revenue_period_rials`, `today_payments_rials`, `charts.revenue_by_day`) در پاسخ **قرار نگیرند** مگر اینکه `permChecker` مجوز مالی (`billing`/`payments` view) برای آن پزشک در آن کلینیک بدهد. + - آمار نوبت/بیمار به نوبت‌های همان پزشک در همان کلینیک محدود شود، نه همهٔ کلینیک‌ها. + - `sms_balance` هم در context کلینیک نباید از کیف پول شخصی پزشک خوانده شود. + +4. مسیر `/admin/dashboard` در `assets/admin/App.tsx:171` هیچ role gate ندارد؛ لازم نیست gate اضافه شود (خود صفحه dispatch می‌کند) اما مطمئن شو `RoleRoute` مسیرهای مالی را برای `scope === 'clinic'` مسدود می‌کند. + +5. تست: پزشک دعوت‌شده در context کلینیک، `GET /api/v1/dashboard/doctor?clinic_uuid=...` → پاسخ نباید هیچ فیلد مالی داشته باشد؛ و بدون `clinic_uuid` وقتی active context کلینیک است، نتیجه باید همان محدودیت را داشته باشد. + +### ۱۰. قرارداد عمومی برای چند schedule (مصرف‌کننده: `nobat724_front`) + +**تصمیم قطعی: همهٔ scheduleها نمایش داده شوند، تفکیک‌شده بر اساس محل نوبت‌دهی.** + +انتخاب یکی و پنهان‌کردن بقیه یعنی حذف ظرفیت واقعی پزشک از سایت — پزشکی که سه‌شنبه‌ها فقط در کلینیک است، آن روز اصلاً قابل رزرو نخواهد بود. ضمناً قیمت و سرویس‌ها بین محل‌ها فرق می‌کند، پس بیمار باید محل را آگاهانه انتخاب کند، نه اینکه سیستم به‌جایش تصمیم بگیرد. + +قرارداد API عمومی — به‌جای یک آبجکت، آرایه‌ای از «محل‌های نوبت‌دهی» برگردد: + +```json +{ + "success": true, + "data": { + "doctor": { "uuid": "...", "name": "..." }, + "booking_locations": [ + { + "location_uuid": "...", + "type": "personal", + "title": "مطب شخصی", + "address": "...", + "clinic_uuid": null, + "booking_mode": "slot", + "services": [], + "next_available_at": 1755000000 + }, + { + "location_uuid": "...", + "type": "clinic", + "title": "کلینیک علی بهروزی", + "address": "...", + "clinic_uuid": "41e325c4-...", + "booking_mode": "service", + "services": [ { "uuid": "...", "name": "...", "price_rials": 0, "duration_minutes": 20 } ], + "next_available_at": 1754900000 + } + ] + } +} +``` + +قواعد: + +- **پیش‌فرض انتخاب‌شده:** محلی با کمترین `next_available_at` (زودترین نوبت آزاد). این هم برای بیمار بهترین است و هم نیاز به قاعدهٔ دلبخواهی «شخصی اول یا کلینیک اول» را حذف می‌کند. اگر هیچ محلی نوبت آزاد نداشت، ترتیب: شخصی، سپس کلینیک‌ها بر اساس نام. +- **لینک مستقیم:** `/doctor/{uuid}?location={location_uuid}` تا هر محل قابل اشتراک‌گذاری و ایندکس باشد. بدون پارامتر → پیش‌فرض بالا. +- **endpointهای اسلات و ثبت نوبت** باید `location_uuid` (یا `clinic_uuid`) اجباری بگیرند. الان محل را از تنها schedule پزشک استنتاج می‌کنند؛ با چند schedule این استنتاج غلط می‌شود و **بی‌سروصدا نوبت را به محل اشتباه ثبت می‌کند**. این را به‌عنوان یک شکست خاموش جدی در نظر بگیر: تا وقتی این پارامتر اجباری نشده، migration بند ۱ را روی production اجرا نکن. +- **سازگاری عقب‌رو:** تا وقتی `nobat724_front` به‌روز نشده، اگر پزشک فقط یک schedule دارد (اکثریت مطلق داده‌های فعلی)، پاسخ قدیمی هم در کنار `booking_locations` برگردانده شود؛ بعد از استقرار فرانت حذف شود. این را در `docs/api/appointment.md` صریح علامت بزن. +- **JSON-LD:** به‌جای یک `openingHoursSpecification`، برای هر محل یک entry جدا با `location` مشخص. یک نود `Physician` با چند `availableAtOrFrom`. پرامپت همتا در `nobat724_front` لازم است. + +## نکات مهم + +- **سازگاری با داده موجود:** هر پزشکی که الان schedule دارد، بعد از migration باید دقیقاً همان رفتار را در context شخصی ببیند. اگر آن schedule عملاً برای کلینیک تنظیم شده بوده (session‌هایش `location_id` کلینیکی دارند)، migration نمی‌تواند خودکار تشخیص دهد — این را به‌عنوان محدودیت شناخته‌شده مستند کن و یک اسکریپت console برای انتقال دستی بنویس. +- سرویس‌ها polymorphic هستند و **هرگز** بین doctor و clinic مشترک نمی‌شوند؛ هیچ‌جا سرویس‌های دو context را union نکن. +- تاریخ‌ها Unix timestamp صحیح بمانند؛ رشته‌های جدید فارسی و تاریخ‌ها شمسی. +- در پنل ادمین از `SearchableSelect` استفاده کن، نه `` خام). + +### ۶. UI پرداخت (frontend) + +در `PaymentStep.tsx`: علاوه بر تخفیف دستی، `GET /session/{uuid}/discount-suggestions` را بخوان و پیشنهادها را نشان بده. اپراتور بتواند یکی را انتخاب (→ `PATCH session { discount_rule_uuid }`) یا حذف کند. نمایش: **مبلغ قبل از تخفیف** (`final_price_rials`)، **مبلغ تخفیف** (`discount_rials`)، **مبلغ نهایی** (`final - discount`)، و **منبع Rule** (`applied_discount_rule_label`). + +## نکات مهم + +- کنترلرها از `BaseController`؛ پاسخ‌ها `$this->success()`/`$this->paginated()`/`$this->error()`. لیست ادمین با array hydration. +- تاریخ‌ها Unix timestamp صحیح؛ قیمت‌ها ریالی (UI تومان → `tomanToRial`). +- تخفیف هرگز از `final_price_rials - paid_total` بیشتر نشود (منطق سقفِ فعلی `applyDiscount` را نگه‌دار/گسترش بده). +- **audit برای گزارش‌گیری**: `applied_discount_rule_id/label` روی session کافی است تا بعداً در گزارش‌های مالی join/گزارش شود؛ در `toArray()` پرونده expose شوند. +- Domain جدید `src/Discount/` طبق ساختار domain-driven پروژه (Controller/Entity/Repository/Service). +- Entity جدید + ستون‌های جدید → **migration لازم** (`doctrine:migrations:diff` سپس `migrate`؛ خطوط drift نامرتبط را از migration پاک کن). +- مستندات: فایل جدید `docs/api/discount.md` + به‌روزرسانی `docs/api/patient.md` برای `discount_rule_uuid` و فیلدهای audit. +- این فیچر بزرگ است — طبق run-prompt هر وظیفه (۱..۶) جدا پیاده، تست و کامیت شود؛ Backend اول (Entity→migration→repo→engine→controller)، سپس frontend. diff --git a/.claude/prompt/doctors-list-city-and-limit.md b/.claude/prompt/doctors-list-city-and-limit.md new file mode 100644 index 00000000..ee68d245 --- /dev/null +++ b/.claude/prompt/doctors-list-city-and-limit.md @@ -0,0 +1,108 @@ +
+ +# افزودن شهر به لیست پزشکان + رفع سقف خاموش limit + +## پروژه + +`clinicpro` (backend) + +**cross-repo:** خروجی این endpoint را `nobat724_front/app/sitemap.js` مصرف می‌کند. +پرامپت همتا (بعد از این اجرا شود): `nobat724_front/.claude/prompt/sitemap-simplify-with-city.md` + +## زمینه + +در ممیزی SEO سایت عمومی دو محدودیت این endpoint باعث دو باگ در sitemap شد: + +۱. **پاسخ لیست پزشکان فیلد شهر ندارد.** سایت عمومی چند-دامنه‌ای است (۳۵ دامنهٔ شهری) و باید بداند هر پزشک به کدام دامنه تعلق دارد. چون شهر در پاسخ نیست، sitemap دامنهٔ اصلی مجبور است **۳۵ بار جداگانه** لیست را با `city_id` بگیرد و از کل کم کند تا بفهمد کدام پزشک شهر اختصاصی ندارد. تولید sitemap ریشه ~۱۳ ثانیه طول می‌کشد. + +۲. **پارامتر `limit` بی‌صدا به ۵۰ سقف می‌خورد.** کلاینت `limit=500` می‌فرستد، پاسخ ۵۰ رکورد است و هیچ نشانه‌ای از سقف‌خوردن در پاسخ نیست. این باعث شد sitemap ماه‌ها روی ۵۰ پزشک بریده بماند (حلقهٔ صفحه‌بندی وقتی `items.length < limit` بود متوقف می‌شد). سمت فرانت با تکیه بر `meta.totalPages` رفع شد، ولی رفتار خاموشِ API همچنان تله است. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Doctor/Entity/Doctor.php` | `toListArray()` — شکل پاسخ لیست | +| `clinicpro/src/Doctor/Repository/DoctorRepository.php` | سقف `limit` در خطوط ۴۵ و ۱۴۲ | +| `clinicpro/src/Doctor/Controller/DoctorController.php` | route `/api/v1/doctors` خط ۲۵۲ | +| `clinicpro/docs/api/doctor.md` | مستندات — الزاماً به‌روز شود | + +## وضعیت فعلی + +`src/Doctor/Entity/Doctor.php:540` — بدون شهر: + +```php +public function toListArray(array $schedules = []): array +{ + $sf = $this->computeScheduleFields($schedules); + return [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'name' => $this->name, + 'gender' => $this->gender, + 'degree' => $this->degree, + 'img' => $this->images ?? [], + 'specialties' => array_map(fn(Specialty $s) => [...], $this->specialties->toArray()), + 'satisfaction' => $this->hasPublicRating() ? (string) $this->doctorRatePercentage : null, + 'point' => $this->hasPublicRating() ? (string) $this->doctorRate : null, + 'free_turn' => $sf['free_turn'], + 'hours_of_work' => $sf['hours_of_work'], + 'active' => $this->activeDoctorAppointment && $sf['has_schedule'], + 'owner_status' => $this->ownerStatus, + ]; +} +``` + +`src/Doctor/Repository/DoctorRepository.php:45` و `:142` — سقف خاموش: + +```php +$limit = min(50, max(1, (int) ($filters['limit'] ?? 10))); +``` + +> نکته: در پاسخ **جزئیات** پزشک (`toDetailArray`) شهر داخل `address[].city` هست، ولی `city` و `state` سطح‌بالا آرایهٔ خالی برمی‌گردند. سایت عمومی برای همین از `address[].city.id` استخراج می‌کند (`nobat724_front/lib/domainHelpers.js` → `extractEntityCityId`). این پرامپت آن رفتار را تغییر نمی‌دهد. + +## وظایف + +### ۱. افزودن شهر به `toListArray()` + +شهرِ پزشک از آدرس‌هایش می‌آید. شهر **اولین آدرس** (یا آدرس اصلی، اگر مفهوم آدرس اصلی وجود دارد) به‌عنوان شهر پزشک برگردد — چون سایت عمومی هم برای canonical دقیقاً همین قاعده («یک شهر اصلی برای پزشک چند-شهری») را اعمال می‌کند. + +```php +'city' => $primaryAddress?->getCity() ? [ + 'id' => (string) $primaryAddress->getCity()->getId(), + 'name' => $primaryAddress->getCity()->getName(), +] : null, +'state' => $primaryAddress?->getProvince() ? [ + 'id' => (string) $primaryAddress->getProvince()->getId(), + 'name' => $primaryAddress->getProvince()->getName(), +] : null, +``` + +- شکل `{ id, name }` باشد تا با `city` در پاسخ لیست کلینیک‌ها یکسان باشد (آنجا آرایه‌ای از همین شکل است). +- `id` رشته باشد — هم‌راستا با بقیهٔ فیلدهای این متد. +- پزشک بدون آدرس → `null` (نه آرایهٔ خالی، تا با «شهر ندارد» تفکیک‌پذیر بماند). +- **N+1 نساز:** آدرس/شهر/استان در همان کوئری `findWithFilters` با `JOIN`/`addSelect` بارگذاری شود، نه lazy per-doctor. + +### ۲. شفاف‌کردن سقف `limit` + +سقف ۵۰ حفظ شود (محافظت از دیتابیس)، ولی دیگر خاموش نباشد: + +- مقدار مؤثر `limit` در `meta` برگردد (اگر الان برنمی‌گردد) تا کلاینت بفهمد درخواستش کوتاه شده. +- در `docs/api/doctor.md` صریح نوشته شود: «`limit` حداکثر ۵۰؛ مقادیر بزرگ‌تر بی‌صدا به ۵۰ کاهش می‌یابند». + +اگر تصمیم گرفتی سقف را برای مصرف‌کنندهٔ sitemap بالاتر ببری، آن را به‌صورت یک حد جداگانه و مستند انجام بده — نه با حذف `min()`. + +### ۳. به‌روزرسانی مستندات + +`clinicpro/docs/api/doctor.md` برای `GET /api/v1/doctors`: +- فیلدهای جدید `city` و `state` با مثال واقعی JSON +- رفتار و سقف `limit` + +## نکات مهم + +- `toListArray()` را مصرف‌کنندگان دیگری هم دارند (پنل ادمین، اپ Tauri). **فیلد اضافه می‌کنیم، فیلد موجود را تغییر نام یا حذف نمی‌کنیم** — تغییر افزایشی و backward-compatible باشد. +- بعد از تغییر، پاسخ واقعی را با یک پزشک دارای آدرس تست کن: + `curl -s "https://clinic-pro.ir/api/v1/doctors?page=1&limit=5" | jq '.data.data[0] | {name, city, state}'` +- پزشک `beaca548-816f-4613-937d-360db01bd7c8` شهرش یاسوج (`city_id: 123`) است — نمونهٔ خوبی برای تأیید. +- Entity تغییر نمی‌کند (فقط متد سریال‌سازی) ⇒ migration لازم نیست. + +
diff --git a/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md b/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md new file mode 100644 index 00000000..4dc6cb2f --- /dev/null +++ b/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md @@ -0,0 +1,320 @@ +# اصلاح تشخیص روز کاری در پنل + حذف محل‌های جعلی از API عمومی + +## پروژه + +`clinicpro` (Backend + پنل ادمین React) + +پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/booking-locations-day-aware.md` +(**backend اول اجرا شود** — قرارداد API تغییر می‌کند.) + +## زمینه + +در تغییرات قبلی، تنظیمات نوبت‌دهی per-context شد: هر پزشک یک برنامه برای مطب شخصی و یکی به ازای +هر کلینیک دارد (`weekly_schedules.clinic_id`، `NULL` = شخصی). همهٔ endpointهای اسلات پارامتر +اختیاری `clinic_uuid` گرفتند و **نبودِ آن یعنی «مطب شخصی»** — نه «هر برنامه‌ای که پیدا شد». + +`ScheduleSection` (تنظیمات نوبت‌دهی) به‌درستی `clinic_uuid` را می‌فرستد، اما **بقیهٔ پنل ادمین +به‌روزرسانی نشد**. این یک رگرسیون است، نه یک قابلیت ناقص. + +## مشکل / هدف + +### مشکل ۱ — «این روز تعطیل است» در پنل + +پزشک `09100652121` در محیط کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`، و مدیر همان کلینیک در +`/admin/appointments`، هر دو پیام «این روز تعطیل است» می‌بینند در حالی که برنامهٔ آن روز در محیط +کلینیک فعال است. + +علت: صفحهٔ نوبت‌ها اسلات‌ها را **بدون `clinic_uuid`** می‌گیرد، پس backend برنامهٔ **مطب شخصی** را +می‌خواند. آن پزشک برنامهٔ شخصیِ تقریباً خالی دارد → صفر اسلات → پیام تعطیلی. + +پیام هم گمراه‌کننده است: `TurnsTimeline` هیچ‌وقت تعطیلی را بررسی نمی‌کند، فقط +`slots.length === 0` را به «تعطیل» ترجمه می‌کند. + +### مشکل ۲ — «مطب شخصی» جعلی در API عمومی + +`GET /api/v1/appointment-booking-locations/{doctorUuid}` برای «دکتر تست» یک محل +`type: "personal"` برمی‌گرداند، در حالی که در دیتابیس: + +```sql +-- برنامه‌های این پزشک: (id, clinic_id, تعداد روز) +2505 NULL 1 -- برنامهٔ شخصی +2509 1003 8 -- برنامهٔ کلینیک + +-- آدرس‌های شخصی این پزشک: +(هیچ ردیفی) + +-- location_id شیفت‌های برنامهٔ شخصی: +NULL +``` + +یعنی محل «مطب شخصی» **هیچ آدرسی ندارد** و شیفتش هم به هیچ آدرسی وصل نیست، ولی در سایت نمایش داده +می‌شود و حتی `opening_hours` تولید می‌کند. `openingHours()` فقط `active` را چک می‌کند و +`location_id` را نادیده می‌گیرد. + +قاعدهٔ درست: محل نوبت‌دهی فقط وقتی وجود دارد که **هم آدرس ثبت شده باشد، هم آن آدرس در شیفت‌های +همان برنامه انتخاب شده باشد**. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/pages/AppointmentsPage.tsx:326` | صفحهٔ `/admin/appointments` | +| `assets/admin/pages/AppointmentsPage.tsx:422-428` | فراخوانی اسلات‌ها — بدون `clinic_uuid` | +| `assets/admin/components/appointments/TurnsTimeline.tsx:166-171` | پیام «این روز تعطیل است» | +| `assets/admin/hooks/useDoctorBookingServices.ts:24-28` | حالت نوبت‌دهی — بدون `clinic_uuid` | +| `assets/admin/components/appointments/ServiceSlotPicker.tsx:58-59` | اسلات سرویسی — بدون `clinic_uuid` | +| `assets/admin/components/NewAppointmentDrawer.tsx:61` | برنامهٔ هفتگی — بدون `clinic_uuid` | +| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx:20-27` | الگوی درستِ استخراج `clinicUuid` | +| `src/Appointment/Controller/AppointmentController.php:270-305` | `bookingLocations()` | +| `src/Appointment/Controller/AppointmentController.php:~700` | `openingHours()` | +| `src/Appointment/Controller/MyAppointmentsController.php:144` | `resolveSlotLocationId` بدون context | +| `src/Admin/Controller/AdminApiController.php:933` | `resolveSlotLocationId` بدون context | +| `src/Appointment/Service/SlotCalculatorService.php` | منبع واحد تولید اسلات | + +## وضعیت فعلی + +### پنل: context حمل نمی‌شود + +`assets/admin/pages/AppointmentsPage.tsx:422-428`: + +```tsx + const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate]; + const slotsQuery = useQuery>({ + queryKey: slotsQueryKey, + queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`), + enabled: viewMode === 'timeline' && !!selectedDoctorUuid, + }); +``` + +`dbUuid` (uuid کلینیک) فقط برای گرفتن فهرست پزشکان استفاده می‌شود +(`/api/v1/clinic/doctor-list/${dbUuid}` در `:384`) و هرگز به‌عنوان `clinic_uuid` ارسال نمی‌شود. + +### پیام گمراه‌کننده + +`assets/admin/components/appointments/TurnsTimeline.tsx:166-171`: + +```tsx + if (!slots.length) return ( +
+
این روز تعطیل است
+
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
+
+ ); +``` + +### محل بدون آدرس فیلتر نمی‌شود + +`src/Appointment/Controller/AppointmentController.php:277-296`: + +```php + $locations = []; + foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) { + $clinic = $schedule->getClinic(); + $meta = $schedule->getMeta(); + $address = $this->addressRepo->findForContext($doctor, $clinic?->getId())[0] ?? null; + + $locations[] = [ + 'location_uuid' => $address?->getUuid(), + 'type' => $clinic === null ? 'personal' : 'clinic', + 'title' => $clinic?->getName() ?? ($address?->getName() ?: 'مطب شخصی'), + ... +``` + +`$address` می‌تواند `null` باشد و همچنان محل ساخته می‌شود. + +## وظایف + +### ۱. حمل context در پنل ادمین + +یک hook مشترک بساز تا منطق در چهار جا تکرار نشود — `assets/admin/hooks/useClinicContext.ts`: + +```ts +/** + * uuid کلینیکِ محیط جاری، یا null برای محیط شخصی پزشک. همان قاعده‌ای که + * ClinicAppointmentSettingsPage استفاده می‌کند. + */ +export function useClinicContext(): string | null { + const dbUuid = useAuthStore(s => s.dbUuid); + const context = useAuthStore(s => s.context); + const availableContexts = useAuthStore(s => s.availableContexts); + + return useMemo(() => { + if (context?.type === 'clinic') return dbUuid; + return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null; + }, [context, dbUuid, availableContexts]); +} +``` + +سپس در این چهار جا مصرفش کن و `clinic_uuid` را به query اضافه کن: + +- `AppointmentsPage.tsx:422-428` (اسلات‌ها) — **حتماً `clinicUuid` را در `queryKey` هم بگذار**، + وگرنه cache بین دو محیط نشت می‌کند. +- `useDoctorBookingServices.ts:24-28` +- `ServiceSlotPicker.tsx:58-59` +- `NewAppointmentDrawer.tsx:61` + +نمونه: + +```tsx +const clinicUuid = useClinicContext(); +const slotsQuery = useQuery>({ + queryKey: ['appt-slots', selectedDoctorUuid, selectedDate, clinicUuid], + queryFn: () => api.get( + `/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` + + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') + ), + enabled: viewMode === 'timeline' && !!selectedDoctorUuid, +}); +``` + +**نکتهٔ مهم:** پزشکی که هم مطب شخصی دارد هم در کلینیک است، در محیط شخصی نباید `clinic_uuid` +بفرستد. `useClinicContext` وقتی `context.type === 'doctor'` است باید `null` برگرداند — قاعدهٔ +fallback به `availableContexts` فقط برای مدیر کلینیک است. اگر این تفکیک را رعایت نکنی، پزشک در +محیط شخصی برنامهٔ کلینیک را می‌بیند و مشکل قبلی وارونه تکرار می‌شود. + +### ۲. پیام دقیق به‌جای «تعطیل» + +`TurnsTimeline.tsx:166-171` نمی‌داند چرا اسلاتی نیست. `GET /api/v1/appointment-slots` را طوری +تغییر بده که دلیل خالی‌بودن را برگرداند: + +```php +return $this->success([ + 'doctor_uuid' => $doctorUuid, + 'clinic_uuid' => $clinic?->getUuid(), + 'date' => $date, + 'sessions' => $sessions, + // چرا خالی است — تا پنل پیام درست بدهد + 'empty_reason' => $sessions === [] ? $this->emptySlotsReason($doctor, $date, $clinic) : null, +]); +``` + +`emptySlotsReason()` یکی از این‌ها را برگرداند: + +| مقدار | معنی | پیام پنل | +|---|---|---| +| `no_schedule` | برنامه‌ای برای این context ثبت نشده | «برای این محل برنامهٔ نوبت‌دهی ثبت نشده است» | +| `holiday` | تعطیلی فعال این روز را پوشش می‌دهد | «این روز تعطیل است» | +| `day_off` | برنامه هست ولی این روز شیفت فعال ندارد | «این روز در برنامهٔ کاری تعریف نشده است» | +| `outside_window` | خارج از بازهٔ نوبت‌دهی یا نوبت‌دهی آنلاین خاموش | «این تاریخ خارج از بازهٔ نوبت‌دهی است» | + +منطقش از همان دادهٔ `SlotCalculatorService` بیرون می‌آید؛ متد کمکی عمومی به آن اضافه کن تا کنترلر +دوباره کوئری نزند. + +### ۳. فیلترکردن محل‌های بدون آدرس در API عمومی + +`bookingLocations()` فقط محلی را برگرداند که **هر دو شرط** را دارد: + +1. حداقل یک آدرس در آن context ثبت شده باشد. +2. حداقل یک شیفت فعال داشته باشد که `location_id`اش یکی از همان آدرس‌ها باشد. + +```php +foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) { + $clinic = $schedule->getClinic(); + $addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId()); + if ($addresses === []) { + continue; // محلی که آدرس ندارد، محل نیست + } + + $byId = []; + foreach ($addresses as $a) { $byId[(int) $a->getId()] = $a; } + + $hours = $this->openingHours($schedule, $byId); + if ($hours === []) { + continue; // هیچ شیفت فعالی روی آدرس‌های این محل نشسته + } + + $address = $byId[$hours[0]['location_id']] ?? $addresses[0]; + ... +} +``` + +و `openingHours()` باید `location_id` را هم بررسی و برگرداند: + +```php +private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array +{ + ... + $locationId = (int) ($session['location_id'] ?? 0); + if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) { + continue; // شیفت بدون آدرس معتبر = قابل رزرو نیست + } + ... + $hours[] = [ + 'day' => ucfirst($dayName), + 'day_index' => (int) $dayIndex, + 'location_id' => $locationId, + 'opens' => $opens, + 'closes' => $closes, + ]; +} +``` + +**این تغییر رفتار عمومی است و باید در `docs/api/appointment.md` صریح ثبت شود:** محلی که آدرس ندارد +یا شیفتی روی آدرسش تعریف نشده، دیگر در `booking_locations` نمی‌آید. + +### ۴. فیلتر بر اساس روز — پارامتر `date` + +سایت باید بتواند بپرسد «در این تاریخ کدام محل‌ها باز است». به `bookingLocations` پارامتر اختیاری +`?date=Y-m-d` اضافه کن: + +- بدون `date` → رفتار فعلی (همهٔ محل‌های معتبر). +- با `date` → فقط محل‌هایی که در آن روز حداقل یک اسلات آزاد دارند + (`$this->slotCalculator->getAvailableSlots($doctor, $date, $clinic)` غیرخالی). + +هر آیتم یک فیلد `available_on_date` (بولین) هم بگیرد تا کلاینت بتواند به‌جای حذف، آن را غیرفعال +نشان دهد. + +پاسخ در حالت `date` باید `date` را echo کند. + +### ۵. دو call site بدون context + +`src/Appointment/Controller/MyAppointmentsController.php:144` و +`src/Admin/Controller/AdminApiController.php:933` هر دو +`resolveSlotLocationId($doctor, $slotStart)` را بدون کلینیک صدا می‌زنند، پس آدرس نوبتِ ثبت‌شده در +کلینیک را `null` یا اشتباه حل می‌کنند. + +هر دو باید context را از همان مسیری که نوبت ساخته می‌شود بگیرند (بدنهٔ درخواست یا +`EntityContextResolver`). اگر context در دسترس نبود، به‌جای حدس‌زدن، آدرس را `null` بگذار و در +لاگ ثبت کن — **حدس‌زدن یعنی ثبت نوبت با آدرس اشتباه**. + +### ۶. پاک‌سازی دادهٔ ناسازگار + +برنامهٔ شخصیِ `id=2505` شیفت فعال با `location_id = NULL` دارد؛ چنین ردیفی امروز از طریق API +ساخته نمی‌شود چون `validateSessions()` جلویش را می‌گیرد، ولی ردیف‌های قدیمی مانده‌اند. + +یک console command بنویس — `app:schedule:audit-locations`: + +- برنامه‌هایی که شیفت فعال با `location_id` تهی یا اشاره به آدرسی خارج از context دارند را + فهرست کند. +- با `--fix` آن شیفت‌ها را `active = false` کند (حذف نکن — دادهٔ کاربر است). +- خروجی: uuid پزشک، context، روز، و `location_id` مشکل‌دار. + +### ۷. تست و مستندات + +تست‌های لازم در `tests/Appointment/`: + +1. اسلات‌های پزشکِ عضو کلینیک با `clinic_uuid` → غیرخالی؛ بدون آن → خالی با + `empty_reason = 'no_schedule'`. +2. `booking_locations` محلی که آدرس ندارد را برنمی‌گرداند. +3. `booking_locations` محلی که آدرس دارد ولی هیچ شیفتی روی آن آدرس نیست را برنمی‌گرداند. +4. `?date=` روزی که فقط کلینیک باز است → فقط یک محل. +5. `empty_reason` برای هر چهار حالت (`no_schedule` / `holiday` / `day_off` / `outside_window`). + +مستندات: `docs/api/appointment.md` — پارامتر `date`، فیلدهای `available_on_date`، `location_id` +داخل `opening_hours`، فیلد `empty_reason` روی `appointment-slots`، و قاعدهٔ جدید فیلترشدن محل‌ها. + +## نکات مهم + +- **این رگرسیون از تغییر per-context قبلی آمده.** هر جای دیگری از پنل که اسلات یا برنامه می‌خواند + و در فهرست بالا نیست را هم بگرد: `grep -rn "appointment-slots\|appointment-service-slots\|month-availability\|weekly-schedule" assets/admin`. +- `SlotCalculatorService` تنها منبع تولید اسلات است و درست کار می‌کند — مشکل در **ورودی**اش است، + نه در خودش. هیچ منطق موازی تولید اسلات نساز. +- Timezone: همهٔ محاسبات با `strtotime`/`date` و timestamp صحیح انجام می‌شود و `SlotCalculator` + فرض می‌کند تاریخ `Y-m-d` محلی است. اگر مشکل روزِ اشتباه دیدی، اول `date_default_timezone` + کانتینر را با `Asia/Tehran` بسنج؛ ولی علتِ گزارش‌شدهٔ فعلی timezone **نیست** — نبودِ + `clinic_uuid` است. +- تعطیلی سراسری پزشک (`holidays.clinic_id IS NULL`) عمداً روی همهٔ محیط‌ها اثر می‌گذارد؛ این + رفتار درست است و نباید تغییر کند. +- کاربر تست: پزشک `09100652121` (uuid `bcabb3a8-cae3-45ec-876c-548f9c1e1569`) در کلینیک + `41e325c4-e825-4067-8438-5d828ecaee09`. مدیر کلینیک برای بازتولید مشکل دوم. +- پاسخ‌ها طبق `BaseController` با `$this->success()` / `$this->error()`؛ تاریخ‌ها timestamp صحیح. diff --git a/.claude/prompt/fix-clinic-doctor-invitation-flow.md b/.claude/prompt/fix-clinic-doctor-invitation-flow.md new file mode 100644 index 00000000..a3ad6781 --- /dev/null +++ b/.claude/prompt/fix-clinic-doctor-invitation-flow.md @@ -0,0 +1,249 @@ +# اصلاح کامل فرآیند دعوت پزشک به کلینیک (Clinic Doctor Invitation) + +## پروژه + +`clinicpro` (backend Symfony + پنل ادمین React). مصرف‌کننده‌ای در `nobat724_front` ندارد — صفحه پذیرش دعوت‌نامه Twig سمت خود Symfony است (`/i/{token}`). + +## زمینه + +کلینیک «علی بهروزی» (موبایل مالک `09024206041`) از مسیر `/admin/settings/clinic-doctors` → «دعوت از پزشکان» یک دعوت‌نامه برای «دکتر تست» با موبایل `09100652121` ارسال کرده است. هیچ‌کدام از سه مرحلهٔ فرآیند درست کار نمی‌کند: + +1. بعد از ارسال دعوت‌نامه هیچ پروفایل پزشکی ساخته نمی‌شود. +2. بعد از باز کردن لینک تأیید و زدن «تأیید»، عملاً هیچ اتفاقی نمی‌افتد؛ پزشک نمی‌تواند با `09100652121` وارد شود و به کلینیک وصل نمی‌شود. +3. در صفحهٔ `/admin/settings/clinic-doctors` اکشن‌های دعوت‌نامه (ارسال مجدد، حذف، تعلیق) خطا می‌دهند. + +ریشهٔ همهٔ اینها مشخص شده است و در بخش «وضعیت فعلی» دقیقاً نقل شده. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/ClinicInvitation/Service/ClinicInvitationService.php` | منطق invite / accept / resend / changeStatus / delete | +| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای JSON `/api/v1/...` | +| `src/ClinicInvitation/Controller/ClinicInvitationWebController.php` | صفحات عمومی `/i/{token}` و `POST /clinic-invitation/{token}/respond` | +| `src/ClinicInvitation/Entity/ClinicDoctorInvitation.php` | Entity دعوت‌نامه (`clinic_doctor_invitations`) | +| `src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php` | `acceptedDoctorIdsByClinic()`، `findPendingByDoctor()` | +| `src/Clinic/Entity/Clinic.php:89-95` | رابطهٔ ManyToMany `clinic_doctors` (طرف owning همین Clinic است) | +| `src/Auth/Controller/PreRegistrationController.php:119-142` | الگوی مرجع ساخت User + Doctor + ارسال پسورد با SMS | +| `assets/admin/components/ClinicDoctorsManager.tsx` | UI لیست پزشکان/دعوت‌نامه‌ها و همهٔ اکشن‌ها | +| `assets/admin/components/ui/InviteDoctorModal.tsx` | فرم ارسال دعوت | +| `assets/admin/api.ts:74` | لایهٔ fetch — پارس پاسخ | +| `docs/api/clinic-invitation.md` | مستند API (باید هم‌راستا شود) | + +## وضعیت فعلی + +### الف) `accept()` هیچ کاربر/پزشکی نمی‌سازد + +`src/ClinicInvitation/Service/ClinicInvitationService.php:73-98` + +```php +public function accept(ClinicDoctorInvitation $inv): void +{ + if (!$inv->isUsable()) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); + } + + $inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED); + $inv->markUsed(); + + $doctor = $inv->getDoctor(); + if ($doctor === null) { + $doctor = $this->doctorRepo->findOneByMobile($inv->getMobile()); + if ($doctor !== null) { + $inv->setDoctor($doctor); + } + } + + if ($doctor !== null) { + $clinic = $inv->getClinic(); + if (!$clinic->getDoctors()->contains($doctor)) { + $clinic->getDoctors()->add($doctor); + } + } + + $this->em->flush(); +} +``` + +`invite()` (`:24-44`) هم فقط پزشک موجود را با موبایل پیدا و attach می‌کند و چیزی نمی‌سازد. +`DoctorRepository::findOneByMobile()` (`src/Doctor/Repository/DoctorRepository.php:31-40`) روی `d.user` join می‌زند و `u.mobileNumber` را می‌سنجد — یعنی فقط پزشکی را پیدا می‌کند که از قبل هم `User` و هم `Doctor` دارد. + +**نتیجه:** برای شمارهٔ `09100652121` که کاربر ندارد، accept فقط وضعیت را `accepted` و توکن را `used` می‌کند؛ `doctor_id` همچنان `NULL` می‌ماند، سطر `clinic_doctors` ساخته نمی‌شود، پزشک لاگین ندارد، و چون `resend()` (`:48`) دعوت‌نامهٔ accepted را رد می‌کند، دعوت‌نامه غیرقابل‌بازیابی می‌شود. همچنین `acceptedDoctorIdsByClinic()` (`Repository:54`) با شرط `i.doctor IS NOT NULL` آن را نادیده می‌گیرد و `/api/v1/doctor/invitations` و `/respond` (`Controller:147, :172`) قبل از هر کاری با «پروفایل پزشک یافت نشد» **404** می‌دهند — این همان ۴۰۴ گزارش‌شده است. + +### ب) تغییر وضعیت (لغو تعلیق) رد می‌شود + +`ClinicDoctorsManager.tsx:235` مقدار `pending` می‌فرستد: + +```tsx +status: inv.status === 'suspended' ? 'pending' : 'suspended' +``` + +اما `ClinicInvitationService.php:59` فقط `['suspended','removed']` را می‌پذیرد و در غیر این‌صورت «وضعیت نامعتبر است» برمی‌گرداند. مستند `docs/api/clinic-invitation.md:176` هم `pending` را مجاز اعلام کرده — یعنی مستند و فرانت با هم موافق‌اند و کد مخالف است. + +### ج) حذف دعوت‌نامه با وجود موفقیت، خطا نشان می‌دهد + +`ClinicInvitationController.php:136` پاسخ می‌دهد `return $this->success(null, 204);` — Symfony بدنهٔ 204 را حذف می‌کند، ولی `assets/admin/api.ts:74` روی هر پاسخ ok بی‌قید `res.json()` صدا می‌زند → `SyntaxError` → `deleteInvMut.onError` در `ClinicDoctorsManager.tsx:97` اجرا می‌شود. + +### د) resend توکن را عوض می‌کند + +`ClinicInvitationService::resend()` → `$inv->refresh()` (`Entity:97`) توکن جدید می‌سازد و لینک SMS قبلی را بی‌سروصدا باطل می‌کند. + +### مسیرهای ثبت‌شده (تأییدشده با `debug:router`) + +``` +POST /api/v1/admin/clinic/{uuid}/invite-doctor +GET /api/v1/admin/clinic/{uuid}/invitations +POST /api/v1/admin/clinic/invitation/{invUuid}/resend +PATCH /api/v1/admin/clinic/invitation/{invUuid}/status +DELETE /api/v1/admin/clinic/invitation/{invUuid} +GET /api/v1/doctor/invitations (ROLE_DOCTOR) +POST /api/v1/doctor/invitation/{invUuid}/respond (ROLE_DOCTOR) +GET /api/v1/clinic-invitation/{token} (public) +POST /api/v1/clinic-invitation/{token}/accept (public) +POST /api/v1/clinic-invitation/{token}/reject (public) +GET /i/{token} | GET /clinic-invitation/{token} (Twig) +POST /clinic-invitation/{token}/respond (CSRF: invitation_{token}) +``` + +مسیرهایی که فرانت صدا می‌زند با اینها یکی است؛ **هیچ 404 مسیرمحوری وجود ندارد** — 404ها از نبودِ پروفایل پزشک می‌آیند. + +## وظایف + +### ۱. ساخت خودکار `User` + `Doctor` هنگام accept (اصلی‌ترین اصلاح) + +در `ClinicInvitationService` یک متد خصوصی `resolveOrCreateDoctor(ClinicDoctorInvitation $inv): Doctor` اضافه کن که: + +1. اگر `$inv->getDoctor()` موجود بود همان را برگرداند. +2. وگرنه با `doctorRepo->findOneByMobile($inv->getMobile())` جست‌وجو کند. +3. وگرنه `User` را با `userRepo->findOneBy(['mobileNumber' => $inv->getMobile()])` پیدا یا بسازد؛ اگر ساخت جدید بود، پسورد تصادفی تولید کند، با hasher هش کند و **حتماً با SMS برای پزشک بفرستد** (بدون این کار پزشک باز هم نمی‌تواند وارد شود). +4. `ROLE_DOCTOR` را به کاربر اضافه کند (`addRole` مثل `PreRegistrationController`). +5. اگر کاربر `Doctor` ندارد، `new Doctor($user, $name)` بسازد و `setMobileNumber()` را ست کند. نام از `$inv->getName()` (عنوان واردشده در دعوت، مثلاً «دکتر تست») و در نبودش از `$user->getRealName()` یا خود موبایل. + +الگوی مرجع — `src/Auth/Controller/PreRegistrationController.php:119-142`: + +```php +$password = bin2hex(random_bytes(4)); +$user = $this->userRepo->findOneBy(['mobileNumber' => $mobile]); +if (!$user) { $user = new User($mobile); } +$user->setPasswordHash($this->hasher->hashPassword($user, $password)); +$user->setRealName($name); +$this->em->persist($user); +... +$user->addRole('ROLE_DOCTOR'); +$doctor = $this->doctorRepo->findOneBy(['user' => $user]); +if (!$doctor) { + $doctor = new Doctor($user, $name); + $doctor->setMobileNumber($mobile); + $this->em->persist($doctor); +} +``` + +سپس `accept()` را بازنویسی کن: + +```php +public function accept(ClinicDoctorInvitation $inv): void +{ + if (!$inv->isUsable()) { + throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410); + } + + $doctor = $this->resolveOrCreateDoctor($inv); // هرگز null برنمی‌گرداند + $inv->setDoctor($doctor); + + $clinic = $inv->getClinic(); + if (!$clinic->getDoctors()->contains($doctor)) { + $clinic->getDoctors()->add($doctor); + } + + $inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED); + $inv->markUsed(); + + $this->em->flush(); +} +``` + +نکات پیاده‌سازی: +- کل accept باید داخل یک transaction باشد (`$this->em->wrapInTransaction(...)`) — نباید حالتی پیش بیاید که دعوت‌نامه `used` شود ولی کاربر ساخته نشود. +- `Doctor` فقط دو فیلد اجباری دارد: `user` و `name` (هر دو آرگومان constructor، `src/Doctor/Entity/Doctor.php:143`)؛ `new Doctor($user, $name)` به‌تنهایی persist‌شدنی است. +- برای لاگین با پسورد، `User::isStaff()` (`src/User/Entity/User.php:130`) لازم است — `ROLE_DOCTOR` این شرط را برآورده می‌کند. +- ارسال SMS پسورد را با همان سرویس SMS و الگوی `dispatchTemplate` انجام بده؛ اگر تمپلیت اختصاصی دعوت وجود ندارد، تمپلیت جدید اضافه کن (از `SmsLog::TAG_PRE_REGISTRATION` الگو بگیر) و متن آن نام کلینیک را هم داشته باشد. +- اگر کاربر از قبل وجود دارد (پسورد دارد)، **پسورد را بازنویسی نکن** — فقط نقش و پروفایل را کامل کن و SMS اطلاع‌رسانی «به کلینیک X متصل شدید» بفرست. + +### ۲. ساخت پروفایل پزشک هنگام ارسال دعوت (اختیاری ولی خواسته‌شدهٔ کاربر) + +کاربر انتظار دارد بلافاصله پس از ارسال دعوت، پروفایل پزشک وجود داشته باشد. در `invite()` هم همان `resolveOrCreateDoctor()` را صدا بزن، اما: + +- در این حالت **پسورد ارسال نکن** و کاربر را در وضعیت «معلق تا تأیید» نگه‌دار — پیشنهاد: `Doctor::setOwnerStatus('unclaimed')` تا زمانی که دعوت accept شود، و در accept به `'claimed'` تغییر کند. +- **مهم:** پزشکِ تازه‌ساخته‌شده نباید قبل از accept به `clinic_doctors` اضافه شود؛ افزودن به کلینیک فقط در accept. +- اگر تصمیم گرفتی این کار را نکنی (به‌دلیل ریسک ساخت کاربر ناخواسته)، در پاسخ به کاربر صریح توضیح بده و در `docs/api/clinic-invitation.md` مستند کن که پروفایل در لحظهٔ accept ساخته می‌شود. + +### ۳. اصلاح `changeStatus` برای پذیرش `pending` + +`ClinicInvitationService.php:59` — `pending` را به وایت‌لیست اضافه کن: + +```php +$allowed = [ + ClinicDoctorInvitation::STATUS_PENDING, + ClinicDoctorInvitation::STATUS_SUSPENDED, + ClinicDoctorInvitation::STATUS_REMOVED, +]; +``` + +مراقب باش: برگشت به `pending` باید دعوت‌نامه را واقعاً قابل‌استفاده کند — اگر `token_used` یا انقضا مانع است، هنگام برگشت به `pending` توکن را refresh کن و SMS دوباره بفرست، یا اگر منطق کسب‌وکار اجازه نمی‌دهد، دکمهٔ لغو تعلیق را در UI برای حالت‌های غیرمجاز غیرفعال کن. حالت انتخابی را در مستند بنویس. + +### ۴. اصلاح پاسخ حذف (رفع toast خطای کاذب) + +دو راه؛ **راه اول ارجح** است: + +- `ClinicInvitationController.php:136` را از `$this->success(null, 204)` به `$this->success(null)` (یعنی 200 با بدنهٔ `{success: true, data: null}`) تغییر بده تا با envelope استاندارد `BaseController` سازگار شود، و `docs/api/clinic-invitation.md` را به‌روز کن. +- یا در `assets/admin/api.ts:74` قبل از `res.json()` شرط `if (res.status === 204) return null;` بگذار. + +پس از تغییر، سایر endpointهایی که 204 برمی‌گردانند را هم بررسی کن تا همین باگ جای دیگری تکرار نشود. + +### ۵. بازبینی کامل همهٔ اکشن‌های صفحهٔ `/admin/settings/clinic-doctors` + +هر شش فراخوانی `ClinicDoctorsManager.tsx` را عملاً تست کن و مطمئن شو خطا نمی‌دهند: + +| خط | فراخوانی | +|---|---| +| `:64` | `GET /api/v1/clinic/doctor-list/${clinicUuid}` | +| `:70` | `GET /api/v1/admin/clinic/${clinicUuid}/invitations?limit=50` | +| `:82` | `POST /api/v1/admin/clinic/invitation/${invUuid}/resend` | +| `:89` | `PATCH /api/v1/admin/clinic/invitation/${invUuid}/status` | +| `:95` | `DELETE /api/v1/admin/clinic/invitation/${invUuid}` | +| `:102` | `DELETE /api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}` | + +به‌علاوه `InviteDoctorModal.tsx:33` → `POST /api/v1/admin/clinic/${clinicUuid}/invite-doctor`. + +نکات: +- بررسی کن `clinicUuid` که `ClinicDoctorsPage.tsx` پاس می‌دهد (`dbUuid`) همان uuid‌ای است که کنترلر انتظار دارد — اگر uuid کاربر به‌جای uuid کلینیک برود، همهٔ این مسیرها 404 می‌دهند. این را با کلینیک واقعی «علی بهروزی» تست کن. +- خطاها باید پیام فارسی معنادار نشان دهند، نه toast عمومی. +- در `resend`، به کاربر هشدار بده که لینک قبلی باطل می‌شود (`Entity:97` توکن جدید می‌سازد). +- `STATUS_REMOVED` (soft delete) از UI اصلاً قابل‌دسترسی نیست چون فرانت همیشه hard delete می‌زند — یا از UI قابل دسترس کن یا حذف کن؛ حالت مرده نگه ندار. + +### ۶. بازیابی دعوت‌نامه‌های خراب‌شدهٔ موجود + +یک migration یا console command بنویس که دعوت‌نامه‌های `status = accepted` با `doctor_id IS NULL` را پیدا کند و برایشان User+Doctor بسازد و به کلینیک وصل کند (همان `resolveOrCreateDoctor`). دعوت‌نامهٔ `09100652121` در کلینیک «علی بهروزی» دقیقاً همین حالت را دارد. + +### ۷. تست انتها‌به‌انتها + +با کلینیک «علی بهروزی» (`09024206041`) این سناریو را کامل اجرا کن: + +1. دعوت پزشک جدید با موبایل تستی. +2. باز کردن `/i/{token}` و زدن «تأیید». +3. بررسی در DB: `users` سطر جدید با `ROLE_DOCTOR`، `doctors` سطر جدید، `clinic_doctors` سطر پیوند، `clinic_doctor_invitations.doctor_id` پرشده. +4. لاگین با آن موبایل و پسورد SMS‌شده از `POST /api/v1/user/login` (یا OTP: در dev کد همیشه `12345`). +5. فراخوانی `GET /api/v1/doctor/invitations` با توکن پزشک — نباید 404 بدهد. +6. بازگشت به `/admin/settings/clinic-doctors` — پزشک باید در لیست پزشکان کلینیک دیده شود. + +## نکات مهم + +- همهٔ controllerها از `BaseController` ارث می‌برند؛ پاسخ‌ها فقط با `$this->success()` / `$this->paginated()` / `$this->error()`. +- `ClinicDoctor` entity وجود ندارد — پیوند یک ManyToMany یک‌طرفه است که owning side آن `Clinic` است (`src/Clinic/Entity/Clinic.php:89-95`)، پس `$clinic->getDoctors()->add($doctor)` درست persist می‌شود ولی عکسش نه. +- مسیرهای عمومی `^/api/v1/clinic-invitation/` در `config/packages/security.yaml:36` و `:95` whitelist شده‌اند؛ اگر endpoint عمومی جدیدی اضافه کردی، آن‌جا هم ثبتش کن. +- فرم Twig در `POST /clinic-invitation/{token}/respond` توکن CSRF با شناسهٔ `invitation_{token}` دارد — اگر فرم را تغییر دادی این را نگه‌دار. +- تاریخ‌ها Unix timestamp صحیح، نمایش شمسی با `formatDate()`. +- در پنل ادمین: لیست‌های paginated → `data?.data` و `data?.meta?.totalRecords`؛ تک‌آیتم → `data?.data`. +- اگر Entity تغییر کرد (مثلاً فیلد جدید روی دعوت‌نامه)، migration بساز. +- **پس از هر تغییر API، `docs/api/clinic-invitation.md` باید در همین session به‌روز شود** (به‌ویژه وایت‌لیست `status` و کد وضعیت حذف). +- `graphify update .` بعد از commit. diff --git a/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md b/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md new file mode 100644 index 00000000..0bf5a65a --- /dev/null +++ b/.claude/prompt/fix-public-booking-state-and-admin-context-slots.md @@ -0,0 +1,220 @@ +# وضعیت نوبت‌دهی عمومی از همهٔ برنامه‌ها + context درست اسلات‌ها در پنل + +## پروژه + +`clinicpro` (Backend + پنل ادمین React) + +پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/doctor-profile-booking-state-from-locations.md` +(**backend اول اجرا شود** — فیلدهای `active` / `free_turn` پاسخ عمومی تغییر می‌کنند.) + +## زمینه — نتیجهٔ عیب‌یابی واقعی (curl + دیتابیس) + +سه علامت گزارش‌شده دوباره بررسی شد. **موتور اسلات سالم است** — هر سه علامت از این است که +«چه کسی، با چه contextی می‌پرسد». شواهد: + +``` +# دادهٔ دیتابیس — دکتر تست (doctor_id=3341, uuid bcabb3a8-…)، کلینیک 1003 (41e325c4-…): +weekly_schedules: + 2505 clinic_id=NULL setting=[{"sessions":[{"active":false,…}]}] ← شخصی، غیرفعال، فرمت لیستِ legacy + 2509 clinic_id=1003 روزهای 0..4 فعال 09:00–13:00، location_id=2631، meta.online_booking_enabled=true + +doctor_addresses: 2631 → type=clinic, clinic_id=1003 ✓ + +# تست مستقیم API (1405/04/27 = 2026-07-18): +GET /api/v1/appointment-slots?doctor_uuid=bcabb3a8…&date=2026-07-18&clinic_uuid=41e325c4… + → sessions پر ✓ +GET /api/v1/appointment-slots?doctor_uuid=bcabb3a8…&date=2026-07-18 (بدون clinic_uuid) + → sessions=[] , empty_reason="day_off" ← برنامهٔ شخصیِ 2505 خوانده می‌شود +GET /api/v1/appointment-booking-locations/bcabb3a8… + → یک محل کلینیکی معتبر با opening_hours و next_available_at ✓ +GET /api/v1/appointment-slots?doctor_uuid=&… + → 404 «دکتر یافت نشد» +``` + +## مشکل / هدف + +### علامت ۱ — سایت عمومی: «نوبت‌دهی غیرفعال است» برای پزشکی که نوبت‌دهی فعال دارد + +`GET /api/v1/doctor/{uuid}` فیلدهای `active` / `free_turn` / `hours_of_work` را از +`scheduleRepo->findByDoctor($doctor)` می‌سازد که **فقط برنامهٔ شخصی** (`clinic_id IS NULL`) +است. دکتر تست برنامهٔ شخصیِ غیرفعال دارد و برنامهٔ کلینیکش دیده نمی‌شود → +`active=false` → سایت «نوبت‌دهی غیرفعال است» نشان می‌دهد. + +### علامت ۲ — پنل با کاربر ادمین: «این روز شیفت کاری ندارد» + +`useClinicContext()` برای `primaryRole === 'admin'` مقدار `null` برمی‌گرداند (ادمین context +کلینیکی ندارد) → اسلات‌ها بدون `clinic_uuid` گرفته می‌شوند → برنامهٔ شخصیِ 2505 → `day_off`. + +### علامت ۳ — پزشک دعوت‌شده در محیط کلینیک: همان پیام + +`AppointmentsPage.tsx:341` مقدار اولیهٔ پزشکِ انتخاب‌شده را از `dbUuid` می‌گیرد؛ برای پزشک +دعوت‌شده در محیط کلینیک، `dbUuid` **uuid کلینیک** است نه پزشک → درخواست +`appointment-slots?doctor_uuid=` → 404 «دکتر یافت نشد» → و چون `TurnsTimeline` +هر حالت ناشناخته/خطا را به `day_off` ترجمه می‌کند، پیام «این روز شیفت کاری ندارد» دیده می‌شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Doctor/Entity/Doctor.php:417-432` | `computeScheduleFields(?WeeklySchedule)` — تک‌برنامه‌ای | +| `src/Doctor/Entity/Doctor.php:512-533` | `toListArray` / `toDetailArray` مصرف‌کننده | +| `src/Doctor/Controller/DoctorController.php:126,179,204,351` | `findByDoctor` (فقط شخصی) | +| `src/Doctor/Controller/DoctorController.php:259-265` | `/api/v1/doctors` — map با overwrite دلبخواهی | +| `src/Appointment/Repository/WeeklyScheduleRepository.php:40` | `findAllByDoctor` (همهٔ contextها) | +| `src/Appointment/Service/SlotCalculatorService.php:182` | `findNextAvailableStart` per-context | +| `assets/admin/pages/AppointmentsPage.tsx:341` | `selectedDoctorUuid` از `dbUuid` | +| `assets/admin/pages/AppointmentsPage.tsx:425-433` | slots query (خودش درست است) | +| `assets/admin/hooks/useClinicContext.ts` | برای admin مقدار null | +| `assets/admin/components/appointments/TurnsTimeline.tsx:149-185` | fallback به `day_off` | +| `assets/admin/stores/authStore.ts` | `doctorUuid` (از `context.doctor_uuid` پر می‌شود) | + +## وضعیت فعلی + +### ۱. فیلدهای عمومی فقط از برنامهٔ شخصی + +`src/Doctor/Controller/DoctorController.php:179` (GET عمومی) و `:351` (PATCH): + +```php +$schedule = $this->scheduleRepo->findByDoctor($doctor); // @deprecated — فقط clinic IS NULL +return $this->success(['data' => array_merge($doctor->toDetailArray($schedule), [... +``` + +`/api/v1/doctors` (`:259-265`) — `findByDoctors` **همهٔ** برنامه‌ها (شخصی + کلینیک) را +برمی‌گرداند و map با overwrite، برنامهٔ «آخری» را نگه می‌دارد — نتیجه دلبخواهی است: + +```php +$scheduleMap = []; +foreach ($this->scheduleRepo->findByDoctors($result['items']) as $schedule) { + $scheduleMap[$schedule->getDoctor()->getId()] = $schedule; // آخری برنده می‌شود +} +``` + +### ۲. انتخاب پزشک در پنل از dbUuid + +`assets/admin/pages/AppointmentsPage.tsx:341`: + +```tsx +const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : ''); +``` + +برای پزشک دعوت‌شده در محیط کلینیک (`context = {type:'clinic', role:'doctor', scope:'clinic'}`)، +`primaryRole='doctor'` و `dbUuid` = uuid **کلینیک** است. `authStore.doctorUuid` +(از `context.doctor_uuid`) uuid درستِ پزشک را دارد و استفاده نمی‌شود. + +### ۳. TurnsTimeline خطا را «روز بدون شیفت» نشان می‌دهد + +`assets/admin/components/appointments/TurnsTimeline.tsx:180`: + +```tsx +if (!slots.length) { + const reason = EMPTY_REASON_TEXT[emptyReason ?? ''] ?? EMPTY_REASON_TEXT.day_off; +``` + +پاسخ 404، خطای شبکه، یا هر `empty_reason` ناشناخته → همیشه «این روز شیفت کاری ندارد». + +## وظایف + +### ۱. تجمیع وضعیت نوبت‌دهی عمومی از همهٔ برنامه‌ها + +`Doctor::computeScheduleFields` آرایه‌ای از برنامه‌ها بگیرد (امضای جدید: +`computeScheduleFields(WeeklySchedule[] $schedules)`؛ null-tolerant برای سازگاری): + +قواعد تجمیع: + +- **`has_schedule` / `active`**: حداقل یک برنامه (در هر context) که هم روز فعال دارد و هم + `meta.online_booking_enabled === true` → true. برنامهٔ شخصیِ خاموش نباید برنامهٔ کلینیکی + روشن را بپوشاند. +- **`free_turn`**: نزدیک‌ترین روز/ساعت در بین **همهٔ** برنامه‌های فعال (همان حلقهٔ فعلی + `computeScheduleParts`، اجراشده روی هر برنامه، سپس min بر اساس فاصلهٔ روز ایرانی). +- **`hours_of_work`**: از همان برنامه‌ای که `free_turn` را داد ساخته شود (ترکیب ساعت‌های دو + محل در یک رشته گمراه‌کننده است). اگر تصمیم دیگری گرفتی در PR توضیح بده. + +سپس چهار call site در `DoctorController` (`:126`، `:179`، `:204`، `:351`) از +`findAllByDoctor($doctor)` استفاده کنند و `/api/v1/doctors` (`:259-265`) map را به +`array` تبدیل کند (`findByDoctors` از قبل همه را می‌آورد — +فقط دیگر overwrite نکن). + +**نکته:** `APPOINTMENT_DISABLED_LABEL` وقتی برگردد که **همهٔ** برنامه‌ها +`online_booking_enabled=false` باشند، نه فقط اولین برنامه (`Doctor.php:423`). + +### ۲. uuid درست پزشک در AppointmentsPage + +```tsx +const doctorUuid = useAuthStore(s => s.doctorUuid); // از context.doctor_uuid +const [selectedDoctorUuid, setSelectedDoctorUuid] = useState( + isDoctor ? (doctorUuid ?? '') : '' +); +``` + +`dbUuid` فقط وقتی uuid پزشک است که `context.type === 'doctor'`؛ به آن اتکا نکن. بررسی کن +`NewAppointmentDrawer` و بقیهٔ مصرف‌کننده‌های `selectedDoctorUuid` هم از همین مقدار +تغذیه می‌شوند (prop می‌گیرند، پس با همین فیکس درست می‌شوند). + +### ۳. TurnsTimeline: خطا ≠ روز بدون شیفت + +- `AppointmentsPage` باید `slotsQuery.isError` و پیام خطای API (`errors[0].message`) را به + `TurnsTimeline` بدهد (prop جدید `errorMessage?: string | null`). +- در `TurnsTimeline`: اول خطا (`errorMessage` → همان پیام + ظاهر خطا)، بعد + `EMPTY_REASON_TEXT[emptyReason]`، و برای reason ناشناخته/غایب یک پیام خنثی: + «برنامهٔ این روز در دسترس نیست» — **هرگز** پیش‌فرض `day_off` نگذار؛ آن پیام یعنی + «backend صریحاً گفت این روز شیفت ندارد». +- دقت: پاسخ خطای API با `success:false` می‌آید؛ `lib/api.ts` را ببین که آیا آن را throw + می‌کند یا resolve — مسیر درست را بر همان اساس بنویس. + +### ۴. انتخاب محل برای ادمین (و هر بیننده‌ای بدون context کلینیک) + +ادمین context کلینیکی ندارد و نباید هم `useClinicContext` برایش چیزی جعل کند. راه درست: +همان منبع سایت عمومی — `GET /api/v1/appointment-booking-locations/{doctorUuid}`: + +- در `AppointmentsPage`، وقتی `isAdmin` و پزشکی انتخاب شده، این endpoint را بگیر + (query key شامل `selectedDoctorUuid`). +- اگر بیش از یک محل بود، یک `SearchableSelect` (قانون پروژه — نه ` set({ kind: e.target.value })}> + + + + +``` + +### `TenantInsuranceContracts.tsx` — یک جدول مسطح، خلاصه فقط در زیرنویس نام + +```tsx +const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? []; +const allInsurances: InsuranceOption[] = (pricingQuery.data as any)?.data?.insurances ?? []; +const activeIds = new Set(contracts.map((c) => c.insurance_id)); +const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id)); +... +
+``` + +## وظایف + +### ۱. انتقال «قیمت ویزیت آزاد» به تنظیمات نوبت‌دهی + +**۱.۱ حذف از `InsurancePricingPage.tsx`:** خط `import FreeVisitPrice ...` و `` را بردار. توضیح `PageHeader` را به «قراردادهای بیمه پایه و تکمیلی» تغییر بده (دیگر ویزیت آزاد اینجا نیست). + +**۱.۲ افزودن به `AppointmentSettingsPage.tsx`:** `import FreeVisitPrice from '../components/FreeVisitPrice';` و کارت را **بالای** `WeeklyScheduleTab` رندر کن. + +نکته‌ی مهم — گاردِ «فقط پزشک»: `FreeVisitPrice` از `GET/PUT /api/v1/insurance-pricing` استفاده می‌کند که entity را از روی نقش کاربر (`ROLE_DOCTOR` یا `ROLE_CLINIC`) resolve می‌کند (خط ۴۸ کنترلر)، پس هم برای پزشک و هم کلینیک کار می‌کند — اما `AppointmentSettingsPage` وقتی `uuid` پزشک نباشد کل محتوا را با پیام «این بخش فقط برای پزشک در دسترس است» جایگزین می‌کند. کارت قیمت ویزیت آزاد را **بیرون از** شرط `!uuid` و **قبل از** آن قرار بده تا مستقل از داشتن `doctorUuid` همیشه نمایش داده شود: + +```tsx +return ( + +
+

مدیریت نوبت دهی

+ + + + {!uuid ? ( +
این بخش فقط برای پزشک در دسترس است.
+ ) : isLoading ? ( + ... + ) : ( + + )} +
+
+); +``` + +### ۲. حذف انتخاب دستی نوع بیمه از `InsuranceModal` + +نوع بیمه دیگر دستی انتخاب نمی‌شود؛ از Tab فعال می‌آید. + +**۲.۱** بلوک `
` دوم با `
شرحتاریخ مراجعهتعدادمبلغ کلسهم بیمه (ادعا)تأییدشده
+ {c.insurance_name ?? `#${c.insurance_id}`} +
+ پوشش {c.coverage_percent}٪ + {c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`} + {c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`} +
+
`) وقتی `expanded === c.uuid` رندر شود و همه‌ی فیلدهای کامل قرارداد را نشان دهد: + - درصد پوشش، فرانشیز، سقف تعهد سالانه + - تاریخ شروع و پایان قرارداد (`effective_from` / `effective_to`) با `formatDate` شمسی (`assets/admin/lib/utils.ts`)؛ اگر `effective_to == null` → «بدون تاریخ پایان» + - نسخه‌ی قرارداد (`version`) و کد بیمه (`insurance_id`) + - وضعیت فعال/غیرفعال +- انیمیشن باز/بسته‌شدن نرم باشد (از `--ease` استفاده کن یا یک transition ساده روی ارتفاع/opacity). + +**۴.۳ نسخه‌ی موبایل (`md:hidden`):** همان الگوی Expandable روی کارت‌ها — کارت جمع‌شده خلاصه را نشان دهد و با کلیک جزئیات کامل باز شود. + +**۴.۴ Empty state هر Tab:** اگر قراردادی برای Tab فعال نبود، پیام مناسب همان نوع: «هنوز بیمه‌ی پایه‌ای اضافه نکرده‌اید.» / «هنوز بیمه‌ی تکمیلی‌ای اضافه نکرده‌اید.» (به‌جای پیام عمومی فعلی). + +## نکات مهم + +- **بدون تغییر backend و بدون migration.** فقط frontend. اگر حین کار حس کردی endpoint کم دارد، اول دوباره بگرد — احتمالاً داده در همان `insurance-pricing` / `tenant-insurances` هست. +- **SOLID / تک‌مسئولیتی (قاعده ۱):** ردیف Expandable و کارت موبایل را به کامپوننت‌های کوچک جدا کن (مثلاً `ContractRow`, `ContractCard`, `ContractDetails`) تا `TenantInsuranceContracts` متورم نشود. `StatusToggle` و `Row` فعلی را نگه‌دار/بازاستفاده کن. +- **توکن‌های طراحی:** هیچ رنگ hex هاردکد نکن؛ از `var(--...)` استفاده کن (`--primary`, `--surface-2`, `--border`, `--text-2/3`, `--success`, `--r`, `--ease`). منبع توکن‌ها `assets/admin/styles.css` است. +- **RTL و فارسی:** همه‌ی رشته‌ها فارسی، اعداد و مبالغ فارسی از طریق `formatRial`/`formatNumber`/`formatDate`. از `insetInlineStart`/`paddingInlineStart` (نه left/right) مثل کد فعلی. +- **`insurance_kind` ممکن است `null` باشد** (قراردادهای قدیمی که `kind` نداشتند و `type` کاتالوگشان هم null بوده) — در فیلتر Tab آن را به `'basic'` fallback بده تا گم نشود. +- **حالت ویرایش:** نوع بیمه در ویرایش تغییر نمی‌کند؛ مودال در ویرایش `kind` قرارداد را حفظ می‌کند و لیست کامل `allInsurances` را می‌دهد (چون `insuranceId` قفل/`isDisabled` است). +- **تست‌ها (قاعده ۴):** فایل تست موجود `assets/admin/components/TenantInsuranceContracts.test.tsx` و `InsuranceModal.test.tsx` را به‌روزرسانی/گسترش بده — سناریوها: (الف) فیلتر Tab قراردادها را درست جدا می‌کند، (ب) لیست انتخاب افزودن فقط بیمه‌های همان نوع را دارد، (ج) کلیک روی ردیف جزئیات را باز/بسته می‌کند، (د) کلیک روی دکمه‌ی ویرایش/سوییچ ردیف را toggle نمی‌کند (`stopPropagation`)، (ه) `AppointmentSettingsPage.test.tsx`: `FreeVisitPrice` رندر می‌شود حتی وقتی `uuid` نیست. تست‌ها را با `yarn test` سبز کن. +- **type check:** `npx tsc --noEmit --project tsconfig.json` بدون خطا. +- **مصرف‌کننده‌ی دیگر `FreeVisitPrice`:** مطمئن شو جایی جز `InsurancePricingPage` آن را import نمی‌کند (grep) تا انتقال چیزی را نشکند. diff --git a/.claude/prompt/insurance-shared-calculation.md b/.claude/prompt/insurance-shared-calculation.md new file mode 100644 index 00000000..d47eaa65 --- /dev/null +++ b/.claude/prompt/insurance-shared-calculation.md @@ -0,0 +1,202 @@ +# اصلاح منطق بیمه: یک منبع واحد محاسبه برای پرداخت، فاکتور و Claim + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React) + +پرامپت همتا: `clinicpro/.claude/prompt/claims-dashboard-redesign.md` (بازطراحی صفحه `/admin/claims`) — **اول این پرامپت اجرا شود**، چون داشبورد Claims به فیلدهای محاسباتی این پرامپت وابسته است. + +## زمینه + +در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09` یک سرویس دارای پوشش بیمه ساخته شده (`/admin/clinic-services/f3e46236-7ddd-49d0-a725-d731c74c24f7`) و برای بیمار `ad0a3d0e-5514-462c-9fcf-20748c1c5e46` ثبت شده است. در صفحه تکمیل پرداخت +`/admin/patients/ad0a3d0e-5514-462c-9fcf-20748c1c5e46/session/4f66d5c0-028f-424f-bec7-a10857f11c04/pay` +پوشش بیمه اعمال نمی‌شود و مبلغ قابل پرداخت بیمار برابر کل مبلغ سرویس نمایش داده می‌شود. + +ریشه مشکل: **دو مسیر محاسباتی مستقل** وجود دارد و `PatientSession` هیچ ستونی برای سهم بیمه ندارد؛ بنابراین breakdown بیمه فقط بعد از ساخت `Invoice` وجود دارد و صفحه پرداخت اصلاً آن را نمی‌بیند. + +## مشکل / هدف + +۱. حذف محاسبه inline ویزیت در `PatientService::calculateFinalPrice()` و یکی‌کردن همه‌ی محاسبات روی `BillingCalculator`. +۲. ذخیره breakdown بیمه روی `PatientSession` تا صفحه پرداخت، فاکتور، سهم بیمار/بیمه، مانده و وضعیت پرداخت همگی از یک مقدار بخوانند. +۳. نمایش سهم بیمه پایه/تکمیلی در صفحه پرداخت. +۴. رفع ناسازگاری‌های فرمول مانده و over-payment guard. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Billing/Service/BillingCalculator.php` | تنها منبع درست محاسبه سهم‌ها (percent + franchise + ceiling) | +| `src/Billing/ValueObject/Money.php` | VO پول؛ `sub()` در صفر clamp می‌شود | +| `src/Insurance/Service/TenantInsuranceService.php` | `coverageRule()` و `coverageRuleForService()` — resolve قرارداد + override سرویس | +| `src/Insurance/Entity/TenantInsurance.php` | قرارداد: `coveragePercent`, `franchiseRials`, `annualCeilingRials`, `isActive`, `effectiveFrom/To` | +| `src/Insurance/Entity/TenantServiceCoverage.php` | override به ازای (قرارداد، serviceItem): `covered`, `coveragePercent`, `franchiseRials`, `ceilingRials` (null = ارث از قرارداد) | +| `src/ClinicService/Entity/ServiceItem.php` | `insuranceCovered` (گیت bool)، `priceRials`، `insurancePriceRials` (فعلاً dead data) | +| `src/Patient/Service/PatientService.php` | `calculateFinalPrice()`، `recomputeSettlement()`، `addSessionPayment()`، `updatePayment()` | +| `src/Patient/Entity/PatientSession.php` | `finalPriceRials`, `servicesTotalRials`, `discountRials`, `getPaidTotalRials()`, `getRemainingRials()` | +| `src/Billing/Service/InvoiceService.php` | ساخت فاکتور از session | +| `src/Patient/Controller/PatientController.php` | `POST /api/v1/session/{uuid}/payments` و لیست sessionها | +| `assets/admin/components/session/PaymentStep.tsx` | UI صفحه پرداخت (مشترک با `NewSessionPage`) | +| `assets/admin/components/InvoiceSummaryModal.tsx` | مودال فاکتور بیمار | + +## وضعیت فعلی + +`src/Billing/Service/BillingCalculator.php` (منطق درست): + +```php +$baseShare = $total->percent($base->coveragePercent); +if ($base->ceilingRials !== null) $baseShare = $baseShare->min(new Money($base->ceilingRials)); +$remaining = $total->sub($baseShare); +// تکمیلی روی باقی‌مانده اعمال می‌شود، نه روی کل +$suppShare = $remaining->percent($supplementary->coveragePercent); +... +$franchise = new Money(($base?->franchiseRials ?? 0) + ($supplementary?->franchiseRials ?? 0)); +$patient = $remaining->add($franchise)->min($total); +``` + +`src/Patient/Service/PatientService.php::calculateFinalPrice()` — سرویس‌ها از `BillingCalculator` می‌آیند اما **ویزیت inline حساب می‌شود** و آن هم از درصدهای ذخیره‌شده روی session، نه از قرارداد: + +```php +$afterBase = $visitPrice * (1 - $baseDiscount / 100); +$afterSupp = $afterBase * (1 - $suppDiscount / 100); +$visitShare = (int) round($afterSupp); +``` + +`assets/admin/components/session/PaymentStep.tsx:117-122` — کل محاسبه سمت کلاینت: + +```ts +const finalPrice = session.final_price_rials ?? 0; +const discountRials = session.discount_rials ?? 0; +const payable = Math.max(0, finalPrice - discountRials); +``` + +هیچ فیلد `base_insurance_rials` / `supplementary_rials` در پاسخ session وجود ندارد، پس سهم بیمه اصلاً قابل نمایش نیست. + +`InvoiceSummaryModal.tsx:66-73` — مانده در شاخه‌ی session سهم بیمه را نادیده می‌گیرد: + +```ts +const remaining = session + ? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials) + : inv ? (paid ? 0 : inv.patient_rials) : 0; +``` + +## وظایف + +### ۱. دیباگ اولیه: چرا پوشش بیمه اعمال نشده؟ + +قبل از هر تغییر کد، با داده واقعی بررسی کن (روی ddev): + +```bash +ddev exec php bin/console dbal:run-sql "SELECT id, insurance_covered, price_rials, insurance_price_rials FROM service_item WHERE uuid = 'f3e46236-7ddd-49d0-a725-d731c74c24f7'" +ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_insurance WHERE entity_type='clinic' AND is_active=1" +ddev exec php bin/console dbal:run-sql "SELECT * FROM tenant_service_coverage" +ddev exec php bin/console dbal:run-sql "SELECT uuid, insurance_base_id, insurance_supplementary_id, services_total_rials, final_price_rials, discount_rials FROM patient_session WHERE uuid = '4f66d5c0-028f-424f-bec7-a10857f11c04'" +``` + +سه fail-point محتمل را مشخص کن و در گزارش بنویس کدام‌یک بوده است: +- `service_item.insurance_covered = 0` → گیت بسته است. +- `patient_session.insurance_base_id = NULL` → بیمه هنگام ثبت سرویس به session نچسبیده (احتمالاً UI ثبت سرویس بیمه بیمار را ارسال نمی‌کند). +- `tenant_insurance` برای این کلینیک وجود ندارد یا `effective_from/to` بازه‌ی تاریخ session را پوشش نمی‌دهد. + +اگر fail-point «بیمه به session نچسبیده» بود، مسیر ثبت سرویس برای بیمار را هم اصلاح کن تا `insurance_base_id`/`insurance_supplementary_id` از بیمه‌ی ثبت‌شده‌ی بیمار پر شود. + +### ۲. یکی‌کردن محاسبه ویزیت + +در `PatientService::calculateFinalPrice()` محاسبه inline ویزیت را حذف کن و مثل خطوط سرویس از `TenantInsuranceService::coverageRule()` + `BillingCalculator::calculateItem()` استفاده کن — دقیقاً همان چیزی که `InvoiceService.php:48-51` انجام می‌دهد. + +فیلدهای `base_insurance_discount_percent` / `supplementary_discount_percent` روی session را به‌عنوان **snapshot** نگه دار (backward compat) اما دیگر ورودی محاسبه نباشند؛ بعد از محاسبه از روی درصدهای قرارداد پرشان کن. + +### ۳. ذخیره breakdown بیمه روی PatientSession + +سه ستون جدید به `PatientSession` اضافه کن (nullable-not، default 0): + +- `baseInsuranceRials` +- `supplementaryInsuranceRials` +- `patientShareRials` + +قرارداد: `patientShareRials` همان چیزی است که `finalPriceRials` باید باشد (سهم بیمار **قبل** از تخفیف دستی). یعنی: + +``` +servicesTotalRials = مجموع مبلغ اصلی همه اقلام (ویزیت + سرویس‌ها) +baseInsuranceRials + supplementaryInsuranceRials + patientShareRials = servicesTotalRials +finalPriceRials = patientShareRials +payable = finalPriceRials - discountRials +remaining = max(0, payable - paidTotal) +``` + +هر جا session ذخیره یا بازمحاسبه می‌شود این سه ستون هم نوشته شوند. migration لازم است: + +```bash +ddev exec php bin/console make:migration +ddev exec php bin/console doctrine:migrations:migrate -n +``` + +**Backfill:** برای sessionهای موجود، مقدار `patientShareRials = finalPriceRials` و دو ستون بیمه = 0 ست شود تا رفتار قدیمی نشکند. + +### ۴. حذف تکرار فرمول مانده و over-payment guard + +- `PatientService::updatePayment()` (حدود `:419-421`) که `payable = finalPrice - discount` را inline دوباره می‌سازد را حذف کن و از `PatientSession::getRemainingRials()` استفاده کن — همان چیزی که `addSessionPayment()` (`:570`) استفاده می‌کند. +- در `updatePayment` هنگام ویرایش یک پرداخت موجود، مبلغ همان پرداخت باید از `paidTotal` کسر شود وگرنه ویرایش به سمت بالا اشتباهاً reject می‌شود. این edge case را تست کن. + +### ۵. خروجی API + +در `toArray()` مربوط به session (مسیر `GET /api/v1/patient/{uuid}/sessions` و پاسخ‌های `POST/PATCH /api/v1/session/{uuid}/...`) این فیلدها اضافه شوند: + +```json +{ + "services_total_rials": 0, + "base_insurance_rials": 0, + "supplementary_insurance_rials": 0, + "patient_share_rials": 0, + "final_price_rials": 0, + "discount_rials": 0, + "paid_total_rials": 0, + "remaining_rials": 0, + "insurance_base_title": null, + "insurance_supplementary_title": null +} +``` + +`remaining_rials` را سرور بدهد تا کلاینت دیگر مانده را خودش نسازد. + +### ۶. UI صفحه پرداخت + +در `assets/admin/components/session/PaymentStep.tsx`: + +- به بخش خلاصه مبالغ (`:196-209`) این ردیف‌ها اضافه شود، **فقط وقتی مقدارشان > 0 است**: + - `سهم بیمه پایه` (+ نام بیمه) + - `سهم بیمه تکمیلی` + - `سهم بیمار` +- `payable` دیگر client-side ساخته نشود؛ از `remaining_rials` سرور استفاده شود. +- ترتیب نمایش: هزینه کل خدمات → سهم بیمه پایه → سهم بیمه تکمیلی → سهم بیمار → تخفیف → مبلغ نهایی قابل پرداخت → پرداخت‌شده → مانده. + +**احتیاط:** این کامپوننت با `NewSessionPage` (ویزارد ۳ مرحله‌ای) مشترک است — هر دو مسیر باید تست شوند. + +### ۷. اصلاح مودال فاکتور + +در `assets/admin/components/InvoiceSummaryModal.tsx`: + +- دو شاخه‌ی واگرای «با session» و «بدون session» را یکی کن؛ هر دو باید ستون‌های یکسان نشان دهند: جمع خدمات / سهم بیمه پایه / سهم بیمه تکمیلی / سهم بیمار / تخفیف / مبلغ نهایی / پرداخت‌شده / مانده. +- `remaining` را از `remaining_rials` سرور بگیر، نه از فرمول محلی. +- منطق «وضعیت واقعی پرداخت مستقل از وضعیت فریزشده فاکتور» (تسویه‌شده اگر مانده صفر) عمداً وجود دارد — حفظش کن. + +## نکات مهم + +- تکمیلی روی **باقی‌مانده بعد از پایه** اعمال می‌شود، نه روی کل. این قاعده در `BillingCalculator` درست است و نباید تغییر کند. +- `ServiceItem.insurancePriceRials` فعلاً write-only است و هیچ محاسبه‌ای نمی‌خواندش. یا آن را به‌عنوان «مبلغ ثابت پوشش» وارد `BillingCalculator` کن (اولویت بالاتر از percent) یا از UI و `toArray()` حذفش کن — تصمیم را در گزارش بنویس. حالت نصفه‌کاره نگه‌داشتنش قابل قبول نیست. +- `annualCeilingRials` امروز به‌صورت **سقف هر قلم** اعمال می‌شود در حالی که نامش سقف سالانه است. انباشت سالانه‌ای در کد نیست. رفتار فعلی را تغییر نده اما در کامنت و در گزارش صریح ذکرش کن. +- مرز ریال/تومان: ورودی‌های UI تومان‌اند، API ریال. از `tomanToRial` / `rialToToman` در `lib/utils.ts` استفاده شود (`RIAL_PER_TOMAN = 10`). +- `Money::sub()` در صفر clamp می‌شود و مقدار منفی نمی‌پذیرد — روی مبالغ سهم‌ها به آن تکیه کن، `max(0, ...)` دستی ننویس. +- قیمت سرویس در session هرچه caller بفرستد ذخیره می‌شود، ولی فاکتور دوباره از `TariffService::resolvePrice()` برای سال جلالی جاری resolve می‌کند. این واگرایی را حل کن: session هم باید از `TariffService` قیمت بگیرد. +- همه controllerها از `BaseController` ارث می‌برند؛ پاسخ‌ها با `$this->success()` / `$this->paginated()` / `$this->error()`. +- تاریخ‌ها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`. +- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`. +- بعد از تغییر API، فایل‌های مربوطه در `clinicpro/docs/api/` به‌روز شوند. + +## تست پذیرش + +۱. سرویس `f3e46236-...` برای بیمار `ad0a3d0e-...` ثبت شود؛ در صفحه `/pay` باید سهم بیمه پایه و سهم بیمار جدا نمایش داده شوند و مبلغ قابل پرداخت = سهم بیمار باشد. +۲. همان session → مودال فاکتور: اعداد باید **دقیقاً** با صفحه پرداخت یکی باشند. +۳. پرداخت جزئی ثبت شود → مانده در هر دو صفحه یکسان کم شود. +۴. پرداخت کامل → وضعیت در هر دو جا «تسویه شده». +۵. سرویسی بدون پوشش بیمه → سهم بیمه ۰، رفتار قبلی بدون تغییر. +۶. ویرایش یک پرداخت موجود به مبلغ بالاتر → نباید اشتباهاً «بیش از مانده» reject شود. diff --git a/.claude/prompt/inventory-unit-select-and-category.md b/.claude/prompt/inventory-unit-select-and-category.md new file mode 100644 index 00000000..3d303ff4 --- /dev/null +++ b/.claude/prompt/inventory-unit-select-and-category.md @@ -0,0 +1,247 @@ +# واحد کالا به‌صورت Select + سیستم دسته‌بندی اصولی کالا (انبارداری) + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React). صفحه هدف: `/admin/inventory`. + +## زمینه + +بخش انبارداری (`InventoryPage`) اجازه ایجاد/ویرایش «کالا» را می‌دهد. دو ضعف طراحی وجود دارد: + +1. **واحد (`unit`)** به‌صورت متن آزاد وارد می‌شود (`AddItemModal` فقط یک `` متنی است، پیش‌فرض `'عدد'`). نتیجه: داده ناهمگون («cc»، «سی سی»، «سیسی»، «میلی لیتر»، «ml» و …) که گزارش‌گیری و یکپارچگی را خراب می‌کند. + +2. **دسته‌بندی وجود ندارد.** چیزی که امروز به‌عنوان «دسته» کار می‌کند در واقع فیلد متن‌آزاد `consumable` («مصرفی») است: اندپوینت `GET /api/v1/inventory-categories` مقادیر متمایز همین ستون را برمی‌گرداند (`InventoryItemRepository::findConsumables`)، و صفحه با `it.consumable === category` فیلتر می‌کند. این یعنی «دسته‌بندی» عملاً متن آزاد و بی‌ساختار است. + +هدف: هر دو فیلد را به لیست‌های استاندارد و **محدودشده (bounded)** تبدیل کنیم که **منبعِ صدق‌شان Backend** باشد، تا فرانت و بک هرگز از هم جدا نیفتند. + +## مشکل / هدف + +- `unit`: تبدیل به Select از واحدهای استاندارد و پرکاربرد مطب/کلینیک. +- افزودن `category`: فیلد دسته‌بندی واقعی و اصولی، از یک لیست ثابت استاندارد، جایگزینِ نقشِ فیلترِ `consumable`. +- لیست هر دو باید در Backend تعریف شود و از طریق یک اندپوینت واحد به فرانت داده شود (بدون هاردکد دوباره در فرانت → جلوگیری از drift). + +## فایل‌های مرتبط + +| فایل | نقش | تغییر | +|------|-----|-------| +| `src/Inventory/Entity/InventoryItem.php` | Entity کالا | افزودن ستون `category`؛ نگهدارنده لیست‌های مجاز | +| `src/Inventory/Controller/InventoryController.php` | endpointها | endpoint متادیتا + اعتبارسنجی `unit`/`category` | +| `src/Inventory/Repository/InventoryItemRepository.php` | کوئری‌ها | `findConsumables` → مبتنی بر `category` | +| `src/Inventory/Service/InventoryService.php` | منطق دامنه | جای مناسب برای منبع لیست‌ها (Vocabulary) | +| `assets/admin/components/inventory/AddItemModal.tsx` | فرم افزودن/ویرایش | دو `` → دو Select | +| `assets/admin/hooks/useInventory.ts` | data hook | type `category`، کوئری متادیتا | +| `assets/admin/pages/InventoryPage.tsx` | صفحه | فیلتر بر اساس `category` | +| `migrations/VersionXX; docs/api/inventory.md` | مهاجرت + مستند | ستون جدید + قرارداد endpoint | + +## وضعیت فعلی (کد واقعی) + +**Entity — `InventoryItem.php`** (واحد متن‌آزاد، بدون دسته): + +```php +#[ORM\Column(type: 'string', length: 30)] +private string $unit = 'عدد'; + +/** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */ +#[ORM\Column(type: 'string', length: 120, nullable: true)] +private ?string $consumable = null; +``` + +**Controller — اعمال فیلدها بدون اعتبارسنجی مقدار مجاز:** + +```php +if (array_key_exists('unit', $data)) { + $unit = trim((string) $data['unit']); + $item->setUnit($unit === '' ? 'عدد' : $unit); +} +``` + +**«دسته‌ها» امروز = مقادیر متمایز `consumable`:** + +```php +// InventoryItemRepository::findConsumables +->select('DISTINCT i.consumable AS consumable') +->where('i.entityType = :type AND i.entityId = :id AND i.consumable IS NOT NULL AND i.consumable != :empty') +``` + +**Modal — واحد به‌صورت input متنی:** + +```tsx +const fields = [ + { key: 'name', label: 'نام کالا', placeholder: 'نام کالا' }, + { key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' }, + { key: 'unit', label: 'واحد', placeholder: 'عدد' }, // ← متن آزاد + ... +]; +``` + +**صفحه — فیلتر بر اساس `consumable`:** + +```tsx +const [category, setCategory] = useState(''); +const filteredItems = items.filter((it) => + ... && (category === '' || it.consumable === category) // ← consumable نقش دسته +); +``` + +## وظایف + +### ۱. تعریف Vocabulary استاندارد در Backend (منبع صدق) + +یک منبع واحد برای لیست واحدها و دسته‌ها بساز. جای پیشنهادی: constant روی `InventoryItem` (یا کلاس کوچک `InventoryVocabulary` در `src/Inventory/`). ساختار پیشنهادی: آرایه‌ی `value => label`؛ `value` انگلیسی پایدار (برای ذخیره)، `label` فارسی (برای نمایش). این هم i18n را تمیز نگه می‌دارد هم داده را پایدار. + +> اگر ترجیح می‌دهی ساده‌تر بمانی و مقدارِ ذخیره‌شده همان برچسب فارسی باشد (هم‌راستا با وضعیت فعلی که `unit` فارسی ذخیره می‌شود)، می‌توانی فقط لیست فارسی مسطح نگه داری. **در این صورت حتماً یک لیست ثابت واحد در Backend داشته باش و فرانت آن را از endpoint بگیرد — نه هاردکد جدا.** تصمیم را در همان session بگیر و در `docs/api/inventory.md` مستند کن. + +**واحدهای استاندارد (کلینیک/مطب) — لیست پیشنهادی:** + +``` +عدد، جفت، دست، بسته، جعبه، قوطی، تیوب، ویال، آمپول، +قرص، کپسول، ورق (بلیستر)، ساشه، رول، متر، سانتی‌متر، +سی‌سی، میلی‌لیتر، لیتر، میلی‌گرم، گرم، کیلوگرم، کیسه، عدد استریل +``` + +پیشنهاد نهایی مرتب و بدون تکرار (حدود ۱۸–۲۰ واحد). واحدهای پرکاربرد را بالای لیست بگذار (عدد، بسته، ویال، آمپول، سی‌سی، میلی‌لیتر). + +**دسته‌بندی‌های استاندارد کلینیک/مطب — لیست پیشنهادی:** + +``` +دارو +لوازم مصرفی و تزریقات (سرنگ، سرسوزن، گاز، پنبه) +لوازم پانسمان و بخیه +مواد ضدعفونی و استریلیزاسیون +تجهیزات پزشکی +بیهوشی و بی‌حسی +لوازم زیبایی و پوست (بوتاکس، فیلر، مزو) +لوازم آزمایشگاهی +لوازم دندان‌پزشکی +ملزومات اداری و مصرفی دفتری +سایر +``` + +این لیست‌ها را در Backend به‌صورت constant قابل‌توسعه بگذار و در docblock توضیح بده که افزودن گزینه = افزودن به همین آرایه (بدون migration، چون مقدار در ستون string ذخیره می‌شود). + +### ۲. Entity: افزودن ستون `category` + اعتبارسنجی مقدار + +- ستون جدید در `InventoryItem`: + +```php +#[ORM\Column(type: 'string', length: 60, nullable: true)] +private ?string $category = null; + +public function getCategory(): ?string { return $this->category; } +public function setCategory(?string $v): self { $this->category = $v; return $this->touch(); } +``` + +- `category` را به `toArray()` اضافه کن. +- constantهای لیست مجاز (`UNITS`, `CATEGORIES`) را روی همین کلاس (یا Vocabulary) قرار بده و در docblock کلاس، توضیح `consumable` را اصلاح کن (دیگر «doubles as filter group» نیست). + +> `consumable` را حذف نکن — سازگاری عقب‌رو و کلاینت tauri را نشکن. آن را همان فیلد یادداشت/طبقه‌بندی آزاد باقی بگذار، اما نقش «دسته/فیلتر» را از آن بردار. + +### ۳. Controller: endpoint متادیتا + اعتبارسنجی نوشتن + +- **endpoint جدید متادیتا** (لیست‌ها را به فرانت بده): + +```php +#[Route('/api/v1/inventory-meta', methods: ['GET'])] +public function meta(): JsonResponse +{ + return $this->success([ + 'units' => InventoryItem::UNITS, // یا Vocabulary::units() + 'categories' => InventoryItem::CATEGORIES, + ]); +} +``` + +- در `applyItemFields()`: + - `unit`: اگر مقدار در لیست مجاز نبود → یا `ERR_VALIDATION_001` با فیلد `unit`، یا fallback به `'عدد'`. اعتبارسنجی سخت‌گیرانه ترجیح داده می‌شود (پیام فارسی: «واحد نامعتبر است»). + - `category`: کلید جدید؛ خالی → `null`؛ مقدار نامعتبر → `ERR_VALIDATION_001` فیلد `category` («دسته‌بندی نامعتبر است»). + +```php +if (array_key_exists('unit', $data)) { + $unit = trim((string) $data['unit']); + if ($unit !== '' && !array_key_exists($unit, InventoryItem::UNITS)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'واحد نامعتبر است', 422); + } + $item->setUnit($unit === '' ? 'عدد' : $unit); +} +if (array_key_exists('category', $data)) { + $cat = trim((string) $data['category']); + if ($cat !== '' && !array_key_exists($cat, InventoryItem::CATEGORIES)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دسته‌بندی نامعتبر است', 422); + } + $item->setCategory($cat === '' ? null : $cat); +} +``` + +> اگر لیستِ مسطحِ فارسی را انتخاب کردی، `array_key_exists` را با `in_array($v, InventoryItem::UNITS, true)` جایگزین کن. الگوی پاسخ‌ها را با `BaseController` (`$this->success/$this->error`) و پرتاب `AppException` هم‌راستا نگه دار. + +### ۴. Repository: تغییر منبع فیلتر دسته به `category` + +`findConsumables` (یا نام بهتر `findCategories`) باید مقادیر متمایز `category` را برگرداند، نه `consumable`: + +```php +->select('DISTINCT i.category AS category') +->where('i.entityType = :type AND i.entityId = :id AND i.category IS NOT NULL AND i.category != :empty') +``` + +> نکته: با endpoint متادیتا (وظیفه ۳) که کل لیست ثابت را می‌دهد، فیلترِ صفحه بهتر است از **لیست ثابت کامل** استفاده کند (نه فقط دسته‌های استفاده‌شده). اما اگر می‌خواهی «فقط دسته‌هایی که کالا دارند» را در dropdown فیلتر نشان دهی، همین کوئری اصلاح‌شده کافی است. تصمیم را در پرامپت‌اجرا بگیر و ثابت بمان. + +### ۵. مهاجرت (Migration) + +- `ddev exec php bin/console doctrine:migrations:diff --no-interaction` سپس `migrate`. +- (اختیاری، توصیه‌شده) Backfill: اگر مقدار `consumable` فعلی دقیقاً با یکی از دسته‌های استاندارد یکی بود، در همان migration به `category` منتقل شود؛ در غیر این صورت `category` نال بماند. + +### ۶. Frontend — Modal: دو Select به‌جای input + +- `useInventory` را گسترش بده: + - type `InventoryItem` و `ItemPayload`: افزودن `category?: string | null`. + - کوئری جدید `metaQuery` روی `GET /api/v1/inventory-meta` (staleTime بالا / `Infinity`، چون تقریباً ثابت است). خروجی: `units`, `categories`. +- `AddItemModal`: + - از کامپوننت طراحی‌سیستم `SearchableSelect` (`components/ui/`) استفاده کن (react-select زیر آن است) برای `unit` و `category` — هماهنگ با CLAUDE.md. + - `unit` الزامی با پیش‌فرض `عدد`؛ `category` انتخابی (می‌تواند خالی بماند مگر بخواهی الزامی کنی — طبق خواسته کاربر «هر کالا باید دسته داشته باشد» → **الزامی‌اش کن** و در `submit` مثل `name` اعتبارسنجی کن: پیام «دسته‌بندی کالا الزامی است»). + - آرایه‌ی `fields` را طوری بازسازی کن که `unit` و `category` از حلقه‌ی input جدا و به‌صورت Select رندر شوند (SRP: input متنی جدا از Select). + - در حالت ویرایش، مقدار فعلی pre-select شود. + +```tsx +// نمونه + ({ value: u.value, label: u.label }))} + onChange={(v) => setForm(f => ({ ...f, unit: v }))} +/> + ({ value: c.value, label: c.label }))} + onChange={(v) => setForm(f => ({ ...f, category: v }))} +/> +``` + +> ساختار خروجی endpoint (`value/label` یا لیست مسطح فارسی) باید با تصمیم وظیفه ۱ یکی باشد. اگر مسطح فارسی است، `options={meta.units.map(u => ({ value: u, label: u }))}`. + +### ۷. Frontend — صفحه: فیلتر بر اساس `category` + +`InventoryPage.tsx`: + +```tsx +// قبل: +(category === '' || it.consumable === category) +// بعد: +(category === '' || it.category === category) +``` + +- dropdown فیلتر بالای جدول از `meta.categories` (لیست کامل ثابت) یا از `categories` هوک (دسته‌های استفاده‌شده) پر شود — طبق تصمیم وظیفه ۴. +- اگر ستون «دسته» در جدول (`InventoryItemsTable`) وجود ندارد، افزودن ستون «دسته‌بندی» را در نظر بگیر (نمایش `label` فارسی). + +## نکات مهم + +- **قرارداد API / کلاینت‌های دیگر:** `InventoryItem::toArray()` مصرف‌کننده دارد؛ افزودن `category` امن است، اما **حذف/تغییر `consumable`** کلاینت `clinic-pro-tauri` (`src/service/response.js`) و مدل tauri را می‌شکند. فقط **اضافه کن**، حذف نکن. +- **منبع واحد لیست‌ها:** فرانت هرگز لیست واحد/دسته را هاردکد نکند؛ همیشه از `inventory-meta`. این تنها راه جلوگیری از drift بین بک و فرانت است (CLAUDE.md: قرارداد API). +- **BaseController pattern:** پاسخ‌ها با `$this->success()`؛ خطاها با `AppException(ErrorCodes::ERR_VALIDATION_001, 'پیام فارسی', 422)` که `ExceptionSubscriber` فرمت می‌کند. کد ولیدیشن فیلددار را با امضای موجود `error(..., 'field')` هماهنگ نگه دار. +- **رشته‌های UI فارسی**، مقدار ذخیره‌شده (value) ترجیحاً انگلیسی پایدار. +- **تست‌ها (الزامی — موفق/خطا/مرزی):** + - Backend (`ApiTestCase`): ساخت کالا با `unit`/`category` معتبر → 201؛ با `unit` نامعتبر → 422 فیلد `unit`؛ با `category` نامعتبر → 422؛ خالی گذاشتن category (اگر nullable) → قبول؛ `inventory-meta` لیست‌ها را برمی‌گرداند. + - Frontend (`InventoryPage.test.tsx` موجود + تست Modal): رندر Selectها، الزامی بودن دسته، فیلتر بر اساس `category`. +- **debug اول:** پیش از ساخت هر چیز، مطمئن شو endpoint موجودی برای متادیتا نیست (نیست — تأیید شد). قاعده «اول بگرد، بعد توسعه، آخر بساز». +- **مستندسازی:** `docs/api/inventory.md` را در همان session به‌روزرسانی کن: endpoint جدید `inventory-meta`، فیلد جدید `category` در بدنه create/update و در پاسخ، و قرارداد اعتبارسنجی. +- **بعد از تغییر کد:** `graphify update .` (پس از commit). diff --git a/.claude/prompt/my-payments-ui-redesign.md b/.claude/prompt/my-payments-ui-redesign.md new file mode 100644 index 00000000..6e187cd7 --- /dev/null +++ b/.claude/prompt/my-payments-ui-redesign.md @@ -0,0 +1,294 @@ +# بازطراحی UI/UX صفحه «لیست پرداخت‌ها» (`/admin/my-payments`) + +## پروژه + +`clinicpro` — پنل ادمین React (`assets/admin/`) + یک اندپوینت خلاصه در بک‌اند Symfony (`src/Billing/`). + +## زمینه + +صفحه‌ی `/admin/my-payments` ([MyPaymentsPage.tsx](clinicpro/assets/admin/pages/MyPaymentsPage.tsx)) با inline-styleهای دستی و یک `` خام نوشته شده و از design-system پروژه استفاده نمی‌کند. در همان پنل، صفحه‌ی `/admin/claims` ([ClaimsPage.tsx](clinicpro/assets/admin/pages/ClaimsPage.tsx)) الگوی درست و پخته‌ی یک صفحه‌ی لیست است: `PageHeader` با breadcrumb، ردیف `StatCard`، کارت فیلترها با `field-label`، میان‌برهای بازه‌ی زمانی، `DataTable` (سورت + جستجو + skeleton + empty state) و `Pagination`. هدف: هم‌سطح‌کردن `my-payments` با همان الگو. + +## مشکل + +وضعیت فعلی صفحه: + +1. **بدون design-system** — جدول خام با `th`/`td` inline style به‌جای `DataTable`. یعنی: بدون skeleton loading، بدون سورت، بدون empty state استاندارد. +2. **بدون هیچ آمار خلاصه‌ای** — کاربر هیچ دید کلی از مجموع مبلغ/تعداد/تسویه‌نشده ندارد (بر خلاف claims که ۴ `StatCard` دارد). +3. **ستون `status` نمایش داده نمی‌شود** — با اینکه `PaymentRow.status` (`paid | unsettled`) از API می‌آید و فیلترش هم در UI هست، در جدول هیچ ستون وضعیتی وجود ندارد. کاربر فیلتر می‌کند ولی نتیجه‌اش را نمی‌بیند. +4. **فیلترها بدون label و بدون کارت** — یک ردیف شناور بالای صفحه، بدون `field-label`، بدون دکمه‌ی «پاک‌کردن فیلترها»، بدون میان‌بر «یک ماه اخیر / یک سال اخیر». +5. **فیلترها در state محلی‌اند، نه در query string** — رفرش صفحه یا اشتراک لینک، فیلترها و شماره‌ی صفحه را از بین می‌برد. `ClaimsPage` این را با `useSearchParams` حل کرده. +6. **جستجو فقط کد ملی است** — با `input` دست‌ساز، در حالی که `DataTable` خودش `searchValue`/`onSearchChange` دارد. +7. **`PersianDateInput` به‌جای `PersianDatePicker`** — ناهماهنگ با claims و بدون `height={38}` هم‌تراز با `SearchableSelect`. +8. **action هدر بی‌ربط است** — دکمه‌ی «اضافه کردن بیمار» در صفحه‌ی پرداخت‌ها منطق ندارد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/assets/admin/pages/MyPaymentsPage.tsx` | صفحه‌ای که بازنویسی می‌شود | +| `clinicpro/assets/admin/pages/ClaimsPage.tsx` | **الگوی مرجع** — ساختار را از این کپی کن | +| `clinicpro/assets/admin/hooks/useMyPayments.ts` | `usePayments`، `PaymentRow`، `MY_PAYMENTS_LIMIT` — hook خلاصه اینجا اضافه می‌شود | +| `clinicpro/assets/admin/components/ui/DataTable.tsx` | جدول design-system | +| `clinicpro/assets/admin/components/ui/StatCard.tsx` | کارت آمار (`tone: amber\|violet\|green\|pink`) | +| `clinicpro/assets/admin/components/ui/StatusBadge.tsx` | بج وضعیت — نیاز به type جدید `invoice` | +| `clinicpro/assets/admin/components/ui/PersianDatePicker.tsx` | انتخاب تاریخ هم‌راستا با claims | +| `clinicpro/assets/admin/types/index.ts` | تعریف `InvoiceListStatus` | +| `clinicpro/src/Billing/Controller/BillingController.php` | اندپوینت `listPayments` (L163) — اندپوینت خلاصه کنارش | +| `clinicpro/src/Billing/Service/InvoiceService.php` | `tenantInvoiceList` — متد خلاصه کنارش | +| `clinicpro/docs/api/billing.md` | مستند API (Standing Rule) | +| `clinicpro/assets/admin/pages/MyPaymentsPage.test.tsx` | تست‌های موجود — باید به‌روز شوند | + +## وضعیت فعلی + +`MyPaymentsPage.tsx` (خلاصه‌ی بخش‌های مشکل‌دار): + +```tsx +const th: React.CSSProperties = { textAlign: 'right', padding: '12px 16px', fontWeight: 600 }; +const td: React.CSSProperties = { padding: '12px 16px' }; + +const [page, setPage] = useState(1); +const [nationalCode, setNationalCode] = useState(''); +const [status, setStatus] = useState(''); +const [from, setFrom] = useState(''); +const [to, setTo] = useState(''); +// ... +
+
+ + + + + + + + + + + ... +``` + +نوع ردیف (`useMyPayments.ts`) — دقت کن `status` موجود است ولی رندر نمی‌شود: + +```ts +export type PaymentRowStatus = 'paid' | 'unsettled'; + +export interface PaymentRow { + invoice_uuid: string; + patient_uuid: string; + patient_name: string | null; + national_code: string | null; + issued_at: number; + amount_rials: number; + status: PaymentRowStatus; +} +``` + +--- + +## وظایف + +### ۱. اندپوینت خلاصه‌ی پرداخت‌ها (بک‌اند) + +طبق قاعده‌ی «اول بگرد، بعد توسعه بده، در آخر بساز»: هیچ اندپوینتی خلاصه‌ی مالی tenant را برنمی‌گرداند (`/api/v1/billing/reports/insurance-debt` فقط بدهی بیمه است، نه پرداخت‌های بیمار). پس یک اندپوینت جدید لازم است — اما **همان فیلترهای `listPayments` را می‌پذیرد** تا کارت‌ها با جدول هم‌خوان بمانند. + +در `InvoiceService`: + +```php +/** + * خلاصه‌ی مالی صورتحساب‌های tenant با همان فیلترهای tenantInvoiceList. + * @return array{total_rials:int, paid_rials:int, unsettled_rials:int, invoices_count:int} + */ +public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array +``` + +پیاده‌سازی با یک DQL aggregate (`SUM`/`COUNT` + `CASE WHEN status = 'paid'`), **نه** با بارگذاری همه‌ی ردیف‌ها در PHP. شرط‌های فیلتر (`national_code`, `status`, `from`, `to`) را دقیقاً از `tenantInvoiceList` بازاستفاده کن — منطق `where` را در یک متد private مشترک بگذار تا دو نسخه از هم واگرا نشوند (SOLID/DRY). + +در `BillingController` کنار `listPayments`: + +```php +#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])] +public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse +{ + [$entityType, $entityId] = $this->resolveEntity($user); + if ($entityId === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + // همان استخراج $filters که در listPayments هست + return $this->success($this->invoiceService->tenantInvoiceSummary($entityType, $entityId, $filters)); +} +``` + +**دقت:** payload را مستقیم پاس بده (`$this->success($summary)`) نه `['data' => $summary]` — در غیر این‌صورت فرانت باید `data?.data?.data` بخواند (pitfall نامبرده در CLAUDE.md). + +**نکته‌ی مسیریابی:** روت `/payments/summary` نباید با روت‌های پارامتری موجود تداخل کند؛ بعد از افزودن، با `ddev exec php bin/console debug:router | grep billing` تأیید کن. + +### ۲. hook خلاصه در فرانت + +در `assets/admin/hooks/useMyPayments.ts`: + +```ts +export interface PaymentsSummary { + total_rials: number; + paid_rials: number; + unsettled_rials: number; + invoices_count: number; +} + +/** خلاصه‌ی مالی با همان فیلترهای لیست — کارت‌های آمار همیشه با جدول هم‌خوان می‌مانند. */ +export function usePaymentsSummary(filters: Omit) { + const qs = new URLSearchParams(); + if (filters.national_code) qs.set('national_code', filters.national_code); + if (filters.status) qs.set('status', filters.status); + if (filters.from) qs.set('from', String(filters.from)); + if (filters.to) qs.set('to', String(filters.to)); + + return useQuery>({ + queryKey: ['payments-summary', filters], + queryFn: () => api.get(`/api/v1/my/billing/payments/summary?${qs.toString()}`), + }); +} +``` + +خواندن در صفحه: `summaryQuery.data?.data`. + +### ۳. بج وضعیت صورتحساب + +`StatusBadge` هیچ mapی برای `paid | unsettled` ندارد (`paymentMap` مربوط به درگاه است: `pending/success/failed/...`). یک type جدید اضافه کن — map موجود را دستکاری نکن: + +در `types/index.ts`: + +```ts +export type InvoiceListStatus = 'paid' | 'unsettled'; +``` + +در `StatusBadge.tsx`: + +```ts +const invoiceMap: Record = { + paid: { color: 'green', label: 'پرداخت شده' }, + unsettled: { color: 'amber', label: 'تسویه نشده' }, +}; +``` + +و `'invoice'` را به union پراپ `type` اضافه کن و در بدنه هندل کن. + +### ۴. بازنویسی `MyPaymentsPage.tsx` بر اساس الگوی `ClaimsPage` + +ساختار نهایی دقیقاً به این ترتیب: + +```tsx +<> + + + {/* ۴ کارت آمار */} +
+ + + + +
+ +
+ {/* ردیف فیلترها: وضعیت + از تاریخ + تا تاریخ + میان‌برها + پاک‌کردن */} + {/* DataTable */} + {/* Pagination — فقط وقتی total > MY_PAYMENTS_LIMIT */} +
+ +``` + +**۴-۱ — انتقال state به query string.** `useState`های `page/nationalCode/status/from/to` را با `useSearchParams` جایگزین کن، دقیقاً با همان `setParam` صفحه‌ی claims (که با هر تغییر فیلتر، `page` را حذف می‌کند): + +```tsx +const [params, setParams] = useSearchParams(); +const search = params.get('search') ?? ''; // کد ملی / نام +const status = params.get('status') ?? ''; +const from = params.get('from') ?? ''; +const to = params.get('to') ?? ''; +const page = Math.max(1, Number(params.get('page') ?? 1)); + +const setParam = (patch: Record) => { + const next = new URLSearchParams(params); + Object.entries(patch).forEach(([k, v]) => (v ? next.set(k, v) : next.delete(k))); + if (!('page' in patch)) next.delete('page'); + setParams(next, { replace: true }); +}; +``` + +**۴-۲ — جستجو داخل `DataTable`.** `input` دست‌ساز و آیکون ذره‌بین را حذف کن؛ به‌جایش: + +```tsx +searchValue={search} +onSearchChange={(v) => setParam({ search: v.replace(/\D/g, '') })} +searchPlaceholder="کد ملی بیمار" +``` + +مقدار به‌عنوان `national_code` به `usePayments` می‌رود (API فقط `national_code` را می‌شناسد؛ جستجوی نام سمت سرور وجود ندارد — placeholder را همین‌طور صادقانه بگذار و ادعای جستجوی نام نکن). + +**۴-۳ — فیلترها با label، مثل claims.** هر کنترل داخل یک `
` با `
+
+ +
+ setEnd(e.target.value)} + dir="ltr" + /> +
+
+ + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// انتقال به لیست رزرو (و بازگشت) — flips is_reserve for a chosen day +// ───────────────────────────────────────────────────────────────────────────── + +export function TransferReserveModal({ + appointment: a, + queryKey, + onClose, +}: { + appointment: Appointment; + queryKey: unknown[]; + onClose: () => void; +}) { + const qc = useQueryClient(); + const [date, setDate] = useState(a.appointment_date); + const toReserve = !a.is_reserve; + + const transfer = useMutation({ + mutationFn: () => { + const day = toEpoch(date, "00:00"); + return api.patch( + `/api/v1/appointment/${a.uuid}`, + toReserve + ? // reserve entries are day-level: midnight-to-midnight, no slot occupation + { + is_reserve: true, + slot_start: day, + slot_end: day, + version: a.version, + } + : { + is_reserve: false, + slot_start: toEpoch(date, a.appointment_time), + slot_end: toEpoch(date, a.end_time), + version: a.version, + }, + ); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey }); + toast.success( + toReserve + ? "به لیست رزرو منتقل شد" + : "به لیست نوبت‌ها منتقل شد", + ); + onClose(); + }, + onError: (e: any) => toast.error(e.message || "خطا در انتقال"), + }); + + return ( + +
+
+ + ! + + {toReserve + ? "نوبت از لیست نوبت ها حذف شده و به لیست نوبت های رزرو شده منتقل می شود." + : "نوبت از لیست رزرو حذف شده و به لیست نوبت ها منتقل می شود."} +
+ +
+ +
+
+ + +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// جایگزینی نوبت — put a different patient into the same slot +// (appointments-replace.pdf: patient search-or-new, بخش/سرویس, deposit, +// locked date/time, پرسنل, وضعیت, توضیحات) +// ───────────────────────────────────────────────────────────────────────────── + +interface PickerOption { + uuid: string; + name?: string; + full_name?: string; +} +interface PickedPatient { + uuid: string; + user_name?: string; + user_mobile?: string; +} + +export function ReplaceAppointmentModal({ + appointment: a, + queryKey, + onClose, +}: { + appointment: Appointment; + queryKey: unknown[]; + onClose: () => void; +}) { + const qc = useQueryClient(); + + // patient: search an existing record or enter a new person + const [patientSearch, setPatientSearch] = useState(""); + const [picked, setPicked] = useState(null); + const [name, setName] = useState(""); + const [mobile, setMobile] = useState(""); + const patientsQ = useQuery>({ + queryKey: ["replace-patients", patientSearch], + queryFn: () => + api.get( + `/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`, + ), + enabled: patientSearch.trim().length >= 2, + }); + + // service specs + staff + status + const [sectionUuid, setSectionUuid] = useState( + a.service_section?.uuid ?? "", + ); + const [itemUuid, setItemUuid] = useState(a.service_item?.uuid ?? ""); + const [staffUuid, setStaffUuid] = useState(a.staff?.uuid ?? ""); + const [status, setStatus] = useState(a.status); + const sectionsQ = useQuery>({ + queryKey: ["service-sections"], + queryFn: () => api.get("/api/v1/service-sections"), + }); + const itemsQ = useQuery>({ + queryKey: ["service-items", sectionUuid], + queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), + enabled: !!sectionUuid, + }); + const staffQ = useQuery>({ + queryKey: ["staff-list"], + queryFn: () => api.get("/api/v1/staff"), + }); + + // deposit + const [depositRequired, setDepositRequired] = useState( + !!a.deposit_required, + ); + const [depositToman, setDepositToman] = useState( + rialToToman(a.deposit_amount_rials ?? 0), + ); + const [note, setNote] = useState(""); + + const effectiveName = picked?.user_name || name.trim(); + const effectiveMobile = picked?.user_mobile || mobile.trim(); + + const replace = useMutation({ + mutationFn: () => + api.patch(`/api/v1/appointment/${a.uuid}`, { + patient_name: effectiveName, + patient_mobile: effectiveMobile, + service_section_uuid: sectionUuid, + service_item_uuid: itemUuid, + staff_uuid: staffUuid, + deposit_required: depositRequired, + deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, + ...(note.trim() ? { note: note.trim() } : {}), + ...(status !== a.status ? { status } : {}), + version: a.version, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey }); + toast.success("نوبت جایگزین شد"); + onClose(); + }, + onError: (e: any) => toast.error(e.message || "خطا در جایگزینی نوبت"), + }); + + const label = { fontSize: 12.5, color: "var(--text-3)" } as const; + const lockedField = { margin: "6px 0 12px", opacity: 0.6 } as const; + const patients = patientsQ.data?.data ?? []; + + const statusOptions: [string, string][] = [ + ["pending", "ثبت شده"], + ["confirmed", "قطعی شده"], + ["following_up", "در حال پیگیری"], + ["salon", "سالن"], + ["completed", "ویزیت شده"], + ["cancelled_by_doctor", "لغو شده"], + ]; + + return ( + +
+ +
+ { + setPicked(null); + setPatientSearch(e.target.value); + }} + placeholder="جستجوی نام، شماره تماس، شماره پرونده..." + /> +
+ {!picked && patients.length > 0 && ( +
+ {patients.map((p) => ( + + ))} +
+ )} + {picked === null && ( +
+
+ setName(e.target.value)} + placeholder="نام و نام خانوادگی" + /> +
+
+ setMobile(e.target.value)} + placeholder="شماره تماس" + dir="ltr" + /> +
+
+ )} + +
+
+ +
+ ({ value: o.uuid, label: o.name ?? "" }))} + value={sectionUuid || null} + onChange={(v) => { setSectionUuid(v ? String(v) : ""); setItemUuid(""); }} + placeholder="انتخاب بخش" + isLoading={sectionsQ.isLoading} + isClearable + height={38} + /> +
+
+
+ +
+ ({ value: o.uuid, label: o.name ?? "" }))} + value={itemUuid || null} + onChange={(v) => setItemUuid(v ? String(v) : "")} + placeholder="انتخاب زیر بخش" + isDisabled={!sectionUuid} + isLoading={itemsQ.isLoading} + isClearable + height={38} + /> +
+
+
+ +
+ + {depositRequired && ( + + )} +
+ {depositRequired && ( +
+ +
+ +
+
+ )} + + {/* the replacement keeps the original slot — date/time locked */} +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+ ({ value: o.uuid, label: o.full_name ?? "" }))} + value={staffUuid || null} + onChange={(v) => setStaffUuid(v ? String(v) : "")} + placeholder="انتخاب..." + isLoading={staffQ.isLoading} + isClearable + height={38} + /> +
+ + +
+ ({ value: v, label: l }))} + value={status || null} + onChange={(v) => setStatus((v ? String(v) : "") as Appointment["status"])} + placeholder="انتخاب وضعیت" + height={38} + /> +
+ + +
+
ردیفنام بیمارکد ملیتاریخمبلغ پرداخت‌شدهعملیات