Merge branch 'dev' into main

# Conflicts:
#	docs/api/doctor.md
This commit is contained in:
hamed
2026-07-19 16:15:30 +03:30
1026 changed files with 190049 additions and 15130 deletions
+156
View File
@@ -0,0 +1,156 @@
# همه‌ی تقویم‌های پنل ادمین باید شمسی باشند (رفع تقویم میلادی PersianDateInput)
## پروژه
`clinicpro` (React 19 admin SPA داخل Symfony — `assets/admin/`)
## زمینه
کل محصول فارسی/RTL و تاریخ‌ها Jalali (شمسی) است. تقویم شمسی قبلاً نوشته شده:
`PersianCalendar.tsx` (پیکر ماه/سال/روز شمسی، خروجی `YYYY-MM-DD` میلادی) و روکش آن
`PersianDatePicker.tsx`. اما یک کامپوننت دیگر به‌نام `PersianDateInput.tsx` هنوز از
`<input type="date">` بومی مرورگر استفاده می‌کند که **تقویم میلادی** مرورگر را باز می‌کند.
هرجای پنل که این کامپوننت استفاده شده، کاربر تقویم میلادی می‌بیند — خلاف قاعده‌ی محصول.
## مشکل / هدف
**هدف:** هرجای پنل ادمین که از انتخاب تاریخ (تقویم) استفاده می‌شود، تقویم شمسی نمایش دهد.
**تنها منبع میلادی در کل ادمین:** `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 */}
<input
ref={hiddenRef}
type="date" // ← تقویم میلادی مرورگر
value={value}
min={min}
max={max}
onChange={e => 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` روی پیکر شمسی
`<input type="date">` را حذف کن و همان الگوی `PersianDatePicker.tsx` را به کار ببر:
state باز/بسته + رندر شرطی `<PersianCalendar>`. لایه‌ی متنی 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 (
<div style={{ position: 'relative', display: 'inline-block', ...style }} className={className}>
<div onClick={() => setOpen(o => !o)} style={{ /* همان استایل visible فعلی: height 36, border/surface, icon, placeholder color */ }}>
<CalendarDaysIcon /* … */ />
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
{value && <span onClick={e => { e.stopPropagation(); onChange(''); }}><XMarkIcon /* … */ /></span>}
</div>
{open && (
<PersianCalendar
value={value}
onChange={v => { onChange(v); setOpen(false); }}
onClose={() => setOpen(false)}
enableYearPicker={enableYearPicker}
/>
)}
</div>
);
}
```
- `min`/`max` را حذف کن (استفاده‌ای ندارند). اگر خواستی امن‌تر باشی، نگه‌شان دار ولی
بدون اثر — ترجیح: حذف، مطابق قاعده‌ی «کد مرده ننویس».
- استایل لایه‌ی visible دقیقاً همان مقادیر فعلی فایل بماند (height 36، `--border`،
`--surface`، `minWidth 148`، fontSize 13، رنگ placeholder با `--text-3`).
### ۲. فعال‌کردن انتخاب سال برای تاریخ تولد
در `pages/PatientRecordFormPage.tsx:132` (فیلد `birth_date`) پراپ `enableYearPicker` را بده
تا کاربر بتواند سریع سال تولد را انتخاب کند:
```tsx
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
```
(الگوی مشابه از قبل در `PatientRecordInfoForm.tsx:133` با `PersianDatePicker … enableYearPicker` هست.)
### ۳. رفع هم‌پوشانی SOLID (اختیاری ولی توصیه‌شده)
بعد از این تغییر، `PersianDateInput` و `PersianDatePicker` تقریباً یکی می‌شوند
(هر دو = لایه‌ی متنی + PersianCalendar). برای پرهیز از دوگانگی:
- گزینه‌ی ساده: `PersianDateInput` را یک روکش نازک روی `PersianDatePicker` کن
(`return <PersianDatePicker {...props} />`)، یا
- در همین تسک فقط رفتار را یکی کن و در کامنت بالای فایل اشاره کن که این دو باید
در آینده ادغام شوند. حذف کامل یکی از آن‌ها → نیازمند به‌روزرسانی همه‌ی 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/*` نیاز به تغییر ندارند.
@@ -0,0 +1,180 @@
# جایگزینی همه‌ی `<select>` بومی پنل ادمین با `SearchableSelect`
## پروژه
`clinicpro` (React 19 admin SPA — `assets/admin/`)
## زمینه
در سراسر پنل ادمین از `<select>` بومی HTML استفاده شده (استایل درون‌خطی تکراری،
بدون جست‌وجو، ظاهر ناهماهنگ با طراحی‌سیستم، بدون RTL/dark درست). طراحی‌سیستم یک
کامپوننت مشترک دارد: `assets/admin/components/ui/SearchableSelect.tsx` (روی `react-select`،
قابل جست‌وجو، RTL، هماهنگ با توکن‌های CSS و dark mode، منو portal با `zIndex 9999`).
**هدف:** همه‌ی `<select>`های بومی پنل با `SearchableSelect` جایگزین شوند تا ظاهر و رفتار
یکدست شود (مثل دسته‌بندی «افزودن کالا» در `AddItemModal.tsx` که از قبل `SearchableSelect` است).
## API کامپوننت `SearchableSelect` (مرجع — تغییرش نده)
```tsx
interface SelectOption { value: string | number; label: string }
interface Props {
options: SelectOption[];
value?: string | number | null;
onChange?: (value: string | number | null) => void; // مقدار خام، نه event
placeholder?: string; // پیش‌فرض «انتخاب کنید...»
isLoading?: boolean;
isDisabled?: boolean;
isClearable?: boolean;
noOptionsMessage?: string; // پیش‌فرض «موردی یافت نشد»
inputId?: string;
height?: number; // پیش‌فرض 42؛ برای هم‌ارتفاعی با فیلدهای 38px مقدار بده
}
```
نکات مهمِ تفاوت با `<select>`:
- `onChange` مقدار خام می‌دهد (`string|number|null`) نه `e.target.value`.
- `options` باید `{ value, label }` باشد — نه `<option>`.
- گزینه‌ی خالی (`<option value="">انتخاب...</option>`) حذف و به `placeholder` منتقل شود؛
اگر خالی‌کردن مجاز است `isClearable` بده.
- `disabled``isDisabled`.
## فایل‌های دارای `<select>` بومی (۱۶ فایل، ~۳۵ مورد)
| فایل | تعداد | نکته |
|------|:----:|------|
| `pages/MyPatientsPage.tsx` | 4 | فیلترها |
| `pages/AppointmentEditPage.tsx` | 4 | |
| `pages/AppointmentCreatePage.tsx` | 4 | |
| `components/NewAppointmentDrawer.tsx` | 4 | بخش/زیربخش/سرویس (وابسته) |
| `components/AppointmentActions.tsx` | 4 | خطوط 850,869,961,976 |
| `pages/DoctorDetailPage.tsx` | 3 | |
| `pages/PatientRecordFormPage.tsx` | 2 | **RHF `register`** (gender:119، referral_source:135) |
| `components/AppointmentFiltersModal.tsx` | 2 | `sel` استایل مشترک؛ سرویس وابسته به بخش (`disabled`) |
| `pages/ReserveAppointmentsPage.tsx` | 1 | |
| `pages/RepresentationSettlementPage.tsx` | 1 | |
| `pages/MyPaymentsPage.tsx` | 1 | |
| `pages/InventoryPage.tsx` | 1 | فیلتر دسته |
| `pages/AppointmentsPage.tsx` | 1 | سرویس (خط 653) |
| `components/inventory/AddPackageModal.tsx` | 1 | |
| `components/dashboard/TauriDashboardView.tsx` | 1 | |
| `components/PatientsFilterModal.tsx` | 1 | |
## وضعیت فعلی — سه الگوی رایج
### الگوی A — controlled با `value` + `onChange` (بیشترین)
```tsx
// AppointmentsPage.tsx:653
<select aria-label="سرویس" value={value} onChange={e => onChange(e.target.value)} style={sel}>
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
</select>
```
### الگوی B — گزینه‌های وابسته / disabled
```tsx
// AppointmentFiltersModal.tsx:100
<select aria-label="سرویس" style={{ ...sel, margin: '6px 0 14px' }} value={f.itemUuid} disabled={!f.sectionUuid}
onChange={...}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
```
### الگوی C — React Hook Form با `register` (خاص — نیازمند Controller)
```tsx
// PatientRecordFormPage.tsx:119
<div className="field"><select {...form.register('gender')} style={{...}}>
<option value="">انتخاب...</option>
<option value="female">زن</option>
<option value="male">مرد</option>
</select></div>
```
## وظایف
> **یک فایل در هر مرحله.** بعد از هر فایل: `tsc` سبز شود، بعد فایل بعدی. ترتیب: از فایل‌های کم‌مورد به پرمورد، یا هر ترتیبی، ولی هر فایل مستقل تست/تایپ‌چک شود.
### ۱. تبدیل الگوی A (controlled)
هر `<select value onChange>` را با این جایگزین کن:
```tsx
<SearchableSelect
options={options.map(s => ({ value: s.uuid, label: s.name }))}
value={value || null}
onChange={v => onChange(v ? String(v) : '')} // اگر state رشته است String() بزن
placeholder="سرویس مورد نظر را انتخاب کنید..." // همان متن option خالی
isClearable // اگر خالی‌کردن مجاز بود
height={38} // برای هم‌ارتفاعی با فیلدهای فعلی
/>
```
- import در بالای فایل: `import SearchableSelect from '../ui/SearchableSelect';`
(عمق مسیر را بر اساس محل فایل تنظیم کن: از `pages/``'../components/ui/SearchableSelect'`).
- استایل درون‌خطی `sel`/`style` روی select حذف شود (کامپوننت خودش استایل دارد).
اگر `margin` بیرونی لازم بود، در یک `<div style={{ margin }}>` دور کامپوننت بگذار.
- `aria-label` را حفظ کن: چون react-select خودش input دارد، برای دسترس‌پذیری از
`inputId` + یک `<label htmlFor>` مخفی یا `aria-label` روی wrapper استفاده کن (اختیاری ولی بهتر).
### ۲. تبدیل الگوی B (وابسته/disabled)
مثل A ولی `isDisabled` را از شرط قبلی بده و در صورت لود‌شدن آسنکرون `isLoading` را از
`query.isLoading` بده:
```tsx
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name }))}
value={f.itemUuid || null}
onChange={v => setF({ ...f, itemUuid: v ? String(v) : '' })}
placeholder="انتخاب سرویس"
isDisabled={!f.sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
```
### ۳. تبدیل الگوی C (React Hook Form)
`register` روی `SearchableSelect` کار نمی‌کند (input بومی نیست). دو راه — راه ساده `watch`+`setValue`:
```tsx
<SearchableSelect
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
value={form.watch('gender') || null}
onChange={v => form.setValue('gender', v ? String(v) : '', { shouldValidate: true, shouldDirty: true })}
placeholder="انتخاب..."
isClearable
height={38}
/>
```
- برای فیلدهای الزامیِ Zod، `shouldValidate: true` را نگه دار تا خطاها به‌روز شوند.
- اگر فیلد قبلاً از `REFERRAL_OPTIONS` می‌ساخت: `REFERRAL_OPTIONS.map(o => ({ value: o, label: o }))`.
### ۴. حذف کد مرده
بعد از تبدیل، هر متغیر استایل مشترکِ بلااستفاده (`const sel = {...}`) و importهای بی‌استفاده
را حذف کن (`tsc`/eslint نشان می‌دهد).
## نکات مهم
- **رفتار داده تغییر نکند:** مقداری که به state/RHF/API می‌رود همان `uuid`/کلید قبلی بماند
(فقط `''``null` را مدیریت کن — `SearchableSelect` هنگام خالی `null` می‌دهد، اگر state
انتظار رشته دارد به `''` تبدیل کن).
- **ارتفاع:** فیلدهای فعلی معمولاً `height: 38px`‌اند؛ `height={38}` بده تا ردیف‌ها جابه‌جا نشوند.
پیش‌فرض کامپوننت 42 است.
- **گزینه‌های وابسته (بخش→زیربخش→سرویس در NewAppointmentDrawer / AppointmentActions):**
با تغییر والد، مقدار فرزند ریست شود (همان منطق فعلی onChange والد را حفظ کن).
- **منوی داخل Modal/Drawer:** `SearchableSelect` منو را با `menuPortal`+`zIndex 9999` به `body`
می‌برد؛ مشکل بریدگی/overflow نخواهد داشت — نیازی به تنظیم اضافه نیست.
- **تست:** بعد از هر فایل `ddev exec npx tsc --noEmit --project tsconfig.json`؛ در پایان
`npx vitest run` (روی host؛ داخل ddev esbuild پلتفرم mismatch دارد). تست‌های موجودِ
فایل‌هایی که select داشتند (مثل `AppointmentFiltersModal.test.tsx`، `PatientRecordInfoForm.test.tsx`)
را اجرا کن؛ اگر با `getByRole('combobox')`/`selectOptions` بودند، به تعامل react-select
(کلیک + انتخاب گزینه با متن) به‌روز کن.
- **بدون کتابخانه جدید:** `react-select` از قبل نصب است. RTL و dark از خود کامپوننت می‌آید.
- تغییر فقط-UI است؛ backend و `docs/api/*` تغییری ندارد.
- **این یک قاعده‌ی دائمی است:** از این پس هیچ `<select>` بومیِ جدیدی در پنل ادمین نساز؛
همیشه `SearchableSelect`.
+179
View File
@@ -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"` روی `<html>` (در `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 / payment14 | `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
```
@@ -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 طبق قاعده‌ی پروژه).
+104
View File
@@ -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`؛ طراحی مودال با تم/کلاس‌های موجود پنل، بدون طراحی جدید.
@@ -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<string[]>([]);
// 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[<service_uuid>]=<minutes>` (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: { "<uuid>": <minutes> }`:
```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` (نه `<select>` خام)، `TanStack Query` برای دیتا، state لوکال React برای انتخاب‌ها، `DigitInput` برای ورودی عددی مدت. chipها با توکن‌های `--primary-soft`/`--primary` مطابق UIِ فعلی.
- **edge caseها**: سرویس بدون `duration_minutes` (⇒ 422 یا فیلترشدن)؛ بخشِ بدون سرویس bookable؛ override صفر/منفی/غیرعدد (رد شود، به پیش‌فرض برگردد)؛ حذف همهٔ سرویس‌ها (اسلات خالی، دکمهٔ ثبت غیرفعال)؛ انتخاب سرویس از دو بخش با مدت‌های override متفاوت (مجموع درست).
- **تست (اجباری، موفق + خطا + مرزی)**:
- backend: `appointment-booking-services` فیلد `service_section` را برمی‌گرداند؛ `appointment-service-slots` با `durations[uuid]` مدت کل و `start_times` را بر اساس override می‌دهد؛ ثبت نوبت با `service_durations` مقدار `slot_end` را بر اساس override می‌سازد و پیش‌فرض سرویس در DB تغییر نمی‌کند.
- frontend: انتخاب بخش → نمایش سرویس‌های همان بخش؛ انباشت بین دو بخش؛ chip «بخش → سرویس»؛ ویرایش مدت یک سرویس و بازتاب در payload؛ حذف سرویس.
- **مستندات**: `docs/api/appointment.md` برای هر سه endpoint به‌روز شود (فیلد `service_section`، پارامتر `durations`، فیلد `service_durations` در body ثبت).
- **بعد از اتمام**: `npx tsc --noEmit`، `ddev exec bin/console lint:container`, `ddev exec bin/phpunit tests/Appointment`, `npx vitest run` تست‌های مربوطه، `ddev exec yarn dev` — همه سبز.
@@ -0,0 +1,212 @@
# انتقال برنامه کاری، اصلاح فرم تنظیمات نوبت، فیلدهای عددی لاتین، رفع باگ هزینه ویزیت در مودال، و لاگ/تایم‌لاین لغو نوبت
## پروژه
`clinicpro` (backend Symfony 7.4 + پنل ادمین React 19 داخل Webpack Encore). تک-ریپو، cross-repo نیست.
> تست: پنل ادمین همیشه با `09390039833 / 09390039833`. اجرا داخل ddev (`ddev exec ...`, `https://clinic-pro.ddev.site`).
## زمینه
فیچر «الزامی کردن هزینه ویزیت» قبلاً پیاده شده (پرامپت `require-visit-price-setting.md`): فلگ روی `EntityInsurancePricing.require_visit_price` ذخیره می‌شود و کنترلر ایجاد نوبت با `VisitPriceRequirementResolver` آن را چک می‌کند. اما چند مشکل UX/باگ باقی مانده و همچنین دو تغییر ساختاری (انتقال برنامه کاری و لاگ لغو) لازم است. این پرامپت ۵ تسک مستقل ولی هم‌حوزه را پوشش می‌دهد؛ **هر تسک را جدا پیاده‌سازی، تست و کامیت کن**.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه `/admin/appointment-settings` (۵۰ خط) — مقصد برنامه کاری، محل دکمه ذخیره |
| `assets/admin/pages/DoctorProfilePage.tsx` | wrapper پروفایل → `<DoctorDetailPage isOwnProfile />` |
| `assets/admin/pages/DoctorDetailPage.tsx` | تعریف `ScheduleSection` (L2058-2089)، رندر آن L2899، `WeeklyScheduleTab` (export L1252) |
| `assets/admin/components/FreeVisitPrice.tsx` | کارت قیمت ویزیت + toggle الزامی + دکمه ذخیره (L66-68) |
| `assets/admin/pages/AppointmentsPage.tsx` | صفحه `/admin/appointments` + `NewAppointmentModal` (L102، رندر L627) — **باگ هزینه ویزیت اینجاست** |
| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه کامل ثبت نوبت (`/admin/appointments/new`) — مرجع درست هزینه ویزیت (L109-151, 477-488) |
| `assets/admin/components/NewAppointmentDrawer.tsx` | drawer «افزودن نوبت» (L126-140) — همان باگ هزینه ویزیت |
| `assets/admin/components/ui/Input.tsx` | input پایه design-system (`cp-input`) — نقطه تمرکز فیلد عددی سراسری |
| `assets/admin/components/ui/DigitInput.tsx` / `PriceInput.tsx` / `MobileInput.tsx` | فیلدهای عددی موجود (همه `inputMode="numeric"` + `dir="ltr"`) |
| `assets/admin/lib/utils.ts` | `toEnglishDigits` (L106-111)، `sanitizeMobileInput` (L114-116)، `tomanToRial`/`rialToToman` (L5-6) |
| `src/Appointment/Controller/AppointmentController.php` | `updateStatus` (L634-669) و `update` (L677-776) — نقطه لغو نوبت |
| `src/Appointment/Entity/Appointment.php` | ثابت‌های وضعیت (L28-29)، جدول transition (L37-41)، `transitionTo()` (L299-314) |
| `src/Shared/Logging/DbLogger.php` / `AppLog.php` | زیرساخت لاگ موجود (فقط WARNING به بالا persist) |
| `docs/api/appointment.md`, `docs/api/insurance.md` | به‌روزرسانی مستندات (Standing Rule) |
---
## تسک ۱ — انتقال کامل «برنامه کاری» از پروفایل به تنظیمات نوبت‌دهی
### وضعیت فعلی
- `AppointmentSettingsPage.tsx:45` همین الان فقط زیرتب `WeeklyScheduleTab` را دارد (بدون تب‌های «تاریخ‌های خاص» و «تعطیلات»):
```tsx
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
```
- `DoctorDetailPage.tsx:2899` کل `ScheduleSection` (سه‌تب: `weekly` / `overrides` / `holidays` + هدر «برنامه کاری») را رندر می‌کند و این برای **هم پروفایل و هم نمای ادمینِ جزئیات پزشک** اجرا می‌شود:
```tsx
{uuid && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
```
### هدف
برنامه کاری فقط از صفحه تنظیمات نوبت‌دهی مدیریت شود؛ از پروفایل کاملاً حذف شود.
### وظایف
1. در `AppointmentSettingsPage.tsx`، به‌جای `WeeklyScheduleTab` تنها، از **`ScheduleSection` کامل** استفاده کن (تا هر سه تب weekly/overrides/holidays در تنظیمات نوبت‌دهی باشد). `ScheduleSection` را از `DoctorDetailPage` export/import کن (اگر export نیست، `export function ScheduleSection` کن) و با همان props فعلی (`doctorUuid={uuid}`) بده. `addresses` را دیگر لازم نیست جدا بدهی چون `ScheduleSection` خودش `available-locations` را fetch می‌کند (L2061-2067).
2. رندر `ScheduleSection` در پروفایل حذف شود. چون خط L2899 هم پروفایل و هم نمای ادمین را سرو می‌کند، آن را **مشروط** کن که فقط وقتی پروفایلِ خودِ کاربر **نیست** رندر شود — تا نمای ادمینِ جزئیات پزشک دست‌نخورده بماند:
```tsx
{uuid && !isOwnProfile && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
```
(نام دقیق prop تشخیص پروفایل را از خود کامپوننت بردار — `isOwnProfile` که `DoctorProfilePage` پاس می‌دهد.)
### نکات
- بعد از انتقال، مطمئن شو دکمه‌های ذخیره داخل تب‌ها (`ذخیره برنامه هفتگی` L1608-1612 و مشابه در overrides/holidays) درست کار می‌کنند — آن‌ها API خودشان را دارند و مستقل از دکمه ذخیره تسک ۲ هستند.
- نمای ادمینِ «جزئیات پزشک» (وقتی ادمین پزشک دیگری را می‌بیند) باید همچنان برنامه کاری را نشان دهد؛ فقط پروفایلِ شخصی نباید.
---
## تسک ۲ — اصلاح چیدمان دکمه ذخیره در صفحه تنظیمات نوبت‌دهی
### وضعیت فعلی
در `AppointmentSettingsPage.tsx` ترتیب فعلی: عنوان → `<FreeVisitPrice/>` (شامل toggle «الزامی کردن هزینه ویزیت» و دکمه ذخیره خودش L66-68) → `ScheduleSection`. دکمه ذخیرهٔ کارت قیمت ویزیت داخل خود کارت است ولی از نظر بصری بعد از toggle در جای مناسبی قرار نمی‌گیرد.
### هدف (بهترین UX انتخاب و پیاده‌سازی شود)
راهکار توصیه‌شده: **دکمه ذخیرهٔ کارت `FreeVisitPrice` بلافاصله زیر فیلد/toggle «الزامی کردن هزینه ویزیت» و در انتهای همان کارت قرار گیرد** (نه شناور بالا). چون منطقاً دکمه ذخیره باید آخرین المان فرمِ آن کارت باشد.
### وظایف
1. در `FreeVisitPrice.tsx` ترتیب داخل کارت را طوری کن که: فیلد قیمت ویزیت آزاد → toggle «الزامی کردن هزینه ویزیت» (L77-93) → **سپس** دکمه ذخیره (L66-68) در انتهای کارت، تراز راست (`marginInlineStart: 'auto'`) با فاصله مناسب از toggle.
2. اگر دکمه ذخیره فعلاً بالای toggle رندر می‌شود، آن را به انتهای JSX کارت منتقل کن.
### نکات
- منطق `save`/state دست‌نخورده بماند؛ فقط ترتیب رندر و استایل جای دکمه.
- الگوی دکمه: `className="btn primary sm"`.
---
## تسک ۳ — اجبار ورودی لاتین در همه فیلدهای عددی سراسری
### وضعیت فعلی
- ابزار موجود: `toEnglishDigits` در `utils.ts:106-111` (فارسی/عربی → لاتین)، و `DigitInput`/`PriceInput`/`MobileInput` که همگی `inputMode="numeric"` + `dir="ltr"` دارند.
- **مشکل**: خیلی از inputها المان خام `<input>` هستند و از این کامپوننت‌ها استفاده نمی‌کنند (مثلاً `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).
- یک فیلد ورودی «هزینه ویزیت (تومان)» با `<PriceInput>` اضافه کن؛ اگر `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`، نه `<select>` خام.
- رشته‌ها فارسی، تاریخ‌ها شمسی، RTL.
- هر تسک جدا تست و کامیت شود. بعد از تغییر کد، `graphify update .` اجرا شود (بعد از کامیت).
@@ -0,0 +1,385 @@
# ایجاد خودکار پرونده و سرویس در همهٔ مسیرهای قطعی‌شدن نوبت
## زمینه
منطق «قطعی شدن نوبت → ساخت پرونده و سرویس» **از قبل نوشته شده** است:
`PatientService::autoCreateOnAppointmentConfirm()`. مشکل این نیست که وجود ندارد — این است که
فقط به **دو** مسیر از پنج مسیرِ قطعی‌شدن وصل است، و در همان دو مسیر هم به‌جای یک پرونده، دو
پرونده (پزشک + کلینیک) می‌سازد.
شواهد از دیتابیس محیط توسعه:
```sql
-- ۱۶ نوبت قطعی، ولی فقط ۱۲ مراجعه
SELECT COUNT(*) FROM appointments WHERE status='confirmed'; -- 16
SELECT COUNT(*) FROM appointments a JOIN patient_sessions ps ON ps.appointment_id=a.id
WHERE a.status='confirmed'; -- 12
-- نوبت 130043 دو مراجعهٔ تکراری دارد (یکی برای پزشک، یکی برای کلینیک):
-- id status address_id session_id entity_type entity_id
-- 130043 confirmed 2631 30 doctor 3343
-- 130043 confirmed 2631 31 clinic 1003
-- و این‌ها هیچ مراجعه‌ای ندارند:
-- 130044, 130028, 130025, 130011, 130010 → session_id = NULL
```
## مشکل / هدف
هدف: هر نوبتی که قطعی می‌شود — **از هر مسیری** — دقیقاً **یک** پرونده و **یک** سرویس در
**همان محیطی** بسازد که نوبت در آن رزرو شده (کلینیک، یا مطب شخصی پزشک مستقل).
پنج ایراد مشخص که باید رفع شوند:
### ۱. مسیر پرداخت آنلاین (Nobat724) اصلاً صدا نمی‌زند — ریشهٔ اصلی
نوبتِ سایت عمومی `pending` ساخته می‌شود و بعد از پرداخت در `PaymentManager` قطعی می‌شود؛
آن‌جا هیچ فراخوانی‌ای وجود ندارد. یعنی **هیچ نوبتی که از Nobat724 و سایت‌های زیرمجموعه
رزرو و پرداخت شود، پرونده نمی‌سازد.**
### ۲. دو پرونده به‌جای یک پرونده
`autoCreateOnAppointmentConfirm` برای پزشکِ عضو کلینیک، هم پروندهٔ `doctor` می‌سازد و هم
`clinic` — دو مراجعهٔ جدا برای یک نوبت واحد (ردیف 130043 بالا). این یعنی درآمد یک نوبت در
دو جا شمرده می‌شود.
**قاعدهٔ درست:** نوبتی که در کلینیک رزرو شده → فقط پروندهٔ همان کلینیک. نوبتی که در مطب
شخصی رزرو شده → فقط پروندهٔ پزشک.
### ۳. تشخیص کلینیک حدسی است
کلینیک از `address_id` استنتاج می‌شود و اگر آدرس نبود، «اگر پزشک فقط عضو یک کلینیک باشد»
همان فرض می‌شود. با مدل per-context فعلی (هر پزشک یک برنامه برای مطب شخصی + یکی به ازای هر
کلینیک) این حدس غلط است و نوبت را بی‌سروصدا به پروندهٔ محیط اشتباه می‌چسباند. نوبت `130032`
با `address_id = NULL` دقیقاً روی همین شاخهٔ حدسی افتاده است.
### ۴. idempotent نیست
هیچ گاردی نیست که مراجعهٔ تکراری برای یک نوبت ساخته نشود. مسیر
`confirmed → cancelled → confirmed` یا دوبار PATCH، مراجعهٔ دوم می‌سازد.
### ۵. نوبت‌های پنل و ادمین اصلاً قطعی نمی‌شوند
`MyAppointmentsController` و `AdminApiController` نوبت را با وضعیت پیش‌فرض `pending`
می‌سازند و هیچ‌جا قطعی نمی‌کنند (`markPendingWithTtl` هم صدا نمی‌شود، پس نه منقضی می‌شود نه
قطعی). نوبت `130046` که همین امروز از پنل ساخته شده هنوز `pending` است. طبق نیاز، نوبتِ
ثبت‌شده توسط خودِ کلینیک/پزشک باید مستقیم `confirmed` باشد — پرداخت آنلاین ندارد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Service/PatientService.php:125-192` | `autoCreateOnAppointmentConfirm` + `autoCreateForEntity` |
| `src/Payment/Service/PaymentManager.php:305-330` | `handleAppointmentConfirmation` — مسیر پرداخت آنلاین |
| `src/Appointment/Controller/AppointmentController.php:871-881` | PATCH status — تنها مسیر سالم فعلی |
| `src/Appointment/Controller/AppointmentController.php:975-988` | PATCH نوبت (ویرایش کامل) |
| `src/Appointment/Controller/AppointmentController.php:495-537` | رزرو عمومی — `markPendingWithTtl` سپس `bookAtomically` |
| `src/Appointment/Controller/MyAppointmentsController.php:142-198` | رزرو از پنل — بدون قطعی‌کردن |
| `src/Admin/Controller/AdminApiController.php:925-947` | رزرو از ادمین — بدون قطعی‌کردن |
| `src/Appointment/Entity/Appointment.php:37-42,101,178-189` | ماشین وضعیت، `status` پیش‌فرض `pending`، سازنده |
| `src/Appointment/Service/BookingContextResolver.php` | تشخیص صریح کلینیکِ نوبت از `clinic_uuid` |
| `src/Patient/Entity/PatientRecord.php:15` | unique روی `(entity_type, entity_id, user_id)` |
| `src/Patient/Entity/PatientSession.php:29-37` | لینک اختیاری به `Appointment` |
| `src/Patient/Repository/PatientSessionRepository.php` | متد lookup بر اساس نوبت **ندارد** |
| `docs/api/patient.md:611-624` | بخش «Auto-Creation on Appointment Confirm» |
## وضعیت فعلی
### دو پرونده + کلینیکِ حدسی
`src/Patient/Service/PatientService.php:125-147`:
```php
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
$doctor = $appointment->getDoctor();
// پرونده‌ی پزشک
$this->autoCreateForEntity('doctor', $doctor->getId(), $appointment, $doctor->getId());
// کلینیک نوبت را تعیین کن: اول از آدرس انتخاب‌شده، وگرنه اگر دکتر فقط عضو یک کلینیک باشد.
$clinicId = null;
$addressId = $appointment->getAddressId();
if ($addressId !== null) {
$clinicId = $this->addressRepo->find($addressId)?->getClinicId();
}
if ($clinicId === null) {
$clinics = $this->clinicRepo->findByDoctor($doctor);
if (count($clinics) === 1) {
$clinicId = $clinics[0]->getId();
}
}
if ($clinicId !== null) {
$this->autoCreateForEntity('clinic', $clinicId, $appointment, $clinicId);
}
}
```
### ساخت مراجعه — بدون گارد تکرار
`src/Patient/Service/PatientService.php:149-192` (بخش مرتبط):
```php
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return;
}
$patient = $appointment->getUser();
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$this->recordRepo->save($record);
}
$session = new PatientSession($record, $appointment); // ← هیچ چکی که قبلاً ساخته نشده باشد
$session->setSessionAt($appointment->getSlotStart());
...
```
### مسیر پرداخت — بدون فراخوانی
`src/Payment/Service/PaymentManager.php:305-314`:
```php
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return;
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
// ← اینجا هیچ ساختِ پرونده‌ای نیست
```
### رزرو پنل — وضعیت pending می‌ماند
`src/Appointment/Controller/MyAppointmentsController.php:190-197`:
```php
} else {
try {
$this->appointmentRepo->bookAtomically($appointment);
} catch (SlotTakenException) {
return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409);
}
}
```
`Appointment::$status` پیش‌فرض `STATUS_PENDING` است (`Appointment.php:101`) و هیچ‌جای این
مسیر عوضش نمی‌کند.
## وظایف
### ۱. context نوبت را صریح کن — ستون `clinic_id` روی `appointments`
حدس‌زدن محل، ریشهٔ ایراد ۳ است. نوبت باید بداند در کدام محیط رزرو شده، همان‌طور که
`weekly_schedules` می‌داند.
`src/Appointment/Entity/Appointment.php`:
```php
/**
* محیط رزرو: null یعنی مطب شخصی پزشک، مقدار یعنی همان کلینیک. مبنای واحدِ
* تشخیص پرونده — از روی آدرس حدس زده نمی‌شود.
*/
#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Clinic\Entity\Clinic $clinic = null;
```
هر سه مسیر رزرو از قبل `$bookingClinic` را با `BookingContextResolver` حل می‌کنند و فقط برای
`resolveSlotLocationId` استفاده می‌کنند — همان را روی نوبت هم بنشان:
- `AppointmentController.php:~510` (رزرو عمومی) — متغیر `$bookingClinic` موجود است
- `MyAppointmentsController.php:~150``$bookingClinic` موجود است
- `AdminApiController.php:~937``$bookingClinic` موجود است
Migration بنویس. برای ردیف‌های موجود `clinic_id` را از `address_id` پر کن (همان استنتاجی که
امروز runtime انجام می‌دهد)، ولی **فقط وقتی آدرس واقعاً کلینیکی است**؛ شاخهٔ حدسیِ «تنها
کلینیک پزشک» را در migration تکرار نکن — ردیف بدون آدرس، مطب شخصی در نظر گرفته شود و در
توضیح migration ذکر شود.
### ۲. یک choke point برای قطعی‌شدن
به‌جای پخش‌کردن فراخوانی در پنج جا، یک سرویس بساز که همه صدایش بزنند —
`src/Appointment/Service/AppointmentConfirmationService.php`:
```php
final class AppointmentConfirmationService
{
/**
* عوارض جانبیِ قطعی‌شدن نوبت. هر مسیری که نوبت را confirmed می‌کند باید این را
* صدا بزند — پرداخت آنلاین، PATCH وضعیت، و رزروِ مستقیمِ پنل/ادمین.
* idempotent است: فراخوانی دوباره برای همان نوبت هیچ چیزی نمی‌سازد.
*/
public function onConfirmed(Appointment $appointment): void
{
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
}
}
```
سپس در این پنج نقطه صدا زده شود:
| فایل | نقطه |
|---|---|
| `PaymentManager.php:312` | بعد از `transitionTo(STATUS_CONFIRMED)`**مهم‌ترین** |
| `AppointmentController.php:879` | جایگزین فراخوانی مستقیم فعلی |
| `AppointmentController.php:982` | جایگزین فراخوانی مستقیم فعلی |
| `MyAppointmentsController.php:~193` | بعد از `bookAtomically` (وظیفهٔ ۵) |
| `AdminApiController.php:~939` | بعد از `bookAtomically` (وظیفهٔ ۵) |
**دربارهٔ `PaymentManager`:** آن‌جا داخل تراکنش پرداخت هستی. ساخت پرونده نباید تأیید پرداخت
را خراب کند — اگر شکست خورد، لاگ کن و پرداخت را نگه دار (همان الگویی که
`docs/api/patient.md:446` برای مطالبهٔ بیمه توضیح داده: «خطا در این مرحله ثبت session را
خراب نمی‌کند»). ولی **بی‌صدا رد نشو** — لاگ سطح `error` با uuid نوبت.
### ۳. یک پرونده در context درست
`autoCreateOnAppointmentConfirm` بازنویسی شود:
```php
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
$clinic = $appointment->getClinic();
// محیط رزرو تعیین‌کننده است: کلینیک، یا مطب شخصی پزشک. هرگز هر دو —
// دو پرونده برای یک نوبت یعنی درآمد یک ویزیت دو بار شمرده می‌شود.
[$entityType, $entityId] = $clinic !== null
? ['clinic', $clinic->getId()]
: ['doctor', $appointment->getDoctor()->getId()];
$this->autoCreateForEntity($entityType, $entityId, $appointment, $entityId);
}
```
سازگاری با ردیف‌های قدیمیِ بدون `clinic_id`: بعد از migration وظیفهٔ ۱ همه پر شده‌اند، پس
شاخهٔ fallback لازم نیست. اگر لازم دیدی نگه‌داری، از `address_id → clinic_id` استفاده کن و
**شاخهٔ «تنها کلینیک پزشک» را حذف کن** — همان حدسی است که باگ می‌سازد.
### ۴. idempotency
متد lookup به `PatientSessionRepository` اضافه کن:
```php
/** مراجعهٔ ساخته‌شده برای این نوبت در همین محیط، یا null. */
public function findByAppointmentAndEntity(Appointment $appointment, string $entityType, int $entityId): ?PatientSession
{
return $this->createQueryBuilder('s')
->join('s.record', 'r')
->where('s.appointment = :appointment')
->andWhere('r.entityType = :entityType')
->andWhere('r.entityId = :entityId')
->setParameter('appointment', $appointment)
->setParameter('entityType', $entityType)
->setParameter('entityId', $entityId)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
```
و در ابتدای `autoCreateForEntity` بعد از گارد feature:
```php
if ($this->sessionRepo->findByAppointmentAndEntity($appointment, $entityType, $entityId) !== null) {
return; // قبلاً ساخته شده — قطعی‌شدن دوباره نباید مراجعهٔ تکراری بسازد
}
```
**نکتهٔ مرزی:** مراجعهٔ آرشیوشده (`PatientSession::$archived`) هم باید «ساخته‌شده» حساب شود؛
وگرنه آرشیو کردنِ یک مراجعهٔ اشتباه باعث ساخت دوبارهٔ آن می‌شود. اگر تصمیم دیگری گرفتی در PR
بنویس.
### ۵. رزرو پنل و ادمین مستقیم `confirmed` شود
نوبتی که خودِ کلینیک یا پزشک از پنل ثبت می‌کند پرداخت آنلاین ندارد و منتظر چیزی نیست؛
`pending` ماندنش یعنی نه در تقویم درست شمرده می‌شود، نه پرونده می‌سازد.
در `MyAppointmentsController` و `AdminApiController`، قبل از `bookAtomically`:
```php
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
```
سپس بعد از موفقیت `bookAtomically`، `confirmationService->onConfirmed($appointment)`.
**دقت:** `bookAtomically` روی `SLOT_OCCUPYING_STATUSES` و `active_slot_key` حساب می‌کند و
`confirmed` جزو آن‌هاست (`Appointment.php:52-55`)، پس قفل اتمیک اسلات دست‌نخورده کار می‌کند.
`transitionTo` را **قبل** از `bookAtomically` بگذار تا `refreshActiveSlotKey()` با وضعیت
نهایی محاسبه شود.
**استثنا:** مسیر `isReserve` (نوبت رزروِ روز-محور، `MyAppointmentsController:188-190`) اسلات
اشغال نمی‌کند و مراجعهٔ زمان‌دار برایش معنا ندارد — رفتار فعلی‌اش را عوض نکن و در
`onConfirmed` هم اگر `isReserve()` بود زود برگرد.
### ۶. گارد اشتراک — تصمیم صریح
`autoCreateForEntity` وقتی ویژگی `patient_records` فعال نباشد بی‌صدا برمی‌گردد. این درست
است (نباید به زور پرونده بسازد) ولی الان **غیرقابل‌تشخیص** است: نه لاگی، نه نشانه‌ای.
- یک لاگ سطح `info` با `entity_type`/`entity_id`/`appointment_uuid` بگذار.
- در `docs/api/patient.md` صریح بنویس که بدون این ویژگی، نوبت قطعی پرونده نمی‌سازد.
### ۷. Backfill نوبت‌های قطعیِ بی‌پرونده
پنج نوبت قطعیِ فعلی مراجعه ندارند. یک console command بنویس —
`app:appointment:backfill-sessions`:
- نوبت‌های `confirmed`/`completed` که مراجعهٔ متناظر ندارند را فهرست کند (uuid پزشک، تاریخ،
context، دلیلِ نبودن).
- با `--fix` همان `onConfirmed` را برایشان اجرا کند.
- خروجی تعداد ساخته‌شده و تعداد رد شده (به‌خاطر گارد اشتراک) را جدا گزارش کند.
نوبت‌های `completed` را هم پوشش بده: مراجعه‌ای که هرگز ساخته نشده با گذشتِ زمان از بین
نمی‌رود، فقط دیرتر لازم می‌شود.
### ۸. تست و مستندات
تست‌ها در `tests/Patient/` و `tests/Appointment/`:
1. نوبت رزروشده در کلینیک، قطعی می‌شود → **یک** پرونده با `entity_type='clinic'`، هیچ
پروندهٔ `doctor`ی ساخته نمی‌شود.
2. نوبت مطب شخصی → **یک** پروندهٔ `doctor`.
3. بیماری که از قبل پرونده دارد → پروندهٔ جدید ساخته نمی‌شود، فقط مراجعهٔ جدید به همان
پرونده اضافه می‌شود.
4. قطعی‌شدن دوباره (confirmed → cancelled → confirmed) → مراجعهٔ دوم ساخته نمی‌شود.
5. مسیر پرداخت: `PaymentManager` نوبت را قطعی می‌کند → پرونده و مراجعه ساخته می‌شوند
(بازتولید مستقیم باگ اصلی).
6. رزرو از پنل → نوبت `confirmed` است و مراجعه دارد.
7. نوبت `isReserve` → مراجعه ساخته نمی‌شود.
8. tenant بدون ویژگی `patient_records` → چیزی ساخته نمی‌شود و خطا هم نمی‌دهد.
مستندات: `docs/api/patient.md` بخش «Auto-Creation on Appointment Confirm» بازنویسی شود —
الان صراحتاً رفتار دوپرونده‌ای را به‌عنوان رفتار درست مستند کرده (`:613-617`) که با این تغییر
باطل می‌شود. فهرست همهٔ مسیرهای قطعی‌شدن، قاعدهٔ تک‌پرونده، و idempotency را بنویس.
`docs/api/appointment.md` هم برای `clinic_id` نوبت و وضعیت اولیهٔ `confirmed` در رزرو
پنل/ادمین به‌روز شود.
## نکات مهم
- **این تغییر رفتار مالی دارد.** حذف پروندهٔ دوم یعنی نوبت‌هایی که تا امروز در داشبورد پزشک
*و* کلینیک شمرده می‌شدند، از این به بعد فقط در یکی شمرده می‌شوند. دادهٔ تاریخیِ تکراری
(مثل دو مراجعهٔ نوبت 130043) را **حذف نکن** — تصمیم پاک‌سازی جدا از این تسک است؛ فقط در
`docs/` به‌عنوان کار بعدی ثبت کن.
- ترتیب پیشنهادی: (۱) ستون `clinic_id` + migration → (۲) choke point → (۳) تک‌پرونده →
(۴) idempotency → (۵) پنل/ادمین → (۷) backfill → (۸) تست و docs. هر مرحله جدا تست شود.
- `PatientRecord` روی `(entity_type, entity_id, user_id)` unique است؛ ساخت هم‌زمانِ دو نوبتِ
یک بیمار می‌تواند به `UniqueConstraintViolationException` بخورد. `findByEntityAndUser` +
`save` اتمیک نیست — این حالت مسابقه را در نظر بگیر (retry یا catch).
- هویت بیمار در مسیر پنل/ادمین با `PatientResolver::resolveForBooking` بر اساس کد ملی حل
می‌شود، ولی در مسیر عمومی `$appointment->getUser()` مستقیم کاربر لاگین‌شده است. پرونده به
`User` وصل می‌شود، پس رزرو «برای شخص دیگر» (`for_self=false`) پرونده را به نام **کاربر
رزروکننده** می‌سازد، نه بیمار واقعی. این یک ایراد جداست — **در scope این تسک نیست**، ولی
اگر با آن برخورد کردی در `docs/` ثبتش کن.
- تاریخ‌ها Unix timestamp صحیح؛ مبالغ ریال؛ رشته‌های جدید فارسی.
- پاسخ‌ها طبق `BaseController` با `$this->success()` / `$this->error()`.
- کاربران تست: ادمین `09390039833`، دکتر تست `09100652121`
(uuid `bcabb3a8-cae3-45ec-876c-548f9c1e1569`) در کلینیک
`41e325c4-e825-4067-8438-5d828ecaee09`، مالک کلینیک `09024206041`. کد OTP در dev همیشه
`12345`.
+131
View File
@@ -0,0 +1,131 @@
<div dir="rtl" markdown="1">
# افزودن شهر به بلاگ (پیش‌نیاز بلاگ شهر-محور)
## پروژه
`clinicpro` (backend)
**cross-repo:** این قرارداد را سایت عمومی مصرف می‌کند.
پرامپت همتا (بعد از این اجرا شود): `nobat724_front/.claude/prompt/blog-city-scoping-activate.md`
## زمینه
سایت عمومی ۳۵ دامنهٔ شهری دارد و هر شهر نمایندهٔ محتوایی خودش را دارد، اما بلاگ هیچ ارتباطی با شهر ندارد: نه در URL، نه در متادیتا، نه در مدل داده. نتیجه اینکه محتوای بلاگ روی همهٔ ۳۵ دامنه یکسان است و هیچ اعتبار محلی نمی‌سازد.
سمت فرانت، منطق شهر-محور **از قبل نوشته و مستقر شده** ولی غیرفعال است چون داده‌اش وجود ندارد:
- `nobat724_front/app/blog/[slug]/page.js` — canonical پست را با `extractEntityCityId(blog)` حساب می‌کند؛ چون `city_id` نیست، همیشه fallback به self می‌خورد.
- برچسب بصری «مخصوص شهر: X» در `components/blog/head/index.js` فقط وقتی `cityName` بیاید رندر می‌شود.
- `spatialCoverage` در JSON-LD مقاله فقط با وجود شهر اضافه می‌شود.
- `app/sitemap.js` هر پست را فقط در sitemap دامنهٔ canonical خودش می‌گذارد.
یعنی به‌محض اینکه API فیلد شهر بدهد، همهٔ این‌ها خودکار فعال می‌شوند.
**وضعیت فعلی داده:** `GET /api/v1/blogs` صفر رکورد برمی‌گرداند (`totalRecords: 0`). پس این تغییر روی داده‌ای اعمال می‌شود که هنوز تولید نشده — فرصت خوبی برای اضافه‌کردن فیلد قبل از پرشدن جدول.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `clinicpro/src/Blog/Entity/Blog.php` | Entity — فیلد جدید اینجا |
| `clinicpro/src/Blog/Controller/BlogController.php` | endpointهای عمومی و ادمین |
| `clinicpro/src/Blog/Repository/BlogRepository.php` | فیلتر لیست |
| `clinicpro/docs/api/blog.md` | مستندات |
| `clinicpro/assets/admin/pages/` (صفحهٔ بلاگ) | انتخاب شهر در فرم ادمین |
## وضعیت فعلی
`src/Blog/Entity/Blog.php` — هیچ ارجاعی به شهر ندارد:
```php
#[ORM\Entity(repositoryClass: BlogRepository::class)]
#[ORM\Table(name: 'blogs')]
#[ORM\Index(columns: ['status', 'created_at'], name: 'idx_blogs_status')]
class Blog
{
#[ORM\Column(type: 'string', length: 255)]
private string $title;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $slug;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private User $author;
#[ORM\Column(type: 'json')]
private array $tags = [];
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_DRAFT;
// ...
}
```
## وظایف
### ۱. فیلد شهر روی Entity
رابطهٔ `ManyToOne` اختیاری به Entity شهر (همان Entity که `Address`/`Clinic` استفاده می‌کنند — از آن‌ها الگو بگیر):
```php
#[ORM\ManyToOne(targetEntity: City::class)]
#[ORM\JoinColumn(name: 'city_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?City $city = null;
```
- **`nullable: true` الزامی است** و معنای صریح دارد: پست بدون شهر = «پست سراسری» که روی دامنهٔ اصلی canonical می‌شود. این حالت باید برای همیشه پشتیبانی شود، نه یک وضعیت موقت.
- `onDelete: 'SET NULL'` تا حذف شهر پست را نکشد.
- ایندکس روی `city_id` اضافه شود (فیلتر لیست per-domain روی همین ستون است).
### ۲. Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
رکوردهای موجود `city_id = NULL` می‌گیرند ⇒ همه «سراسری» می‌شوند. رفتار فعلی سایت را نمی‌شکند.
### ۳. شهر در پاسخ API
در سریال‌سازی پست (هم لیست هم جزئیات) شهر با **همان شکلی که سایت عمومی از کلینیک انتظار دارد** برگردد:
```php
'city' => $this->city ? [
'id' => (string) $this->city->getId(),
'name' => $this->city->getName(),
] : null,
```
`nobat724_front/lib/domainHelpers.js``extractEntityCityId()` هر سه شکل `city_id` مسطح، `city: {id}` و `city: [{id}]` را می‌پذیرد؛ پس هر کدام از این‌ها کار می‌کند — ولی `{ id, name }` را انتخاب کن چون `name` برای برچسب بصری «مخصوص شهر: X» لازم است.
### ۴. فیلتر شهر در لیست عمومی
`GET /api/v1/blogs` پارامتر `city_id` بپذیرد با این معنا:
> پست‌های **آن شهر** + پست‌های **سراسری** (`city_id IS NULL`)
این دقیقاً همان چیزی است که لیست بلاگ per-domain لازم دارد (`app/blogs/page.js` روی هر دامنهٔ شهری). بدون این پارامتر، رفتار فعلی (همهٔ پست‌ها) حفظ شود.
```sql
WHERE b.status = 'published' AND (b.city_id = :cityId OR b.city_id IS NULL)
```
### ۵. انتخاب شهر در پنل ادمین
در فرم ایجاد/ویرایش بلاگ، انتخابگر شهر با گزینهٔ خالی «سراسری».
از `SearchableSelect` استفاده کن (قانون پروژه: هرگز `<select>` بومی).
### ۶. مستندات
`clinicpro/docs/api/blog.md`: فیلد `city` در پاسخ لیست و جزئیات، پارامتر `city_id`، و معنای `null` = سراسری.
## نکات مهم
- معنای `null` را در مستندات صریح بنویس — سایت عمومی بر پایهٔ همین تصمیم می‌گیرد پست را روی دامنهٔ اصلی canonical کند یا روی دامنهٔ شهر.
- پستی که شهر دارد نباید روی دامنه‌های دیگر از لیست حذف شود؛ فقط **canonical** و **sitemap** آن به دامنهٔ شهر می‌رود. تصمیم «چه چیزی در کدام لیست دیده شود» با پارامتر `city_id` است و اختیاری.
- منبع شهر در سایت عمومی `data/city.json` است (۳۵ رکورد با `domain`) و `id` آن با `id` شهر در دیتابیس یکی است (مثلاً یاسوج = ۱۲۳). اگر این تطابق برقرار نیست، **قبل از هر کاری این را گزارش کن** — کل نگاشت شهر→دامنه به آن وابسته است.
</div>
+210
View File
@@ -0,0 +1,210 @@
# بازطراحی صفحه Claims به داشبورد مدیریتی بیمار-محور
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React)
پیش‌نیاز: `clinicpro/.claude/prompt/insurance-shared-calculation.md` باید **قبل** از این پرامپت اجرا شود — ستون‌های سهم بیمه/بیمار روی session و منطق واحد محاسبه از آنجا می‌آید.
## زمینه
صفحه `/admin/claims` امروز یک لیست کارتی از رکوردهای Claim است: هر Claim یک کارت، و داخل هر کارت یک `<table>` تودرتو از اقلام. این نه DataTable استاندارد پروژه است و نه برای پیگیری پرونده‌های بیمه‌ی یک بیمار کاربردی — کاربر نمی‌تواند ببیند «بیمار X مجموعاً چقدر ادعا دارد و چقدر وصول شده».
## هدف
تبدیل صفحه به داشبورد دو سطحی:
- **سطح ۱ — لیست بیماران:** هر ردیف یک بیمار با تجمیع مبالغ و وضعیت کلی.
- **سطح ۲ — جزئیات یک بیمار:** همه‌ی درخواست‌های بیمه‌ی آن بیمار با جزئیات کامل و لاگ تغییرات.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/ClaimsPage.tsx` | صفحه فعلی (۳۶۶ خط) — بازنویسی می‌شود |
| `assets/admin/App.tsx:237` | ثبت route `claims`؛ نقش‌های `['doctor','clinic']` + `blockClinicScope` + `<FeatureGate feature="insurance">` |
| `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
<thead>
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
<th>شرح</th><th>تاریخ مراجعه</th>
<th style={{ textAlign: 'center' }}>تعداد</th>
<th style={{ textAlign: 'left' }}>مبلغ کل</th>
<th style={{ textAlign: 'left' }}>سهم بیمه (ادعا)</th>
<th style={{ textAlign: 'left' }}>تأییدشده</th>
</tr>
</thead>
```
مشکلات موجود که باید در بازطراحی رفع شوند:
- `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ها و `<FeatureGate feature="insurance">`.
- `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`، هرگز `<select>` نیتیو.
- `StatusBadge` فعلاً `type` برای `claim` ندارد — نوع جدید `claim` با نگاشت pending=خاکستری، submitted=آبی، approved=کهربایی، rejected=قرمز، paid=سبز، mixed=بنفش اضافه شود و `STATUS_META` محلی حذف شود.
- استایل: از design tokenها (`var(--text-3)`, `.card`, `.btn primary sm`, `.badge`) استفاده شود. هگز hardcode ممنوع.
- پاسخ paginated: items از `data?.data`، total از `data?.meta?.totalRecords`. single: `data?.data` (گاهی double-nested).
- تاریخ‌ها Unix timestamp صحیح؛ تبدیل بازه با `toUnix` / `endOfDayUnix` مثل کد فعلی (`ClaimsPage.tsx:21-30`)؛ نمایش شمسی با `formatDate` / `formatDateTime`.
- مبالغ با `formatRial`؛ مرز ریال/تومان رعایت شود.
- همه controllerها از `BaseController`؛ پاسخ‌ها با `$this->paginated()` / `$this->success()` / `$this->error()`.
- انتقال وضعیت غیرمجاز باید سمت سرور هم reject شود (`Claim::TRANSITIONS` منبع حقیقت است) — اتکا به مخفی‌کردن دکمه در UI کافی نیست.
- تست با کاربر `09390039833 / 09390039833` روی `https://clinic-pro.ddev.site`.
- بعد از تغییر API، `clinicpro/docs/api/` به‌روز شود.
## تست پذیرش
۱. `/admin/claims` لیست بیماران با تجمیع درست نمایش می‌دهد؛ جمع ستون سهم بیمه با `insurance-debt` هم‌خوان است.
۲. کلیک روی بیمار → صفحه جزئیات با همه‌ی رکوردهای بیمه‌ی همان بیمار.
۳. انتقال وضعیت یک Claim (submit با شماره پیگیری، سپس approve، سپس pay) → لاگ در timeline ثبت می‌شود.
۴. reject بدون دلیل → خطا؛ با دلیل → ثبت و نمایش دلیل.
۵. فیلتر بازه زمانی + بیمه اعمال شود، صفحه رفرش شود → فیلترها از URL بازیابی می‌شوند.
۶. کاربر بدون فیچر `insurance` → صفحه در دسترس نیست (`FeatureGate`).
۷. مبالغ سهم بیمه/بیمار در این صفحه با صفحه پرداخت و مودال فاکتور **دقیقاً** یکی است.
@@ -0,0 +1,86 @@
<div dir="rtl" markdown="1">
# پاک‌سازی رکوردهای آلودهٔ پزشک و کلینیک
## پروژه
`clinicpro` (لایهٔ داده + اعتبارسنجی)
## زمینه
ممیزی SEO سایت عمومی رکوردهایی را در نتایج **عمومی** پیدا کرد که نام واقعی ندارند:
- پزشک با `name: "09390039833"` (شماره‌تلفن به‌جای نام) و `owner_status: "claimed"`
- پزشک با `name: "test"` و `owner_status: "claimed"`
- کلینیک `9c163d69-0051-4745-a423-c830135b1c01` با `title: "09398631203"`، `is_active: false`، بدون `caption`/`logo`/`services`
این رکوردها در HTML عمومی رندر می‌شدند و `<title>` صفحه‌ای مثل «09398631203 | یاسوج نوبت» می‌ساختند.
**وضعیت فعلی:** سایت عمومی موقتاً محافظت شده — `nobat724_front/lib/entityQuality.js` رکوردهایی با نام شبیه شماره‌تلفن یا `test` را `noindex` می‌کند و از sitemap بیرون می‌گذارد. ولی این فقط ماسک است: داده هنوز آلوده است، در API عمومی برمی‌گردد و در لیست‌ها به کاربر نمایش داده می‌شود.
سنجش روی دادهٔ واقعی: از ۵۰ رکورد اول لیست پزشکان، ۲ رکورد آلوده بودند.
## هدف
۱. رکوردهای آلودهٔ موجود پاک/غیرفعال شوند.
۲. جلوی تولید رکورد آلودهٔ جدید گرفته شود (اعتبارسنجی).
## وظایف
### ۱. گزارش دامنهٔ آلودگی (اول اندازه‌گیری، بعد حذف)
اسکریپت یا کوئری که بشمارد و **فهرست کند** (بدون تغییر داده):
- پزشکانی که `name` فقط رقم است یا با الگوی موبایل ایران می‌خواند (`^0?9\d{9}$` پس از حذف فاصله و خط تیره)
- پزشکان با نام در مجموعهٔ `test`, `تست`, `-`, `—` (بدون حساسیت به بزرگی/کوچکی)
- کلینیک‌ها با همان دو الگو روی `title`/`name`
- از هر گروه: تعداد کل، چندتا `owner_status = claimed`، چندتا نوبت/رابطهٔ واقعی دارند
الگوها را از `nobat724_front/lib/entityQuality.js` بردار تا معیار دو طرف یکی بماند:
```js
const PHONE_LIKE = /^0?9\d{9}$/;
const PLACEHOLDER_NAMES = new Set(["test", "تست", "-", "—"]);
```
**خروجی این مرحله را قبل از هر حذفی گزارش کن.** اگر رکوردی نوبت واقعی یا پرداخت دارد، حذف نیست — تصمیمش با تیم است.
### ۲. پاک‌سازی
بر اساس گزارش مرحلهٔ ۱، برای هر گروه تصمیم صریح بگیر و مستند کن:
| وضعیت رکورد | اقدام |
|---|---|
| بدون هیچ رابطهٔ واقعی (نوبت/پرداخت/کاربر) | حذف |
| دارای رابطهٔ واقعی ولی نام آلوده | نام اصلاح شود یا از انتشار عمومی خارج شود؛ حذف نشود |
| `owner_status: claimed` با نام آلوده | claim نامعتبر است — بازبینی دستی لازم دارد |
اسکریپت پاک‌سازی باید **dry-run پیش‌فرض** داشته باشد و فقط با فلگ صریح بنویسد.
### ۳. اعتبارسنجی برای جلوگیری از تکرار
در مسیر ایجاد/ویرایش پزشک و کلینیک (هم API عمومی، هم پنل ادمین، هم import):
- نام برابر الگوی شماره‌تلفن → خطای اعتبارسنجی فارسی
- نام در فهرست placeholderها → خطای اعتبارسنجی فارسی
- حداقل طول معنادار برای نام
پیام خطا فارسی باشد (قانون پروژه). به `clinicpro/src/Doctor/Controller/DoctorImportController.php` هم اعمال شود — import مسیر محتمل ورود دادهٔ آلوده است.
### ۴. بازبینی معیار `is_active` کلینیک
کلینیک `9c163d69` با `is_active: false` هنوز از API عمومی برمی‌گردد. تصمیم بگیر و مستند کن:
- `is_active: false` یعنی «موقتاً غیرفعال» → در API عمومی بماند ولی سایت `noindex` کند (رفتار فعلی سمت فرانت)
- یا یعنی «حذف‌شده» → از پاسخ‌های عمومی حذف شود
هر کدام را انتخاب کردی، در `docs/api/clinic.md` بنویس.
## نکات مهم
- **این تغییرات لایهٔ داده‌اند و برگشت‌ناپذیر.** قبل از حذف، بکاپ بگیر.
- بعد از پاک‌سازی، sitemap سایت عمومی خودکار بزرگ‌تر می‌شود (فیلتر `isThinDoctor` رکورد کمتری کنار می‌گذارد) — نیازی به تغییر فرانت نیست.
- اگر بعد از پاک‌سازی داده تمیز شد، فیلتر نام در `nobat724_front/lib/entityQuality.js` را **حذف نکن**؛ به‌عنوان لایهٔ دفاعی بماند.
- تست: `clinicpro/TEST_USERS.md` — کاربر تست `09390039833` است. مطمئن شو اسکریپت پاک‌سازی حساب‌های تست توسعه را با دادهٔ آلودهٔ production اشتباه نگیرد.
</div>
@@ -0,0 +1,96 @@
# رفع کامل عملیات نوبت در حالت کلینیک (context / permissions)
## پروژه
`clinicpro` (backend + پنل ادمین)
## زمینه
در حالت کلینیک تقریباً هیچ‌یک از عملیات نوبت کار نمی‌کند. کاربر تست کلینیک: نام کاربری `09024206041` / رمز `09024206041` (بعد از ریست دیتابیس: `ddev exec php create_test_users.php`).
ریشه‌یابی انجام شده: مسیر **نوشتن** نوبت (`MyAppointmentsController`) کلینیک را می‌فهمد، اما مسیر **خواندن/تغییر تک‌نوبت** (`AppointmentController`) فقط بیمار، پزشکِ مالک و ادمین را می‌شناسد. نتیجه: کاربر کلینیک نوبت می‌سازد ولی روی `GET /appointment/{uuid}`، `PATCH /appointment/{uuid}`، `PATCH /appointment/{uuid}/status` و `GET /appointment/{uuid}/events` خطای 403 می‌گیرد — یعنی ویرایش، جابه‌جایی، انتقال/جایگزینی رزرو، تغییر وضعیت و مشاهده جزئیات همگی می‌شکنند.
## مشکل / هدف
تمام عملیات زیر باید در حالت کلینیک (مدیر کلینیک + منشی کلینیک) بدون خطا و مطابق منطق دسترسی کار کند:
- ویرایش نوبت، ثبت سرویس برای نوبت، مشاهده جزئیات، جابه‌جایی، انتقال به لیست رزرو، جایگزینی از لیست رزرو، تغییر وضعیت (همه وضعیت‌ها).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Appointment/Controller/AppointmentController.php` | `canView`/`canManage` (خطوط ~686-698)، endpoint های detail/status/update/events |
| `src/Appointment/Controller/MyAppointmentsController.php` | لیست role-scoped، `canBookForDoctor` (~460)، `todayStats` (~385) |
| `src/Appointment/Repository/AppointmentRepository.php` | کوئری‌های slot فقط بر اساس doctor (~76, 122-172) |
| `src/Appointment/Entity/Appointment.php` | `refreshActiveSlotKey` (~204-211) — کلید slot بدون clinic |
| `src/Shared/Context/EntityContextResolver.php` | resolver کانتکست (`canActInClinic` خط ~68) |
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | مجوزهای پزشکِ عضو کلینیک |
| `src/Secretary/Security/SecretaryPermissionChecker.php` + `src/Secretary/Entity/DoctorSecretary.php` | مجوز منشی (`active`، `OWNER_CLINIC`) |
| `src/Patient/Controller/PatientController.php` | `resolveEntity` (~1186)، `appointments` (~940, ~955) |
| `assets/admin/components/AppointmentActions.tsx` | منوی عملیات + مودال‌های move/transfer/replace + `findRecordUuid` |
| `assets/admin/components/ui/AppointmentStatusDropdown.tsx` | تغییر وضعیت (`PATCH .../status` با `version`) |
| `assets/admin/pages/AppointmentsPage.tsx`, `ReserveAppointmentsPage.tsx`, `AppointmentEditPage.tsx`, `AppointmentDetailPage.tsx` | صفحات مصرف‌کننده |
## وضعیت فعلی
`AppointmentController` (~686-698) — کلینیک و منشی اصلاً بررسی نمی‌شوند:
```php
// canView/canManage: فقط بیمار (user)، پزشک مالک (doctor->getUser()) و ROLE_ADMIN.
// appointment->getClinic() هیچ‌جا چک نمی‌شود.
```
سایر ناهماهنگی‌های تأییدشده:
1. `AppointmentController::listByDoctor` (~626): فقط پزشکِ مالک یا ادمین — مدیر کلینیک برای پزشک عضو 403 می‌گیرد.
2. `PatientController::appointments` (~940): برای کلینیک از `acceptedDoctorIdsByClinic` استفاده می‌کند؛ اگر عضویت پزشک غیرفعال شود، نوبت‌های کلینیکیِ ثبت‌شده با `appointment.clinic_id` از پرونده «گم» می‌شوند — باید بر اساس `appointment.clinic` کوئری شود نه عضویت فعلی.
3. `todayStats` (~385): بدون شاخه ADMIN و بدون گیت `canView` منشی — ناهماهنگ با `myAppointments`.
4. کلید یکتای slot: `sprintf('%d:%d', doctorId, slotStart)` — clinic در کلید نیست؛ `isSlotTaken`/`occupiedIntervals`/`bookAtomically` همه فقط `a.doctor` را فیلتر می‌کنند. پزشکی که همزمان مطب شخصی و کلینیک دارد، رزرو در یک محیط، محیط دیگر را می‌بندد.
5. دو سبک موازی authorization: `PatientController::resolveEntity` از `UserActiveContext` می‌خواند ولی `MyAppointmentsController` شاخه‌بندی role دارد — رفتار منشی بین این دو ناسازگار است.
## وظایف
### ۱. تمرکز authorization تک‌نوبت در یک سرویس
یک سرویس واحد (مثلاً `src/Appointment/Security/AppointmentAccessChecker.php`) بساز با دو متد `canView(User, Appointment)` و `canManage(User, Appointment)` و در هر ۴ endpoint تک‌نوبت (`detail`, `update`, `status`, `events`) جایگزین چک‌های فعلی کن. منطق:
- ادمین: همیشه مجاز.
- بیمار (`appointment.user`): فقط `canView` + لغو خودش (رفتار فعلی حفظ شود).
- پزشک مالک (`appointment.doctor.user`): مجاز.
- **مدیر کلینیک**: اگر `appointment.getClinic() !== null` و کاربر مالک همان کلینیک است → مجاز (view + manage).
- **پزشک عضو کلینیک**: اگر نوبت کلینیکی است و پزشک عضو همان کلینیک است → از `ClinicDoctorPermissionChecker::can(user, clinic, 'appointments', action)` عبور کند (که `active=false` را خودش رد می‌کند).
- **منشی**: از `UserActiveContext` (مثل `PatientController::resolveEntity`) scope را دربیاور؛ اگر scope کلینیک است، نوبت باید متعلق به همان کلینیک و پزشکِ نوبت جزو پزشکان محول‌شده به منشی باشد؛ اگر scope پزشک است، `appointment.doctor` باید همان پزشک باشد. سپس `SecretaryPermissionChecker::can` با action مناسب (`edit`/`cancel`/`view`).
### ۲. رفع `listByDoctor` و `todayStats`
- `listByDoctor`: به مدیر کلینیک اجازه بده لیست نوبت‌های پزشکِ عضو را ببیند — اما فقط نوبت‌های همان کلینیک (`a.clinic = :clinic`).
- `todayStats`: شاخه ADMIN و گیت `canView` منشی را هم‌ارز `myAppointments` اضافه کن؛ برای کاربر بدون role معتبر، خروجی صفر/403 بده نه شمارش unscoped.
### ۳. رفع کوئری نوبت‌های پرونده
در `PatientController::appointments` شاخه کلینیک را از «doctorIds عضو فعلی» به فیلتر مستقیم `a.clinic = :clinicId` تغییر بده تا با غیرفعال شدن پزشک، تاریخچه نوبت‌های کلینیک از پرونده حذف نشود.
### ۴. کلید slot با محیط (clinic)
`refreshActiveSlotKey` را به `doctorId:clinicIdOrZero:slotStart` تغییر بده و `isSlotTaken`/`occupiedIntervals`/`expireLapsedPending`/`bookAtomically` را clinic-aware کن (پارامتر nullable clinic؛ `IS NULL` برای مطب شخصی). **migration لازم است** (تغییر مقدار ستون + بازتولید کلیدهای فعال موجود در migration data step). دقت: اگر منطق فعلی عمداً تداخل بین‌محیطی را می‌بندد (پزشک فیزیکی یک نفر است)، این وظیفه را با بررسی تنظیمات زمان‌بندی (schedule هر محیط جدا است یا نه) تأیید کن — اگر schedule ها ذاتاً غیرهمپوشان‌اند، فقط مستند کن و تغییر نده.
### ۵. تست end-to-end با کاربر کلینیک
با `09024206041` (و طبق `TEST_USERS.md` برای منشی/پزشک عضو) از طریق API یا پنل، تک‌تک این سناریوها را اجرا و سبز کن:
- ساخت نوبت پنل → مشاهده جزئیات → ویرایش (زمان/سرویس/یادداشت) → جابه‌جایی slot → انتقال به رزرو (`is_reserve:true`) → بازگشت از رزرو → جایگزینی بیمار → تمام گذارهای وضعیت مجاز (`ALLOWED_TRANSITIONS`).
- «ثبت سرویس برای نوبت» (منوی عملیات → `findRecordUuid``/admin/patients/{recordUuid}/session/new`): بررسی کن `GET /api/v1/patient?search=` در حالت کلینیک پرونده درست (entityType=clinic) را برمی‌گرداند و اگر پرونده وجود ندارد، فرانت پیام مناسب بدهد (نه crash).
- همه با پاسخ envelope استاندارد `BaseController` (`success`/`error`) و کد خطای معنادار، نه 500.
### ۶. فرانت: حذف فرض‌های doctor-only
بعد از باز شدن backend، بررسی کن صفحات clinic-mode چیز دیگری نمی‌شکنند: `AppointmentsPage` (در clinic mode «dbUuid = clinic id» است و doctor از `doctorUuid` جدا می‌آید)، مودال‌های `AppointmentActions` همه `version` را می‌فرستند (optimistic lock)، و خطای 409 نسخه با پیام فارسی مناسب toast شود.
## نکات مهم
- همه controller ها از `BaseController` ارث می‌برند؛ پاسخ‌ها فقط با `$this->success()/error()/paginated()`.
- **Voter وجود ندارد** — الگوی پروژه سرویس‌های checker است؛ همین الگو را ادامه بده، Voter جدید معرفی نکن.
- `ClinicDoctorPermission.can()` و `SecretaryPermissionChecker` هر دو `active=false` را رد می‌کنند — منبع حقیقتِ «پایان همکاری» همین است؛ چک موازی دستی ننویس.
- تغییر Entity ⇒ migration؛ تغییر هر endpoint ⇒ به‌روزرسانی `docs/api/*` در همین سشن.
- این پرامپت پیش‌نیاز `appointment-confirm-flow.md` است (دکمه قطعی‌کردن در حالت کلینیک به همین `canManage` تکیه دارد).
@@ -0,0 +1,203 @@
# یکسان‌سازی تنظیمات نوبت‌دهی + تب پزشکان در پنل کلینیک
## زمینه
تنظیمات نوبت‌دهی امروز فقط برای پزشکِ مستقل در دسترس است. مالک کلینیک نمی‌تواند نوبت‌دهی پزشکان کلینیکش را تنظیم کند: مسیر `/admin/appointment-settings` با `RoleRoute roles={['doctor']}` بسته است، هر ۱۴ endpoint در `AppointmentSettingsController` شرط یکسانِ «پزشک == کاربر جاری یا ادمین» دارند، و هیچ ورودی منویی برای نقش `clinic` وجود ندارد.
خبر خوب: زیرساخت تقریباً کامل است. هر ۱۴ endpoint از قبل uuid پزشک را از path یا body می‌گیرند، و کامپوننت `ScheduleSection` هم `doctorUuid` را به‌صورت prop می‌گیرد. یعنی برای «مالک کلینیک تنظیمات پزشک X را مدیریت کند» فقط سه چیز مانع است: شرط هویت در بک‌اند، گارد route، و نبود ورودی منو.
> پیش‌نیاز: `clinic-doctor-permissions.md` (Entity و چکر مجوز از آنجا می‌آید). اول آن را اجرا کن.
## مشکل / هدف
۱. **پنل شخصی پزشک** باید دقیقاً مثل پزشک مستقل کار کند — هیچ تفاوتی در ساختار و امکانات.
۲. **پنل کلینیک** در «تنظیمات → نوبت‌دهی» باید برای هر پزشک یک تب داشته باشد و با انتخاب تب، تنظیمات همان پزشک را نشان دهد.
۳. **یک پیاده‌سازی واحد** — نه دو نسخه موازی. هر دو حالت باید همان کامپوننت را رندر کنند.
۴. تغییرات هر پزشک فقط روی خودش اثر بگذارد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Appointment/Controller/AppointmentSettingsController.php` | هر ۱۴ endpoint تنظیمات نوبت‌دهی |
| `src/Appointment/Entity/WeeklySchedule.php` | `OneToOne` Doctor، unique روی `doctor_id` |
| `src/Appointment/Entity/DateOverride.php` | `ManyToOne` Doctor، unique روی `(doctor_id, date)` |
| `src/Appointment/Entity/Holiday.php` | `ManyToOne` Doctor |
| `assets/admin/pages/DoctorDetailPage.tsx:2058` | `ScheduleSection` — پیاده‌سازی واقعی، داخل یک فایل صفحه |
| `assets/admin/pages/DoctorDetailPage.tsx:1252, 1772, 1944` | `WeeklyScheduleTab` / `DateOverridesTab` / `HolidaysTab` |
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه پزشک مستقل (۳۶ خط، فقط پوسته) |
| `assets/admin/components/FreeVisitPrice.tsx` | قیمت ویزیت — **بدون پارامتر پزشک، فقط JWT-scoped** |
| `assets/admin/App.tsx:232` | route `appointment-settings` با `roles={['doctor']} blockClinicScope` |
| `assets/admin/components/layout/SettingsLayout.tsx:22-35` | `SETTINGS_MENU` (منوی موبایل) |
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx:22` | منوی دسکتاپ تنظیمات — تعریف موازی و جدا |
| `docs/api/appointment-settings.md` | مستند API |
## وضعیت فعلی
**شرط هویت، ۱۴ بار کپی شده**`src/Appointment/Controller/AppointmentSettingsController.php:75-77` و مشابهش در `:123-125`، `:199-201`، `:406-408`:
```php
if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```
نسخه‌های فرزند: `$schedule->getDoctor()->getUser()->getId() !== $user->getId()`، و همین برای `$override` و `$holiday`.
`ROLE_CLINIC` در کل این کنترلر یک بار هم استفاده نشده. `ClinicRepository` تزریق شده (`:36`) ولی فقط در `availableLocations` (`:410`) برای لیست آدرس‌ها به کار می‌رود، نه برای مجوز.
**صفحه پزشک مستقل، uuid را از authStore می‌گیرد**`assets/admin/pages/AppointmentSettingsPage.tsx:12-14, 31`:
```tsx
const doctorUuid = useAuthStore((s) => s.doctorUuid);
const dbUuid = useAuthStore((s) => s.dbUuid);
const uuid = doctorUuid ?? dbUuid ?? undefined;
...
<ScheduleSection doctorUuid={uuid} />
```
**کامپوننت اصلی از قبل پارامتری است**`DoctorDetailPage.tsx:2062-2064` و `:1263, 1309-1310`:
```tsx
queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`)
...
? 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 });
```
**گارد route پزشکِ در scope کلینیک را بیرون می‌اندازد**`assets/admin/App.tsx:117-123`:
```tsx
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
return <Navigate to="/admin/dashboard" replace />;
}
```
**ورودی منو فقط برای پزشک**`PurchaseSubscriptionSidebar.tsx:22` و `SettingsLayout.tsx` (هر دو باید ویرایش شوند):
```tsx
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] },
```
## وظایف
### ۱. بک‌اند — یک helper واحد به‌جای ۱۴ شرط تکراری
در `AppointmentSettingsController` یک متد خصوصی اضافه کن و **هر ۱۴ شرط را با آن جایگزین کن**:
```php
private function assertDoctorAccess(Doctor $doctor, User $user): void
{
if ($user->hasRole('ROLE_ADMIN')) {
return;
}
if ($doctor->getUser()->getId() === $user->getId()) {
return;
}
// مالک کلینیکی که این پزشک عضو آن است
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null && $clinic->hasDoctor($doctor)) {
return;
}
throw new AppException(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
```
نکات:
- `Clinic::hasDoctor()` از قبل در `src/Clinic/Entity/Clinic.php:156` وجود دارد.
- برای endpointهای فرزند (`{uuid}` = uuid برنامه/override/holiday) همان helper را با `$schedule->getDoctor()` صدا بزن.
- اگر پرامپت مجوزها اجرا شده، برای پزشکِ عضو کلینیک هم مسیر بده: اگر `$user` خودش پزشکِ عضو همان کلینیک است، `ClinicDoctorPermissionChecker::can($user, $clinic, 'appointment_settings', 'update')` را چک کن. برای متدهای GET با `'view'`.
- منطق اعتبارسنجی موجود دست نخورد: `assertModeImmutable` (`:48-54``serviceModeHasNoBookable` (`:56-60``validateSessionsHaveLocation` (`:432-442`).
### ۲. استخراج `ScheduleSection` به فایل مستقل
امروز `ScheduleSection` داخل `assets/admin/pages/DoctorDetailPage.tsx` (خط ۲۰۵۸) تعریف و از آنجا export می‌شود. برای اینکه «یک ساختار واحد» واقعاً یک ماژول باشد و صفحه‌ای از صفحه دیگر import نکند:
- `assets/admin/components/schedule/ScheduleSection.tsx` بساز و `ScheduleSection` + `WeeklyScheduleTab` (`:1252`) + `DateOverridesTab` (`:1772`) + `DateOverrideModal` (`:1632`) + `HolidaysTab` (`:1944`) + `HolidayModal` (`:1871`) و helperهای مربوطه (`BookingMeta` `:101-114`، `calcSlotCount` `:338`، `SessionEditor` `:1118`، `SlotEditor` `:1077`) را به آن منتقل کن.
- `DoctorDetailPage.tsx` و `AppointmentSettingsPage.tsx` هر دو از همان فایل import کنند.
- **هیچ تغییری در منطق نده** — این مرحله صرفاً جابه‌جایی است. بعد از انتقال، `tsc` و `yarn dev` باید بدون خطا رد شوند و رفتار صفحه پزشک مستقل عیناً همان باشد.
### ۳. صفحه تنظیمات نوبت‌دهی کلینیک با تب پزشکان
`assets/admin/pages/ClinicAppointmentSettingsPage.tsx`:
```tsx
// لیست پزشکان کلینیک → تب‌ها → همان ScheduleSection با doctorUuid انتخاب‌شده
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const doctorList = (doctorsQ.data?.data as any)?.data ?? doctorsQ.data?.data ?? [];
const [activeUuid, setActiveUuid] = useState<string | null>(null);
const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
<SettingsLayout active="appointment">
<div className="seg"> {/* همان الگوی تب در ClinicDoctorsManager */}
{doctorList.map(d => (
<button key={d.uuid} className={selected === d.uuid ? 'active' : ''} onClick={() => setActiveUuid(d.uuid)}>
{d.name}
</button>
))}
</div>
{selected && <ScheduleSection key={selected} doctorUuid={selected} />}
</SettingsLayout>
```
نکات حیاتی:
- `key={selected}` روی `ScheduleSection` **الزامی است** — بدون آن، state داخلی تب (برنامه هفتگی در حال ویرایش) بین پزشک‌ها نشت می‌کند و ممکن است تنظیمات پزشک A روی B ذخیره شود. این دقیقاً همان چیزی است که خواسته «تغییرات هر پزشک فقط روی خودش» را نقض می‌کند.
- `clinicUuid` را مثل `ClinicDoctorsPage.tsx` از context نوع `clinic` بگیر، نه مستقیم از `dbUuid` (کاربری که هم پزشک است هم مالک کلینیک، `dbUuid`‌اش ممکن است uuid پزشک باشد و همه فراخوانی‌ها ۴۰۴ شوند).
- حالت خالی: کلینیک بدون پزشک → پیام «هیچ پزشکی به این کلینیک متصل نیست» + لینک به `/admin/settings/clinic-doctors`.
- اگر تعداد پزشکان زیاد شد، تب‌ها باید افقی اسکرول شوند نه شکسته.
### ۴. Route و منو
`assets/admin/App.tsx`:
```tsx
<Route path="settings/appointment-settings"
element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
```
مسیر موجود `appointment-settings` (`:232`، `roles={['doctor']} blockClinicScope`) دست‌نخورده بماند — آن پنل شخصی پزشک است و باید دقیقاً مثل امروز کار کند.
**هر دو منو** باید ورودی بگیرند (تعریفشان موازی و جداست):
- `SettingsLayout.tsx:22-35``SETTINGS_MENU`: یک آیتم با `roles: ['clinic']` و مقصد `/admin/settings/appointment-settings`. آیتم فعلی `roles: ['doctor']` دست‌نخورده بماند.
- `PurchaseSubscriptionSidebar.tsx:22` → همان.
هر دو آیتم `key: 'appointment'` داشته باشند تا `active="appointment"` در `SettingsLayout` برای هر دو کار کند.
### ۵. تکلیف `FreeVisitPrice`
`assets/admin/components/FreeVisitPrice.tsx` روی `/api/v1/insurance-pricing` کار می‌کند و **هیچ پارامتر پزشکی نمی‌گیرد** — فقط از JWT scope می‌گیرد. اگر آن را داخل تب کلینیک رندر کنی، مالک کلینیک قیمت ویزیتِ خودش را ویرایش می‌کند نه پزشک انتخاب‌شده. یکی از دو کار را بکن و در گزارش صریح بگو کدام:
- **الف)** به endpointهای `/api/v1/insurance-pricing` پارامتر اختیاری `doctor_uuid` اضافه کن (با همان `assertDoctorAccess`) و `FreeVisitPrice` را prop-محور کن. سازگاری عقب‌رو حفظ شود: بدون `doctor_uuid` رفتار امروز.
- **ب)** فعلاً `FreeVisitPrice` را از تب کلینیک حذف کن و در همان‌جا یادداشت بگذار.
گزینه (الف) ارجح است چون خواسته «هیچ تفاوتی بین دو حالت نباشد» است، ولی اگر انتخاب شد باید مستند `docs/api/insurance.md` هم به‌روز شود.
### ۶. تست
`tests/Appointment/ClinicOwnerScheduleAccessTest.php`:
- مالک کلینیک برنامه هفتگی پزشکِ عضو را می‌خواند و PATCH می‌کند → ۲۰۰
- مالک کلینیک روی پزشکی که عضو کلینیکش نیست → ۴۰۳
- پزشک روی برنامه خودش → ۲۰۰ (رگرسیون: رفتار قبلی نشکند)
- پزشک روی برنامه پزشک دیگر → ۴۰۳
- `ROLE_ADMIN` روی هر پزشکی → ۲۰۰
- ذخیره برنامه پزشک A، `WeeklySchedule` پزشک B دست‌نخورده می‌ماند (شرط «فقط روی همان پزشک اثر بگذارد»)
- همین ماتریس برای `date-override` و `holidays`
`docs/api/appointment-settings.md` را به‌روز کن: قاعده جدید دسترسی (مالک/عضو کلینیک) در هر ۱۴ endpoint، و کد خطای ۴۰۳.
## نکات مهم
- `WeeklySchedule` روی `doctor_id` قید `unique` دارد (`Entity:12`) و `OneToOne` است — یعنی هر پزشک دقیقاً یک برنامه دارد و منطق upsert است. اگر تب‌ها `doctorUuid` را درست پاس ندهند، PATCH روی برنامه پزشک اشتباه می‌نشیند و داده‌ی واقعی از بین می‌رود. این پرخطرترین بخش این تسک است.
- `DateOverride` قید `unique(doctor_id, date)` دارد؛ در حالت تب، تداخل تاریخ بین پزشکان معنا ندارد ولی خطای unique را باید به پیام فارسی معنادار تبدیل کنی نه ۵۰۰.
- در booking mode سرویسی، `countBookableByEntity('doctor', $doctor->getId())` (کنترلر `:59`) hard-code روی `'doctor'` است؛ کاتالوگ سرویس خود کلینیک این شرط را برآورده نمی‌کند. اگر پزشکِ عضو کلینیک سرویس شخصی ندارد، حالت سرویسی برایش قابل فعال‌سازی نیست — این را در UI با پیام فارسی روشن کن، نه با خطای خام.
- `booking_mode` بعد از اولین ذخیره قفل می‌شود (`assertModeImmutable` `:48-54` و `modeLocked` در `WeeklyScheduleTab:1282`) — این رفتار در تب کلینیک هم باید دقیقاً همان باشد.
- هر session فعال باید `location_id` داشته باشد (`validateSessionsHaveLocation` `:432-442`)؛ آدرس‌های در دسترس از `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` می‌آید که خودش از `ClinicRepository` تغذیه می‌شود — برای پزشکِ عضو کلینیک، آدرس‌های کلینیک باید در لیست باشند.
- همه controllerها از `BaseController`؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح؛ نمایش شمسی با `formatDate()`.
- Form: React Hook Form + Zod؛ server state: TanStack Query v5؛ برای هر select از `SearchableSelect` استفاده کن نه `<select>` بومی.
- CSS: از کلاس‌های موجود (`seg`، `card`، `btn primary sm`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
- بعد از انتقال کامپوننت‌ها حتماً `ddev exec npx tsc --noEmit` و `ddev exec yarn dev` را اجرا کن — این refactor حجم زیادی import جابه‌جا می‌کند.
+241
View File
@@ -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<string, any>` — تعریف شده ولی هیچ مصرف‌کننده‌ای ندارد |
| `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
<button className="mini-btn" title="مشاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<button className="mini-btn danger" title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
```
## وظایف
### ۱. 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` استفاده کن، نه `<select>` بومی.
### ۸. تست + مستندات
تست‌ها در `tests/Clinic/ClinicDoctorPermissionTest.php`:
- مالک کلینیک مجوز را می‌خواند و PATCH می‌کند → ۲۰۰
- PATCH فقط کلیدهای ارسالی را عوض می‌کند و بقیه دست‌نخورده می‌ماند (deep-merge)
- پزشکِ عضو نمی‌تواند مجوز خودش را عوض کند → ۴۰۳
- پزشکِ کلینیک دیگر → ۴۰۴
- `getOrCreate` برای پزشکی که قبل از feature عضو شده، سطر پیش‌فرض می‌سازد
- context خروجی `/oauth/userinfo` برای پزشکِ عضو، `permissions` دارد و برای مالک ندارد
`docs/api/clinic.md` را با دو endpoint جدید، شکل کامل envelope، و جدول کلیدها به‌روز کن.
## نکات مهم
- **سه ضعفِ سیستم منشی را تکرار نکن:** (۱) `SecretaryPermissionChecker` هیچ call site ندارد — چکر جدید باید واقعاً صدا زده شود؛ (۲) `DoctorSecretary::toArray()` envelope را flatten می‌کند ولی `available_contexts` نمی‌کند، پس کلاینت دو شکل می‌بیند — همه‌جا یک شکل بده؛ (۳) از ۲۲ فلگ منشی فقط ۲ تا واقعاً enforce می‌شود — کلید بدون enforcement اضافه نکن.
- همه controllerها از `BaseController` ارث می‌برند؛ پاسخ فقط با `$this->success()` / `$this->paginated()` / `$this->error()`.
- تاریخ‌ها Unix timestamp صحیح (`time()`), نه `DateTime`.
- لیست‌های admin با DQL array hydration (`->getArrayResult()`).
- پنل ادمین: paginated → `data?.data` و `data?.meta?.totalRecords`؛ تک‌آیتم → `data?.data` (ممکن است double-nested باشد).
- هر تغییر Entity ⇒ `doctrine:migrations:diff` + `migrate`.
- **مالک کلینیک هرگز نباید بتواند خودش را قفل کند** — چکر برای مالک همیشه `true` برمی‌گرداند، قبل از هر lookup.
- edge case: پزشکی که هم مالک کلینیک است هم عضو کلینیک دیگر — `resolvePrimaryRole()` (`AuthController.php:684-691`) یک نقش برنده می‌دهد، ولی مجوز باید per-context حساب شود نه per-role.
- edge case: جداسازی پزشک از کلینیک باید سطر `clinic_doctor_permissions` را هم حذف کند (`onDelete: CASCADE` روی FKها این را پوشش نمی‌دهد چون جدا از `clinic_doctors` است — در endpoint detach صریحاً حذف کن).
- CSS: از کلاس‌های موجود (`btn primary sm`، `mini-btn`، `badge`، `card`، `field`) استفاده کن؛ کتابخانه جدید اضافه نکن؛ RTL.
+77
View File
@@ -0,0 +1,77 @@
# دسترسی پزشک و مدیر کلینیک به پرونده‌های کلینیک
## پروژه
`clinicpro` (backend + پنل ادمین) — بعد از `clinic-appointment-operations-fix.md` و `appointment-confirm-flow.md` اجرا شود.
## زمینه
پرونده‌ها per-محیط silo شده‌اند: `PatientRecord` مالک چندریختی دارد — `entityType` (`doctor|clinic|system`) + `entityId` با یکتایی `(entity_type, entity_id, user_id)`. `PatientController::resolveEntity` (~1186) هر کاربر را به **یک** scope نگاشت می‌کند (پزشک → پرونده‌های شخصی خودش، کلینیک → پرونده‌های کلینیک) و `ownsRecord()` (~1232) تساوی دقیق می‌سنجد. نتیجه فعلی: پزشکِ دعوت‌شده به کلینیک، پرونده‌های بیمارانش **در آن کلینیک** را نمی‌بیند (فقط مدیر کلینیک می‌بیند).
## مشکل / هدف
- پزشک عضو کلینیک و مدیر کلینیک هر دو به پرونده‌های بیماران آن پزشک در آن کلینیک دسترسی داشته باشند (مشاهده + مدیریت).
- تا وقتی پزشک در کلینیک فعال است (`ClinicDoctorPermission.active` / عضویت)، همه پرونده‌های مرتبطش در آن کلینیک برایش قابل مشاهده/مدیریت باشد.
- با پایان همکاری یا غیرفعال شدن، دسترسی پزشک طبق سطح دسترسی سیستم محدود/قطع شود؛ مدیر کلینیک دسترسی کامل بماند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Controller/PatientController.php` | `resolveEntity` (~1186)، `ownsRecord` (~1221-1240)، همه endpoint های پرونده/session/پرداخت |
| `src/Patient/Entity/PatientRecord.php` | مالک چندریختی، بدون FK پزشک |
| `src/Patient/Entity/PatientSession.php` | `appointment` nullable → پل به `appointment.doctor` |
| `src/Patient/Service/PatientService.php` | ساخت پرونده/session (`autoCreateForEntity` ~144) |
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | `can(user, clinic, 'patients', action)``active=false` را رد می‌کند |
| `src/Clinic/Entity/ClinicDoctorPermission.php` | resource `patients` در `DEFAULT_PERMISSIONS` |
| `src/Shared/Context/EntityContextResolver.php` | کانتکست فعال (پزشکی که داخل کلینیک سوییچ کرده) |
| `assets/admin/pages/MyPatientsPage.tsx`, `PatientsListPage.tsx`, `PatientDetailPage.tsx` | UI پرونده‌ها |
| `assets/admin/stores/authStore.ts`, `hooks/useClinicContext.ts` | کانتکست SPA (`switchContext`) |
## وضعیت فعلی
```php
// PatientController::resolveEntity — نگاشت تک‌مقصدی:
// ROLE_DOCTOR → ['doctor', doctorId] // فقط پرونده‌های مطب شخصی
// ROLE_CLINIC → ['clinic', clinicId] // فقط پرونده‌های کلینیک
// ROLE_SECRETARY → از UserActiveContext
// ownsRecord(): record.entityType === entityType && record.entityId === entityId
```
نکته کلیدی مدل: پرونده کلینیکی per-بیمار است نه per-پزشک (unique روی clinic+user). «پرونده‌های بیماران آن پزشک» یعنی پرونده‌های کلینیکی‌ای که بیمارشان با آن پزشک session/نوبت داشته — از مسیر `PatientSession.appointment.doctor` (و برای سشن‌های دستی `createdByType/createdById`) قابل استخراج است.
## وظایف
### ۱. Backend — دسترسی پزشک به پرونده‌های کلینیک
`resolveEntity`/`ownsRecord` را از نگاشت تک‌مقصدی به مدل «scope فعال + عضویت» ارتقا بده:
- وقتی پزشک با `UserActiveContext` روی کانتکست کلینیک است (SPA با `switchContext` این را ست می‌کند)، scope پرونده = `['clinic', clinicId]` **مشروط به** `ClinicDoctorPermissionChecker::can(user, clinic, 'patients', action)` — که خودش عضویت غیرفعال را رد می‌کند. این الزام «قطع دسترسی بعد از پایان همکاری» را بدون منطق جدید برآورده می‌کند.
- محدودسازی به «بیماران آن پزشک»: در لیست پرونده‌ها (`GET /api/v1/patient(s)`) وقتی scope=clinic و کاربر پزشک عضو است (نه مدیر)، فیلتر کن به پرونده‌هایی که حداقل یک session با `appointment.doctor = doctor` یا `createdByType='doctor' AND createdById=doctorId` دارند (subquery/EXISTS در repository). مدیر کلینیک بدون این فیلتر، همه را می‌بیند.
- دسترسی تک‌پرونده (detail/session/پرداخت/یادداشت/پیوست): همان قاعده — مدیر کلینیک کامل؛ پزشک عضو فعال فقط اگر پرونده طبق فیلتر بالا «مالِ بیماران خودش» باشد. تصمیم باز که باید حین اجرا گرفته و مستند شود: آیا پزشک به کل پرونده مشترک بیمار (شامل session های پزشک دیگر همان کلینیک) دید دارد یا فقط session های خودش؟ پیش‌فرض پیشنهادی: دید کامل به پرونده، مدیریت فقط روی session های خودش.
- `assertPatientGate` (feature اشتراک `patient_records`) سر جای خودش بماند — گیت اشتراک باید بر اساس محیط کلینیک چک شود نه اشتراک شخصی پزشک.
### ۲. Backend — منشی
منشی کلینیک (`DoctorSecretary` با `OWNER_CLINIC` و `active`) طبق همان الگو: scope کلینیک + محدود به پزشکان محول‌شده + `SecretaryPermissionChecker::can(..., 'patients', ...)`. رفتار فعلی منشی نباید پس‌رفت کند.
### ۳. Frontend — نمایش پرونده‌های کلینیک برای پزشک
- وقتی پزشک کانتکست کلینیک را انتخاب کرده (`useClinicContext` مقدار دارد)، `MyPatientsPage`/`PatientsListPage` باید پرونده‌های کلینیکِ scope شده را نشان دهند — احتمالاً بدون تغییر فرانت کار می‌کند چون scope سمت سرور است؛ تست کن و فقط اگر endpoint/پارامتر جدید لازم شد دست بزن.
- حالت خطای «دسترسی قطع شده» (پزشک غیرفعال‌شده): پیام فارسی روشن، نه صفحه خالی.
### ۴. تست
- پزشک عضو فعال در کلینیک `09024206041` (طبق `TEST_USERS.md` بساز/استفاده کن): در کانتکست کلینیک پرونده بیمارانش را می‌بیند و session/پرداخت ثبت می‌کند؛ در کانتکست شخصی فقط پرونده‌های مطب خودش.
- مدیر کلینیک: همه پرونده‌های کلینیک، قبل و بعد از غیرفعال‌سازی پزشک.
- پزشک را غیرفعال کن (`ClinicDoctorPermission.active=false`): پزشک 403/فیلتر می‌شود، مدیر همچنان کامل؛ پرونده‌ها و تاریخچه دست‌نخورده می‌مانند (هیچ حذف/انتقالی رخ نمی‌دهد).
- قطعی‌کردن نوبت کلینیکی توسط پزشک عضو (خروجی پرامپت قبلی) پرونده را در محیط کلینیک می‌سازد و همان پرونده برای هر دو نقش دیده می‌شود.
- `docs/api/*` برای هر endpoint تغییرکرده به‌روز شود.
## نکات مهم
- **FK پزشک به `PatientRecord` اضافه نکن** — یکتایی `(clinic, user)` عمداً پرونده مشترک کلینیکی است؛ ارتباط پزشک از مسیر session/appointment استخراج می‌شود.
- Voter نساز؛ الگوی checker service موجود.
- لیست‌ها با DQL array hydration (`getArrayResult`)؛ فیلتر EXISTS را در repository اضافه کن نه در PHP.
- اگر schema تغییر کرد (بعید، ولی مثلاً index برای کوئری EXISTS): migration.
@@ -0,0 +1,134 @@
# منشیِ مشترک کلینیک: تخصیص یک منشی به چند پزشک با دسترسی محدود
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React — همان ریپو).
## زمینه
در یک کلینیک که چند پزشک دارد، مدیر کلینیک می‌خواهد **یک منشی را به یک یا چند پزشکِ همان کلینیک** تخصیص دهد، به‌طوری‌که دسترسی آن منشی **فقط به پزشکانِ تعیین‌شده** محدود باشد (نه همه‌ی پزشکان کلینیک). این ارتباط باید many-to-many، و بعداً قابل افزودن/حذف بدون تغییر ساختاری باشد.
**مهم — schema از قبل آماده است:** موجودیت `DoctorSecretary` (`src/Secretary/Entity/DoctorSecretary.php`) یک join row است: `(doctor + secretary User + owner_type[doctor|clinic] + clinic? + permissions json)` با unique روی `(doctor_id, secretary_id, owner_type)`. یعنی یک منشیِ user همین حالا می‌تواند **چند ردیف** داشته باشد (یکی per پزشک). Repository هم متد `findDoctorsBySecretaryInClinic($user, $clinic)` را دارد که دقیقاً «پزشکانِ تخصیص‌یافته‌ی این منشی در کلینیک» را برمی‌گرداند. **هیچ migration/تغییر schema لازم نیست.**
## مشکل / هدف
سه شکاف وجود دارد که باید پر شود:
1. **نوشتن تک‌پزشکی:** هر مسیر نوشتن فقط یک پزشک می‌گیرد. `POST /api/v1/secretary` فقط یک `doctor_uuid` می‌پذیرد؛ برای تخصیص به N پزشک باید N بار صدا زد. هیچ سرویس/endpoint اتمیک برای چند پزشک یا برای «هم‌گام‌سازی مجموعه‌ی پزشکانِ یک منشی» وجود ندارد. اصلاً پوشه‌ی `src/Secretary/Service/` نیست (منطق داخل کنترلر).
2. **UI تک‌انتخابی:** فرم کلینیک در `MySecretariesPage.tsx` پزشک را تک‌انتخابی می‌گیرد؛ multi-select و ویرایش لیست پزشکانِ منشیِ موجود نیست.
3. **⚠️ عدم اعمال scope (هسته‌ی خواسته):** منشیِ کلینیک الان **همه‌ی پزشکان کلینیک** را می‌بیند. لیست نوبت با `d MEMBER OF c.doctors` فیلتر می‌شود و گیت رزرو فقط عضویت در کلینیک را چک می‌کند — نه پزشکانِ تخصیص‌یافته. فقط داشبورد درست scope می‌شود (`findDoctorsBySecretaryInClinic`). بدون رفع این، «دسترسی محدود به پزشکان تعیین‌شده» فقط ظاهری است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Secretary/Entity/DoctorSecretary.php` | join row؛ `OWNER_CLINIC`، `DEFAULT_PERMISSIONS`، `mergePermissions()` |
| `src/Secretary/Controller/SecretaryController.php` | `create` (خط ۳۹، تک‌پزشکی)، `update`/`show`/`deactivate`، `listByClinic` (خط ۲۴۱)، `canManage` (خط ۲۶۲) |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | `findDoctorsBySecretaryInClinic` (خط ۹۲)، `findByClinic` (خط ۱۲۶)، `findActiveBySecretaryForClinic` (خط ۷۶)، `countActiveByDoctor` (خط ۲۴) |
| `src/Appointment/Controller/MyAppointmentsController.php` | `resolveSecretaryFilter` (خط ۵۰۴)، اعمال فیلتر clinic (خط ۳۰۳)، `secretaryCanBookForDoctor` (خط ۴۷۳) |
| `src/Dashboard/Controller/DashboardController.php` | خط ۴۱۶–۴۳۱ — الگوی درستِ scope با `findDoctorsBySecretaryInClinic` (مرجع کپی) |
| `src/Patient/Controller/PatientController.php` | `resolveEntity()` خط ۱۱۹۸ — scope بیمار (clinic vs doctor) |
| `assets/admin/pages/MySecretariesPage.tsx` | فرم مدیریت منشی؛ شاخه‌ی clinic (خط ۶۰۴)، picker تک‌انتخابی (خط ۷۳۷)، create mutation (خط ۶۵۴) |
| `assets/admin/types/index.ts` | تایپ `Secretary`/`SecretaryPermissions` (خط ۳۲۶) — بدون آرایه‌ی پزشک |
| `docs/api/secretary.md` | مستندات endpointها |
## وضعیت فعلی
### create تک‌پزشکی — `SecretaryController.php:39`
```php
#[Route('/api/v1/secretary', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $currentUser): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? ''); // ← فقط یک پزشک
// ...
$ownerClinic = null;
if ($currentUser->hasRole('ROLE_CLINIC')) {
$ownerClinic = $this->clinicRepo->findByUser($currentUser);
if ($ownerClinic === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $ownerClinic)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
} // ...
// find-or-create secretary user، duplicate guard روی (doctor, secretary, ownerType)
$secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic); // ← یک ردیف
// ...
}
```
### scope ناقص برای نوبت — `MyAppointmentsController.php:303` و `:504`
```php
// اعمال فیلتر منشیِ کلینیک — همه‌ی پزشکان کلینیک، نه تخصیص‌یافته‌ها:
if ($filterType === 'clinic') {
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
->andWhere('c = :clinic')->setParameter('clinic', $filterValue);
}
```
```php
private function resolveSecretaryFilter(User $user): ?array {
// ...
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); // فقط «آیا در کلینیک هست»
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return ['clinic', $clinic, $canView]; // ← مجموعه‌ی پزشکانِ مجاز را حمل نمی‌کند
}
// ...
}
```
### الگوی درست (داشبورد) — `DashboardController.php:~416`
```php
// scope کلینیکِ منشی را به پزشکانِ تخصیص‌یافته محدود می‌کند:
$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic);
```
## وظایف
> هر وظیفه: پیاده‌سازی → تست (موفق/خطا/مرزی) → مستند → گزارش. یک وظیفه در هر مرحله.
### ۱. سرویس منشی + تخصیص چند‌پزشکیِ اتمیک (Backend)
- پوشه/کلاس جدید `src/Secretary/Service/SecretaryService.php` بساز و منطق چاق فعلیِ `create` را به آن منتقل کن (SOLID؛ کنترلر فقط HTTP).
- متد `assignToDoctors(User $currentUser, string $mobile, array $doctorUuids, array $meta): array` که برای **مدیر کلینیک**:
- کلینیکِ مالک را از `$currentUser` می‌گیرد؛ هر `doctorUuid` باید عضو همان کلینیک باشد (`isDoctorInClinic`) وگرنه ۴۰۳/۴۲۲.
- منشیِ user را find-or-create می‌کند (مثل کد فعلی: نقش `ROLE_SECRETARY`، نام، پسورد اختیاری).
- برای هر پزشک یک `DoctorSecretary(doctor, user, OWNER_CLINIC, clinic)` می‌سازد؛ ردیف تکراری `(doctor, secretary, ownerType)` را **skip** کند (نه خطا).
- permissions ورودی را روی همه‌ی ردیف‌های ساخته‌شده اعمال کند (`mergePermissions`).
- همه در یک تراکنش؛ خروجی: لیست ردیف‌های نهایی + پزشکانِ skip‌شده.
- endpointها:
- `POST /api/v1/secretary` را طوری توسعه بده که **علاوه بر** `doctor_uuid` (سازگاری قدیمی)، آرایه‌ی `doctor_uuids: string[]` را هم بپذیرد؛ اگر آرایه آمد و کاربر `ROLE_CLINIC` است → `assignToDoctors`. رفتار تک‌پزشکیِ فعلی نشکند.
- `PUT /api/v1/secretaries/clinic/{clinicUuid}/secretary/{secretaryUuid}/doctors` (یا مسیر مشابه) برای **هم‌گام‌سازی**: بدنه `doctor_uuids: string[]`؛ ردیف‌های owner=clinicِ این منشی در این کلینیک را با مجموعه‌ی جدید sync می‌کند (افزودن نبودها، حذف/غیرفعال‌سازیِ اضافه‌ها). گارد: مالک کلینیک یا ادمین (`canManage`).
- **سقف پلن:** توجه کن `countActiveByDoctor` per-doctor است؛ تخصیص یک منشی به N پزشک روی سقفِ هر پزشک حساب می‌شود. همین منطق per-doctor را برای هر پزشک در حلقه چک کن (اگر پزشکی به سقف رسید، همان پزشک را skip و در خروجی گزارش کن، بقیه ادامه یابند).
- مستند: `docs/api/secretary.md` — بدنه‌ی جدید، مسیر sync، خطاها، مثال JSON واقعی.
- تست PHPUnit (`tests/Secretary/...`): تخصیص چند‌پزشکی موفق، skipِ تکراری، ۴۰۳ برای پزشکِ خارج از کلینیک، sync (افزودن+حذف)، غیرمالک ۴۰۳.
### ۲. اعمال scope دسترسی به پزشکانِ تخصیص‌یافته (Backend) — هسته
- `resolveSecretaryFilter` (`MyAppointmentsController.php:504`) در شاخه‌ی clinic: به‌جای بازگرداندن فقط Clinic، **مجموعه‌ی پزشکانِ تخصیص‌یافته** را با `findDoctorsBySecretaryInClinic($user, $clinic)` بگیر و در خروجی حمل کن (مثلاً `['clinic', $clinic, $canView, $doctorIds]`).
- اعمال فیلتر (`:303`): به‌جای `d MEMBER OF c.doctors` برای همه‌ی کلینیک، `a.doctor IN (:doctorIds)` با آن مجموعه. اگر مجموعه خالی بود → صفحه‌ی خالی.
- `secretaryCanBookForDoctor` (`:473`) در شاخه‌ی clinic: علاوه بر عضویت در کلینیک و permission، چک کن `$doctor` در `findDoctorsBySecretaryInClinic` باشد.
- مسیرهای مشابه را هم هم‌سو کن: `PatientController::resolveEntity` (`:1198`) و Billing clinic-scope (اگر منشیِ کلینیک بیمار/مالی می‌بیند) باید به همان مجموعه‌ی پزشکان محدود شوند. الگو را از `DashboardController.php:~416` (که درست است) کپی کن — منطق مشترک را در سرویس/متد کمکی بگذار، نه کپیِ پراکنده.
- تست: منشیِ تخصیص‌یافته به پزشک A (نه B) در کلینیکِ دارای A و B → لیست نوبت فقط A؛ رزرو برای B ممنوع؛ داشبورد و لیست هم‌خوان.
### ۳. Frontend — انتخاب چند پزشک و ویرایش تخصیص
- در `MySecretariesPage.tsx` شاخه‌ی `isClinic`:
- picker پزشک را از تک‌انتخابی به **multi-select** تبدیل کن (چک‌لیستِ پزشکانِ کلینیک؛ از الگوی `MultiCheckList`/`SearchableSelect` موجود استفاده کن — طبق قانون پروژه از `SearchableSelect` استفاده شود، نه `<select>` بومی).
- 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 <file>` — 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).
@@ -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<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
: api.post<ApiResponse<any>>('/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 <LoadingSkeleton />;
if (primaryRole === 'admin') return <AdminDashboard />;
if (primaryRole === 'clinic') return <ClinicDashboard />;
if (primaryRole === 'doctor') return <DoctorDashboard />;
...
```
در حالی که 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 <InvitedDoctorDashboard />;
if (primaryRole === 'doctor') return <DoctorDashboard />;
```
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` استفاده کن، نه `<select>` بومی.
- **نشت داده مالی:** وظیفهٔ ۹ فقط یک مسئلهٔ UI نیست — `GET /api/v1/dashboard/doctor` الان درآمد شخصی پزشک را بدون هیچ فیلتر contextی برمی‌گرداند. اصلاح backend اجباری است.
- ترتیب پیاده‌سازی پیشنهادی: (۱) Entity + migration → (۲) `EntityContextResolver` → (۳) کنترلر تنظیمات + validation → (۴) آدرس‌ها و سرویس‌ها → (۵) فرانت → (۶) `AppointmentController` عمومی → (۷) داشبورد پزشک دعوت‌شده (وظیفهٔ ۹) → (۸) تست و docs. هر مرحله جدا تست شود. وظیفهٔ ۹ به `EntityContextResolver` وابسته است ولی مستقل از migration قابل شروع است.
- کاربر تست: `09390039833 / 09390039833`. سناریوی باگ: دکتر تست `09100652121` در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`.
+149
View File
@@ -0,0 +1,149 @@
# سیستم مدیریت تخفیف عمومی (Discount Rules Engine) + اعمال در پرداخت پرونده
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> پیش‌نیاز منطقی: `session-autofill-on-appointment-confirm.md` (تخفیف روی `final_price_rials` اعمال می‌شود).
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
## زمینه
الان تخفیف فقط **تسویه‌ی دستی** است: در صفحه پرداخت پرونده، اپراتور `percent` یا `fixed` با مقدار آزاد وارد می‌کند؛ روی `PatientSession.discount_type/discount_value/discount_rials` ذخیره می‌شود (`PATCH /api/v1/session/{uuid}``PatientService::applyDiscount()`). هیچ **قانون تخفیف** تعریف‌شده‌ای وجود ندارد، هیچ محاسبه‌ی خودکاری بر اساس تگ/سرویس/مبلغ/… نیست، و هیچ ردی از «کدام قانون» اعمال شده ذخیره نمی‌شود (audit gap).
هدف: یک سیستم **تخفیف عمومی (Generic Discount Rules)** که در `/admin/subscription` مدیریت شود و هنگام پرداخت پرونده، تخفیف‌های قابل‌اعمال را خودکار محاسبه، به اپراتور پیشنهاد، و پس از انتخاب با ثبت منبعِ Rule اعمال کند.
## هدف / قابلیت
1. Entity + CRUD ادمین برای **DiscountRule** با انواع مختلف.
2. **Engine** که برای یک پرونده، قوانین قابل‌اعمال را ارزیابی و مبلغ تخفیف هرکدام را محاسبه کند.
3. اعمال در صفحه پرداخت با نمایش مبلغ قبل/تخفیف/نهایی + منبع Rule + امکان انتخاب/حذف.
4. **Audit**: ثبت اینکه کدام Rule اعمال شده، در پرونده و سوابق مالی، برای گزارش‌گیری.
## انواع قوانین (rule types)
| نوع | `type` | هدف (`target_*`) | نمونه |
|-----|--------|------------------|-------|
| تگ بیمار | `patient_tag` | `target_tag_id` (TenantTag) | VIP، پرسنل، خانواده پزشک، خیریه |
| مبلغ فاکتور | `invoice_amount` | `min_amount_rials` (آستانه) | بالای ۲م → ۱۰٪ |
| بیمار خاص | `specific_patient` | `target_record_id` (PatientRecord) + بازه‌ی زمانی اختیاری | بیمار A همیشه ۳۰٪ |
| مناسبتی | `occasion` | `valid_from`/`valid_to` (+ زیرنوع تولد) | تولد بیمار، کمپین، بازه |
| سرویس | `service` | `target_service_item_id` (ServiceItem) | لیزر ۲۰٪ |
| تعداد مراجعات | `visit_count` | `min_visit_count` | بعد از مراجعه ۵م → ۱۰٪ |
هر Rule مشترکاً دارد: `discount_type` (`percent`|`fixed``value` (درصد یا ریال)، `priority` (int، بزرگ‌تر = مهم‌تر)، `combinable` (bool)، `active` (bool)، `valid_from`/`valid_to` (nullable int unix — برای موقت/مناسبتی)، و مالکیت scope (`owner_type` doctor|clinic + `owner_id`) هم‌سو با بقیه‌ی داده‌های per-tenant.
## اولویت / ترکیب‌پذیری
- قوانین قابل‌اعمال بر اساس `priority` نزولی مرتب شوند.
- پیش‌فرض: **فقط یک تخفیف** (بالاترین priority) اعمال می‌شود.
- اگر Rule `combinable = true` باشد، می‌تواند با سایر combinableها جمع شود (جمع مبلغ ریالی، با سقفِ `final_price_rials`).
- اپراتور می‌تواند به‌جای پیشنهاد خودکار، دستی یکی را انتخاب یا حذف کند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Discount/Entity/DiscountRule.php` (جدید) | Entity قانون تخفیف |
| `src/Discount/Repository/DiscountRuleRepository.php` (جدید) | کوئری‌ها (array hydration برای لیست ادمین) |
| `src/Discount/Service/DiscountEngine.php` (جدید) | ارزیابی قوانین برای یک `PatientSession` → لیست پیشنهادها |
| `src/Discount/Controller/DiscountController.php` (جدید) | CRUD ادمین + endpoint محاسبه برای یک session |
| `src/Patient/Entity/PatientSession.php` | افزودن ستون‌های audit `applied_discount_rule_id` (nullable) + `applied_discount_rule_label` (nullable) — `discount_type/value/rials` و `setDiscount()` (L196-203) موجودند |
| `src/Patient/Service/PatientService.php` | `applyDiscount()` (L244-283) — گسترش برای پذیرش/ثبت `rule` |
| `src/Patient/Controller/PatientController.php` | `updateSession()` (L1034-1073) — عبور `discount_rule_uuid` |
| `src/Patient/Entity/PatientRecord.php` | `getTags()` (ManyToMany `patient_record_tags` → TenantTag) — برای `patient_tag` |
| `src/Tag/Entity/TenantTag.php` | برچسب بیمار (per-tenant، `name`/`color`) |
| `src/ClinicService/Entity/ServiceItem.php` | برای `service``getPriceRials()` |
| `assets/admin/pages/AdminSubscriptionPage.tsx` | صفحه‌ی تب‌دار (`.seg`) — افزودن تب «مدیریت تخفیف‌ها» |
| `assets/admin/components/session/PaymentStep.tsx` | UI پرداخت — نمایش/انتخاب تخفیف‌های پیشنهادی |
| `docs/api/*.md` | مستندات (فایل جدید `docs/api/discount.md` + به‌روزرسانی `patient.md`) |
## وضعیت فعلی
`src/Patient/Service/PatientService.php` — تخفیف دستی، بدون منبع Rule:
```php
public function applyDiscount(PatientSession $session, ?string $type, int $value): void
{
$final = $session->getFinalPriceRials();
if ($type === 'percent') {
if ($value > 100) throw new AppException(/* ... */ 'discount_value');
$rials = (int) round($final * $value / 100);
} else {
if ($value > $final) throw new AppException(/* ... */ 'discount_value');
$rials = $value;
}
if ($rials > $final - $session->getPaidTotalRials()) throw new AppException(/* ... */);
$session->setDiscount($type, $value, $rials); // ← فقط type/value/rials؛ بدون rule
}
```
`assets/admin/components/session/PaymentStep.tsx` — تخفیف فقط `percent`/`fixed` با مقدار آزاد (L98-140):
```tsx
const applyDiscount = () => {
if (!discountType || !discountValue) return;
discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) });
};
// discountMut → PATCH /api/v1/session/{sessionUuid}
```
`assets/admin/pages/AdminSubscriptionPage.tsx` — تب‌دار با `.seg` (L437-453):
```tsx
const [tab, setTab] = useState<'plans' | 'report'>('plans');
// <div className="seg"> ... <button onClick={() => setTab('plans')}>پلن‌ها</button> ...
{tab === 'plans' && <PlansTab />}
{tab === 'report' && <ReportTab />}
```
## وظایف
### ۱. Entity + migration — `DiscountRule`
`src/Discount/Entity/DiscountRule.php` با ستون‌ها: `id`, `uuid`, `owner_type` (doctor|clinic), `owner_id` (int), `name` (string), `type` (یکی از انواع بالا), `discount_type` (percent|fixed), `value` (int), `priority` (int, default 0), `combinable` (bool, default false), `active` (bool, default true), `valid_from`/`valid_to` (int nullable), و فیلدهای target اختیاری: `target_tag_id`, `target_record_id`, `target_service_item_id`, `min_amount_rials`, `min_visit_count` (همه nullable int)، `created_at`/`updated_at`. constant array برای انواع. `toArray()`. migration لازم.
همچنین دو ستون audit روی `PatientSession`: `applied_discount_rule_id` (int nullable) + `applied_discount_rule_label` (string nullable) — migration جدا یا همان.
### ۲. Repository + Engine
`DiscountRuleRepository`: `findActiveForOwner($ownerType, $ownerId)` و لیست array hydration برای ادمین.
`DiscountEngine::evaluate(PatientSession $session): array` — برای هر Rule فعالِ owner:
- `patient_tag`: اگر `$session->getRecord()->getTags()` شامل `target_tag_id` باشد.
- `invoice_amount`: اگر `final_price_rials >= min_amount_rials`.
- `specific_patient`: اگر `record_id == target_record_id` و در بازه‌ی زمانی (`valid_from/to`).
- `occasion`: اگر now در بازه؛ زیرنوع تولد → مقایسه با تاریخ تولد بیمار.
- `service`: اگر یکی از `session->getServices()` سرویسِ `target_service_item_id` باشد (تخفیف روی همان خط).
- `visit_count`: اگر تعداد پرونده‌های قبلی بیمار `>= min_visit_count`.
خروجی: آرایه‌ای از `{ rule_uuid, rule_name, type, discount_type, value, discount_rials, combinable, priority }` مرتب بر priority نزولی. مبلغ ریالی هر پیشنهاد با سقف `final_price_rials` و باقی‌مانده محاسبه شود.
### ۳. Controller — CRUD ادمین + محاسبه
`src/Discount/Controller/DiscountController.php` (extends `BaseController`):
- `GET/POST/PATCH/DELETE /api/v1/admin/discount-rules[/{uuid}]` — CRUD، `#[IsGranted]` مثل بقیه‌ی adminها، scope به owner جاری.
- `GET /api/v1/session/{uuid}/discount-suggestions` — خروجی `DiscountEngine::evaluate()` برای آن پرونده.
### ۴. اعمال تخفیف با ثبت منبع (backend)
`PatientService::applyDiscount()` را گسترش بده تا `?DiscountRule $rule = null` بگیرد و هنگام ست، `applied_discount_rule_id` + `applied_discount_rule_label` را روی session بنویسد. در `updateSession()` (`PATCH /api/v1/session/{uuid}`) اگر `discount_rule_uuid` آمد، Rule را resolve و مقدار/نوع را از خود Rule بگیر (نه ورودی دستی) و pass کن؛ مسیر دستیِ فعلی (`discount_type`/`discount_value` بدون rule) حفظ شود.
### ۵. تب «مدیریت تخفیف‌ها» در subscription (frontend)
در `AdminSubscriptionPage.tsx`: union تب را به `'plans' | 'report' | 'discounts'` گسترش بده، یک `<button>` به `.seg` اضافه کن، و `<DiscountTab />` جدید بساز — جدول قوانین + مودال ساخت/ویرایش (TanStack Query + RHF + Zod + `Modal`/`ConfirmDialog`)، با فرم پویا بر اساس `type` (نمایش فیلد target مربوطه). selectها با `SearchableSelect` (نه `<select>` خام).
### ۶. 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.
@@ -0,0 +1,108 @@
<div dir="rtl" markdown="1">
# افزودن شهر به لیست پزشکان + رفع سقف خاموش 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 لازم نیست.
</div>
@@ -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<ApiResponse<any>>({
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 (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
</div>
);
```
### محل بدون آدرس فیلتر نمی‌شود
`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<ApiResponse<any>>({
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 صحیح.
@@ -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.
@@ -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:0013: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=<CLINIC-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=<clinic-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<string>(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<doctorId, WeeklySchedule[]>` تبدیل کند (`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<string>(
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` (قانون پروژه — نه `<select>` بومی) برای
انتخاب محل نمایش بده؛ پیش‌فرض = اولین آیتم (آرایه بر اساس `next_available_at` مرتب است).
- `clinic_uuid` مؤثر برای slots/service-slots/booking-services در حالت ادمین از محل
انتخاب‌شده بیاید (`selected.clinic_uuid`، که برای مطب شخصی `null` است)، نه از
`useClinicContext`. برای نقش‌های clinic/doctor رفتار فعلی `useClinicContext` بماند.
- endpoint عمومی است؛ نیازی به endpoint جدید نیست (قانون «اول توسعه، بعد ساخت»).
### ۵. عادی‌سازی فرمت legacy برنامهٔ شخصی
ردیف 2505 فرمت لیست دارد: `[{"sessions":[…]}]` — فقط ایندکس 0 (شنبه) تعریف است و `meta`
ندارد. کد فعلی خطا نمی‌دهد ولی روزهای 1..6 برایش `null` است و meta از `DEFAULT_META` می‌آید.
به console command موجود `app:schedule:audit-locations` (یا command جدید
`app:schedule:normalize-format` اگر تفکیک مسئولیت تمیزتر است) حالت زیر را اضافه کن:
- شناسایی ردیف‌هایی که `setting` آن‌ها آرایهٔ لیستی است یا کلیدهای `"0".."6"` کامل نیست.
- با `--fix` به فرمت canonical (`{"0":…,"6":…,"meta":…}`) تبدیل کند؛ روزهای غایب
`{"sessions":[]}` و meta غایب = `DEFAULT_META`. دادهٔ session موجود دست نخورد.
### ۶. تست و مستندات
تست‌ها در `tests/Doctor/` و `tests/Appointment/`:
1. پزشک با برنامهٔ شخصی غیرفعال + برنامهٔ کلینیکی فعال → `GET /api/v1/doctor/{uuid}` باید
`active=true` و `free_turn` غیرتهی بدهد. (بازتولید مستقیم علامت ۱)
2. پزشک با هر دو برنامه، هر دو `online_booking_enabled=false``free_turn` =
`APPOINTMENT_DISABLED_LABEL`.
3. `/api/v1/doctors`: پزشک چندبرنامه‌ای — نتیجه مستقل از ترتیب ردیف‌های `findByDoctors`.
4. (frontend) `TurnsTimeline` با `errorMessage` → پیام خطا؛ با `emptyReason` ناشناخته →
پیام خنثی، نه day_off. (vitest موجود در `assets/admin`)
مستندات: `docs/api/doctor.md` — معنای جدید `active` / `free_turn` (تجمیع همهٔ محل‌ها)
صریح ثبت شود؛ قانون ثابت پروژه.
## نکات مهم
- **هیچ منطق موازی اسلات نساز** — `SlotCalculatorService` سالم است (با curl تأیید شد)؛
مشکل فقط انتخاب schedule/context در ورودی‌هاست.
- `findByDoctor` از قبل `@deprecated` است؛ این تسک چهار مصرف‌کنندهٔ باقی‌مانده در
`DoctorController` را حذف می‌کند — بعدش اگر مصرف‌کنندهٔ دیگری نماند، خود متد را حذف کن.
- `free_turn` رشتهٔ فارسی نمایشی است (مثل «شنبه 09:00») — قراردادش را عوض نکن؛
`nobat724_front` همین رشته را خام نمایش می‌دهد.
- تجمیع باید ارزان بماند: `/api/v1/doctors` صفحه‌ای ۱۰+ پزشک دارد؛ `findByDoctors` همین
حالا همهٔ برنامه‌ها را در یک کوئری می‌آورد — کوئری اضافه per-doctor نزن.
- کاربران تست: ادمین `09390039833`، دکتر تست `09100652121`
(uuid `bcabb3a8-cae3-45ec-876c-548f9c1e1569`)، مالک کلینیک `09024206041` (دو-نقشی)،
کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`. کد OTP در dev همیشه `12345`.
- تست دستی پس از build (`yarn build`): سه سناریوی گزارش‌شده —
(۱) `/doctor/bcabb3a8…` در سایت، (۲) `/admin/appointments` با ادمین برای ۱۴۰۵/۰۴/۲۷،
(۳) همان صفحه با دکتر تست در محیط کلینیک برای ۱۴۰۵/۰۴/۳۰.
- پاسخ‌ها طبق `BaseController`؛ تاریخ‌ها timestamp صحیح؛ رشته‌های جدید فارسی.
@@ -0,0 +1,173 @@
# رفع باگ: نوبت‌های رزروشده در سایت عمومی «آزاد» نمایش داده می‌شوند
## پروژه
`clinicpro` (backend — منبع واحد محاسبه‌ی آزاد/رزرو).
یک بررسی ثانویه‌ی کوچک هم در `nobat724_front` لازم است (پارامتر تاریخِ درخواست) — در وظیفه‌ی ۳ توضیح داده شده.
## زمینه
سایت عمومی نوبت‌دهی روی صفحه‌ی `/appointment/<doctor-uuid>` تایم‌اسلات‌های یک پزشک را از
`GET /api/v1/appointment-slots?doctor_uuid=...&date=YYYY-MM-DD` می‌گیرد. بک‌اند برای هر اسلات یک فلگ
`is_available` برمی‌گرداند و فرانت فقط همان فلگ را رعایت می‌کند (`components`/`List.js`: اسلات فقط وقتی قابل
انتخاب است که `is_available === true`). پس **درست/غلط بودنِ آزاد نمایش‌دادن کاملاً به همین فلگِ بک‌اند وابسته است.**
## مشکل / هدف
برای پزشک `ab747d75-2114-42b8-9e6d-abdaa338edbe` در تاریخ `1405-04-27` (میلادی: **2026-07-18**) همه‌ی اسلات‌ها
«آزاد» نمایش داده می‌شوند، در حالی که برخی نوبت‌ها قبلاً ثبت/رزرو شده‌اند.
ریشه‌ی محتمل (اثبات‌شده در کد): **رزروهای روز-محور (`is_reserve`) با `slot_end == slot_start` ذخیره می‌شوند
(بازه‌ی صفر)**، و منطق «اشغال‌بودن» بر پایه‌ی هم‌پوشانی بازه است؛ رکوردِ بازه‌صفر عملاً هیچ اسلاتی را اشغال
نمی‌کند → همه آزاد. هدف: این ناسازگاری برطرف شود و رزروها/نوبت‌ها به‌درستی اسلات‌ها را ببندند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Appointment/Controller/AppointmentController.php` (متد `slots()`، خط ۱۱۵–۱۳۷) | endpoint `GET /api/v1/appointment-slots` |
| `src/Appointment/Service/SlotCalculatorService.php` (`getAllSlotsWithAvailability`، خط ۶۰–۷۲) | تولید اسلات‌ها + تگ `is_available` |
| `src/Appointment/Repository/AppointmentRepository.php` (`isSlotTaken`، خط ۹۱–۱۱۳) | بررسی اشغال‌بودن (هم‌پوشانی بازه) |
| `src/Appointment/Controller/MyAppointmentsController.php` (خط ۶۴–۷۲، ۹۸) | مسیر ثبتِ رزرو روز-محور که `slot_end = slot_start` می‌گذارد |
| `nobat724_front/app/component/date/dateTime/index.js` (خط ۳۲) | ساخت پارامتر `date` برای درخواست اسلات‌ها (بررسی ثانویه) |
## وضعیت فعلی
### ۱) بررسی اشغال‌بودن — هم‌پوشانی بازه (منبع باگ)
`AppointmentRepository::isSlotTaken()` برای هر اسلات یک بار صدا زده می‌شود:
```php
// AppointmentRepository.php:91
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :slotEnd') // هم‌پوشانی
->andWhere('a.slotEnd > :slotStart') // هم‌پوشانی
->andWhere('a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))')
->setParameter('doctor', $doctor)
->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('now', time())
->setParameter('slotStart', $slotStart)
->setParameter('slotEnd', $slotEnd);
// ...
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
```
### ۲) ثبت رزرو روز-محور با بازه‌ی صفر
```php
// MyAppointmentsController.php:64
$isReserve = (bool) ($data['is_reserve'] ?? false);
// Reserve entries are day-level: only a date is picked in the UI, so
// slot_end may equal slot_start and the past-slot rule does not apply.
if ($isReserve && $slotEnd < $slotStart) {
$slotEnd = $slotStart; // ← بازه‌ی صفر
}
// ...
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); // slotStart == slotEnd
```
**چرا باگ:** برای رکوردِ `slotStart == slotEnd == R`، شرط `a.slotStart < :slotEnd AND a.slotEnd > :slotStart`
فقط وقتی برقرار است که `R` اکیداً داخل بازه‌ی اسلات کاندید `(S, E)` باشد. اگر `R` برابر ابتدای روز (۰۰:۰۰) یا
هر لحظه‌ای بیرونِ اسلات‌ها باشد، هیچ اسلاتی اشغال نمی‌شود → همه آزاد. رزروِ «کل روز» عملاً هیچ‌چیز را نمی‌بندد.
### ۳) پارامتر تاریخ در فرانت (بررسی ثانویه)
```js
// nobat724_front/app/component/date/dateTime/index.js:32
const dateStr = moment.unix(date).format("YYYY-MM-DD");
```
`date` یک timestamp است که با `dateToTimestamp` روی `Asia/Tehran` (`startOf("day")`) ساخته شده، ولی
`moment.unix(date)` در منطقه‌زمانیِ سیستم/مرورگر فرمت می‌شود. اگر TZ اجرا Tehran نباشد، ممکن است `date=`
یک روز جابه‌جا شود و روزِ اشتباه کوئری گردد.
## وظایف
### ۱. تشخیص قطعی با داده‌ی واقعی (اول این)
داخل ddev، رکوردهای واقعیِ همان پزشک و روز را ببین (بازه‌ی timestamp تهرانِ 2026-07-18):
```bash
ddev exec php bin/console dbal:run-sql "
SELECT a.id, a.status, a.slot_start, a.slot_end,
FROM_UNIXTIME(a.slot_start) AS s, FROM_UNIXTIME(a.slot_end) AS e, a.expires_at
FROM appointments a
JOIN doctors d ON d.id = a.doctor_id
WHERE d.uuid = 'ab747d75-2114-42b8-9e6d-abdaa338edbe'
AND a.slot_start >= UNIX_TIMESTAMP('2026-07-18 00:00:00')
AND a.slot_start < UNIX_TIMESTAMP('2026-07-19 00:00:00')
ORDER BY a.slot_start
"
```
با خروجی مشخص کن کدام حالت است و بر همان اساس ادامه بده:
- **`slot_end == slot_start`** روی رکوردها → باگِ بازه‌صفرِ رزرو (وظیفه‌ی ۲). محتمل‌ترین.
- `slot_end > slot_start` ولی `is_available` باز هم `true` → مشکل تاریخ/منطقه‌زمانی (وظیفه‌ی ۳) یا `status`.
- `status` مقداری غیر از `confirmed`/`pending`ِ معتبر → رکوردها عمداً شمرده نمی‌شوند؛ منطق `isSlotTaken` را بازبینی کن.
### ۲. رفع باگِ رزروِ بازه‌صفر (فیکس اصلی)
دو راه؛ **راه A ترجیح داده می‌شود** چون داده را در همان لحظه‌ی ثبت درست می‌کند و به منطقِ کوئری دست نمی‌زند:
**راه A — رزروِ روز-محور را به بازه‌ی کل روز تبدیل کن** در `MyAppointmentsController.php` (خط ۶۶–۷۰):
```php
if ($isReserve && $slotEnd < $slotStart) {
// رزرو روز-محور: کل روز را ببند تا با منطق هم‌پوشانی، همه‌ی اسلات‌های آن روز اشغال شوند.
$dayStart = strtotime(date('Y-m-d', $slotStart) . ' 00:00:00');
$slotStart = $dayStart;
$slotEnd = $dayStart + 86400;
}
```
- توجه: پس از این تغییر `resolveSlotLocationId($doctor, $slotStart)` (خط ۱۰۱) با `slot_start = 00:00`
دیگر اسلاتی پیدا نمی‌کند و `null` می‌دهد؛ همین رفتار قابل‌قبول است (رزرو روزانه آدرس اسلات ندارد) ولی مطمئن شو
خطایی تولید نمی‌شود.
- اگر رزروهای قدیمیِ بازه‌صفر در دیتابیس هست، یک migration/Command یک‌باره برای گسترش آن‌ها به بازه‌ی روز بنویس
وگرنه رکوردهای موجود همچنان اسلات‌ها را نمی‌بندند.
**راه B — به‌جای تغییر داده، منطقِ اشغال را برای بازه‌ی روز-محور اصلاح کن** (اگر نمی‌خواهی معنای داده عوض شود):
در `isSlotTaken` رکوردهای `slotEnd <= slotStart` را به‌عنوان «قفلِ کل روزِ `slotStart`» در نظر بگیر (شرط اضافه: هم‌پوشانی
عادی **یا** رکوردِ بازه‌صفری که در همان روزِ اسلات کاندید است). این راه پیچیده‌تر و مستعد خطا است؛ فقط اگر راه A ممکن نبود.
### ۳. بررسی ثانویه‌ی پارامتر تاریخ در فرانت
در `nobat724_front/app/component/date/dateTime/index.js` خط ۳۲، فرمت تاریخ را صریحاً روی تهران کن تا با
`dateToTimestamp` (که تهران است) هم‌تراز شود و off-by-one رخ ندهد:
```js
import moment from "moment-jalaali"; // موجود است
const dateStr = moment.unix(date).tz("Asia/Tehran").format("YYYY-MM-DD");
```
اگر `moment-jalaali` متد `.tz` ندارد، از همان helperِ تهران (`moment-timezone`) که `dateToTimestamp` استفاده می‌کند
بهره بگیر. این فقط وقتی اثر دارد که TZ اجرا تهران نباشد؛ در ddev (TZ=Asia/Tehran) بی‌اثر است ولی درستی را تضمین می‌کند.
### ۴. تست
- **موفق:** یک رزرو روز-محور (`is_reserve=true`) برای پزشک تست ثبت کن؛ سپس `GET /api/v1/appointment-slots` همان روز
باید همه‌ی اسلات‌ها را `is_available=false` بدهد.
- **موفق:** یک نوبت عادی (`slot_end > slot_start`) روی یک اسلات مشخص؛ فقط همان اسلات باید بسته شود، بقیه آزاد.
- **مرزی:** نوبت `pending` منقضی‌شده (`expires_at < now`) → اسلات باید دوباره آزاد شود.
- **خطا/رگرسیون:** روزِ بدون هیچ رزرو → همه‌ی اسلات‌های آینده آزاد بمانند (بازه‌ی روز اشتباهاً چیزی نبندد).
- تستِ واحد برای `AppointmentRepository::isSlotTaken` با رکوردِ بازه‌ی کل‌روز اضافه کن.
- اجرا: `ddev exec php bin/phpunit --filter Appointment`.
## نکات مهم
- تایم‌استمپ‌ها **int یونیکس** هستند؛ TZِ ddev = `Asia/Tehran` (تأییدشده در `.ddev/*compose*`). ثبت نوبت و
تولید اسلات هر دو در تهران‌اند و هم‌تراز؛ پس ریشه، منطقِ بازه است نه منطقه‌زمانی.
- `isSlotTaken` فقط `STATUS_CONFIRMED` و `STATUS_PENDING`ِ منقضی‌نشده را می‌شمارد؛ رفتار درست است — دست نزن مگر
در وظیفه‌ی ۱ خلافش ثابت شود.
- مسیر عمومی `book()` در `AppointmentController` بازه‌ی درست (`slot_end > slot_start`) دارد و اسلات را درست می‌بندد؛
باگ فقط در مسیر رزروِ `MyAppointmentsController` است.
- هیچ فیلترِ `clinic` در `isSlotTaken` نیست؛ اگر لازم شد جدا بررسی کن، ولی خارج از دامنه‌ی این باگ است.
- اگر route/response خروجی endpoint تغییر کرد، `docs/api/appointment.md` را همان session به‌روزرسانی کن (قاعده‌ی مستندات).
- بعد از تغییر کد، `graphify update .` را اجرا کن (پس از commit).
@@ -0,0 +1,235 @@
# بازطراحی صفحه بیمه و قیمت‌گذاری — تفکیک نوع بیمه با Tab + ردیف Expandable + انتقال ویزیت آزاد
## پروژه
`clinicpro` (پنل ادمین React — فقط frontend؛ backend موجود کافی است)
## زمینه
مسیر `/admin/insurance-pricing` امروز سه بخش را در یک صفحه نشان می‌دهد: «قیمت ویزیت آزاد» (`FreeVisitPrice`)، و «مدیریت بیمه» (`TenantInsuranceContracts`) که همه‌ی قراردادهای بیمه‌ی پایه و تکمیلی را در یک جدول مسطح فهرست می‌کند. مدیر مطب هنگام افزودن بیمه باید نوع بیمه را دستی از یک `select` انتخاب کند و اطلاعات کلیدی هر بیمه (پوشش، فرانشیز، سقف) فقط به‌صورت یک زیرنویس کمرنگ در ستون نام دیده می‌شود. جزئیات کامل قرارداد جایی نمایش داده نمی‌شود.
هدف: تجربه‌ی مدیریت بیمه را برای یک مدیر حرفه‌ای مطب/کلینیک سریع و خوانا کنیم — تفکیک پایه/تکمیلی با Tab، حذف انتخاب دستی نوع، نمایش خلاصه‌ی خوانا در ردیف، و ردیف‌های Expandable برای جزئیات کامل. همچنین «قیمت ویزیت آزاد» به صفحه‌ی «تنظیمات نوبت‌دهی» منتقل شود.
## backend — نیازی به تغییر نیست (اول گشتم)
طبق قاعده‌ی «اول بگرد، بعد بساز» endpointهای موجود کافی‌اند؛ **هیچ تغییر backend لازم نیست**:
- `GET /api/v1/insurance-pricing` (در [src/Insurance/Controller/InsuranceController.php](src/Insurance/Controller/InsuranceController.php) خط ۲۲۰) هر بیمه‌ی فعال را با فیلد `type` (`'basic'` | `'supplementary'`) و `free_visit_price_rials` برمی‌گرداند.
- `GET /api/v1/billing/tenant-insurances` (خط ۳۰۶) برای هر قرارداد `insurance_kind` (`kind` قرارداد یا در نبودش `type` کاتالوگ) و همه‌ی فیلدهای پوشش/فرانشیز/سقف/تاریخ را برمی‌گرداند.
- `POST /api/v1/billing/tenant-insurances` (خط ۳۳۳) فیلد `kind` را در payload می‌پذیرد و ذخیره می‌کند.
- `PUT /api/v1/insurance-pricing` (خط ۲۵۸) با `{ free_visit_price_rials }` قیمت ویزیت آزاد را ذخیره می‌کند.
پس فیلتر بر اساس نوع بیمه کاملاً **سمت frontend** انجام می‌شود (روی داده‌های موجود همین دو endpoint).
## فایل‌های مرتبط
| فایل | نقش | تغییر |
|------|-----|-------|
| [assets/admin/pages/InsurancePricingPage.tsx](assets/admin/pages/InsurancePricingPage.tsx) | صفحه‌ی بیمه و قیمت‌گذاری | حذف `<FreeVisitPrice/>` و توضیح مربوطه |
| [assets/admin/pages/AppointmentSettingsPage.tsx](assets/admin/pages/AppointmentSettingsPage.tsx) | صفحه‌ی تنظیمات نوبت‌دهی | افزودن `<FreeVisitPrice/>` |
| [assets/admin/components/FreeVisitPrice.tsx](assets/admin/components/FreeVisitPrice.tsx) | کارت قیمت ویزیت آزاد | بدون تغییر (فقط جابه‌جا می‌شود) |
| [assets/admin/components/TenantInsuranceContracts.tsx](assets/admin/components/TenantInsuranceContracts.tsx) | جدول مدیریت بیمه | بازنویسی: Tab پایه/تکمیلی + ردیف Expandable + خلاصه‌ی خوانا |
| [assets/admin/components/InsuranceModal.tsx](assets/admin/components/InsuranceModal.tsx) | مودال افزودن/ویرایش بیمه | حذف `select` نوع بیمه؛ `kind` از prop می‌آید |
## وضعیت فعلی
### `InsurancePricingPage.tsx`
```tsx
<PageHeader
title="بیمه و قیمت‌گذاری"
description="قیمت ویزیت آزاد و قراردادهای بیمه (پایه و تکمیلی)"
/>
<FreeVisitPrice />
<TenantInsuranceContracts />
```
### `AppointmentSettingsPage.tsx` (بخش render)
```tsx
) : (
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
)}
```
### `InsuranceModal.tsx` — انتخاب دستی نوع (حذف شود)
```tsx
<div style={field}>
<label style={label}>نوع بیمه</label>
<select className="input" value={form.kind} onChange={(e) => set({ kind: e.target.value })}>
<option value="basic">پایه</option>
<option value="supplementary">تکمیلی</option>
</select>
</div>
```
### `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));
...
<td style={{ padding: '12px', fontWeight: 600 }}>
{c.insurance_name ?? `#${c.insurance_id}`}
<div style={{ fontSize: 11, color: 'var(--text-3)', ... }}>
پوشش {c.coverage_percent}٪
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
</div>
</td>
```
## وظایف
### ۱. انتقال «قیمت ویزیت آزاد» به تنظیمات نوبت‌دهی
**۱.۱ حذف از `InsurancePricingPage.tsx`:** خط `import FreeVisitPrice ...` و `<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 (
<SettingsLayout active="appointment">
<div className="fade-in">
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice />
{!uuid ? (
<div className="card" ...>این بخش فقط برای پزشک در دسترس است.</div>
) : isLoading ? (
...
) : (
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
)}
</div>
</SettingsLayout>
);
```
### ۲. حذف انتخاب دستی نوع بیمه از `InsuranceModal`
نوع بیمه دیگر دستی انتخاب نمی‌شود؛ از Tab فعال می‌آید.
**۲.۱** بلوک `<select>` نوع بیمه را کامل حذف کن.
**۲.۲** یک prop جدید `kind: 'basic' | 'supplementary'` به `Props` اضافه کن (نوع بیمه‌ی جاری بر اساس Tab). در حالت افزودن، `EMPTY_FORM.kind` را با این prop مقداردهی کن؛ در حالت ویرایش، `kind` قرارداد حفظ می‌شود:
```tsx
interface Props {
open: boolean;
editContract: Contract | null;
options: InsuranceOption[];
kind: string; // نوع بیمه‌ی Tab فعال — برای رکورد جدید
onClose: () => void;
onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
isPending?: boolean;
}
useEffect(() => {
if (!open) return;
setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
}, [open, editContract, kind]);
```
`buildInsurancePayload` بدون تغییر می‌ماند (همان `kind` را در payload می‌گذارد). یک نشانگر فقط‌خواندنی از نوع بیمه در مودال نشان بده تا کاربر بداند در کدام دسته اضافه می‌کند (مثلاً یک `chip` کوچک با `KIND_LABEL[kind]` نزدیک عنوان یا فیلد نام)، اما قابل تغییر نباشد.
### ۳. Tab پایه/تکمیلی در `TenantInsuranceContracts`
**۳.۱** یک state برای Tab فعال:
```tsx
type Kind = 'basic' | 'supplementary';
const [tab, setTab] = useState<Kind>('basic');
```
**۳.۲** نوار Tab بالای جدول (بعد از هدر «مدیریت بیمه»). از توکن‌های موجود استفاده کن (`--primary`, `--border`, `--text-3`) — الگوی Tab را از یک کامپوننت موجود که Tab دارد (مثلاً tabهای صفحه‌ی جزئیات) هم‌راستا کن. هر Tab تعداد قراردادهای همان نوع را به‌صورت badge نشان دهد:
```tsx
const KINDS: { key: Kind; label: string }[] = [
{ key: 'basic', label: 'بیمه پایه' },
{ key: 'supplementary', label: 'بیمه تکمیلی' },
];
```
**۳.۳** فیلتر قراردادها بر اساس Tab (روی `insurance_kind`) — سپس جستجو روی همان زیرمجموعه اعمال شود:
```tsx
const byKind = contracts.filter((c) => (c.insurance_kind ?? 'basic') === tab);
const rows = useMemo(() => filterInsurances(byKind, search), [byKind, search]);
```
**۳.۴** فیلتر لیست انتخاب هنگام افزودن بر اساس Tab (روی `type` کاتالوگ). فقط بیمه‌های همان نوع که هنوز قرارداد ندارند:
```tsx
const available = allInsurances
.filter((i) => i.type === tab)
.filter((i) => !activeIds.has(i.insurance_id));
```
توجه: `InsuranceOption.type` از پیش در interface هست (`assets/admin/components/InsuranceModal.tsx` خط ۱۰) و از `pricingQuery` می‌آید (`data.insurances[].type`).
**۳.۵** مودال را با `kind={tab}` صدا بزن و در حالت افزودن `options={available}` (که حالا بر اساس Tab فیلتر شده):
```tsx
<InsuranceModal
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
kind={tab}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
/>
```
دکمه‌ی «افزودن بیمه» برچسبش را بر اساس Tab دقیق‌تر کن: «افزودن بیمه پایه» / «افزودن بیمه تکمیلی».
### ۴. خلاصه‌ی خوانا + ردیف Expandable
ستون «نوع بیمه» در جدول دیگر لازم نیست (با Tab مشخص است) — حذفش کن و جایش خلاصه‌ی اطلاعات کلیدی را مستقیماً در ردیف نشان بده.
**۴.۱ خلاصه‌ی ردیف (سطر جمع‌شده):** به‌جای زیرنویس کمرنگ، اطلاعات کلیدی را در یک ستون خوانا با جداکننده‌ی `·` نشان بده:
```
پوشش ۹۰٪ · فرانشیز ۵۰٬۰۰۰ تومان · سقف پوشش ۲٬۰۰۰٬۰۰۰ تومان
```
از `formatRial` (`assets/admin/lib/utils.ts`) برای مبالغ و ارقام فارسی استفاده کن. اگر `annual_ceiling_rials == null` به‌جای سقف «سقف پوشش نامحدود» نشان بده.
**۴.۲ Expandable Row:** هر ردیف با کلیک باز/بسته شود. state:
```tsx
const [expanded, setExpanded] = useState<string | null>(null); // contract.uuid
const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
```
- کل ردیف `clickable` باشد (`cursor: pointer`) و یک آیکون chevron (`ChevronDownIcon`/`ChevronUpIcon` از `@heroicons/react/24/outline`) وضعیت باز/بسته را نشان دهد.
- کلیک روی دکمه‌های عملیات (ویرایش) و روی سوییچ وضعیت **نباید** ردیف را toggle کند → در `onClick` آن‌ها `e.stopPropagation()`.
- ردیف جزئیات (`<tr>` دوم با `<td colSpan>`) وقتی `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) تا انتقال چیزی را نشکند.
@@ -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 شود.
@@ -0,0 +1,247 @@
# واحد کالا به‌صورت Select + سیستم دسته‌بندی اصولی کالا (انبارداری)
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React). صفحه هدف: `/admin/inventory`.
## زمینه
بخش انبارداری (`InventoryPage`) اجازه ایجاد/ویرایش «کالا» را می‌دهد. دو ضعف طراحی وجود دارد:
1. **واحد (`unit`)** به‌صورت متن آزاد وارد می‌شود (`AddItemModal` فقط یک `<input>` متنی است، پیش‌فرض `'عدد'`). نتیجه: داده ناهمگون («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` | فرم افزودن/ویرایش | دو `<input>` → دو 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
// نمونه
<SearchableSelect
label="واحد"
value={form.unit || 'عدد'}
options={meta.units.map(u => ({ value: u.value, label: u.label }))}
onChange={(v) => setForm(f => ({ ...f, unit: v }))}
/>
<SearchableSelect
label="دسته‌بندی"
value={form.category}
options={meta.categories.map(c => ({ 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).
+294
View File
@@ -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های دستی و یک `<table>` خام نوشته شده و از 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('');
// ...
<div className="card" style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', ... }}>
<th style={th}>ردیف</th>
<th style={th}>نام بیمار</th>
<th style={th}>کد ملی</th>
<th style={th}>تاریخ</th>
<th style={th}>مبلغ پرداختشده</th>
<th style={{ ...th, textAlign: 'left' }}>عملیات</th>
</tr>
</thead>
...
```
نوع ردیف (`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<PaymentFilters, 'page'>) {
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<ApiResponse<PaymentsSummary>>({
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<InvoiceListStatus, { color: BadgeColor; label: string }> = {
paid: { color: 'green', label: 'پرداخت شده' },
unsettled: { color: 'amber', label: 'تسویه نشده' },
};
```
و `'invoice'` را به union پراپ `type` اضافه کن و در بدنه هندل کن.
### ۴. بازنویسی `MyPaymentsPage.tsx` بر اساس الگوی `ClaimsPage`
ساختار نهایی دقیقاً به این ترتیب:
```tsx
<>
<PageHeader
title="لیست پرداخت‌ها"
description="پرداخت‌های ثبت‌شده‌ی بیماران شما"
breadcrumbs={[{ label: 'داشبورد', to: '/admin' }, { label: 'لیست پرداخت‌ها' }]}
/>
{/* ۴ کارت آمار */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard tone="violet" label="مجموع صورتحساب‌ها" value={formatRial(s.total_rials)} />
<StatCard tone="green" label="پرداخت‌شده" value={formatRial(s.paid_rials)} />
<StatCard tone="pink" label="تسویه‌نشده" value={formatRial(s.unsettled_rials)} />
<StatCard tone="amber" label="تعداد صورتحساب" value={formatNumber(s.invoices_count)} />
</div>
<div className="card" style={{ padding: 18 }}>
{/* ردیف فیلترها: وضعیت + از تاریخ + تا تاریخ + میان‌برها + پاک‌کردن */}
{/* DataTable */}
{/* Pagination — فقط وقتی total > MY_PAYMENTS_LIMIT */}
</div>
</>
```
**۴-۱ — انتقال 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<string, string>) => {
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.** هر کنترل داخل یک `<div style={{ minWidth: ... }}>` با `<label className="field-label">`:
- «وضعیت» → `SearchableSelect` با `[{value:'',label:'همه وضعیت‌ها'},{value:'paid',label:'پرداخت شده'},{value:'unsettled',label:'تسویه نشده'}]`، `height={38}`
- «از تاریخ» / «تا تاریخ» → `PersianDatePicker` با `height={38}` (جایگزین `PersianDateInput`)
- میان‌برها: `<button className="btn ghost sm">` برای «یک ماه اخیر» و «یک سال اخیر» — همان `isoNDaysAgo(30)/isoNDaysAgo(365)` + `todayIso()` صفحه‌ی claims
- «پاک‌کردن فیلترها» با `<ArrowPathIcon style={{ width: 13 }} />` — فقط وقتی `hasFilters` true است
**۴-۴ — ستون‌ها با `Column<PaymentRow>`.** ستون «ردیف» را حذف کن (شماره‌ی مصنوعی در جدول صفحه‌بندی‌شده ارزشی ندارد و فضای مفید می‌گیرد) و ستون وضعیت را اضافه کن:
```tsx
const columns: Column<PaymentRow>[] = [
{ key: 'patient_name', header: 'بیمار', render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar name={r.patient_name} />
<span style={{ fontWeight: 600 }}>{r.patient_name ?? '—'}</span>
</div>
) },
{ key: 'national_code', header: 'کد ملی', render: (r) => (
<span dir="ltr">{r.national_code ?? '—'}</span>
) },
{ key: 'issued_at', header: 'تاریخ', render: (r) => (
<span dir="ltr">{formatDate(r.issued_at)} - {formatTime(r.issued_at)}</span>
) },
{ key: 'amount_rials', header: 'مبلغ', render: (r) => (
<span style={{ fontWeight: 600 }}>{formatRial(r.amount_rials)}</span>
) },
{ key: 'status', header: 'وضعیت', render: (r) => <StatusBadge type="invoice" value={r.status} /> },
];
```
`sortable` را روی هیچ ستونی نگذار مگر اینکه اندپوینت `listPayments` واقعاً `sort`/`dir` بپذیرد — سورت غیرفعال بهتر از سورتِ بی‌اثر است. اگر تصمیم گرفتی سورت اضافه کنی، باید هم در `tenantInvoiceList` و هم در کنترلر پشتیبانی شود و در `docs/api/billing.md` مستند شود.
**۴-۵ — عملیات و حالت خالی.**
```tsx
actions={(row) => (
<button className="btn primary sm" onClick={() => navigate(`/admin/my-payments/${row.patient_uuid}`)}>
جزئیات
</button>
)}
emptyMessage="پرداختی ثبت نشده است."
loading={listQuery.isLoading}
```
**۴-۶ — حذف چیزهای زائد.** `th`/`td`ی inline، بلوک `isLoading` دستی، بلوک empty state دستی، `import` های `MagnifyingGlassIcon`/`EyeIcon`/`BanknotesIcon`/`UserPlusIcon`/`PersianDateInput`، ثابت `EMPTY` و دکمه‌ی «اضافه کردن بیمار» از `PageHeader` حذف شوند. `Avatar`، `formatTime` و `dayBound` بمانند.
### ۵. مستندات و تست
- `clinicpro/docs/api/billing.md`: اندپوینت `GET /api/v1/my/billing/payments/summary` را با پارامترهای query و نمونه‌ی پاسخ اضافه کن (Standing Rule).
- `MyPaymentsPage.test.tsx` را به ساختار جدید به‌روز کن: باید render کارت‌های آمار، نمایش بج وضعیت، و به‌روزرسانی query string با تغییر فیلتر را پوشش دهد. صفحه حالا `useSearchParams` دارد → تست باید داخل `MemoryRouter` رندر شود.
- تست بک‌اند برای `tenantInvoiceSummary`: حالت موفق، حالت با فیلتر، و حالت خالی (باید صفر برگرداند نه `null`).
- اجرا: `ddev exec npx tsc --noEmit --project tsconfig.json` · `ddev exec yarn test` · `ddev exec php bin/phpunit`
## نکات مهم
- **الگو را از `ClaimsPage` کپی کن، طراحی جدید نساز.** همان توکن‌ها (`var(--gap)`, `var(--r-lg)`), همان کلاس‌ها (`card`, `btn ghost sm`, `btn primary sm`, `field-label`), همان چیدمان.
- تاریخ‌ها Unix ثانیه‌اند. `dayBound(from,false)` / `dayBound(to,true)` را برای مرز روز نگه دار — API مقدار خام روز را نمی‌فهمد.
- **همیشه `SearchableSelect`، هرگز `<select>` بومی** (قاعده‌ی پروژه).
- کارت‌های آمار باید فیلترهای فعال را منعکس کنند: `usePaymentsSummary` همان `national_code/status/from/to` را می‌گیرد. اگر خلاصه بدون فیلتر بماند، عدد کارت با جمع جدول نمی‌خواند و کاربر گمراه می‌شود.
- **Edge case:** وقتی `summaryQuery` هنوز loading است یا خطا داده، کارت‌ها باید `formatRial(0)` نشان دهند نه `NaN`/`undefined` — با `?? 0` پیش از فرمت.
- **Edge case:** فیلتر `status=paid` باعث می‌شود `unsettled_rials` صفر شود؛ این درست است، نه باگ.
- **Edge case:** `patient_name` و `national_code` nullable هستند → `'—'`.
- RTL و اعداد فارسی: `formatRial`/`formatNumber`/`formatDate` از `lib/utils` — عدد خام رندر نکن. کد ملی و تاریخ با `dir="ltr"`.
- `Pagination` فقط وقتی `total > MY_PAYMENTS_LIMIT` رندر شود (مثل claims).
+304
View File
@@ -0,0 +1,304 @@
# نرمال‌سازی ارقام فارسی/عربی در همه فیلدهای عددی
## پروژه
`clinicpro` (پنل ادمین React + یک لایه دفاعی در backend). لایه backend همه کلاینت‌ها را پوشش می‌دهد — `nobat724_front` و `clinic-pro-tauri` هم از همان `/api/v1/...` استفاده می‌کنند، پس نیازی به پرامپت جدا برای آن‌ها نیست.
## زمینه
کاربر فارسی‌زبان با کیبورد فارسی، عدد را با ارقام فارسی (`۰-۹`) یا عربی (`٠-٩`) تایپ می‌کند. ابزار نرمال‌سازی از قبل در پروژه هست (`toEnglishDigits` در `assets/admin/lib/utils.ts`) و چند کامپوننت (`MobileInput`، `DigitInput`، `PriceInput`، `Input` با prop `numeric`) از آن استفاده می‌کنند — ولی **اکثر فیلدهای عددی پنل از هیچ‌کدام استفاده نمی‌کنند**.
دو نوع خرابی متفاوت رخ می‌دهد و باید هر دو در ذهن باشد:
- **`type="number"`** → مرورگر مقدار را نامعتبر می‌داند و `e.target.value` رشتهٔ **خالی** برمی‌گرداند. یعنی کاربر عدد را می‌بیند ولی فیلد خالی/صفر ذخیره می‌شود — **باگ از دست رفتن داده**، نه مقدار غلط.
- **`type="text"` / `type="tel"`** → ارقام فارسی دست‌نخورده تا دیتابیس می‌روند. مثلاً شماره موبایل `۰۹۱۲...` ذخیره می‌شود و بعداً هیچ‌وقت با `09...` مچ نمی‌شود.
نقطهٔ شروع گزارش کاربر: فرم «افزودن منشی» — هم موبایل و هم کد ملی از نوع دوم‌اند و مستقیم به API می‌روند.
## مشکل / هدف
۱. فرم منشی (موبایل + کد ملی) ارقام فارسی را بدون تبدیل ارسال می‌کند.
۲. حدود ۵۰ فیلد عددی دیگر در پنل همین مشکل را دارند.
۳. هیچ محافظ سمت backend وجود ندارد (فقط یک endpoint نرمال‌سازی می‌کند).
۴. چند پیاده‌سازی تکراری از همان تابع تبدیل در فایل‌های مختلف پخش شده است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/lib/utils.ts:105-135` | `toEnglishDigits`، `sanitizeMobileInput`، `iranMobileSchema` |
| `assets/admin/components/ui/Input.tsx:19-25` | prop `numeric` — پیاده‌سازی درست، **صفر مصرف‌کننده** |
| `assets/admin/components/ui/MobileInput.tsx` | فیلد موبایل |
| `assets/admin/components/ui/DigitInput.tsx` | فیلد فقط‌رقم با `maxDigits` |
| `assets/admin/components/ui/PriceInput.tsx` | فیلد مبلغ با جداکننده |
| `assets/admin/pages/MySecretariesPage.tsx:226-266, 437, 441` | فرم منشی + `DefaultTextField` خام |
| `assets/admin/pages/RepresentationProfilePage.tsx:21-26` | `toLatinDigits` تکراری — باید حذف شود |
| `assets/admin/components/inventory/AddItemModal.tsx:29` | wrapper محلی `digits()` |
| `src/Shared/Util/PersianText.php:31-34` | نرمال‌ساز backend — فقط در یک controller استفاده شده |
| `src/Doctor/Controller/DoctorClaimController.php:95-99` | تنها مصرف‌کنندهٔ فعلی `PersianText` روی ارقام |
## وضعیت فعلی
### ابزار موجود — `assets/admin/lib/utils.ts:105-116`
```ts
// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی.
export function toEnglishDigits(input: string): string {
if (!input) return '';
return input
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
export function sanitizeMobileInput(input: string): string {
return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11);
}
```
> کامنت بالای `toEnglishDigits` غلط است — این تابع کاراکتر غیرعددی را حذف **نمی‌کند**، فقط ارقام را ترجمه می‌کند. کامنت را اصلاح کن.
### الگوی درستِ موجود — `assets/admin/components/ui/Input.tsx:19-25`
```tsx
const handleChange = numeric
? (e: React.ChangeEvent<HTMLInputElement>) => {
const latin = toEnglishDigits(e.target.value);
if (latin !== e.target.value) e.target.value = latin;
onChange?.(e);
}
: onChange;
```
### فرم منشی — `assets/admin/pages/MySecretariesPage.tsx:437, 441`
```tsx
<DefaultTextField placeholder="09121234567" value={form.telephone} onChange={(v) => setField("telephone", v)} disabled={disabled || mode === "edit"} />
...
<DefaultTextField placeholder="کد ملی" value={form.national_code} onChange={(v) => setField("national_code", v)} disabled={disabled} />
```
`DefaultTextField` (`:226-266`) یک `<input>` خام بدون `type`/`inputMode`/`dir` است و مقدار را عیناً پاس می‌دهد. مقدار در `:773-775` و `:803` بدون هیچ پردازشی ارسال می‌شود. این فرم اصلاً Zod schema ندارد.
### الگوی درست schema — `assets/admin/lib/utils.ts:125-135`
```ts
export const iranMobileSchema = z
.string()
.transform((v) => toEnglishDigits(v).replace(/\D/g, ''))
.refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود');
```
### schemaهایی که ارقام فارسی را رد می‌کنند (چون `\d` فقط ASCII است)
```ts
// components/PatientRecordInfoForm.tsx:20
national_code: z.string().trim().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد').or(z.literal('')),
// pages/PatientRecordFormPage.tsx:21-22
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
```
### backend — `src/Shared/Util/PersianText.php:31-34`
```php
$text = strtr($text, array_combine(
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
));
```
فقط در `DoctorClaimController` روی ارقام استفاده شده. بقیهٔ endpointها (منشی، بیمار، پرسنل، کلینیک، سرویس، اشتراک، حساب بانکی) ارقام فارسی را بدون تغییر در دیتابیس می‌نویسند.
## وظایف
### ۱. تکمیل ابزارهای مشترک در `lib/utils.ts`
- کامنت غلط `toEnglishDigits` را اصلاح کن.
- این‌ها را اضافه کن:
```ts
/** فقط ارقام لاتین، با محدودیت طول اختیاری. */
export function digitsOnly(input: string, maxLen?: number): string {
const d = toEnglishDigits(input).replace(/\D/g, '');
return maxLen ? d.slice(0, maxLen) : d;
}
/** برای z.coerce.number() که روی ارقام فارسی NaN می‌دهد. */
export const persianSafeNumber = (schema: z.ZodNumber) =>
z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema);
export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/;
export const iranNationalCodeSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
export const iranNationalCodeOptionalSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی نامعتبر است');
```
تست‌ها را در `assets/admin/lib/utils.test.ts` اضافه کن (کنار تست‌های موجود `toEnglishDigits` در خطوط ۱۱۸-۱۳۶): ورودی فارسی، عربی، مخلوط، خالی، و رشتهٔ دارای کاراکتر غیرعددی.
### ۲. حذف پیاده‌سازی‌های تکراری
- `assets/admin/pages/RepresentationProfilePage.tsx:21-26` → تابع محلی `toLatinDigits` را حذف و با `toEnglishDigits` جایگزین کن (مصرف در `:158` و `:211`).
- `assets/admin/components/inventory/AddItemModal.tsx:29``digits()` محلی را با `digitsOnly` مشترک جایگزین کن.
### ۳. فرم منشی — نقطهٔ شروع گزارش کاربر
در `assets/admin/pages/MySecretariesPage.tsx`:
- موبایل (`:437`) → `<MobileInput>` (یا `DigitInput` با `maxDigits={11}`).
- کد ملی (`:441`) → `<DigitInput maxDigits={10}>`.
- **یا** ساده‌تر و کم‌ریسک‌تر: به `DefaultTextField` یک prop `numeric?: boolean` و `maxDigits?: number` اضافه کن که داخلش `digitsOnly` صدا بزند، سپس روی این دو فیلد `numeric` بگذار. اگر این راه را رفتی، `type="tel"`، `inputMode="numeric"` و `dir="ltr"` را هم ست کن.
- در `:773-775` و `:803` هم قبل از ارسال `digitsOnly` بزن (دفاع لایه‌ای — کاربر می‌تواند paste کند).
- این فرم schema ندارد؛ حداقل `iranMobileSchema` و `iranNationalCodeOptionalSchema` را روی همین دو فیلد اعمال کن تا خطای فارسی معنادار نشان داده شود.
### ۴. مهاجرت همهٔ فیلدهای عددی
فهرست کامل زیر لیست کار است. برای هر مورد:
- فیلد پول/مبلغ → `<PriceInput>`
- فیلد شمارهٔ ملی/موبایل/کارت/شبا/کد پستی → `<DigitInput maxDigits={n}>`
- بقیه (درصد، مدت، تعداد، وزن، سطح) → `<Input numeric>` یا `type="text" inputMode="numeric"` + `digitsOnly` در `onChange`
- **هیچ فیلد `type="number"` جدیدی نساز** و موجودها را به `type="text" inputMode="numeric"` تبدیل کن، وگرنه مشکل «مقدار خالی» باقی می‌ماند.
- اگر فیلد با React Hook Form `register` شده، `setValueAs` یا `onChange` سفارشی لازم است:
```tsx
<input
type="text"
inputMode="numeric"
dir="ltr"
{...register('price_rials', { setValueAs: (v) => digitsOnly(String(v ?? '')) })}
/>
```
#### منشی
| فایل:خط | فیلد |
|---|---|
| `MySecretariesPage.tsx:437` | `telephone` |
| `MySecretariesPage.tsx:441` | `national_code` |
#### پرسنل
| فایل:خط | فیلد |
|---|---|
| `pages/StaffPage.tsx:265` | `phone` |
| `pages/StaffPage.tsx:269` | `national_code` |
#### بیماران
| فایل:خط | فیلد |
|---|---|
| `pages/PatientRecordFormPage.tsx:129` | `national_code` |
| `pages/PatientRecordFormPage.tsx:132` | `mobile` |
| `components/PatientRecordInfoForm.tsx:117` | `national_code` — فقط prop `numeric` را به `<Input>` اضافه کن |
| `components/PatientRecordInfoForm.tsx:184` | `postal_code` — همان |
| `pages/MyPatientsPage.tsx:1441, 1452, 1464` | `visit_price_rials`، دو فیلد درصد تخفیف |
#### کلینیک/پزشک (تلفن ثابت — موبایل‌ها از قبل درست‌اند)
| فایل:خط | فیلد |
|---|---|
| `pages/ClinicsPage.tsx:278` | `telephone` |
| `pages/ClinicDetailPage.tsx:368` | `telephone` |
| `pages/ClinicFormPage.tsx:65` | `telephone` |
| `pages/DoctorDetailPage.tsx:652` | `telephone` |
#### نوبت
| فایل:خط | فیلد |
|---|---|
| `components/NewAppointmentDrawer.tsx:318` | `duration` |
| `pages/AppointmentCreatePage.tsx:437` | `duration` |
| `components/AppointmentFiltersModal.tsx:90` | `nationalCode` (فیلتر جستجو — بدون تبدیل هیچ‌وقت مچ نمی‌شود) |
#### زمان‌بندی
| فایل:خط | فیلد |
|---|---|
| `components/schedule/ScheduleSection.tsx:458` | `rest_interval` |
| `components/schedule/ScheduleSection.tsx:465` | `time_to_rest` |
| `components/schedule/ScheduleSection.tsx:705` | `buffer_minutes` |
| `components/schedule/ScheduleSection.tsx:751` | `booking_window_value` |
#### مبلغ / درصد
| فایل:خط | فیلد |
|---|---|
| `components/FreeVisitPrice.tsx:65` | قیمت ویزیت |
| `components/session/CreateStep.tsx:414, 441, 445` | قیمت ویزیت، دو درصد بیمه |
| `components/InsuranceModal.tsx:164, 168, 172` | `coverage`، `franchise`، `ceiling` |
| `components/ServiceInsuranceModal.tsx:130` | درصد پوشش |
| `components/DiscountTab.tsx:242, 247, 297` | `value` (درصد)، `priority`، `min_visit_count` |
| `pages/ClinicServicesPage.tsx:529` | `duration_minutes` — placeholder فعلی `"مثلاً: ۵۰"` با ارقام فارسی است و کاربر را به اشتباه می‌اندازد؛ اصلاحش کن |
| `pages/SmsWalletPage.tsx:531` | `amount_rials` |
| `pages/RepresentationSettlementPage.tsx:130` | `amount` |
#### تنظیمات / ادمین
| فایل:خط | فیلد |
|---|---|
| `pages/SettingsPage.tsx:319, 325, 356, 373, 399, 405, 495` | ساعت لغو، ساعت یادآوری، درصد کمیسیون، درصد مالیات، سه فیلد مبلغ |
| `pages/LogsPage.tsx:257` | روزهای نگهداری لاگ |
| `pages/CategoriesPage.tsx:352, 574, 702, 827` | `weight` (چهار جا) |
| `pages/AdminSubscriptionPage.tsx:274, 278, 323, 327, 333` | `level`، `max_secretaries`، `duration_months`، `price_rials`، `sort_order` |
#### نمایندگان
| فایل:خط | فیلد |
|---|---|
| `pages/RepresentationsPage.tsx:251` | `commission_percent` |
| `pages/RepresentationDetailPage.tsx:490` | `commission_percent` |
#### بانکی — هیچ‌کدام تبدیل ندارند
| فایل:خط | فیلد |
|---|---|
| `components/paymentMethods/BankAccountFormModal.tsx:91` | `cardNumber``DigitInput maxDigits={16}` |
| `components/paymentMethods/BankAccountFormModal.tsx:~95` | `accountNumber` |
| `components/paymentMethods/BankAccountFormModal.tsx:99` | `shabaNumber` → شبا حرف `IR` دارد؛ `digitsOnly` خام آن را خراب می‌کند. فقط `toEnglishDigits` بزن و حروف را نگه‌دار |
#### فقط یکدست‌سازی (از قبل درست کار می‌کنند)
`pages/LoginPage.tsx:242, 280, 330` و `components/ui/NotificationMobileCard.tsx:128` از `sanitizeMobileInput` استفاده می‌کنند — به `<MobileInput>` مهاجرت بده، اولویت پایین.
### ۵. اصلاح schemaهای Zod
- `components/PatientRecordInfoForm.tsx:20` و `pages/PatientRecordFormPage.tsx:21-22` → با `iranNationalCodeSchema` / `iranMobileSchema` جایگزین کن.
- همهٔ `z.coerce.number()`ها را با `persianSafeNumber(z.number()...)` بپوشان: `AdminSubscriptionPage.tsx:35, 36, 45, 46, 48`؛ `ClinicServicesPage.tsx:28, 31, 32`؛ `RepresentationsPage.tsx:28`؛ `SmsWalletPage.tsx:25`؛ `MyPatientsPage.tsx:70-74`.
### ۶. لایه دفاعی backend
یک نرمال‌سازی سطح-request بساز تا هیچ کلاینتی (پنل ادمین، `nobat724_front`، `clinic-pro-tauri`) نتواند ارقام فارسی وارد دیتابیس کند.
پیشنهاد: `src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php` روی `kernel.request` که برای درخواست‌های `/api/v1/**` با بدنهٔ JSON، مقدار کلیدهای شناخته‌شده را با `PersianText::normalize` تبدیل کند:
```php
private const NUMERIC_KEYS = [
'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
'national_code', 'postal_code', 'card_number', 'account_number', 'sheba', 'iban',
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
'duration_minutes', 'commission_percent', 'coverage', 'franchise', 'ceiling',
];
```
نکات:
- بازگشتی روی آرایه‌های تودرتو اعمال شود (مثلاً `insurances[].patient_share_rials`).
- مقدار فقط ترجمهٔ رقم شود؛ **حذف کاراکتر غیرعددی نکن** (شبا حرف دارد، تلفن ثابت خط تیره).
- فقط روی `string` اعمال شود، `int`/`bool`/`null` دست‌نخورده بماند.
- اگر تشخیص دادی subscriber بیش از حد گسترده است و ریسک دارد، جایگزین کم‌ریسک‌تر: `PersianText::normalize` را در همان چند controller حساس (منشی، بیمار، پرسنل، حساب بانکی) دستی صدا بزن و در گزارش بگو کدام مسیر را رفتی و چرا.
تست backend در `tests/Shared/` بنویس: POST با موبایل فارسی → مقدار ذخیره‌شده لاتین است.
### ۷. تست و مستندات
- `ddev exec yarn test` برای تست‌های `lib/utils.test.ts`
- `ddev exec npx tsc --noEmit` و `ddev exec yarn dev`
- `ddev exec php bin/phpunit tests/Shared`
- اگر subscriber ساختی، رفتار جدید را در `docs/api/README.md` (یا فایل مناسب `docs/api/`) به‌عنوان یک قاعدهٔ سراسری مستند کن: «ارقام فارسی/عربی در فیلدهای عددی سمت سرور نرمال می‌شوند».
## نکات مهم
- **`type="number"` دشمن این کار است.** با ارقام فارسی مقدار خالی برمی‌گرداند و هیچ `onChange` هندلری نجاتش نمی‌دهد. تبدیل به `type="text" inputMode="numeric"` بخش اجباری هر مورد است، نه اختیاری.
- شبا (`IR` + ۲۴ رقم) و تلفن ثابت (`021-1234...`) کاراکتر غیرعددی معتبر دارند — روی این‌ها فقط `toEnglishDigits` بزن نه `digitsOnly`.
- `PriceInput` از قبل خروجی `number` می‌دهد؛ جایگزینی مستقیم `type="number"` با آن ممکن است تایپ فرم را عوض کند — امضای `onChange` را چک کن.
- `<Input numeric>` از قبل ساخته شده و تست نشده چون هیچ مصرف‌کننده‌ای ندارد؛ بعد از اولین استفاده حتماً دستی تست کن.
- فیلدهایی که با RHF `register` شده‌اند با دست‌کاری مستقیم `e.target.value` درست کار نمی‌کنند مگر `setValueAs` یا `Controller` استفاده شود.
- RTL: فیلدهای عددی باید `dir="ltr"` داشته باشند تا عدد وارونه نمایش داده نشود.
- از کلاس‌های CSS موجود استفاده کن (`input`، `field`، `cp-input`)؛ کتابخانه جدید اضافه نکن.
- این تغییر بزرگ و پرتکرار است — **قابلیت‌به‌قابلیت پیش برو** و بعد از هر گروه `tsc` و build بگیر، نه یک‌جا.
@@ -0,0 +1,204 @@
# گزینه «الزامی کردن هزینه ویزیت» در تنظیمات نوبت‌دهی
## پروژه
`clinicpro` (backend + پنل ادمین React)
## زمینه
در صفحه `/admin/appointment-settings` کامپوننت `FreeVisitPrice` مبلغ «قیمت ویزیت آزاد» را از `GET /api/v1/insurance-pricing` می‌خواند و با `PUT` همان endpoint ذخیره می‌کند (ذخیره در `EntityInsurancePricing` با `insurance_id = NULL`). این مبلغ در گام «ایجاد سرویس» ثبت مراجعه (`CreateStep.tsx`) به‌عنوان مقدار پیش‌فرض «قیمت ویزیت» استفاده می‌شود، اما هیچ‌کدام از فرم‌ها آن را الزامی نمی‌کنند و صفحه ثبت نوبت (`AppointmentCreatePage.tsx`) اصلاً فیلد هزینه ویزیت ندارد. کلینیک‌هایی که می‌خواهند هیچ مراجعه/نوبتی بدون هزینه ویزیت ثبت نشود، ابزاری برای اجبار آن ندارند.
## مشکل / هدف
یک تنظیم boolean با عنوان **«الزامی کردن هزینه ویزیت»** (به‌همراه متن راهنما) به صفحه appointment-settings اضافه شود که وقتی فعال است:
1. «قیمت ویزیت آزاد» الزامی شود (بدون مقدار > 0 ذخیره تنظیمات ممکن نباشد) — هم در UI هم در backend.
2. در گام «ایجاد سرویس» ثبت مراجعه (`/admin/patients/<uuid>/session/new`)، فیلد «قیمت ویزیت» الزامی شود؛ بدون مقدار > 0 ثبت نشود — UI + backend.
3. در صفحه ثبت نوبت (`/admin/appointments/new`) فیلد جدید «هزینه ویزیت» اضافه شود (**تصمیم تأییدشده توسط کاربر**): با فلگ فعال الزامی، با فلگ غیرفعال اختیاری؛ مقدار پیش‌فرض از «قیمت ویزیت آزاد».
وقتی غیرفعال است، همه این فیلدها اختیاری بمانند (رفتار فعلی). وضعیت الزامی/اختیاری باید در UI واضح باشد (ستاره `*` روی label + پیام خطای فارسی زیر فیلد).
> «صدور فاکتور سرویس» در این پنل همان گام ۱ ویزارد ثبت مراجعه است (`CreateStep`) که `POST /api/v1/patient/{uuid}/session` را صدا می‌زند؛ گام‌های پرداخت/جزییات (`PaymentStep`/`DetailsStep`) قیمت ویزیت را فقط از session ساخته‌شده می‌خوانند و فیلد ورودی ندارند. پس الزام فاکتور با اعتبارسنجی همین گام + گارد backend پوشش داده می‌شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Insurance/Entity/EntityInsurancePricing.php` | محل ذخیره «قیمت ویزیت آزاد» (ردیف `insurance_id = NULL`) — ستون جدید فلگ اینجا اضافه می‌شود |
| `src/Insurance/Controller/InsuranceController.php` | `GET`/`PUT /api/v1/insurance-pricing` — expose و اعتبارسنجی فلگ |
| `src/Patient/Service/PatientService.php` | `createSession()` — اعتبارسنجی الزامی بودن `visit_price_rials` |
| `src/Appointment/Entity/Appointment.php` | ستون جدید `visit_price_rials` (nullable) |
| کنترلر ایجاد نوبت (`POST /api/v1/admin/appointment` و `/api/v1/my/appointment`) | پذیرش و اعتبارسنجی `visit_price_rials` — با `ddev exec php bin/console debug:router \| grep appointment` پیدا کن |
| `assets/admin/components/FreeVisitPrice.tsx` | UI تنظیم قیمت ویزیت آزاد — toggle + helper text + الزامی شدن قیمت |
| `assets/admin/components/session/CreateStep.tsx` | گام «ایجاد سرویس» — الزامی شدن «قیمت ویزیت» |
| `assets/admin/pages/AppointmentCreatePage.tsx` | صفحه ثبت نوبت — فیلد جدید «هزینه ویزیت» |
| `docs/api/insurance.md`، `docs/api/patient.md`، `docs/api/appointment.md` | به‌روزرسانی مستندات (Standing Rule) |
## وضعیت فعلی
`EntityInsurancePricing` فقط مبلغ دارد، فلگ ندارد:
```php
#[ORM\Column(name: 'patient_share_rials', type: 'integer')]
private int $patientShareRials = 0;
public function isFreeVisit(): bool { return $this->insuranceId === null; }
```
`InsuranceController::saveInsurancePricing` بدون هیچ اعتبارسنجی upsert می‌کند:
```php
if (array_key_exists('free_visit_price_rials', $data)) {
$this->upsertPricing($entityType, $entityId, null, (int) $data['free_visit_price_rials']);
}
```
`PatientService::createSession` مقدار صفر را می‌پذیرد:
```php
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
```
`CreateStep.tsx` قیمت ویزیت را اختیاری می‌گیرد (پیش‌فرض از free visit، ولی صفر هم ثبت می‌شود):
```tsx
const [visitPrice, setVisitPrice] = useState('0');
// ...
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
useEffect(() => {
if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit));
}, [freeVisit]);
// ...
<span style={fieldLabel}>قیمت ویزیت (تومان)</span>
<input className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت" value={visitPrice} onChange={(e) => setVisitPrice(e.target.value)} />
```
`AppointmentCreatePage.tsx` payload فقط بیعانه دارد، هزینه ویزیت ندارد:
```tsx
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
```
و `Appointment` entity ستون قیمت ویزیت ندارد (فقط `deposit_amount_rials`).
## وظایف
### ۱. Backend — فلگ `require_visit_price` روی EntityInsurancePricing
ستون boolean جدید روی همان ردیف free-visit (بدون endpoint جدید — توسعه endpoint موجود، طبق قاعده ۲):
```php
#[ORM\Column(name: 'require_visit_price', type: 'boolean', options: ['default' => false])]
private bool $requireVisitPrice = false;
public function isRequireVisitPrice(): bool { return $this->requireVisitPrice; }
public function setRequireVisitPrice(bool $v): self { $this->requireVisitPrice = $v; $this->updatedAt = time(); return $this; }
```
- `toArray()` هم `require_visit_price` را اضافه کن.
- Migration: `ddev exec php bin/console doctrine:migrations:diff` سپس `migrate`.
- در `EntityInsurancePricingRepository` (یا متد کمکی در سرویس مشترک) یک lookup ساده: فلگ فعال است اگر ردیف free-visit موجود و `requireVisitPrice === true`.
### ۲. Backend — `GET`/`PUT /api/v1/insurance-pricing`
در `getInsurancePricing`: کنار `free_visit_price_rials`، کلید `require_visit_price` (از ردیف free-visit، پیش‌فرض `false`) برگردان.
در `saveInsurancePricing`:
```php
$requireVisitPrice = null;
if (array_key_exists('require_visit_price', $data)) {
$requireVisitPrice = (bool) $data['require_visit_price'];
}
$price = array_key_exists('free_visit_price_rials', $data) ? (int) $data['free_visit_price_rials'] : /* مقدار فعلی ردیف free-visit یا 0 */;
// اگر فلگ (جدید یا ذخیره‌شده قبلی) فعال است، قیمت باید > 0 باشد
$effectiveFlag = $requireVisitPrice ?? /* فلگ ذخیره‌شده فعلی */;
if ($effectiveFlag && $price <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است', 422, 'free_visit_price_rials');
}
```
سپس upsert موجود + ست کردن فلگ روی ردیف free-visit. (اگر فقط فلگ ارسال شود و ردیف free-visit وجود نداشته باشد، ردیف با قیمت 0 ساخته نشود مگر فلگ false باشد — سناریوی مرزی تست شود.)
### ۳. Backend — الزامی شدن `visit_price_rials` در ثبت session
در `PatientService::createSession` (یا کنترلر آن، هر جا اعتبارسنجی‌های مشابه انجام می‌شود)، قبل از ساخت session:
```php
$requireVisit = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null)?->isRequireVisitPrice() ?? false;
if ($requireVisit && (int) ($data['visit_price_rials'] ?? 0) <= 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'هزینه ویزیت الزامی است', 422);
}
```
(الگوی خطا را با بقیه اعتبارسنجی‌های همین مسیر هماهنگ کن — اگر کنترلر `$this->error()` برمی‌گرداند همان الگو.)
### ۴. Backend — فیلد `visit_price_rials` روی Appointment
- ستون nullable روی `Appointment`:
```php
#[ORM\Column(name: 'visit_price_rials', type: 'integer', nullable: true)]
private ?int $visitPriceRials = null;
```
- getter/setter + افزودن به `toArray()` (کنار `deposit_amount_rials`).
- Migration جدید.
- در endpointهای ایجاد نوبت (`POST /api/v1/admin/appointment` و `POST /api/v1/my/appointment` — کنترلر مربوطه را با `debug:router` پیدا کن): `visit_price_rials` را از payload بپذیر و ست کن. اعتبارسنجی: فلگ را برای entity پزشکِ نوبت (doctor) resolve کن؛ اگر فعال بود و مقدار ارسالی `<= 0` بود → خطای 422 با پیام فارسی «هزینه ویزیت الزامی است».
- توجه: `resolveEntity` در `InsuranceController` بر اساس کاربر جاری است؛ برای ایجاد نوبت توسط admin/منشی، فلگ باید بر اساس **پزشک نوبت** (و در نبود قیمت‌گذاری پزشک، کلینیک مرتبط — همان ترتیبی که `BillingCalculator`/pricing فعلی استفاده می‌کند) خوانده شود، نه کاربر لاگین‌شده.
### ۵. Frontend — `FreeVisitPrice.tsx`
- Toggle «الزامی کردن هزینه ویزیت» (همان الگوی سوییچ `AppointmentCreatePage` خطوط ۴۳۷–۴۵۰) زیر فیلد قیمت.
- Helper text (متن راهنما) زیر toggle با استایل `fontSize:12, color:'var(--text-3)'` — متن پیشنهادی:
«با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی می‌شود و بدون آن امکان ذخیره وجود ندارد.»
- interface را گسترش بده: `interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }` و state محلی برای toggle.
- `saveMut` هر دو کلید را بفرستد: `{ free_visit_price_rials, require_visit_price }`.
- اعتبارسنجی client-side: اگر toggle فعال و قیمت خالی/صفر → دکمه ذخیره خطا بدهد (پیام خطای فارسی زیر فیلد + `toast.error`)، درخواست ارسال نشود.
- وقتی toggle فعال است، label قیمت با `*` قرمز: «قیمت (تومان) *».
### ۶. Frontend — `CreateStep.tsx`
- query `insurance-pricing` از قبل هست؛ فلگ را بخوان:
```tsx
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
```
- label: `قیمت ویزیت (تومان){requireVisit && ' *'}` (ستاره قرمز).
- در `submit()` قبل از mutate:
```tsx
if (requireVisit && visit <= 0) {
toast.error('هزینه ویزیت الزامی است');
return;
}
```
- پیام خطای inline زیر فیلد وقتی الزامی و خالی/صفر (state خطا که با تغییر مقدار پاک شود).
### ۷. Frontend — `AppointmentCreatePage.tsx`
- query جدید: `useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing') })``freeVisit` و `requireVisit`.
- بخش جدید «هزینه ویزیت» (بعد از «بیعانه» یا کنار آن): `PriceInput` با state `visitPriceRials`، مقدار اولیه از `freeVisit` (با `useEffect` مشابه CreateStep، فقط وقتی کاربر دستی تغییر نداده).
- label با `*` وقتی `requireVisit` فعال است؛ helper کوتاه «هزینه ویزیت این نوبت (تومان)».
- payload: `...(visitPriceRials > 0 || requireVisit ? { visit_price_rials: visitPriceRials } : {})` — و شرط `valid` را گسترش بده: `&& (!requireVisit || visitPriceRials > 0)` تا دکمه «ثبت اطلاعات» بدون مقدار غیرفعال بماند.
- پیام inline قرمز زیر فیلد وقتی الزامی و صفر.
### ۸. تست‌ها و مستندات
- **PHPUnit:** ذخیره تنظیمات با فلگ فعال و قیمت 0 → 422؛ با قیمت معتبر → 200؛ ثبت session با فلگ فعال بدون `visit_price_rials` → 422، با مقدار → 201؛ فلگ غیرفعال → رفتار قبلی (صفر مجاز)؛ ایجاد نوبت با/بدون فلگ.
- **Vitest:** `FreeVisitPrice` — toggle فعال + قیمت خالی → خطا و عدم ارسال؛ `CreateStep` — الزامی بودن قیمت ویزیت با فلگ (mock query)؛ سناریوی فلگ غیرفعال بدون تغییر رفتار.
- `docs/api/insurance.md` (کلید جدید `require_visit_price` در GET/PUT insurance-pricing + خطای 422)، `docs/api/patient.md` (اعتبارسنجی جدید session)، `docs/api/appointment.md` (فیلد جدید `visit_price_rials`) — همه در همین سشن.
## نکات مهم
- **واحدها:** backend ریال (`_rials`)، UI تومان — تبدیل با `rialToToman`/`tomanToRial` (الگوی `FreeVisitPrice`). `PriceInput` مقدار ریالی نگه می‌دارد (الگوی `depositRials`) — سازگاری واحد را در AppointmentCreatePage دوبار چک کن.
- **Envelope:** پاسخ insurance-pricing با `$this->success([...])` تک‌سطح است و frontend فعلی با `(data as any)?.data` می‌خواند — همین الگو را حفظ کن، double-nesting نساز.
- گارد اصلی backend است؛ اعتبارسنجی UI فقط تجربه کاربری. هر سه endpoint (insurance-pricing PUT، session POST، appointment POST) باید مستقل از UI مقدار را رد کنند.
- edge case: فلگ فعال ولی ردیف free-visit حذف/بدون قیمت → ثبت مراجعه باید 422 بدهد نه crash؛ `?->` و پیش‌فرض `false` رعایت شود.
- edge case: کاربر با نقش admin (بدون پروفایل پزشک) در appointments/new — query insurance-pricing ممکن است 403 بدهد (`resolveEntity` کاربر جاری)؛ در این حالت فلگ را `false` فرض کن و فیلد اختیاری بماند، یا فلگ را بر اساس پزشک انتخاب‌شده از endpoint مناسب بخوان — هنگام پیاده‌سازی بررسی و مستند کن.
- دو migration جدا (EntityInsurancePricing، Appointment) یا یکی — خروجی `migrations:diff` را قبل از migrate بازبینی کن.
- رشته‌های UI فارسی؛ کد/کامیت انگلیسی.
- سوییچ toggle را از الگوی موجود کپی کن؛ کامپوننت جدید عمومی نساز مگر تکرار سوم.
+217
View File
@@ -0,0 +1,217 @@
# نوبت‌دهی بر اساس مدت سرویس (Service-based booking) — Backend + Admin
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React).
**Cross-repo:** بخش نوبت‌دهی آنلاین در `nobat724_front` است → پرامپت همتا: `nobat724_front/.claude/prompt/service-based-online-booking.md` (این پرامپت اول اجرا شود؛ قرارداد endpointها را همان‌جا مصرف می‌کنند).
## زمینه
الان نوبت‌دهی «اسلاتی» است: در `WeeklySchedule.setting` (JSON) برای هر روز یک یا چند `session` تعریف می‌شود و `SlotCalculatorService::buildSessionSlots()` بازهٔ session را با گام ثابت `duration_per_patient` به اسلات‌های هم‌اندازه می‌شکند. مدت هر نوبت مستقل از نوع خدمت است.
هدف: افزودن حالت دوم «نوبت‌دهی بر اساس سرویس»، به‌طوری‌که مدت هر نوبت از `ServiceItem.durationMinutes` (که **الان هم در Entity هست ولی در محاسبهٔ نوبت استفاده نمی‌شود**) بیاید، نه از گام ثابت. حالت اسلاتی باید دست‌نخورده بماند و حالت جدید فقط یک گزینهٔ قابل‌انتخاب باشد.
خبر خوب: بیشتر زیرساخت موجود است و نباید بازساخته شود:
- `ServiceItem.durationMinutes` (`service_items.duration_minutes`, nullable) — مدت هر سرویس.
- `Appointment.serviceItem` / `serviceSection` / `staff` (ManyToOne) — از قبل روی نوبت هست.
- `Appointment.isReserve` (bool) — **همان «نوبت آزاد»** است (در سایت «نوبت رزرو»). day-level، اسلات اشغال نمی‌کند، فقط منشی ثبت می‌کند. **بازسازی نکن؛ از همین استفاده کن.**
- `Holiday` و `DateOverride` entities — تعطیلات و استثناها از قبل هستند.
- `AppointmentRepository::isSlotTaken()` **از قبل overlap واقعیِ بازه‌ای می‌زند** (`a.slotStart < :slotEnd AND a.slotEnd > :slotStart`) — برای نوبت‌های متغیرالطول هم درست کار می‌کند.
## هدف / spec انگلیسی
Add a per-doctor booking mode `slot | service` stored in `WeeklySchedule` meta. In `service` mode:
- Working hours per weekday come from the existing `sessions` windows (`start_time`/`end_time`), but `duration_per_patient` is ignored; appointment length = sum of selected services' `durationMinutes` + optional `buffer_minutes`.
- A new endpoint returns candidate start times: first-fit free gaps inside each session window that fit the requested duration, treating existing bookings (interval-overlap) as busy.
- Booking accepts service items, derives `slot_end = slot_start + Σ durationMinutes + buffer`, and inserts atomically without overlap.
## فایل‌های مرتبط
| فایل | نقش | تغییر |
|------|-----|-------|
| `src/Appointment/Entity/WeeklySchedule.php` | متای برنامهٔ هفتگی | افزودن `booking_mode` + `buffer_minutes` به `DEFAULT_META` و `setMeta()` |
| `src/Appointment/Service/SlotCalculatorService.php` | محاسبهٔ زمان | افزودن مسیر service-based (متد جدید `getServiceStartTimes`) |
| `src/Appointment/Repository/AppointmentRepository.php` | `isSlotTaken` / `bookAtomically` | افزودن قفلِ per-doctor برای حالت سرویس (توضیح در نکات) |
| `src/Appointment/Controller/AppointmentController.php` | endpoint اسلات + book | endpoint جدید سرویس + پذیرش سرویس در `book()` |
| `src/Appointment/Controller/MyAppointmentsController.php` | ثبت توسط منشی | پذیرش سرویس/مدت در ایجاد نوبت منشی |
| `src/ClinicService/Entity/ServiceItem.php` | مدت + نمایش در نوبت‌دهی | افزودن فیلد `bookable` (bool) — **migration لازم**`durationMinutes` از قبل هست |
| `src/ClinicService/Controller/ClinicServiceController.php` (createItem L143, updateItem L188) | POST/PATCH سرویس | پذیرش `bookable` کنار `duration_minutes` موجود |
| `src/ClinicService/Repository/ServiceItemRepository.php` | کوئری سرویس | افزودن `findBookableByEntity`/شمارش سرویس‌های bookable برای enforcement |
| `docs/api/appointment.md`, `docs/api/appointment-settings.md` | مستندات | به‌روزرسانی هم‌زمان (Standing Rule) |
| `assets/admin/pages/DoctorDetailPage.tsx` (`WeeklyScheduleTab`, ~L1231؛ SessionConfig L92, defaults L304) | ویرایشگر برنامهٔ هفتگی | افزودن سوییچ حالت + فیلد بافر؛ در حالت سرویس مخفی‌کردن `duration_per_patient` |
| `assets/admin/pages/AppointmentSettingsPage.tsx` | «مدیریت نوبت دهی» | همان `WeeklyScheduleTab` را render می‌کند — خودکار سوییچ را می‌گیرد |
| `assets/admin/components/NewAppointmentDrawer.tsx` | فرم ثبت نوبتِ منشی | در حالت سرویس: پیشنهاد زمان‌های خالی به‌جای ورود دستی ساعت |
| `assets/admin/pages/ClinicServicesPage.tsx` (617 خط) | مدیریت سرویس‌ها | مطمئن شو فیلد «مدت (دقیقه)» برای هر ServiceItem قابل‌ویرایش است |
## وضعیت فعلی (کد واقعی)
### مدت خدمت — هست ولی استفاده نمی‌شود
```php
// src/ClinicService/Entity/ServiceItem.php:58
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
private ?int $durationMinutes = null; // getter L87, setter L124, در toArray L153
```
### متای برنامهٔ هفتگی
```php
// src/Appointment/Entity/WeeklySchedule.php:18
public const DEFAULT_META = [
'online_booking_enabled' => true,
'booking_window_value' => 1,
'booking_window_unit' => 'month',
];
// setMeta() (L76) فقط سه کلید بالا را whitelist می‌کند
```
### ساخت اسلاتِ ثابت (حالت فعلی = slot mode)
```php
// src/Appointment/Service/SlotCalculatorService.php:225 buildSessionSlots()
$dur = (int)($session['duration_per_patient'] ?? 20) * 60; // گام ثابت
while ($currentSec + $dur <= $endSec) { ... $currentSec += $dur; }
```
### overlap واقعی از قبل درست است
```php
// src/Appointment/Repository/AppointmentRepository.php:91 isSlotTaken()
->andWhere('a.slotStart < :slotEnd')
->andWhere('a.slotEnd > :slotStart') // interval overlap — نه exact key
```
### book() فعلی فقط slot_start/slot_end می‌گیرد
```php
// src/Appointment/Controller/AppointmentController.php:224
$slotStart = (int)($data['slot_start'] ?? 0);
$slotEnd = (int)($data['slot_end'] ?? 0);
// ... new Appointment($doctor, $user, $slotStart, $slotEnd)
```
## وظایف
### ۱. متای WeeklySchedule: افزودن `booking_mode` و `buffer_minutes`
در `WeeklySchedule.php`:
```php
public const MODE_SLOT = 'slot';
public const MODE_SERVICE = 'service';
public const DEFAULT_META = [
'online_booking_enabled' => true,
'booking_window_value' => 1,
'booking_window_unit' => 'month',
'booking_mode' => self::MODE_SLOT, // پیش‌فرض = رفتار فعلی
'buffer_minutes' => 0,
];
```
در `setMeta()` این دو کلید را هم whitelist کن (validate: `booking_mode ∈ {slot,service}`، `buffer_minutes` = `max(0, (int))`). چون Entity تغییر نمی‌کند (فقط محتوای JSON)، **migration لازم نیست**؛ ولی `getMeta()` با `array_merge(DEFAULT_META, ...)` مقدار پیش‌فرض را به رکوردهای قدیمی می‌دهد — این backward-compat را حفظ می‌کند.
### ۲. SlotCalculatorService: مسیر service-based
متد جدید که برای یک مدت مشخص (به دقیقه) زمان‌های شروعِ ممکن را برمی‌گرداند. از `buildAllSessions()` موجود استفاده کن تا window/holiday/override/booking-window همه رعایت شوند، ولی به‌جای اسلاتِ ثابت، gap-packing کن:
```php
/**
* زمان‌های شروعِ ممکن برای نوبتی به طول $durationMinutes (+ بافر) در یک روز.
* first-fit: داخل هر session، از ابتدای window شروع می‌کند، بازه‌های اشغال‌شده
* (نوبت‌های موجود) را رد می‌کند و اولین جای پیوستهٔ کافی را پیشنهاد می‌دهد.
*
* @return array[] [{start, end, start_time, end_time, location_id}]
*/
public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes): array
{
$buffer = (int)($this->getBookingMeta($doctor)['buffer_minutes'] ?? 0);
$needSec = ($durationMinutes + $buffer) * 60;
if ($needSec <= 0) return [];
$sessions = $this->buildAllSessions($doctor, $date); // window/holiday/override رعایت می‌شود
$now = time();
$result = [];
foreach ($sessions as $session) {
// مرزهای واقعی window از start_time/end_time همان session
// (نه از اسلات‌های ثابتِ ساخته‌شده)
$winStart = $dayStart + parseTime(session.start_time);
$winEnd = $dayStart + parseTime(session.end_time);
$busy = بازه‌های اشغال‌شدهٔ [winStart, winEnd) از AppointmentRepository (فقط SLOT_BLOCKING + pending زنده)؛
// پیمایش با گام مناسب (مثلاً بافر یا ۵ دقیقه) و بررسی عدم تداخل با $busy:
for ($t = $winStart; $t + $needSec <= $winEnd; ) {
$end = $t + $needSec;
if ($t >= $now && !overlapsAny($t, $end, $busy)) {
$result[] = ['start'=>$t, 'end'=>$t + $durationMinutes*60, /* بافر جزو نمایش نیست */
'start_time'=>gmdate('H:i',...), 'location_id'=>session.location_id];
$t = $end; // بعد از این نوبت + بافر ادامه بده
} else {
$t = پرش به انتهای بازهٔ اشغال‌شدهٔ متداخل، یا + گام کوچک;
}
}
}
return $result;
}
```
نکات پیاده‌سازی:
- برای گرفتن نوبت‌های موجودِ یک روز، یک متد repository اضافه کن (مثلاً `findBusyIntervals(Doctor, int $dayStart, int $dayEnd): array` که `[slotStart, slotEnd]` نوبت‌های blocking + pendingِ زنده و **غیر-reserve** را برمی‌گرداند). `isReserve=true` هیچ بازه‌ای اشغال نمی‌کند.
- `slot_end` ذخیره‌شده = `start + durationMinutes*60` (بدون بافر)؛ بافر فقط فاصلهٔ بین نوبت‌ها را در پیشنهاد ایجاد می‌کند (تا نوبت بعدی زودتر از `end+buffer` پیشنهاد نشود). این تصمیم را در docstring بنویس تا edge سازگار بماند.
- اگر هیچ جای کافی نبود، آرایهٔ خالی برگردان (کنترلر پیام مناسب می‌دهد).
### ۳. Endpoint جدید: زمان‌های خالی بر اساس سرویس
در `AppointmentController` (عمومی، مثل `/appointment-slots`):
```
GET /api/v1/appointment-service-slots?doctor_uuid=..&date=YYYY-MM-DD&service_item_uuids[]=..&service_item_uuids[]=..
```
- مدت = مجموع `durationMinutes` سرویس‌های داده‌شده (اگر سرویسی `durationMinutes` نداشت → خطای ۴۲۲ «مدت سرویس تعریف نشده»).
- خروجی با envelope استاندارد:
```json
{ "success": true, "data": {
"doctor_uuid": "...", "date": "YYYY-MM-DD",
"total_duration_minutes": 45, "buffer_minutes": 5,
"start_times": [ { "start": 1750000000, "end": 1750002700, "start_time": "15:00", "location_id": 12 } ]
} }
```
- اگر پزشک در حالت `slot` است، این endpoint می‌تواند خطای ۴۲۲ «این پزشک در حالت نوبت‌دهی سرویس نیست» بدهد یا خالی برگرداند — تصمیم را مستند کن.
- `ServiceItem` repository از قبل هست (`ServiceItemRepository::findByUuid`).
### ۴. book() و MyAppointmentsController: پذیرش سرویس
در `AppointmentController::book()` و `MyAppointmentsController` (POST `/api/v1/my/appointment`):
- ورودی جدید اختیاری: `service_item_uuids: string[]` (و/یا `service_item_uuid` تکی که الان هم پذیرفته می‌شود).
- اگر پزشک `service` mode است و سرویس داده شده: `slot_end` را از `slot_start + Σ durationMinutes*60` **در سمت سرور** محاسبه کن (به `slot_end` کلاینت اعتماد نکن) و همان serviceItem را روی نوبت set کن.
- حالت `slot` دقیقاً مثل الان بماند (از `slot_end` کلاینت استفاده کن).
- قبل از insert، در همان تراکنش `isSlotTaken` (که overlap واقعی می‌زند) کافی است برای صحت منطقی؛ ولی **race concurrency** را ببین نکتهٔ زیر.
### ۴.۵ نشان «نمایش در نوبت‌دهی» روی سرویس + اجبار در حالت سرویس
پزشک ممکن است نخواهد همهٔ سرویس‌ها در نوبت‌دهی نمایش داده شوند. پس:
- **`ServiceItem`:** فیلد جدید `bookable` (bool, default `false`, ستون `bookable`) = «نمایش در نوبت‌دهی». getter/setter + در `toArray()`. **migration بساز و اجرا کن** (این تنها Entity change است).
- **`ClinicServiceController` (createItem L143, updateItem L188):** `bookable` را مثل `duration_minutes` بپذیر (`if (array_key_exists('bookable', $data)) $item->setBookable((bool)$data['bookable']);`).
- **`ServiceItemRepository`:** متد `countBookableByEntity($entityType, $entityId): int` (یا `findBookable...`) برای enforcement.
- **فیلتر نوبت‌دهی:** endpoint `appointment-service-slots` و `book()`/منشی فقط سرویس‌های `bookable=true` را بپذیرند؛ سرویس غیر-bookable → ۴۲۲ «این سرویس برای نوبت‌دهی فعال نیست».
- **اجبار حالت سرویس:** در `AppointmentSettingsController::createSchedule`/`updateSchedule`، وقتی `meta.booking_mode === service` و هیچ سرویسِ `bookable` برای آن پزشک/کلینیک وجود ندارد → ۴۲۲ «برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است». (سرویس‌ها به entity کلینیک/پزشک وصل‌اند از طریق `ServiceSection.entityType/entityId` — همان resolve موجود در ClinicServiceController.)
### ۵. پنل ادمین
- **`WeeklyScheduleTab` (DoctorDetailPage.tsx):** بالای ویرایشگر یک سوییچ «نوبت‌دهی اسلاتی / بر اساس سرویس» + فیلد «بافر بین نوبت‌ها (دقیقه)» اضافه کن که به `meta.booking_mode` و `meta.buffer_minutes` map شود (همراه schedule در همان POST/PATCH `weekly-schedule` ذخیره می‌شود؛ `meta` از قبل پشتیبانی می‌شود). در حالت سرویس، فیلد `duration_per_patient` هر session را مخفی/غیرفعال کن (چون بی‌اثر است) و فقط ساعت شروع/پایان window و آدرس بماند.
- **`ClinicServicesPage.tsx`:** برای هر ServiceItem دو کنترل: فیلد «مدت (دقیقه)» → `duration_minutes` و سوییچ «نمایش در نوبت‌دهی» → `bookable`. هر دو در POST/PATCH `/service-item` ارسال شوند.
- **`WeeklyScheduleTab`:** وقتی حالت «سرویس» انتخاب شد و پزشک هیچ سرویسِ bookable ندارد، پیام/لینک به صفحهٔ سرویس‌ها نشان بده و اجازهٔ ذخیره نده (backend هم ۴۲۲ می‌دهد).
- **`NewAppointmentDrawer.tsx`:** الان منشی دستی `duration` + ساعت شروع/پایان وارد می‌کند (L70-74, L194-208). در حالت سرویس پزشک:
- بعد از انتخاب یک/چند سرویس، `service_item_uuids[]` را به endpoint جدید بفرست و لیست «زمان‌های خالی پیشنهادی» را نمایش بده؛ منشی یکی را انتخاب می‌کند (به‌جای ورود دستی ساعت). `slot_start/slot_end` از انتخاب پر می‌شود.
- اگر هیچ زمانی نبود پیام «امروز جای خالی برای این سرویس نیست» + امکان رفتن به روز بعد.
- مسیر «نوبت آزاد» (`isReserve=true`, L28/L92) دست‌نخورده بماند — بدون زمان، فقط منشی.
- در حالت اسلاتی، همان رفتار فعلی (ورود دستی/اسلات) حفظ شود.
### ۶. مستندات و تست
- `docs/api/appointment.md`: endpoint `GET /appointment-service-slots` + پارامترهای جدید `book`.
- `docs/api/appointment-settings.md`: کلیدهای متای جدید `booking_mode`, `buffer_minutes`.
- تست‌های PHPUnit (موفق + خطا + مرزی): `getServiceStartTimes` (پر شدن، gap بین دو نوبت، عدم جای کافی)، محاسبهٔ `slot_end` سمت سرور، عدم تداخل، حفظ رفتار slot mode. تست Vitest برای سوییچ حالت و جریان جدید Drawer.
## نکات مهم
- **⚠️ race در حالت سرویس (مهم‌ترین edge):** unique constraint روی `active_slot_key = "doctorId:slotStart"` است — یعنی فقط دو نوبت با **شروع دقیقاً یکسان** را در سطح DB می‌گیرد. در حالت اسلاتی چون شروع‌ها روی گرید ثابت‌اند، هر تداخل ⇒ شروع یکسان ⇒ constraint می‌گیرد. اما در حالت سرویس، دو درخواست هم‌زمانِ «۱۵:۰۰ به مدت ۳۰د» و «۱۵:۲۰ به مدت ۳۰د» شروعِ متفاوت دارند، پس `activeSlotKey` متفاوت است و constraint نمی‌گیرد؛ هر دو `isSlotTaken` را خالی می‌بینند و هر دو insert می‌شوند → **تداخل**. راه‌حل: در `bookAtomically` **در حالت سرویس** قبل از `isSlotTaken`، یک قفلِ per-doctor بگیر تا رزروهای یک پزشک سریالایز شوند — یا pessimistic lock روی ردیف `Doctor` (`$em->lock($doctor, LockMode::PESSIMISTIC_WRITE)`) یا MySQL `GET_LOCK("appt:doctor:{id}")`/`RELEASE_LOCK`. حالت اسلاتی را تغییر نده (همان unique-key کافی است).
- **حفظ حالت اسلاتی:** هیچ رفتار موجودی نباید تغییر کند وقتی `booking_mode = slot`. مسیر جدید فقط شاخهٔ `service`.
- **نوبت آزاد = `isReserve` موجود، نه type جدید.** بازسازی نکن. در تقویم روز از قبل با پرچم متمایز است (`ReserveAppointmentsPage.tsx` + فیلتر `?reserve=1` در `my/appointments`). فقط مطمئن شو بازه‌ای اشغال نمی‌کند (`refreshActiveSlotKey` وقتی `isReserve` → key null است).
- **تغییر حالت نباید نوبت‌های قبلی را خراب کند:** نوبت‌های ثبت‌شده `slot_start/slot_end` مطلق (Unix) دارند و مستقل از حالت‌اند؛ سوییچ حالت فقط روی محاسبهٔ نوبت‌های جدید اثر دارد. این را در docstring/تست تثبیت کن.
- **ویرایش/لغو و آزادسازی زمان:** از قبل کار می‌کند — لغو → `transitionTo(cancelled_*)``refreshActiveSlotKey` → key null → `isSlotTaken` دیگر آن بازه را busy نمی‌بیند. `update`/`rescheduleTo` هم موجود است. فقط مطمئن شو مسیر service اینها را نمی‌شکند.
- **الگوهای پروژه:** کنترلرها از `BaseController` ارث می‌برند؛ پاسخ با `$this->success()/error()`؛ timestampها Unix `int`؛ رشته‌های UI فارسی؛ کد/کامیت انگلیسی. هر session فعال در schedule باید `location_id` داشته باشد (`validateSessionsHaveLocation`) — در حالت سرویس هم حفظ شود.
- **قاعدهٔ ۲ (اول بگرد بعد بساز):** `durationMinutes`، `serviceItem`، `isReserve`، `Holiday`، `DateOverride`، overlapِ `isSlotTaken` همه موجودند؛ فقط متای mode/buffer + یک متد محاسبه + یک endpoint + وصل‌کردن UI اضافه می‌شود.
@@ -0,0 +1,259 @@
# اصلاحات بخش مدیریت سرویس‌ها (بیمه، ورودی‌های عددی، صفحه جزئیات)
## پروژه
`clinicpro` — پنل ادمین React (`assets/admin/`) + یک اندپوینت جدید در backend (`src/ClinicService/`).
cross-repo نیست؛ سایت عمومی این بخش را مصرف نمی‌کند.
## زمینه
بخش «مدیریت سرویس‌ها» (`/admin/clinic-services`) الان یک صفحه واحد است: نمای بخش‌ها → نمای کارت‌های سرویس، و
همهٔ عملیات (ویرایش، تعرفهٔ سالانه، پوشش بیمه) در مودال باز می‌شود. سه اشکال دارد:
1. **دو جای تنظیم بیمه:** در مودال ویرایش سرویس یک سوییچ «این خدمت شامل بیمه می‌شود» + «قیمت تقریبی با بیمه» وجود دارد،
در حالی که تنظیمات واقعی بیمه (درصد پوشش، فرانشیز، سقف، به تفکیک هر بیمه‌گر) در `ServiceInsuranceModal` است.
کاربر دو منبع حقیقت می‌بیند.
2. **NaN با کیبورد فارسی:** فیلد «درصد پوشش» در `ServiceInsuranceModal` مقدار خام را `Number()` می‌کند؛ رقم فارسی → `NaN`.
3. **صفحهٔ جزئیات ندارد:** کلیک روی سرویس هیچ کاری نمی‌کند؛ همه‌چیز در مودال پراکنده است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/ClinicServicesPage.tsx` | صفحهٔ اصلی (۶۳۴ خط): نمای بخش‌ها + کارت سرویس‌ها + مودال ایجاد/ویرایش سرویس |
| `assets/admin/components/ServiceInsuranceModal.tsx` | مودال پوشش بیمه به تفکیک قرارداد بیمه‌گر (منشأ باگ NaN، خط ۱۳۴) |
| `assets/admin/components/ServiceTariffModal.tsx` | مودال تعرفه‌های سالانه |
| `assets/admin/components/ui/PriceInput.tsx` | ورودی مبلغ (تبدیل رقم را درست انجام می‌دهد ولی رفتار ویرایش ناقص است) |
| `assets/admin/lib/forms.ts` | `numericField()` / `latinDigitsField()` — wrapper صحیح برای RHF |
| `assets/admin/lib/utils.ts` | `toEnglishDigits`, `digitsOnly`, `rialToToman`, `tomanToRial`, `formatRial` |
| `assets/admin/components/ui/DigitInput.tsx` | ورودی فقط-رقم برای state معمولی (غیر RHF) |
| `assets/admin/App.tsx` | جدول route ها (خط ۲۴۸: `clinic-services`) |
| `src/ClinicService/Controller/ClinicServiceController.php` | اندپوینت‌های سرویس/بخش/تعرفه |
| `src/ClinicService/Entity/ServiceItem.php` | Entity + `toArray()` (خط ~۱۵۰) |
| `docs/api/clinicservice.md` (یا معادلش) | مستندات API که باید در همین session به‌روز شود |
## وضعیت فعلی
### ۱) سوییچ بیمه در مودال سرویس — `ClinicServicesPage.tsx:547-591`
```tsx
{/* بیمه */}
<div style={{ border: '1px solid var(--border)', ... }}>
<label ...>
<ShieldCheckIcon ... />
<div>
<div>این خدمت شامل بیمه میشود</div>
<div>نشانهی سریع برای فهرست سرویسها</div>
</div>
<span className="switch">
<input type="checkbox" checked={itemForm.watch('insurance_covered') ?? false} ... />
</span>
</label>
{itemForm.watch('insurance_covered') && (
<PriceInput value={itemForm.watch('insurance_price_rials') ?? 0} ... /> // قیمت تقریبی با بیمه
)}
</div>
```
### ۲) باگ NaN — `ServiceInsuranceModal.tsx:129-135`
```tsx
<input
type="text" inputMode="numeric" dir="ltr" className="input"
value={draft.coverage_percent ?? ''}
placeholder="ارث"
onChange={(e) => setDraft((d) => ({
...d,
coverage_percent: e.target.value === '' ? null : Number(e.target.value), // ← «۲۰» ⇒ NaN
}))}
/>
```
`placeholder="ارث"` هم غلط تایپی است (باید «ارث از قرارداد» باشد).
### ۳) `PriceInput` — `ui/PriceInput.tsx:31-42`
```tsx
const [display, setDisplay] = useState(...);
useEffect(() => { setDisplay(value !== '' && Number(value) > 0 ? formatDisplay(Number(value), latin) : ''); }, [value, latin]);
const handleChange = (e) => {
const raw = toEnglishDigits(e.target.value).replace(/[^0-9]/g, '');
const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10));
onChange(num);
setDisplay(num > 0 ? formatDisplay(num, latin) : '');
};
```
تبدیل رقم درست است، ولی: مقدار `0` همیشه به رشتهٔ خالی تبدیل می‌شود (کاربر نمی‌تواند صفر را ببیند/بنویسد)،
`Math.max(min, …)` هنگام تایپ رقمِ اول مقدار را به `min` می‌پراند، و واحد (تومان) در خود فیلد دیده نمی‌شود
در حالی که label می‌گوید «قیمت پایه (تومان)» ولی مقدار ذخیره‌شده ریال است (`tomanToRial` در mutation).
### ۴) نبود صفحهٔ جزئیات
`App.tsx:248` فقط یک route دارد:
```tsx
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
```
backend هم اندپوینت «یک سرویس با uuid» ندارد؛ فقط `GET /api/v1/service-items` (همه) و
`GET /api/v1/service-items/{sectionUuid}` (به‌تفکیک بخش).
## وظایف
> ترتیب اجرا مهم است: ۲ → ۳ → ۱ → ۴ → ۵. اول ابزار عددی درست شود، بعد UI روی آن بنا شود.
### ۱. حذف تنظیمات بیمه از مودال ایجاد/ویرایش سرویس
- بلاک «بیمه» (`ClinicServicesPage.tsx:547-591`) کامل حذف شود؛ `insurance_covered` و `insurance_price_rials`
از `itemSchema`، از `openEditItem`/`openCreateItem` و از payload های `createItem`/`editItem` حذف شوند.
- **backend را تغییر نده:** ستون‌های `insurance_covered` / `insurance_price_rials` روی `ServiceItem` باقی می‌مانند
(دادهٔ قدیمی + استفاده در جای دیگر). فقط دیگر از این فرم ارسال نمی‌شوند. اندپوینت‌ها `isset()`-based هستند
(`ClinicServiceController.php:183,227`) پس نبودِ فیلد در body مشکلی ایجاد نمی‌کند.
- به‌جای آن، در همان محلِ حذف‌شده یک اشارهٔ کوتاه بگذار که کاربر را به مدیریت بیمه هدایت کند — یک باکس اطلاع
با همان استایل باکس راهنمای موجود در `ServiceInsuranceModal.tsx:196-202` (`background: var(--primary-soft)`)
و یک دکمهٔ `btn sm` که همان `setInsuranceItem(item)` را باز می‌کند. در حالت «سرویس جدید» (هنوز uuid ندارد)
فقط متن راهنما نمایش داده شود، بدون دکمه.
- کارت سرویس (`ClinicServicesPage.tsx:390-392`) که `item.insurance_covered` را نشان می‌دهد باید به‌جای فیلد
حذف‌شده، وضعیت واقعی بیمه را از پوشش‌های ثبت‌شده نشان دهد یا اگر داده در دسترس نیست، آن ردیف حذف شود.
**ساده‌ترین راه سازگار: ردیف «سهم بیمار (بیمه)» از کارت حذف شود** و اطلاعات بیمه فقط در صفحهٔ جزئیات (وظیفهٔ ۴) بیاید.
### ۲. رفع ریشه‌ای NaN در ورودی‌های عددی
هیچ فیلد عددی نباید مستقیم `Number(e.target.value)` بزند. یک ابزار مشترک در `lib/utils.ts` اضافه کن:
```ts
/**
* رشتهٔ ورودی کاربر (با ارقام فارسی/عربی، کاما، فاصله) را به عدد امن تبدیل می‌کند.
* هرگز NaN برنمی‌گرداند؛ ورودی نامعتبر ⇒ null.
*/
export function parseUserNumber(raw: string | number | null | undefined): number | null {
if (raw == null || raw === '') return null;
const s = toEnglishDigits(String(raw)).replace(/[,\s٫٬]/g, '');
if (!/^-?\d*\.?\d+$/.test(s)) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/** همان، با clamp اختیاری — برای درصد (۰..۱۰۰) و مقادیر غیرمنفی. */
export function parseUserNumberClamped(raw: string | number | null | undefined, min: number, max: number): number | null {
const n = parseUserNumber(raw);
return n == null ? null : Math.min(max, Math.max(min, n));
}
```
سپس:
- **`ServiceInsuranceModal.tsx:129-135`** — فیلد «درصد پوشش» بازنویسی شود: مقدار نمایشی را در یک state رشته‌ای
نگه دار (تا کاربر بتواند فیلد را خالی کند یا در حال تایپ باشد)، و مقدارِ ذخیره‌شونده را با
`parseUserNumberClamped(v, 0, 100)` بساز. `placeholder="ارث"``placeholder="ارث از قرارداد"`.
فرانشیز و سقف قبلاً `PriceInput` هستند و بعد از وظیفهٔ ۳ خودبه‌خود درست می‌شوند.
- **`ClinicServicesPage.tsx:530`** — `duration_minutes` از `numericField()` استفاده می‌کند (درست است)؛ اما چون
`z.coerce.number()` روی رشتهٔ خالی `0` می‌دهد، schema به `z.coerce.number().min(0).optional().or(z.literal(''))`
یا یک `preprocess` تبدیل شود تا «خالی» به `undefined` نگاشت شود، نه صفر.
- **سراسر پنل** — این موارد بررسی و اصلاح شوند (نتیجهٔ grep روی `assets/admin`):
- `components/ImageCropModal.tsx:59``Number(e.target.value)` روی `<input type="range">`؛ چون range همیشه
مقدار لاتین می‌دهد بی‌خطر است؛ فقط تأیید کن و دست نزن.
- `components/paymentMethods/PosFormModal.tsx:88,92` — «شماره ترمینال» و «شماره حساب» ورودی آزادند و رقم فارسی
را همان‌طور ذخیره می‌کنند؛ باید به `DigitInput` تبدیل شوند.
- فایل‌های دارای `z.coerce.number()`: `ClinicServicesPage.tsx`, `SmsWalletPage.tsx`, `AdminSubscriptionPage.tsx`,
`RepresentationsPage.tsx`, `MyPatientsPage.tsx` — در هرکدام مطمئن شو input متناظر با `numericField(register(...))`
یا `PriceInput` رندر می‌شود، نه `register(...)` خام. هرجا خام بود اصلاح کن.
- **تست:** برای `parseUserNumber` تست واحد بنویس (`lib/utils.test.ts` یا فایل جدید) با موارد:
`'۲۵' → 25`، `'٢٥' → 25`، `'1,200' → 1200`، `'' → null`، `'abc' → null`، `'۱۲.۵' → 12.5`، `'-۳' → -3`.
و یک تست کامپوننتی برای فیلد درصد پوشش که با تایپ `'۲۵'` مقدار `25` می‌دهد و هرگز `NaN` نمایش نمی‌دهد.
### ۳. اصلاح `PriceInput` (نمایش و ورود مبلغ)
`ui/PriceInput.tsx` بازنویسی شود با این رفتار:
- ارقام فارسی/عربی و کاما و فاصله در ورودی پذیرفته و نرمال شوند (از `parseUserNumber` استفاده کن).
- **پیست** (paste) با متن مثل `«۸۵,۰۰۰ تومان»` باید به `85000` تبدیل شود، نه خطا.
- مقدار `0` نباید به رشتهٔ خالی تبدیل شود مگر کاربر خودش پاک کرده باشد؛ تفکیک «خالی» از «صفر» لازم است
(state داخلی رشته‌ای + `onChange(number)`).
- `Math.max(min, …)` نباید حین تایپ اعمال شود (clamp فقط `onBlur`).
- نمایش با جداکنندهٔ هزارگان `fa-IR` (رفتار فعلی) حفظ شود؛ `direction: ltr` و `text-align: left` بماند.
- یک `suffix` اختیاری اضافه شود (`suffix="تومان"`) تا واحد داخل فیلد دیده شود؛ در
`ClinicServicesPage.tsx:480` و همهٔ کاربردهای مبلغ استفاده شود.
- مقدار ذخیره‌شده همیشه عدد معتبر باشد (هرگز `NaN`/`undefined`).
- تست موجود اگر هست به‌روز شود؛ اگر نیست تست واحد بنویس (تایپ فارسی، پیست با واحد، صفر، خالی، clamp روی blur).
> مراقب باش: label «تومان» است ولی مقدار API ریال است (`tomanToRial` در `createItem`/`editItem`
> و `rialToToman` در `openEditItem`). این نگاشت را تغییر نده.
### ۴. صفحهٔ اختصاصی جزئیات سرویس
**Backend — یک اندپوینت جدید (تنها موردی که واقعاً لازم است):**
اندپوینت «یک سرویس با uuid» وجود ندارد؛ گرفتن کل لیست و فیلتر سمت کلاینت با refresh مستقیم روی صفحهٔ جزئیات
شکننده است. اضافه کن:
```php
#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
// همان الگوی مالکیت/tenant که در updateItem (خط ۲۰۵) استفاده شده
// خروجی: $this->success($item->toArray()) ← بدون nest اضافه
}
```
- `ServiceItem::toArray()` باید `section` (uuid + name)، `created_at`، `updated_at`، `staff_members`،
`bookable`، `duration_minutes` را داشته باشد؛ اگر ندارد اضافه کن.
- `docs/api/` مربوطه در همین session به‌روز شود (قانون استاندارد پروژه).
- migration لازم نیست (تغییر schema نداریم).
**Frontend:**
- فایل جدید `assets/admin/pages/ServiceDetailPage.tsx`.
- route جدید در `App.tsx` کنار route فعلی، با همان `RoleRoute roles={['doctor', 'clinic']} blockClinicScope`:
`<Route path="clinic-services/:uuid" element={...} />`.
- در `ClinicServicesPage.tsx` کلیک روی بدنهٔ کارت سرویس → `navigate('/admin/clinic-services/' + item.uuid)`.
منوی ⋮ و سوییچ‌ها باید `e.stopPropagation()` داشته باشند تا ناوبری اتفاق نیفتد (الگوی موجود در کارت بخش‌ها، خط ۲۶۰).
- محتوای صفحه:
| بخش | منبع داده |
|------|-----------|
| اطلاعات پایه (نام، بخش، وضعیت فعال، نمایش در نوبت‌دهی، زمان متوسط، پرسنل) | `GET /api/v1/service-item/{uuid}` |
| قیمت پایه | همان (نمایش با `formatRial`) |
| تعرفه‌های سالانه | `GET /api/v1/service-items/{uuid}/tariffs` (موجود) |
| بیمه‌های مرتبط و پوشش | `GET /api/v1/billing/tenant-insurances` + `.../{uuid}/service-coverage` — همان کوئری‌های `ServiceInsuranceModal` |
| تاریخ ایجاد / آخرین ویرایش | `created_at` / `updated_at` با `formatDateTime` (شمسی) |
- **کالاهای مرتبط:** الان هیچ رابطه‌ای بین `ServiceItem` و `src/Inventory/` وجود ندارد
(`InventoryPackage` polymorphic است با `entity_type`/`entity_id` ولی هیچ‌جا با `service_item` پر نمی‌شود).
بنابراین در این پرامپت **این سکشن ساخته نشود**. اگر لازم شد، به‌عنوان کار جدا مطرح کن و در گزارش پایانی
بنویس چه چیزی لازم است (رابطهٔ جدید + endpoint + UI).
- **لاگ تغییرات:** هیچ زیرساخت audit-log برای `ServiceItem` وجود ندارد. این سکشن هم ساخته نشود؛
به‌جایش فقط «تاریخ ایجاد» و «آخرین ویرایش» نمایش داده شود. در گزارش پایانی ذکر کن.
### ۵. UI/UX صفحهٔ جزئیات — بدون طراحی جدید
**اجباری:** هیچ تم/طرح/کامپوننت جدیدی ساخته نشود. صفحه دقیقاً با Layout و Design System فعلی پنل پیاده شود:
- از `components/ui/PageHeader` برای عنوان + breadcrumb («سرویس‌ها ‹ {نام بخش} ‹ {نام سرویس}») + دکمهٔ اقدام.
- از `card` / `card-pad` / `card-title-row` / `section-title` / `muted` / `badge` / `field` / `field-label`
و توکن‌های `styles.css` (`--surface`, `--border`, `--primary`, `--r`, `--gap`) استفاده شود. **هیچ hex هاردکد.**
- تب‌ها با همان الگوی `className="seg"` که در `pages/ClinicAppointmentSettingsPage.tsx:74-85` استفاده شده.
- انتخاب‌ها با `SearchableSelect` (نه `<select>` بومی)، تأییدها با `ConfirmDialog`، مبالغ با `PriceInput`،
تاریخ‌ها با `formatDate`/`formatDateTime` شمسی، اعداد با `formatNumber`.
- ویرایش سرویس، تعرفه و پوشش بیمه از همین صفحه در دسترس باشند با **همان مودال‌های موجود**
(`ServiceTariffModal`، `ServiceInsuranceModal`) — مودال جدید ساخته نشود.
- حالت‌های loading / empty / error با همان الگوی موجود در `ClinicServicesPage.tsx` (متن `در حال بارگذاری...`،
کارت خالی با آیکون Heroicon).
- RTL و رشته‌های فارسی؛ آیکون‌ها فقط Heroicons v2 outline.
## نکات مهم
- **ترتیب:** ابزار عددی (وظیفهٔ ۲ و ۳) اول؛ بعد UI. اگر اول UI بسازی، باگ NaN را در صفحهٔ جدید تکرار می‌کنی.
- **قانون API:** اندپوینت جدید فقط `GET /api/v1/service-item/{uuid}` است و دلیلش بالا نوشته شده. هیچ اندپوینت
دیگری ساخته نشود — تعرفه و پوشش بیمه اندپوینت آماده دارند.
- **پاسخ‌ها:** `$this->success($item->toArray())` — بدون `['data' => ...]` که باعث double-nest می‌شود.
توجه: اندپوینت‌های billing (`tenant-insurances`, `service-coverage`) **double-nested هستند** و در کد فعلی با
`(data as any)?.data?.data` خوانده می‌شوند؛ همان الگو را در صفحهٔ جدید تکرار کن.
- **مالکیت/tenant:** الگوی چک مالکیت را از `updateItem` (`ClinicServiceController.php:205`) کپی کن؛ کاربر نباید
بتواند سرویس tenant دیگر را ببیند. یک تست خطا برای این حالت لازم است (۴۰۳/۴۰۴).
- **تست (قانون پروژه، بدون استثنا):** هر وظیفه تست موفق + خطا + مرزی داشته باشد و تست‌ها اجرا و سبز شوند:
- PHPUnit برای اندپوینت جدید: سرویس موجود، uuid ناموجود، سرویس متعلق به tenant دیگر.
- Vitest برای `parseUserNumber`, `PriceInput`, فیلد درصد پوشش، و رندر `ServiceDetailPage` (mock شدهٔ کوئری‌ها).
- تست موجود `pages/ClinicServicesPage.test.tsx` بعد از حذف بلاک بیمه احتمالاً می‌شکند — به‌روز شود.
- **بررسی نهایی:** `ddev exec php bin/phpunit`، `yarn test`، `npx tsc --noEmit --project tsconfig.json`.
- در گزارش پایانی صریح بنویس چه چیزی ساخته نشد و چرا (کالاهای مرتبط، لاگ تغییرات).
@@ -0,0 +1,124 @@
# پرکردن خودکار پرونده (Session) هنگام قطعی شدن نوبت: تاریخ/ساعت مراجعه + آیتم‌های هزینه + جمع کل
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
## زمینه
وقتی نوبت به وضعیت **قطعی/تأیید (`confirmed`)** تغییر می‌کند، `AppointmentController` تابع `PatientService::autoCreateOnAppointmentConfirm()` را صدا می‌زند تا برای بیمار یک پرونده (`PatientSession`) بسازد. اما مسیر auto-create فقط `new PatientSession($record, $appointment)` می‌سازد و ذخیره می‌کند — **هیچ داده‌ی هزینه یا زمان مراجعه‌ای ثبت نمی‌شود**. در نتیجه پرونده‌ی ساخته‌شده از نوبت: `session_at = null` (به created_at برمی‌گردد)، `visit_price_rials = 0`، `services_total_rials = 0`، `final_price_rials = 0` و لیست سرویس‌ها خالی است — درحالی‌که خودِ `Appointment` هم `visit_price_rials` و هم `serviceItems` را دارد. این یک باگ است.
مسیر دیگر (ویزارد دستیِ ثبت مراجعه `createSession()`) همه‌ی این‌ها را درست پر می‌کند و **الگوی مرجع** است.
## هدف
مسیر auto-create پرونده از نوبت باید مثل ویزارد، این‌ها را خودکار و **بدون ورود دستی قیمت** پر کند:
1. **تاریخ/ساعت مراجعه** (`session_at`) از زمان واقعی نوبت (`Appointment::getSlotStart()`).
2. **آیتم‌های هزینه‌ی سرویس** به‌صورت تفکیک‌شده: برای هر سرویسِ نوبت یک `SessionService` با قیمت snapshot از خود سرویس (`ServiceItem::getPriceRials()`).
3. **هزینه ویزیت** (`visit_price_rials`) از نوبت (و اگر نوبت مقدار نداشت، از تنظیم «قیمت ویزیت آزاد»).
4. **جمع کل**: `services_total_rials` = مجموع خطوط سرویس؛ `final_price_rials` = `services_total_rials + visit_price_rials`.
5. نمایش در UI پرونده: تاریخ/ساعت مراجعه به‌عنوان اولین اطلاعات + جدول تفکیک‌شده‌ی هزینه‌ها (ویزیت + هر سرویس) + جمع کل.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Service/PatientService.php` | `autoCreateOnAppointmentConfirm()` (L94-135) و `autoCreateForEntity()` (L119-135) — **محل باگ**؛ `createSession()` (L137-237) الگوی مرجع |
| `src/Patient/Entity/PatientSession.php` | Setterها: `setSessionAt()` (L204)، `setVisitPriceRials()` (L190)، `setServicesTotalRials()` (L193)، `setFinalPriceRials()` (L194)، `addService()` (L121) |
| `src/Patient/Entity/SessionService.php` | خط هزینه؛ constructor قیمت را از `ServiceItem::getPriceRials()` snapshot می‌کند (L44-53)؛ `getLineTotalRials()` (L62) |
| `src/ClinicService/Entity/ServiceItem.php` | `getPriceRials()` (L87) — منبع قیمت سرویس |
| `src/Appointment/Entity/Appointment.php` | `getSlotStart()`، `getVisitPriceRials()` (L251)، `getServiceItems()` (L235، ManyToMany `appointment_service_items`) |
| `src/Insurance/Service/VisitPriceRequirementResolver.php` | resolve تنظیم ویزیت (L22-37) — منبع fallback قیمت ویزیت آزاد |
| `src/Insurance/Entity/EntityInsurancePricing.php` | ردیف free-visit (`insurance_id = null`): `getPatientShareRials()` (L54) = قیمت ویزیت آزاد پیش‌فرض |
| `assets/admin/components/session/DetailsStep.tsx` | نمایش خلاصه پرونده — الان `session_at` و جدول تفکیکی ندارد |
| `docs/api/patient.md`، `docs/api/appointment.md` | به‌روزرسانی مستندات (Standing Rule) |
## وضعیت فعلی (باگ)
`src/Patient/Service/PatientService.php` — auto-create فقط می‌سازد و ذخیره می‌کند، بدون هیچ داده‌ای:
```php
private function autoCreateForEntity(string $entityType, int $entityId, Appointment $appointment, int $createdById): void
{
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return;
}
$patient = $appointment->getUser();
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
$this->recordRepo->save($record);
}
$session = new PatientSession($record, $appointment); // ← هیچ‌چیز دیگر ست نمی‌شود
$this->sessionRepo->save($session);
}
```
الگوی مرجع در `createSession()` (چطور باید پر شود) — خطوط کلیدی:
```php
// visit price
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
// session_at (زمان مراجعه)
if (!empty($data['session_at'])) { $session->setSessionAt((int) $data['session_at']); }
// خطوط سرویس + مجموع
foreach ($data['services'] as $s) {
$item = $this->serviceItemRepo->findByUuid($s['uuid']);
$session->addService(new SessionService($session, $item, $staff, (int)($s['qty'] ?? 1)));
}
$session->setServicesTotalRials($servicesTotal);
$session->setFinalPriceRials($servicesTotal + $visitPrice + ...);
```
## وظایف
### ۱. پرکردن پرونده‌ی auto-create از روی نوبت (backend)
در `autoCreateForEntity()` بعد از ساخت `$session` و **قبل از** `save()`، از `$appointment` پر کن:
```php
$session = new PatientSession($record, $appointment);
// ۱) زمان مراجعه = زمان واقعی نوبت
$session->setSessionAt($appointment->getSlotStart());
// ۲) هزینه ویزیت: از نوبت، fallback به «قیمت ویزیت آزاد» تنظیمات
$visitPrice = $appointment->getVisitPriceRials()
?? $this->resolveFreeVisitPrice($entityType, $entityId);
$session->setVisitPriceRials($visitPrice ?? 0);
// ۳) خطوط سرویس تفکیک‌شده (قیمت snapshot از خود سرویس)
$servicesTotal = 0;
foreach ($appointment->getServiceItems() as $item) {
$line = new SessionService($session, $item, null, 1); // قیمت از ServiceItem::getPriceRials()
$session->addService($line);
$servicesTotal += $line->getLineTotalRials();
}
// ۴) جمع کل
$session->setServicesTotalRials($servicesTotal);
$session->setFinalPriceRials($servicesTotal + ($visitPrice ?? 0));
$this->sessionRepo->save($session);
```
- `resolveFreeVisitPrice($entityType, $entityId)`: یک helper که ردیف free-visit (`insurance_id = null`) را برای این doctor/clinic از `pricingRepo->findOneForInsurance(TYPE_DOCTOR|TYPE_CLINIC, $entityId, null)` می‌خواند و `getPatientShareRials()` را برمی‌گرداند (یا null). از منطق موجود `VisitPriceRequirementResolver` الگو بگیر.
- **توجه به مسیر دوگانه**: `autoCreateOnAppointmentConfirm` این متد را هم برای `doctor` و هم (در صورت وجود) `clinic` صدا می‌زند، پس ممکن است **دو پرونده** ساخته شود (یکی برای پزشک، یکی برای کلینیک). این رفتار فعلی است؛ آن را تغییر نده، فقط هر دو را درست پر کن.
- **قیمت دستی وارد نشود** — همیشه از `ServiceItem::getPriceRials()` و تنظیم ویزیت خوانده شود.
### ۲. نمایش تاریخ/ساعت مراجعه + جدول هزینه تفکیکی در UI پرونده (frontend)
در `assets/admin/components/session/DetailsStep.tsx`:
- **اولین اطلاعات**: «تاریخ و ساعت مراجعه» از `session_at` (شمسی با `formatDateTime`/`formatDate` — Unix timestamp صحیح). اگر `session_at` خالی بود، از `created_at`.
- **جدول هزینه‌ی تفکیک‌شده**: یک ردیف «ویزیت: {formatRial(visit_price_rials)}» وقتی `visit_price_rials > 0`، سپس هر خط سرویس از آرایه‌ی `services` (`service_name` + `line_total_rials`)، و در انتها «جمع کل: {final_price_rials}». از `toArray()` پرونده که `services`، `visit_price_rials`، `services_total_rials`، `final_price_rials`، `session_at` را می‌دهد استفاده کن.
## نکات مهم
- تاریخ‌ها Unix timestamp صحیح (`setSessionAt(int)`), نمایش شمسی با `formatDate`/`formatDateTime`.
- قیمت‌ها ریالی ذخیره؛ نمایش با `formatRial`. تبدیل تومان↔ریال با `tomanToRial`/`rialToToman`.
- تغییری در `Appointment` یا schema لازم نیست (فقط خواندن)؛ **بدون migration** مگر بخواهی ستون audit اضافه کنی (لازم نیست).
- تست: یک نوبت را از `pending` به `confirmed` ببر (`PATCH /api/v1/appointment/{uuid}/status`) و بررسی کن پرونده‌ی ساخته‌شده `session_at`، `visit_price_rials`، خطوط سرویس و `final_price_rials` درست دارد (endpoint `GET /api/v1/patient/{recordUuid}/sessions`).
- بعد از تغییر، `docs/api/patient.md` را اگر خروجی session تغییر معنایی کرد به‌روز کن.
- این پرامپت **پیش‌نیاز منطقی** پرامپت `discount-rules-engine.md` است (تخفیف روی `final_price_rials` اعمال می‌شود که اینجا درست می‌شود).
@@ -0,0 +1,107 @@
# ویرایش سرویس‌های مراجعه + ویرایش/حذف پرداخت + Audit Log مالی جامع
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev. قرارداد پول: ذخیره/API ریال، UI تومان (`tomanToRial`/`rialToToman`). تاریخ Unix.
## زمینه
مراجعه (`PatientSession`) فقط **ایجاد** می‌شود؛ پس از ثبت، امکان ویرایش سرویس‌ها/کالاها/قیمت ویزیت/بیمه وجود ندارد و پرداخت‌های ثبت‌شده نه ویرایش می‌شوند نه حذف. کاربر می‌خواهد بتواند همه‌ی این‌ها را ویرایش کند، **اما** هر تغییر مالی/خدماتی باید در یک **Audit Log** ثبت و قابل‌مشاهده باشد (چه کسی، چه چیزی، کِی، مقدار قبل/بعد، نوع عملیات).
## مشکل / هدف
1. ویرایش کامل یک مراجعه پس از ثبت: سرویس‌ها (افزودن/حذف/تعداد)، کالاهای مصرفی، قیمت ویزیت، بیمه/درصدها، یادداشت، تاریخ مراجعه — با محاسبه‌ی مجدد `services_total_rials`/`final_price_rials`.
2. ویرایش و حذف پرداخت‌های ثبت‌شده، با نگه‌داشتن سازگاری `paid_total`/`remaining`/`payment_method`/`paid_at`.
3. **Audit Log جامع** برای همه‌ی تغییرات مالی/خدماتی: مقدار قبل، مقدار بعد، کاربر، تاریخ/زمان، نوع عملیات (create/update/delete). نمایش تاریخچه در UI.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Patient/Service/PatientService.php` | `createSession()` (create-only، L165-265)، `addSessionPayment()` (L338-386)، `calculateFinalPrice()` (L63-93) |
| `src/Patient/Controller/PatientController.php` | `updateSession` PATCH (L1037-1092، فیلدهای محدود)، `addSessionPayment` POST (L1100-1122)؛ **بدون** endpoint ویرایش/حذف payment و ویرایش services |
| `src/Patient/Entity/SessionService.php` | خط سرویس؛ **immutable** (بدون setter)؛ constructor snapshot قیمت |
| `src/Patient/Entity/SessionConsumable.php` | خط کالا؛ immutable |
| `src/Patient/Entity/SessionPayment.php` | پرداخت؛ فقط setter برای createdBy/Name؛ `METHODS` (L18)؛ toArray (L74-84) |
| `src/Patient/Repository/SessionServiceRepository.php` / `SessionConsumableRepository.php` / `SessionPaymentRepository.php` | فقط `save()`**بدون `remove()`** |
| `src/Patient/Entity/PatientSession.php` | `getPaidTotalRials()` (L164-170)، `getRemainingRials()` (L173-176)؛ collections با `cascade:['remove']` |
| `src/Appointment/Entity/AppointmentEvent.php` + Repository + endpoint | **الگوی مرجع audit** (id, uuid, FK, type, title, actor_user_id, actor_name, reason, created_at؛ `findByAppointmentUuid`؛ `GET /appointment/{uuid}/events`) |
| `src/Settlement/Service/WalletService.php` | `resolveActorName(?User)` (L34-43) — نام نمایشی کاربر |
| `src/Shared/Constant/ErrorCodes.php` | `ERR_SESSION_NOT_FOUND`, `ERR_SESSION_PAYMENT_INVALID`, `ERR_SESSION_PAYMENT_EXCEEDS` (L64-66) |
| `assets/admin/components/SessionServiceCard.tsx` | dropdown «...» (L101-114) — محل افزودن «ویرایش» + «تاریخچه تغییرات» |
| `assets/admin/pages/PatientDetailPage.tsx` | تب services (L225-248)؛ کارت‌ها؛ `sessionsQ` |
| `assets/admin/components/session/PaymentStep.tsx` | ردیف پرداخت‌ها (L250-270) — محل ویرایش/حذف + audit |
| `assets/admin/components/session/CreateStep.tsx` | فرم ثبت (submit body L233-245) — **الگوی فرم ویرایش** |
| `assets/admin/pages/NewSessionPage.tsx` | ویزارد ثبت — قابل بازاستفاده برای ویرایش |
| `docs/api/patient.md` | مستندات |
## وضعیت فعلی
`updateSession` فیلدهای محدود می‌پذیرد (notes/archived/discount/paid_at/payment_method) — نه services/consumables/visit_price:
```php
// PatientController::updateSession (خلاصه)
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
if (array_key_exists('archived', $data)) { $session->setArchived((bool)$data['archived']); }
// discount_rule_uuid / discount_type / paid_at / payment_method ...
// ← هیچ services / consumables / visit_price_rials
```
`createSession` تنها جایی است که `SessionService`/`SessionConsumable` ساخته و مجموع محاسبه می‌شود (create-only). پرداخت فقط `POST` دارد؛ `grep payments/{` صفر → نه PATCH نه DELETE.
## وظایف
### ۱. Entity + migration — `SessionAuditLog` (الگوی AppointmentEvent)
`src/Patient/Entity/SessionAuditLog.php` (جدید):
- ستون‌ها: `id`, `uuid`, `session` (ManyToOne `PatientSession`, `onDelete: CASCADE`), `field` string (مثل `visit_price_rials`, `services`, `consumables`, `payment`, `discount`), `operation` string (`create`|`update`|`delete`), `old_value` text nullable, `new_value` text nullable, `actor_user_id` int nullable, `actor_name` string nullable, `note` string nullable, `created_at` int.
- constructor `(PatientSession $session, string $field, string $operation)`؛ fluent `setActor(?int,?string)`, `setValues(?string $old, ?string $new)`, `setNote(?string)`.
- `toArray()`: `field`, `operation`, `old_value`, `new_value`, `actor_name`, `note`, `created_at`.
- Repository `SessionAuditLogRepository` با `save()` و `findBySessionUuid(string $uuid): array` (array hydration، مرتب بر `created_at DESC`).
- migration.
> مقادیر قبل/بعد را به‌صورت رشته‌ی خوانا ذخیره کن (مثلاً برای پول ریال عددی؛ برای لیست سرویس‌ها یک خلاصه مثل «تزریق ژل ×۱، لیزر ×۲» یا JSON فشرده). ثبات مهم‌تر از فرمت است.
### ۲. Service — ثبت audit + منطق ویرایش/حذف
در `PatientService`:
- helper `logSessionChange(PatientSession $s, string $field, string $op, ?string $old, ?string $new, ?User $actor, ?string $note = null)` که `SessionAuditLog` می‌سازد و ذخیره می‌کند (`actor_name` با `walletService->resolveActorName`).
- **`updateSessionServices(PatientSession $s, array $data, User $actor)`**: سرویس‌ها/کالاها/قیمت ویزیت/بیمه را جایگزین کند:
- snapshot مقادیر قبل (visit_price, services خلاصه, consumables خلاصه, services_total, final_price).
- سرویس‌های قبلی را `remove` (به `SessionServiceRepository` متد `remove()` اضافه کن)، سپس از `$data['services']` دوباره بساز (مثل createSession).
- همین برای consumables (`SessionConsumableRepository::remove()`).
- `setVisitPriceRials`, بیمه/درصدها، `session_at`, notes را ست کن.
- با `calculateFinalPrice(...)` + مجموع کالاها، `services_total_rials`/`final_price_rials` را بازمحاسبه کن (دقیقاً مثل createSession L213-240).
- برای هر فیلدِ تغییرکرده یک `logSessionChange(... 'update' ...)` با old/new بزن.
- **`updatePayment(SessionPayment $p, array $data, User $actor)`**: `method`/`amount_rials`/`paid_at` را ویرایش کند (setterها را به `SessionPayment` اضافه کن). سقف: مجموع پرداخت‌ها نباید از `final - discount` بیشتر شود. audit با field=`payment`, op=`update`, old/new = مبلغ قبل/بعد.
- **`deletePayment(SessionPayment $p, User $actor)`**: پرداخت را `remove` (به `SessionPaymentRepository::remove()`). audit op=`delete`, old=مبلغ.
- **بازمحاسبه‌ی فیلدهای کش‌شده**: بعد از ویرایش/حذف پرداخت، اگر `getRemainingRials() > 0` بود `payment_method='pending'` و `paid_at=null`؛ اگر صفر شد `payment_method`/`paid_at` را ست کن. (چون `getPaidTotalRials()` از collection زنده جمع می‌زند ولی `payment_method`/`paid_at` کش‌اند.)
- **wallet edge**: اگر پرداخت `wallet` بود، ویرایش/حذف باید تراکنش کیف پول را جبران کند (بازگشت/کسر تفاوت). اگر جبران خارج از scope است، **حذف/ویرایش پرداخت wallet را مسدود کن** (خطای ۴۲۲ با پیام فارسی) تا مغایرت مالی ایجاد نشود — این ساده‌تر و امن‌تر است؛ در پرامپت این گزینه را انتخاب کن مگر بازگشت کیف پول ساده باشد.
### ۳. Controller — endpointهای جدید
در `PatientController` (extends BaseController، owner-scope مثل `updateSession`):
- **ویرایش services**: `updateSession` را گسترش بده تا اگر `services`/`consumables`/`visit_price_rials`/insurance آمد، `patientService->updateSessionServices()` صدا زده شود؛ یا یک route جداگانه `PATCH /api/v1/session/{uuid}/services`. (گسترش `updateSession` تمیزتر است.)
- **ویرایش پرداخت**: `PATCH /api/v1/session/{uuid}/payments/{paymentUuid}``updatePayment`.
- **حذف پرداخت**: `DELETE /api/v1/session/{uuid}/payments/{paymentUuid}``deletePayment`.
- **تاریخچه**: `GET /api/v1/session/{uuid}/audit-log``$this->success($auditRepo->findBySessionUuid($uuid))`.
- گارد: پرداخت باید متعلق به همان session باشد؛ session متعلق به owner (`ownsRecord`).
### ۴. Frontend — فرم ویرایش + کنترل پرداخت + نمایش تاریخچه
1. **منوی کارت** (`SessionServiceCard.tsx` dropdown L101-114): افزودن آیتم‌های «ویرایش» و «تاریخچه تغییرات» (props جدید `onEdit(session)`, `onViewAudit(session)`).
2. **فرم ویرایش**: از `CreateStep` بازاستفاده کن (یا کامپوننت مشترک) در حالت edit؛ با داده‌ی فعلی session پر شود و به `PATCH /session/{uuid}` (با body مثل CreateStep L233-245) بفرستد. مسیر `session/{uuid}/edit` یا مودال.
3. **ردیف پرداخت** (`PaymentStep.tsx` L250-270): برای هر پرداخت آیکون ویرایش (مودال کوچک: مبلغ تومان + روش + تاریخ → `PATCH .../payments/{uuid}`) و حذف (`ConfirmDialog``DELETE`). بعد از هر عملیات `invalidate()`.
4. **تاریخچه تغییرات**: یک مودال/بخش که `GET /session/{uuid}/audit-log` را می‌خواند و هر رکورد را نشان می‌دهد: نوع عملیات (ایجاد/ویرایش/حذف — رنگ‌بندی)، فیلد، مقدار قبل → بعد، کاربر، تاریخ/زمان شمسی (`formatDateTime`). مرتب نزولی.
## نکات مهم
- **همه‌ی مسیرهای تغییر باید audit بزنند**: ویرایش سرویس، کالا، قیمت ویزیت، مبلغ سرویس‌ها، ویرایش/حذف پرداخت، تخفیف. حتی `applyDiscount`/`applyDiscountRule` موجود را هم به `logSessionChange` مجهز کن (field=`discount`).
- تاریخ‌ها Unix؛ پول ریال (ذخیره) / تومان (UI). لیست‌های admin array hydration.
- Entity جدید + ستون‌ها → migration (diff سپس migrate؛ خطوط drift نامرتبط را از migration پاک کن).
- سازگاری مالی: بعد از هر ویرایش/حذف پرداخت، `paid_total`/`remaining`/`is_paid`/`paid_at` باید درست بمانند (بازمحاسبه‌ی فیلدهای کش‌شده).
- **wallet**: تصمیم امن = مسدودکردن ویرایش/حذف پرداخت `wallet` مگر جبران کیف پول پیاده شود.
- مستندات: `docs/api/patient.md` — endpointهای جدید (services edit، payment PATCH/DELETE، audit-log GET) با method/path/permission/body/response/errors.
- این فیچر بزرگ و حساس مالی است — هر وظیفه (۱..۴) جدا پیاده، تست (شامل مسیر خطا/مرزی) و کامیت شود. Backend اول. بعد از کد `graphify update .` (بعد کامیت).
@@ -0,0 +1,167 @@
# رفع باگ واحد پول پرداخت + اطلاعات پرداخت‌ها + منوی سرویس + آرشیو مراجعات
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو.
> تست: پنل ادمین با `09390039833 / 09390039833`. اجرا داخل ddev.
> قرارداد واحد پول پروژه (`utils.ts`): **واحد ذخیره/API = ریال**، **واحد نمایش/ورودی UI = تومان**. تبدیل با `tomanToRial` (×۱۰) و `rialToToman` (÷۱۰). نمایش با `formatRial(rial)` که خودش ÷۱۰ می‌کند و « تومان» می‌چسباند.
## زمینه
صفحه پرداخت مراجعه (`/admin/patients/{uuid}/session/{sessionUuid}/pay`) و صفحه خدمات بیمار (`/admin/patients/{uuid}?tab=services`) چند مشکل/کمبود دارند: باگ واحد پول در ثبت پرداخت (تومان به‌عنوان ریال ذخیره می‌شود → یک صفر کم)، نمایش ناقص پرداخت‌های ثبت‌شده، نبود منوی عملیات روی هر مراجعه، و نبودِ قابلیت آرشیو مراجعات اشتباه.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/components/session/PaymentStep.tsx` | فرم پرداخت — **باگ واحد پول** (L83 discount fixed، L94 payment) + ردیف پرداخت‌ها (L252-260) |
| `assets/admin/components/session/DetailsStep.tsx` | خلاصه مراجعه — ردیف پرداخت‌ها (L103-111) |
| `assets/admin/lib/utils.ts` | `tomanToRial`/`rialToToman` (L4-11)، `formatRial` (L8)، `formatDateTime` (L38-46) |
| `src/Patient/Entity/SessionPayment.php` | `toArray()` (L74-84) از قبل `paid_at` + `created_by_name` دارد — backend درست است |
| `assets/admin/components/SessionServiceCard.tsx` | کارت مراجعه — آیکون «...» تزئینی (L81)، دکمه footer «مشاهده فاکتور»/«تکمیل پرداخت» (L99-119)، type `SessionPaymentEntry` (L5-11) |
| `assets/admin/pages/PatientDetailPage.tsx` | تب services (L213-235)، fetch لیست (L140-150)، `viewInvoice` (L85-93)، `InvoiceSummaryModal` (L254) |
| `src/Patient/Entity/PatientSession.php` | Entity مراجعه — **ستون `archived` ندارد** (باید افزوده شود)؛ `toArray()` (L221-265) |
| `src/Patient/Repository/PatientSessionRepository.php` | `findByRecord` (L22-32) + `countByRecord` (L34-42) — بدون فیلتر archived |
| `src/Patient/Controller/PatientController.php` | `sessions` GET (L912-934)، `updateSession` PATCH (L1036)، `sessionWithBilling` (L973-990) |
| `docs/api/patient.md` | به‌روزرسانی مستندات (Standing Rule) |
---
## تسک ۱ — رفع باگ واحد پول در ثبت پرداخت و تخفیف ثابت
### وضعیت فعلی (باگ — frontend خالص)
`PaymentStep.tsx` مبلغ تومانِ ورودی را **بدون** `tomanToRial` تحت کلید `amount_rials` می‌فرستد؛ backend همه‌جا ریال فرض می‌کند (`getRemainingRials`, wallet withdraw) و درست است. پس تومان خام به‌عنوان ریال ذخیره می‌شود → یک صفر کم (÷۱۰ در نمایش).
```tsx
// L91-94 — payment
const submitPayment = (method: string) => {
if (amount <= 0) return;
payMut.mutate({ method, amount_rials: amount, paid_at: isoToUnix(paymentDate) }); // ← amount تومان است
};
// L80-83 — discount (فقط حالت fixed مبلغ است؛ percent درصد است)
const applyDiscount = () => {
if (!discountType || discountValue <= 0) return;
discountMut.mutate({ discount_type: discountType, discount_value: discountValue }); // ← fixed تومان است
};
```
`utils.ts`: `tomanToRial = (t) => Math.round(t * 10)`.
### وظایف
1. در `submitPayment`، مبلغ را قبل از ارسال به ریال تبدیل کن:
```tsx
payMut.mutate({ method, amount_rials: tomanToRial(amount), paid_at: isoToUnix(paymentDate) });
```
2. در `applyDiscount`، فقط برای `discount_type === 'fixed'` مقدار را به ریال تبدیل کن (percent درصد است، تبدیل نشود):
```tsx
const value = discountType === 'fixed' ? tomanToRial(discountValue) : discountValue;
discountMut.mutate({ discount_type: discountType, discount_value: value });
```
3. `tomanToRial` را از `../../lib/utils` import کن.
4. **backend را تغییر نده** — تبدیل در backend باعث double-convert مسیر percent و سایر callerهای درست می‌شود (تخفیف دستی از قبل در `PatientService::applyDiscount` روی ریال کار می‌کند و از UI صفحه دیگر هم درست می‌آید؛ فقط این صفحه باگ دارد).
### نکات
- **edge case**: تخفیف بر اساس قانون (`discount_rule_uuid`) مبلغ را از backend می‌گیرد (نه UI) — دست نزن.
- بعد از fix، یک پرداخت ۵۰۰٬۰۰۰ تومانی ثبت کن و تأیید کن در «پرداخت‌شده‌ها» و مانده، مبلغ درست (۵۰۰٬۰۰۰ تومان) نمایش داده می‌شود، نه ۵۰٬۰۰۰.
---
## تسک ۲ — نمایش کامل پرداخت‌های ثبت‌شده (تاریخ/ساعت + ثبت‌کننده)
### وضعیت فعلی
`SessionPayment::toArray()` از قبل `paid_at` (Unix) و `created_by_name` را می‌دهد و type frontend (`SessionPaymentEntry`) هم دارد. اما ردیف نمایش فقط روش + مبلغ را نشان می‌دهد:
```tsx
// PaymentStep.tsx L252-260 و DetailsStep.tsx L103-111 (مشابه)
{payments.map((p) => (
<div key={p.uuid} ...>
<span>...<span>{METHOD_LABELS[p.method] ?? p.method}</span></span>
<span>مبلغ : {formatRial(p.amount_rials)}</span>
</div>
))}
```
### وظایف
در **هر دو** `PaymentStep.tsx` و `DetailsStep.tsx`، ردیف پرداخت را کامل کن تا علاوه بر روش و مبلغ، این‌ها را هم نشان دهد:
- **تاریخ و ساعت پرداخت**: `formatDateTime(p.paid_at)` (شمسی + HH:MM؛ از `utils.ts` import کن). اگر `paid_at` خالی بود، از `p.created_at`.
- **ثبت‌کننده**: `p.created_by_name` (اگر موجود) — مثلاً «ثبت: {created_by_name}».
چیدمان تمیز بماند (مثلاً خط دوم کوچک‌تر و کم‌رنگ زیر روش/مبلغ).
### نکات
- `formatDateTime` ورودی Unix ثانیه می‌گیرد؛ `paid_at`/`created_at` هر دو Unix صحیح‌اند.
- backend تغییر نمی‌کند — داده از قبل موجود است.
---
## تسک ۳ — منوی «...» روی هر مراجعه: «مشاهده فاکتور» + «آرشیو»
### وضعیت فعلی
در `SessionServiceCard.tsx` آیکون `FilesServiceMore` (L81) **تزئینی** است — بدون onClick/منو. «مشاهده فاکتور» فقط به‌صورت دکمه footer وقتی `paid` است وجود دارد؛ «آرشیو» اصلاً نیست.
### وظایف
1. آیکون «...» را به **dropdown trigger** تبدیل کن (منوی کوچک با کلیک، بسته‌شدن با کلیک بیرون). آیتم‌ها:
- **مشاهده فاکتور** → همان `onViewInvoice(session)` که کارت از prop می‌گیرد (منطق `viewInvoice` در `PatientDetailPage` L85-93؛ اگر `invoice_uuid` نبود، ابتدا صادر و بعد باز می‌شود).
- **آرشیو** (یا «خروج از آرشیو» اگر `session.archived`) → یک prop جدید `onArchive(session, archived: boolean)` که کارت صدا می‌زند؛ در `PatientDetailPage` به mutation آرشیو (تسک ۴) وصل شود.
2. props جدید کارت: `onArchive?: (session: SessionCardData, archived: boolean) => void`. type `SessionCardData` را با `archived?: boolean` گسترش بده.
3. از الگوی dropdown موجود پروژه استفاده کن (اگر کامپوننت منوی مشترک هست از آن؛ وگرنه یک منوی ساده با `Portal`/absolute + بستن با کلیک بیرون، هم‌راستا با بقیه).
### نکات
- «مشاهده فاکتور» در منو نباید دکمه footer را حذف کند مگر بخواهی یکدست کنی — کافی است در منو هم باشد.
- برای مراجعه‌ی بدون فاکتور، «مشاهده فاکتور» همان مسیر صدور idempotent را طی می‌کند (رفتار فعلی `viewInvoice`).
---
## تسک ۴ — آرشیو مراجعات (backend + UI فیلتر)
### وضعیت فعلی
`PatientSession` هیچ ستون `archived`/`status`/`deleted_at` ندارد. `findByRecord`/`countByRecord` بدون فیلتر همه را برمی‌گردانند. `sessions` GET پارامتر فیلتر ندارد.
### وظایف (Backend اول)
1. **Entity + migration**: به `PatientSession` ستون `archived` (bool، default false) و `archived_at` (int nullable، Unix) اضافه کن؛ getter/setter (`isArchived`, `setArchived(bool)` که `archived_at = archived ? time() : null` را ست کند). در `toArray()` کلید `archived` را expose کن. `make:migration` + `migrate`.
2. **Repository**: `findByRecord`/`countByRecord` یک پارامتر فیلتر بگیرند: `all` | `active` | `archived` (پیش‌فرض `active`). `active``s.archived = false`، `archived``s.archived = true`، `all` → بدون شرط.
3. **`sessions` GET**: پارامتر query `filter` (پیش‌فرض `active`) را بخوان و به repo بده. پس **پیش‌فرض آرشیوها نمایش داده نشوند**.
4. **endpoint آرشیو**: در `updateSession` (`PATCH /api/v1/session/{uuid}`) پذیرش فیلد `archived` (bool) → `session->setArchived((bool)$data['archived'])`. (یا اگر تمیزتر است یک route اختصاصی `PATCH /api/v1/session/{uuid}/archive`.) owner-scope مثل بقیه‌ی `updateSession`.
### وظایف (Frontend)
5. **دکمه/فیلتر نمایش آرشیو**: در تب services (`PatientDetailPage.tsx` L213-235) دکمه فیلتر تزئینی موجود (`TurnsFilter`) را فعال کن یا یک segmented/دکمه «نمایش آرشیو» اضافه کن؛ یک state `filter: 'active' | 'all' | 'archived'` (پیش‌فرض `active`). query key و URL شامل filter شود:
```tsx
const [filter, setFilter] = useState<'active'|'all'|'archived'>('active');
const sessionsQ = useQuery({
queryKey: ['patient-sessions', uuid, filter],
queryFn: () => api.get(`/api/v1/patient/${uuid}/sessions?filter=${filter}`),
enabled: !!uuid,
});
```
6. **اکشن آرشیو**: `onArchive` (تسک ۳) به یک mutation وصل شود که `PATCH /api/v1/session/{uuid}` با `{ archived: true/false }` می‌زند و `['patient-sessions', uuid]` را invalidate می‌کند. toast مناسب («مراجعه آرشیو شد» / «از آرشیو خارج شد»).
7. کارت آرشیوشده در حالت نمایش آرشیو یک نشانه‌ی بصری داشته باشد (مثلاً badge «آرشیو» یا کم‌رنگ).
### نکات
- تاریخ‌ها Unix صحیح؛ لیست‌های admin طبق قانون. تغییر Entity → migration.
- **سوابق حفظ شود**: آرشیو فقط مخفی می‌کند (soft)، حذف نیست؛ فاکتور و پرداخت‌ها دست‌نخورده می‌مانند.
- بعد از تغییر API، `docs/api/patient.md` را به‌روز کن (پارامتر `filter` روی `sessions`، فیلد `archived` روی `updateSession`/entity).
- edge case: آرشیو کردن مراجعه‌ی تسویه‌شده مجاز است (فقط مخفی می‌شود)؛ گزارش‌های مالی نباید آرشیوها را از سابقه حذف کنند (فقط لیست پیش‌فرض این صفحه فیلتر شود).
---
## قوانین عمومی
- کنترلرها از `BaseController`؛ پاسخ‌ها `$this->success()`/`$this->paginated()`/`$this->error()`.
- تاریخ‌ها Unix؛ قیمت‌ها ریال (ذخیره/API)، تومان (UI) با `tomanToRial`/`rialToToman`.
- TanStack Query + الگوهای موجود؛ selectها `SearchableSelect`.
- هر تسک جدا تست و کامیت شود. Backend اول در تسک ۴. بعد از کد، `graphify update .` (بعد کامیت).
@@ -0,0 +1,206 @@
# انتقال مدیریت کلینیک و پزشکان کلینیک به یک تب مجزا در تنظیمات (نقش‌محور)
## پروژه
`clinicpro` (پنل ادمین React + یک اصلاح کوچک permission در Backend Symfony — همان ریپو، cross-repo نیست).
## زمینه
کاربری که به‌عنوان **مدیر/مالک کلینیک** ثبت‌نام می‌کند `primaryRole === 'clinic'` می‌گیرد (برچسب «مالک کلینیک»). امروز در منوی تنظیمات یک تب به نام **«مدیریت مطب»** وجود دارد (`key: 'clinic'`) که به `/admin/my-clinic` می‌رود؛ آن صفحه بلافاصله به `/admin/clinics/{dbUuid}` = `ClinicDetailPage` **ری‌دایرکت** می‌کند. یعنی با کلیک روی تب تنظیمات، کاربر از پوسته‌ی تنظیمات (`SettingsLayout`) خارج می‌شود و به یک صفحه‌ی جنریک ادمین (همان صفحه‌ای که مدیرکل برای هر کلینیک می‌بیند) پرتاب می‌شود. این صفحه هم مدیریت اطلاعات کلینیک و هم مدیریت پزشکانِ کلینیک (لیست/دعوت/تعلیق/حذف دعوتنامه/جداسازی پزشک) را در خود دارد.
دو مشکل:
1. مدیریت پزشکانِ کلینیک به‌جای اینکه یک تب مستقل و تمیز داخل تنظیمات باشد، داخل یک صفحه‌ی بزرگ ادمین قاطی شده و کاربر را از تنظیمات بیرون می‌برد.
2. تب «مدیریت مطب» در **sidebar دسکتاپِ تنظیمات** (`PurchaseSubscriptionSidebar`) اصلاً نقش‌محور نیست و برای همه (از جمله پزشک مهمان) نمایش داده می‌شود — این در `SettingsLayout.test.tsx` هم به‌عنوان رفتار فعلی ثبت شده.
## هدف
1. یک **تب مجزا** در تنظیمات به نام **«پزشکان کلینیک»** ساخته شود که کل مدیریت کلینیک و پزشکانِ کلینیک را **داخل پوسته‌ی تنظیمات** (`SettingsLayout`) در دسترس بگذارد.
2. تب فعلی **«مدیریت مطب»** به‌طور کامل از هر دو منوی تنظیمات حذف شود (موبایل: `SETTINGS_MENU`؛ دسکتاپ: `PurchaseSubscriptionSidebar`).
3. تمام امکانات مدیریت کلینیک + پزشکانِ کلینیک از همان تب جدید در دسترس باشد.
4. کنترل دسترسی نقش‌محور:
* **مدیر کلینیک** (`primaryRole === 'clinic'`): افزودن، ویرایش، حذف/جداسازی و مدیریت کامل پزشکانِ کلینیک.
* **پزشک** (`primaryRole === 'doctor'`): این تب مدیریتی را **اصلاً نبیند** و به تنظیمات مدیریتی کلینیک دسترسی نداشته باشد؛ فقط بخش‌های مربوط به خودش (پروفایل/نوبت‌دهی/دعوت‌نامه‌های دریافتی خودش که جای دیگری است).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/components/layout/SettingsLayout.tsx` | منبع حقیقت `SETTINGS_MENU` (منوی موبایل) + `menuForRole()` + پوسته‌ی تنظیمات |
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx` | sidebar دسکتاپِ تنظیمات؛ `NAV_ITEMS` مستقل و **بدون نقش‌گِیت** |
| `assets/admin/pages/MyClinicPage.tsx` | تب فعلی «مدیریت مطب» → فقط ری‌دایرکت به `ClinicDetailPage` |
| `assets/admin/pages/ClinicDetailPage.tsx` | صفحه‌ی جنریک ادمین؛ بلوک «پزشکان + دعوتنامه‌ها» (خطوط ~۴۷۶–۹۹۰) منبع کد قابل‌استخراج |
| `assets/admin/App.tsx` | جدول route؛ `RoleRoute` (خط ۱۱۷) و route `my-clinic` (خط ۱۹۱) |
| `assets/admin/stores/authStore.ts` | `primaryRole`, `dbUuid`, `context` (`ContextItem.scope`) |
| `assets/admin/components/layout/SettingsLayout.test.tsx` | تست رفتار فعلی نمایش «مدیریت مطب» |
| `assets/admin/pages/SettingsMenuPage.test.tsx` | تست منوی موبایل |
| `assets/admin/components/layout/Sidebar.tsx` (خط ~۲۰۶) | لینک `/admin/my-clinic` در نویگیشن اصلی |
| `src/Clinic/Controller/ClinicController.php` (خط ۳۵۵–۳۵۶) | endpoint جداسازی پزشک — **`ROLE_ADMIN` only** |
| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای دعوت/لیست/تعلیق/حذف دعوتنامه (`IS_AUTHENTICATED_FULLY`) |
## وضعیت فعلی
### منوی تنظیمات موبایل — `SettingsLayout.tsx`
```tsx
export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] },
{ key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon, to: '/admin/my-clinic', roles: ['clinic'] },
// ...
];
export function menuForRole(role: string | null | undefined): SettingsMenuItem[] {
return SETTINGS_MENU.filter((i) => !i.roles || (role != null && i.roles.includes(role)));
}
```
### sidebar دسکتاپ — `PurchaseSubscriptionSidebar.tsx` (بدون نقش‌گِیت!)
```tsx
const NAV_ITEMS: NavItem[] = [
// ...
{ key: 'clinic', label: 'مدیریت مطب', to: '/admin/my-clinic' }, // برای همه‌ی نقش‌ها دیده می‌شود
// ...
];
export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { /* هیچ نقشی نمی‌گیرد */ }
```
### تب فعلی — `MyClinicPage.tsx` (فقط ری‌دایرکت، از تنظیمات خارج می‌شود)
```tsx
function MyClinicPageContent() {
const { dbUuid, fetchMe } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (dbUuid) navigate(`/admin/clinics/${dbUuid}`, { replace: true }); // ← به ClinicDetailPage می‌پرد
// ...
}, [dbUuid, fetchMe, navigate]);
// ...
}
```
### route فعلی — `App.tsx:191`
```tsx
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
```
### endpointهای موجود مدیریت پزشکانِ کلینیک (از `ClinicDetailPage.tsx`)
```
GET /api/v1/clinic/doctor-list/{uuid} لیست پزشکان کلینیک
GET /api/v1/admin/clinic/{uuid}/invitations?limit=50 لیست دعوتنامه‌ها
POST /api/v1/admin/clinic/{uuid}/invite-doctor دعوت پزشک (InviteDoctorModal)
POST /api/v1/admin/clinic/invitation/{invUuid}/resend ارسال مجدد
PATCH /api/v1/admin/clinic/invitation/{invUuid}/status تعلیق/فعال
DELETE /api/v1/admin/clinic/invitation/{invUuid} حذف دعوتنامه
DELETE /api/v1/admin/clinic/{uuid}/doctor/{doctorUuid} جداسازی پزشک ← ROLE_ADMIN only ⚠️
```
### مشکل permission در Backend — `ClinicController.php:355`
```php
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')] // ← مالک کلینیک نمی‌تواند پزشک را جدا کند
public function detachDoctor(...) { ... }
```
## وظایف
### ۱. Backend — اجازه‌ی جداسازی پزشک به مالک کلینیک
هدف task شماره ۴ این است که مدیر کلینیک بتواند پزشک را حذف/جدا کند، ولی endpoint جداسازی الان `ROLE_ADMIN` است.
- در `src/Clinic/Controller/ClinicController.php` متد `detachDoctor` (خط ۳۵۵): گارد `#[IsGranted('ROLE_ADMIN')]` را به `#[IsGranted('IS_AUTHENTICATED_FULLY')]` تغییر بده و **داخل متد/سرویس یک بررسی مالکیت** اضافه کن: کاربر فعلی یا ادمین باشد یا مالک همان کلینیک (`clinicUuid`). اگر نه → `throw new AppException(ErrorCodes::ERR_FORBIDDEN, null, 403)`.
- **اول بگرد**: احتمالاً همین الگوی بررسی مالکیت در `invite-doctor`/`invitations` (که `IS_AUTHENTICATED_FULLY` هستند) در سرویس `ClinicInvitation` وجود دارد — همان helper را دوباره استفاده کن، کد جدید ننویس.
- endpointهای دعوتنامه را هم بررسی کن که مالک کلینیک (نه فقط ادمین) بتواند صدایشان بزند؛ اگر بررسی مالکیت ندارند، همان helper را اضافه کن.
- پس از تغییر، فایل `docs/api/clinic.md` (و در صورت لزوم مستندِ ClinicInvitation) را در همین session به‌روزرسانی کن (Standing Rule).
- تست: PHPUnit برای سه حالت — مالک کلینیک (موفق)، پزشک/کاربر غیرمالک (۴۰۳)، ادمین (موفق).
> اگر بررسی مالکیت روی این endpointها از قبل به‌شکل کامل وجود دارد، فقط گارد `ROLE_ADMIN` را شل کن و دلیلش را در توضیح PR/commit بنویس.
### ۲. استخراج بلوک مدیریت پزشکان به یک کامپوننت مشترک (SOLID)
`ClinicDetailPage.tsx` بلوک «پزشکان + دعوتنامه‌ها» را در خطوط ~۸۴۷–۹۹۰ دارد (tab پزشکان/دعوتنامه‌ها، دعوت، ارسال مجدد، تعلیق، حذف دعوتنامه، جداسازی پزشک، `InviteDoctorModal`، `ConfirmDialog` جداسازی). این منطق نباید کپی شود.
- یک کامپوننت جدید بساز: `assets/admin/components/ClinicDoctorsManager.tsx` با prop `clinicUuid: string` و `readOnly?: boolean`.
- تمام state/queryها/mutationهای مربوط به `doctorsQ`, `invitationsQ`, `resendInvMut`, `changeInvStatusMut`, `deleteInvMut`, `detachDoctorMut`, `inviteOpen`, `detachDoctorConfirm` را به این کامپوننت منتقل کن (از `ClinicDetailPage` بردار).
- `ClinicDetailPage.tsx` را ریفکتور کن تا همین کامپوننت مشترک را با `clinicUuid={uuid}` رندر کند (رفتار صفحه‌ی ادمین نباید تغییر کند).
- endpointها و envelopeها دقیقاً همان‌های فعلی (`data?.data ?? raw`).
### ۳. صفحه‌ی تنظیماتِ تب جدید — `ClinicDoctorsPage.tsx`
- فایل جدید: `assets/admin/pages/ClinicDoctorsPage.tsx`.
- `dbUuid` مالک کلینیک را از `useAuthStore` بگیر (اگر خالی بود `fetchMe()` مثل `MyClinicPage`). سپس **داخل** `SettingsLayout` رندر کن — نه ری‌دایرکت:
```tsx
export default function ClinicDoctorsPage() {
const { dbUuid, fetchMe } = useAuthStore();
useEffect(() => { if (!dbUuid) fetchMe(); }, [dbUuid, fetchMe]);
return (
<SettingsLayout active="clinic-doctors">
{dbUuid
? <ClinicDoctorsManager clinicUuid={dbUuid} />
: <div style={{ padding: 40, textAlign: 'center' }}>
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
</div>}
</SettingsLayout>
);
}
```
- اگر می‌خواهی ویرایش اطلاعات کلینیک (نام/تلفن/تخصص‌ها/بیمه/گالری/آدرس) هم زیر همین تب باشد (task: «تمام امکانات مدیریت کلینیک»)، یک دکمه/لینک «ویرایش اطلاعات کلینیک» به همان `ClinicDetailPage` بگذار یا آن بلوک‌ها را هم به کامپوننت مشترک اضافه کن. **پیشنهاد:** برای این iteration فقط مدیریت پزشکان + دعوت را داخل تب بیاور و ویرایش اطلاعات کلینیک را با یک لینک به صفحه‌ی موجود نگه‌دار تا صفحه‌ی تنظیمات سبک بماند؛ اگر کاربر مدیریت کامل خواست، در وظیفه‌ی جدا انجام شود.
### ۴. route جدید + حذف route قدیمی — `App.tsx`
- route جدید (به‌جای/کنار `my-clinic`) با گارد نقش:
```tsx
<Route
path="settings/clinic-doctors"
element={
<RoleRoute roles={['clinic']} blockClinicScope>
<ClinicDoctorsPage />
</RoleRoute>
}
/>
```
- `RoleRoute` قبلاً `roles=['clinic']` را چک می‌کند و با `blockClinicScope` پزشکِ مهمان در scope کلینیک را هم رد می‌کند — همین برای task شماره ۴ کافی است (پزشک به تب مدیریتی نمی‌رسد).
- route قدیمی `my-clinic` و `MyClinicPage` را حذف کن؛ اگر لینک قدیمی ممکن است جایی باز شود، یک ری‌دایرکت از `my-clinic` به `settings/clinic-doctors` بگذار.
### ۵. به‌روزرسانی هر دو منوی تنظیمات
- در `SettingsLayout.tsx` آیتم `{ key:'clinic', label:'مدیریت مطب', ... }` را حذف و جایگزین کن با:
```tsx
{ key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] },
```
- در `PurchaseSubscriptionSidebar.tsx`:
- آیتم `{ key:'clinic', label:'مدیریت مطب', to:'/admin/my-clinic' }` را حذف و با `{ key:'clinic-doctors', label:'پزشکان کلینیک', to:'/admin/settings/clinic-doctors' }` جایگزین کن.
- این sidebar را **نقش‌محور** کن: `primaryRole` را از `useAuthStore` بگیر و `NAV_ITEMS` را با همان منطق `menuForRole` فیلتر کن (به `NavItem` فیلد اختیاری `roles?: string[]` اضافه کن و به آیتم `clinic-doctors` بده `roles: ['clinic']`، به آیتم `doctor`/`appointment` هم `roles:['doctor']` مطابق `SETTINGS_MENU`). این باعث می‌شود پزشک تب «پزشکان کلینیک» را در دسکتاپ هم نبیند (رفع باگ فعلی).
### ۶. اصلاح لینک نویگیشن اصلی — `Sidebar.tsx`
- خط ~۲۰۶ که `/admin/my-clinic` می‌سازد را به `/admin/settings/clinic-doctors` تغییر بده (فقط برای نقش `clinic`). منطق نقش همان‌جا را حفظ کن.
### ۷. تست‌ها
- `SettingsLayout.test.tsx`: تست فعلی که انتظار دارد «مدیریت مطب» دیده شود را به‌روز کن — حالا:
* برای `role='clinic'` باید «پزشکان کلینیک» دیده شود و «مدیریت مطب» **نباشد**.
* برای `role='doctor'` باید «پزشکان کلینیک» **دیده نشود** (چون دسکتاپ حالا نقش‌محور است — کامنت قدیمیِ «desktop sidebar is not role-gated» را هم اصلاح کن).
- `SettingsMenuPage.test.tsx`: `menuForRole('doctor')` نباید `clinic-doctors` بدهد؛ `menuForRole('clinic')` باید بدهد.
- تست جدید برای `ClinicDoctorsManager` (رندر لیست پزشکان از mock، نمایش دکمه‌های مدیریت وقتی `readOnly` نیست).
- `yarn test` و `npx tsc --noEmit` باید سبز شوند.
## نکات مهم
- **نقش‌ها:** `admin` (مدیرکل) · `clinic` (مالک/مدیر کلینیک — برچسب «مالک کلینیک») · `doctor` · `secretary` · `representation` · `user`. «مدیر کلینیک» در این سیستم = `primaryRole === 'clinic'`. پزشکِ مهمانِ دعوت‌شده = `doctor` با `context.scope === 'clinic'` که با `blockClinicScope` در `RoleRoute` رد می‌شود.
- **envelope:** لیست پزشکان و دعوتنامه‌ها با `data?.data ?? raw` استخراج می‌شوند (double-nest احتمالی). دقیقاً از الگوی فعلی `ClinicDetailPage` کپی کن، تغییر نده.
- **تاریخ‌ها:** timestampهای Unix (مثل `expires_at`)؛ انقضا با `Date.now()/1000 > inv.expires_at` سنجیده می‌شود — همین را نگه‌دار.
- **SearchableSelect:** طبق قانون پروژه هر جا `select` لازم شد از `SearchableSelect` استفاده کن، نه `<select>` بومی.
- **رشته‌ها فارسی، RTL.** دکمه‌ها/بج‌ها/آیکن‌ها از همان کلاس‌های موجود (`btn`, `badge`, `mini-btn`, `seg`).
- **گارد سطح UI کافی نیست:** چون پزشک نباید بتواند مدیریت کند، هم UI را گِیت کن (`RoleRoute` + منوی نقش‌محور) و هم Backend را (وظیفه‌ی ۱). بدون وظیفه‌ی ۱، دکمه‌ی «جداسازی پزشک» برای مالک کلینیک ۴۰۳ می‌دهد.
- **SOLID:** منطق مدیریت پزشکان فقط در `ClinicDoctorsManager` باشد؛ نه در `ClinicDetailPage` کپی بماند نه در `ClinicDoctorsPage` دوباره نوشته شود.
- **بعد از اتمام:** `graphify update .` برای به‌روز نگه‌داشتن گراف (طبق قانون پروژه).
@@ -0,0 +1,159 @@
# پیکسل‌به‌پیکسل کردن منوی تنظیمات + ریشه‌گرفتن رنگ‌ها از شخصی‌سازی
## پروژه
`clinicpro` (فقط پنل ادمین React — تغییر backend ندارد).
اپ دسکتاپ `clinic-pro-tauri` فقط **مرجع بصری** است، نه هدف تغییر. صفحه‌ی مرجع:
`http://127.0.0.1:5170/setting/purchase-subscription`
(کد آن: `clinic-pro-tauri/src/components/setting/listMenu/purchaseSubscription/PurchaseSubscriptionSidebar.jsx`)
## زمینه
پنل ادمین `clinicpro` یک ناحیه‌ی «تنظیمات» دارد که کنار محتوا یک ساید‌منوی مشترک (`PurchaseSubscriptionSidebar`) نمایش می‌دهد. این منو در همه‌ی صفحات تنظیمات از طریق `SettingsLayout` رندر می‌شود.
- صفحه‌ی `/admin/subscription` (`SubscriptionPage.tsx`) درست و پیکسل‌به‌پیکسل است — **مرجع درون‌پروژه‌ای**.
- صفحه‌ی `/admin/appointment-settings` (`AppointmentSettingsPage.tsx`) **خراب** است: محتوا روی زمینه‌ی خاکستری `#fafafa` نشسته، پنل سفید ندارد و فاصله‌ها با مرجع فرق دارد.
## مشکل / هدف
سه اصلاح مشخص:
1. **رنگ‌ها باید از شخصی‌سازی ریشه بگیرند.** رنگ ردیف فعال منو در `PurchaseSubscriptionSidebar.tsx` به‌صورت هاردکد `#f17732` است. باید از متغیر برند شخصی‌سازی (`var(--accent)`) استفاده کند تا با «رنگ اصلی» انتخاب‌شده در پنل شخصی‌سازی (`Topbar.tsx``setBrandHue`) هماهنگ شود.
2. **محتوای appointment-settings باید روی پنل سفید `#ffffff` بنشیند، نه `#fafafa`.** مرجع (tauri و نیز `SubscriptionPage.tsx`) محتوا را داخل یک پنل با `background: var(--surface)` (سفید) می‌گذارد. در appointment-settings محتوا مستقیم روی `--bg` صفحه (`#fafafa`) است. `#fafafa` رنگِ **ناحیه‌ی منو** است، نه محتوا.
3. **منو باید چسبیده به نوارِ کناری اصلی (سمت راست) باشد.** در مرجع tauri منوی تنظیمات فلاش به ساید‌بار اصلی می‌چسبد؛ در clinicpro به‌خاطر padding محتوا فاصله افتاده.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx` | ساید‌منوی مشترک تنظیمات — رنگ ردیف فعال اینجاست |
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحه‌ی خراب که باید پیکسل‌به‌پیکسل شود |
| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع درست** — الگوی wrap محتوا در پنل سفید |
| `assets/admin/components/layout/SettingsLayout.tsx` | شل تنظیمات (grid: `[248px_minmax(0,1fr)] gap-5`) — محل اصلاح فلاش‌شدن |
| `assets/admin/styles.css` | تعریف متغیرها: `--surface:#ffffff`، `--bg:#fafafa`، `--accent:#f0682a`، `--gap:20px`، `.content{padding:var(--gap);max-width:1480px;margin:0 auto}` |
| `clinic-pro-tauri/.../PurchaseSubscriptionSidebar.jsx` | مرجع بصری (تغییر نده) |
## وضعیت فعلی
### ۱) رنگ هاردکد در ساید‌منو — `PurchaseSubscriptionSidebar.tsx`
```tsx
const rowStyle: React.CSSProperties = {
borderRadius: 12, height: 44, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
lineHeight: 1, textAlign: 'right',
background: isActive ? '#f17732' : 'transparent', // ← هاردکد
color: isActive ? '#FFFFFF' : 'var(--text-2)',
cursor: item.to ? 'pointer' : 'not-allowed',
transition: 'background .14s',
};
// ...
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
```
### ۲) صفحه‌ی خراب — `AppointmentSettingsPage.tsx` (بازگشت فعلی)
```tsx
return (
<SettingsLayout active="appointment">
<div className="fade-in"> {/* ← fade-in تکراری؛ SettingsLayout خودش fade-in دارد */}
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice />
{!uuid ? (
<div className="card" style={{ padding: 32, textAlign: 'center', ... }}>این بخش فقط برای پزشک در دسترس است.</div>
) : isLoading ? (
<div style={{ padding: 16, ... }}>در حال بارگذاری...</div>
) : (
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} /> {/* ← مستقیم روی #fafafa */}
)}
</div>
</SettingsLayout>
);
```
### مرجع درست — `SubscriptionPage.tsx` (محتوا داخل پنل سفید)
```tsx
return (
<SettingsLayout active="subscription">
<div style={{ background: 'var(--surface)', minWidth: 0 }}> {/* ← پنل سفید #ffffff */}
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
{/* محتوا */}
</div>
</div>
</SettingsLayout>
);
```
## وظایف
### ۱. رنگ ردیف فعال ساید‌منو را از شخصی‌سازی بگیر
در `PurchaseSubscriptionSidebar.tsx`:
- `background: isActive ? '#f17732'``background: isActive ? 'var(--accent)'`
- بررسی کن که در حالت hover و active در تم تیره هم درست باشد (متغیرهای `--accent`، `--surface-2` هر دو تم را در `styles.css` پوشش می‌دهند — نیازی به `.dark &` جدا نیست).
- رنگ متن ردیف فعال `#FFFFFF` بماند (روی برند خوانا است).
### ۲. محتوای appointment-settings را داخل پنل سفید بگذار و ساختار را مثل مرجع کن
در `AppointmentSettingsPage.tsx`:
- `<div className="fade-in">` تکراری را حذف کن (چون `SettingsLayout` خودش `fade-in` دارد) و به‌جای آن دقیقاً الگوی `SubscriptionPage` را به‌کار ببر:
```tsx
return (
<SettingsLayout active="appointment">
<div style={{ background: 'var(--surface)', minWidth: 0 }}>
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice />
{!uuid ? (
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بخش فقط برای پزشک در دسترس است.
</div>
) : isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : (
<WeeklyScheduleTab doctorUuid={uuid} addresses={addresses} />
)}
</div>
</SettingsLayout>
);
```
- بعد از تغییر، چشمی چک کن که `WeeklyScheduleTab` و `FreeVisitPrice` روی سفید (`--surface`) بنشینند و padding/فاصله‌ها با `SubscriptionPage` یکی باشد. اگر `WeeklyScheduleTab` خودش زمینه/کارت داخلی دارد، مطمئن شو با پنل سفید بیرونی دوبار‌کارت (double-card) نمی‌شود.
### ۳. منوی تنظیمات را فلاش به نوار کناری اصلی بچسبان
مشکل: `.content` در `styles.css` دارای `padding: var(--gap)` (۲۰px) و `max-width:1480px; margin:0 auto` است؛ همین padding سمت راست، بین نوار کناری اصلی و منوی تنظیمات فاصله می‌اندازد.
در `SettingsLayout.tsx` کاری کن ستون منو تا لبه‌ی نوار کناری اصلی کشیده شود؛ گزینه‌ی پیشنهادی: یک negative margin سمت راست روی ریشه‌ی `SettingsLayout` برابر `--gap` تا padding محتوا خنثی شود، بدون شکستن `max-width` بخش محتوا:
```tsx
return (
<div className="fade-in" dir="rtl" style={{ width: '100%' }}>
<div className="grid grid-cols-1 lg:grid-cols-[248px_minmax(0,1fr)] gap-5"
style={{ marginInlineStart: 'calc(-1 * var(--gap))' /* منو تا لبه‌ی نوار کناری */ }}>
<PurchaseSubscriptionSidebar active={active} />
<div style={{ minWidth: 0 }}>{children}</div>
</div>
</div>
);
```
- **مهم:** RTL است؛ نوار کناری اصلی سمت راست است و منوی تنظیمات هم سمت راستِ گرید. جهت negative margin را با تست چشمی درست کن (`marginInlineStart`/`marginInlineEnd`) تا منو دقیقاً به نوار کناری بچسبد و لبه‌ی چپِ محتوا از قاب بیرون نزند.
- چون `SettingsLayout` مشترک است، بعد از این تغییر **همه‌ی صفحات تنظیمات** (subscription، appointment، tags، insurance، …) را چک کن که هم‌زمان درست بمانند.
## نکات مهم
- **هیچ تغییری در tauri نده** — فقط مرجع بصری است.
- منبع رنگ‌ها = متغیرهای CSS در `assets/admin/styles.css`؛ رنگ برند شخصی‌سازی از `--accent` می‌آید (پنل `Topbar.tsx``setBrandHue`). هر رنگ هاردکد جدید ممنوع.
- هر دو تم روشن/تیره باید سالم بمانند (`--surface`، `--accent`، `--surface-2` در هر دو تم تعریف شده‌اند).
- تست بصری پیکسل‌به‌پیکسل: `SubscriptionPage` (درست) را کنار `AppointmentSettingsPage` بگذار؛ عرض منو، فاصله، زمینه‌ی سفید و رنگ ردیف فعال باید یکسان باشند.
- تست‌های موجود را نگه‌دار: `PurchaseSubscriptionSidebar.test.tsx`، `SettingsLayout.test.tsx`، `AppointmentSettingsPage.test.tsx` — بعد از تغییر `npm test` (یا `ddev exec`) اجرا کن. اگر تستی رنگ هاردکد `#f17732` را چک می‌کند، آن را به `var(--accent)` به‌روزرسانی کن.
- backend/API دست نمی‌خورد → نیازی به به‌روزرسانی `docs/api/*` نیست.
+128
View File
@@ -0,0 +1,128 @@
---
name: figma-to-feature
description: وقتی کاربر یک لینک figma.com/design با node-id می‌دهد، صفحه را تحلیل
کن، نیازهای فرانت‌اند و بک‌اند را استخراج کن و پس از تأیید پیاده‌سازی کن.
---
## بخش ۰ — زبان (قبل از هر کاری)
- ورودی من فارسی است. منظور را استخراج کن، نه ترجمه‌ی لغوی.
- متن را به یک normalized English spec تبدیل کن با فیلدهای:
Goal / Scope (in-out) / Constraints / Acceptance criteria / Ambiguities
- اصطلاحات فینگلیش (کامپوننت، اندپوینت، باتن) اصطلاح فنی‌اند، ترجمه نکن.
- اسم متغیر، مسیر فایل، اسم کامپوننت و هر چیز داخل بک‌تیک را عیناً حفظ کن.
- spec انگلیسی + خلاصه‌ی برداشتت به فارسی را نشانم بده و منتظر تأیید بمان.
اگر Ambiguities خالی نبود، سؤال‌ها را بپرس. بدون تأیید، کد ننویس.
- خروجی: کد/کامنت/داکیومنت/کامیت انگلیسی. گفت‌وگو با من فارسی.
رشته‌های UI فارسی و از فایل i18n پروژه — هاردکد ممنوع.
## بخش ۱ — استخراج از فیگما
- fileKey و node-id را از URL دربیاور.
- get_design_context → ساختار و لِی‌اوت
- get_variable_defs → رنگ/اسپیسینگ/تایپوگرافی
- get_screenshot → مرجع تطبیق بصری
- download_assets → آیکون و تصاویر
- توکن‌های فیگما را با mapping.md به متغیرهای واقعی پروژه نگاشت کن.
## بخش ۲ — ممیزی کدبیس (اجباری، قبل از هر تحلیلی)
هر بار که یک لینک صفحه می‌گیری، باید هم بک‌اند و هم فرانت‌اند را واقعاً بگردی.
حدس زدن ممنوع؛ فقط چیزی که با Grep/Read در کد دیدی.
### الف) ممیزی بک‌اند
- routes/controllers را بگرد: کدام اندپوینت‌ها مرتبط با این صفحه از قبل وجود دارند؟
- مدل‌ها و اسکیمای دیتابیس: کدام جدول/فیلد لازم است و از قبل هست؟
- سرویس‌ها و validationها و middleware مرتبط
- برای هر مورد بنویس: مسیر فایل + شماره خط
### ب) ممیزی فرانت‌اند
- کامپوننت‌های design system که می‌شود reuse کرد (با مسیر فایل)
- روت مربوطه هست یا نه
- hook/service/state موجود برای این داده
- فایل i18n: کلیدهای متنی این صفحه از قبل هستند؟
- برای هر مورد بنویس: مسیر فایل + شماره خط
### ج) خروجی ممیزی — این جدول را بده
| مورد | لایه | وضعیت | فایل | اقدام |
|------|------|-------|------|-------|
| نام دقیق | Frontend/Backend | ✅ موجود / ✏️ نیاز به ادیت / 🆕 جدید | مسیر:خط | یک جمله |
### د) مبهم‌ها
هر چیزی که از دیزاین معلوم نیست: empty state، حالت خطا، لودینگ، pagination،
دسترسی/نقش کاربر، اعتبارسنجی فیلدها. لیست کن و بپرس.
منتظر تأیید من بمان.
---
## بخش ۳ — TODO List (اجباری)
بعد از تأیید ممیزی، با ابزار TodoWrite یک TODO بساز. قواعد:
- ترتیب حتماً: **Backend → Frontend → i18n → تست → تطبیق بصری**
(فرانت را قبل از آماده شدن اندپوینت نساز.)
- هر آیتم اتمیک و قابل تست باشد. آیتم مبهم مثل «صفحه را بساز» ممنوع.
- هر آیتم TODO باید تست خودش را هم شامل شود، نه یک آیتم «تست» در آخر.
- ساختار پیشنهادی:
1. [BE] مایگریشن/مدل X — + تست
2. [BE] توسعه‌ی اندپوینت Y (یا ساخت جدید، اگر توجیه شد) — + integration test
3. [FE] service/hook برای فراخوانی Y — + unit test
4. [FE] کامپوننت A (presentational) — + تست رندر
5. [FE] کامپوننت B (تعاملی) — + تست تعامل
6. [FE] مونتاژ صفحه و روت
7. [i18n] کلیدهای متنی فارسی
8. [QA] اجرای کل تست‌ها
9. [QA] مقایسه با اسکرین‌شات فیگما و اصلاح اختلاف‌ها
TODO را قبل از شروع نشانم بده.
---
## بخش ۴ — اجرا، مرحله به مرحله
- **همیشه فقط یک آیتم in_progress باشد.** موازی‌کاری ممنوع.
- ترتیب TODO را رعایت کن؛ از روی آیتم‌ها نپر.
- بعد از هر آیتم: تستش را اجرا کن. **آیتم بدون تست سبز، completed علامت نمی‌خورد.**
- بعد از هر آیتم یک خط فارسی گزارش بده: چه ساختی، کدام فایل، تست سبز شد یا نه.
- اگر وسط کار به چیزی برخوردی که در ممیزی ندیده بودی (اندپوینت پنهان، کامپوننت
مشابه، تضاد با SOLID) → **توقف کن**، TODO را به‌روز کن، و از من تأیید بگیر.
خودسرانه scope را عوض نکن.
- در آخر: خروجی را با اسکرین‌شات فیگما مقایسه کن، اختلاف‌ها را لیست و اصلاح کن،
و کل تست‌ها را یک بار دیگر اجرا کن.
## بخش ۵ — بستن کار (به همین ترتیب)
1. **اول کدها را commit کن.** وقتی همهٔ تست‌ها سبز شد، تغییرات را با یک پیام
انگلیسی معنادار commit کن (طبق Conventional Commits). قبل از graphify commit
کن، نه بعدش.
2. **بعد graphify را به‌روز کن:** `graphify update .` — تا گراف با کد جدید هم‌گام
شود. این مرحله فقط پس از commitِ موفق اجرا می‌شود.
### ۱. SOLID
- SRP: هر کامپوننت/کلاس یک مسئولیت. کامپوننتی که هم fetch می‌کند هم رندر می‌کند
باید به hook/service + کامپوننت presentational شکسته شود.
- OCP: رفتار جدید با prop/strategy، نه if/else تو در تو در کد موجود.
- LSP: هر پیاده‌سازی جایگزین قرارداد اینترفیس را کامل رعایت کند.
- ISP: props و اینترفیس بزرگ ممنوع؛ به قراردادهای کوچک بشکن.
- DIP: UI و لایه‌ی بیزنس مستقیم به axios/fetch/ORM وابسته نشوند.
اگر SOLID با ساختار فعلی تضاد داشت، توقف کن و بپرس؛ خودسرانه بازنویسی نکن.
### ۲. API جدید — آخرین گزینه
1. کل لایه‌ی routes/controllers را بگرد.
2. اگر اندپوینتی با یک پارامتر یا فیلد اضافه کافی است → همان را
backward-compatible توسعه بده.
3. فقط اگر هیچ اندپوینتی نبود، جدید بساز.
در جدول تحلیل برای هر نیاز بنویس: «موجود X» / «توسعه‌ی X» / «جدید — چون این‌ها
را بررسی کردم و کافی نبودند: [...]». بدون این توجیه، اندپوینت جدید نساز.
### ۳. مستندسازی — دقیق و مختصر
- هر تابع/کامپوننت عمومی: بلاک کوتاه (چه می‌کند، ورودی، خروجی، خطاها).
- هر اندپوینت: متد، مسیر، payload، response، کدهای خطا — در همان فرمت
مستندات فعلی پروژه.
- کامنت بدیهی ممنوع. «چرا» را بنویس، نه «چه».
### ۴. تست — بدون تست کار تمام نیست
- منطق بیزنس/سرویس/هوک: unit test با حالت موفق + خطا + مرزی.
- اندپوینت جدید یا توسعه‌یافته: integration test.
- کامپوننت تعاملی: تست رندر + تست تعامل.
- از فریم‌ورک تست موجود پروژه استفاده کن.
- تست‌ها را اجرا کن و خروجی سبز را نشان بده.
+140
View File
@@ -0,0 +1,140 @@
# Figma → Project token mapping
نگاشت توکن‌های خروجی `get_variable_defs` فیگما به متغیرهای واقعی این پروژه.
**منبع حقیقت:** `assets/admin/styles.css` (بلاک `:root`). هرگز hex هاردکد نکن — همیشه `var(--token)`.
---
## Colors — brand
| نقش فیگما (نمونه نام‌ها) | متغیر پروژه | مقدار |
|---|---|---|
| Primary / Brand / Indigo 500 | `--primary` | `#5559CE` |
| Primary hover / 600 | `--primary-600` | `#494CB3` |
| Primary pressed / 700 | `--primary-700` | `#3E41A0` |
| Primary tint / subtle bg | `--primary-soft` | `#ecedfb` |
| Primary tint 2 | `--primary-soft2` | `#d9dbf6` |
| On-primary / text on brand | `--on-primary` | `#ffffff` |
| Accent / Orange (CTA ثانویه، آواتار) | `--accent` | `#f0682a` |
| Accent hover | `--accent-600` | `#db5a1f` |
| Accent tint | `--accent-bg` | `#fdeee4` |
## Colors — surface / text / border
| نقش فیگما | متغیر پروژه | مقدار |
|---|---|---|
| Page background | `--bg` | `#fafafa` |
| Alt background | `--bg-2` | `#f2f2f5` |
| Card / surface | `--surface` | `#ffffff` |
| Surface raised 2 | `--surface-2` | `#f6f8fc` |
| Surface raised 3 | `--surface-3` | `#eef2f8` |
| Border default | `--border` | `#e4e9f1` |
| Border strong | `--border-2` | `#d6dde8` |
| Text primary | `--text` | `#0f1b2e` |
| Text secondary | `--text-2` | `#56657c` |
| Text muted / placeholder | `--text-3` | `#8a98ad` |
| Focus ring | `--ring` | `rgba(85,89,206,.32)` |
## Colors — status
| نقش | fg | bg |
|---|---|---|
| Success | `--success` `#15a35a` | `--success-bg` `#e6f6ed` |
| Warning | `--warning` `#d98a09` | `--warning-bg` `#fcf2df` |
| Danger / Error | `--danger` `#e0394a` | `--danger-bg` `#fdebed` |
| Info | `--info` `#2b86d8` | `--info-bg` `#e7f1fb` |
| Violet | `--violet` `#7c5cf0` | `--violet-bg` `#efeafe` |
## Colors — dashboard stat cards
| رنگ | bg | fg |
|---|---|---|
| Amber | `--stat-amber-bg` | `--stat-amber-fg` `#FFC051` |
| Violet | `--stat-violet-bg` | `--stat-violet-fg` `#5559CE` |
| Green | `--stat-green-bg` | `--stat-green-fg` `#009D79` |
| Pink | `--stat-pink-bg` | `--stat-pink-fg` `#F17732` |
---
## Typography
| فیگما | پروژه |
|---|---|
| Font family (fa + latin) | `--font-sans` = `"Vazirmatn", ui-sans-serif, system-ui, sans-serif` |
| منبع فونت | `@fontsource/vazirmatn/{300,400,500,600,700,800}.css` (در `styles.css`) |
اوزان موجود: 300 / 400 / 500 / 600 / 700 / 800. اندازه/line-height فیگما → کلاس‌های Tailwind (`text-sm`, `text-lg`, …).
## Radius
| فیگما | پروژه | مقدار |
|---|---|---|
| xs (chip داخلی) | `--r-xs` | `7px` |
| sm (badge, input کوچک) | `--r-sm` | `8px` |
| md (input, button, card عادی) | `--r` | `14px` |
| lg (card بزرگ) | `--r-lg` | `18px` |
| xl (modal) | `--r-xl` | `24px` |
| full (avatar, pill, toggle) | `--r-pill` | `999px` |
## Shadow / elevation
| فیگما | پروژه |
|---|---|
| Elevation 1 (card) | `--shadow-sm` |
| Elevation 2 (dropdown/hover) | `--shadow` |
| Elevation 3 (modal/popover) | `--shadow-lg` |
## Spacing & layout dimensions
| نقش | پروژه | مقدار |
|---|---|---|
| Grid gap | `--gap` | `20px` (compact: `14px`) |
| Card padding | `--card-pad` | `22px` (compact: `16px`) |
| Table row height | `--row-h` | `56px` (compact: `46px`) |
| Sidebar width | `--sidebar-w` | `243px` |
| Sidebar collapsed | `--collapsed-w` | `90px` |
| Topbar height | `--topbar-h` | `64px` |
| Motion easing | `--ease` | `cubic-bezier(.22,.61,.36,1)` |
اسپیسینگ آزاد (margin/padding داخل اجزا) → مقیاس Tailwind (`p-4`, `gap-2`, …)؛ برای ابعاد ساختاری بالا از متغیرها استفاده کن.
## Theming
- Dark mode: بازتعریف متغیرها زیر `[data-theme="dark"]` در `styles.css`. رنگ خام دارک ننویس؛ همان `var(--token)` خودکار سوییچ می‌شود.
- Density: `[data-density="compact"]` مقادیر `--gap` / `--card-pad` / `--row-h` را کم می‌کند.
- RTL: کل پنل `dir="rtl"`؛ در نگاشت left/right فیگما را به start/end منطقی تبدیل کن.
---
## Component mapping (فیگما → کامپوننت موجود پروژه)
قبل از ساخت، از `assets/admin/components/ui/` reuse کن:
| المان فیگما | کامپوننت پروژه (`assets/admin/components/ui/`) |
|---|---|
| Table / list با ستون | `DataTable.tsx` (sort، search، skeleton، empty، bulk) |
| Modal / dialog | `Modal.tsx` |
| Delete/confirm dialog | `ConfirmDialog.tsx` |
| Page title + breadcrumb + action | `PageHeader.tsx` |
| Stat / KPI card | `StatCard.tsx` |
| Status pill / badge | `StatusBadge.tsx` |
| Pagination bar | `Pagination.tsx` |
| Searchable / async select | `SearchableSelect.tsx` |
| Appointment status control | `AppointmentStatusDropdown.tsx` |
| Mobile number input | `MobileInput.tsx` |
| Price / amount input | `PriceInput.tsx` |
| Jalali date input/picker/calendar | `PersianDateInput.tsx` / `PersianDatePicker.tsx` / `PersianCalendar.tsx` |
| Overlay/portal مبنا | `Portal.tsx` |
| Feature-flag gate | `FeatureGate.tsx` |
| Captcha | `Altcha.tsx` |
کامپوننت‌های ترکیبی فیچرمحور (نه generic) → `assets/admin/components/*.tsx`.
آیکون‌ها → `@heroicons/react/24/outline` (اول موجودها؛ فقط اگر نبود از `download_assets` فیگما).
---
## قواعد نگاشت
1. هر توکن فیگما را به نزدیک‌ترین متغیر بالا map کن. اگر معادل نبود → **توقف و بپرس**، توکن جدید خودسر به `styles.css` اضافه نکن.
2. رنگ/فاصله/شعاع خام (hex/px) در کامپوننت ممنوع؛ فقط `var(--token)` یا کلاس Tailwind.
3. اختلاف جزئی رنگ فیگما با پالت پروژه → پالت پروژه برنده است (تطبیق با design system، نه عین فیگما).
4. منبع مقادیر همیشه `assets/admin/styles.css` است؛ این فایل خلاصه‌ی نگاشت است، نه منبع مستقل — هنگام تغییر `styles.css` این را هم به‌روز کن.
+530
View File
@@ -0,0 +1,530 @@
---
name: qa-clinicpro
description: تست QA اپلیکیشن ClinicPro مثل یک کاربر واقعی — ابتدا ساخت همهٔ نقش‌ها و پروفایل‌های کامل (پزشک مستقل، پزشک عضو کلینیک، کلینیک، منشی، نماینده، بیمار، …) و تعیین ماتریس سطح دسترسی، سپس تست ماتریس دسترسی با تک‌تک آن‌ها. هر مانعی سر راه تست را مثل یک دولوپر ارشد Symfony/React خودش رفع می‌کند و تست را ادامه می‌دهد. اجرای اپ، ورود با هر نقش، پیمایش صفحات پنل ادمین، اسکرین‌شات، کشف خطاهای کنسول و شبکه، تست UI/UX و RTL، تست دسترسی نقش‌ها (authz)، تست قرارداد API و اندازه‌گیری کارایی، و تولید Bug Report. Use when asked to QA, test, smoke-test, find bugs in, screenshot, or verify ClinicPro's admin panel or API — «تست کن»، «باگ پیدا کن»، «QA کن»، «این صفحه را بررسی کن».
---
# QA ClinicPro
ClinicPro = بک‌اند Symfony 7.4 + یک **SPA کلاینت‌ساید React 19** که از `/admin/*` سرو می‌شود.
یعنی `curl` و فلگ `--screenshot` کروم به درد نمی‌خورند — هر دو روی فرم لاگین می‌نشینند،
چون JWT در `localStorage['clinicpro-auth']` است.
درایور این skill آن کار را انجام می‌دهد: با API لاگین می‌کند، `localStorage` را seed
می‌کند، بعد ناوبری می‌کند و **خطاهای کنسول، درخواست‌های شکست‌خورده، مسیری که واقعاً روی آن
فرود آمده، و اسکرین‌شات** را گزارش می‌دهد — با CDP روی `WebSocket` نیتیو Node 22،
**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
مسیرها نسبت به `clinicpro/` هستند.
## پیش‌نیازها
هیچ نصبی لازم نیست. فقط این دو:
```bash
ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
```
کروم جای دیگری است؟ `CHROME_BIN` را ست کن. بک‌اند جای دیگری است؟ `CLINICPRO_BASE`.
## کاربران تست
**`TEST_USERS.md` منسوخ است** — هیچ‌کدام از کاربرانش (`09100000001`, `09100100000`, …)
در دیتابیس وجود ندارند و همه `ERR_AUTH_005` می‌گیرند. اسکریپت‌های `create_test_users.php`
و `seed_realistic_data.php` هم که آن فایل ارجاع می‌دهد در ریپو نیستند.
پرسوناهای QA در `ROLES` داخل درایور تعریف شده‌اند. **واحد کار «پرسونا» است، نه
`ROLE_*`** — پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR` دارند ولی دادهٔ متفاوتی
می‌بینند، پس هرکدام یک ردیف جداگانه‌اند.
| پرسونا | موبایل | پسورد | نقش‌ها | تمایز |
|---|---|---|---|---|
| `admin` | `09120671756` | `QaTest@1234` | `ROLE_ADMIN` | — |
| `clinic` | `09127000000` | `QaTest@1234` | `ROLE_CLINIC` | مالک کلینیک |
| `secretary` | `09123456778` | `QaTest@1234` | `ROLE_SECRETARY` | منشیِ یک پزشک |
| `doctor` | `09390039833` | `09390039833` | `ROLE_DOCTOR` | حساب قدیمی، وضعیت عضویتش نامعلوم |
| `representation` | `09124000001` | `09124000001` | `ROLE_REPRESENTATION` | نماینده شهر |
| `doctor_solo` | `09129000001` | `QaTest@1234` | `ROLE_DOCTOR` | **پزشک مستقل** — مطب شخصی، بدون کلینیک |
| `doctor_member` | `09129000002` | `QaTest@1234` | `ROLE_DOCTOR` | پزشک **عضو کلینیک** |
| `clinic_doctor` | `09129000003` | `QaTest@1234` | `ROLE_CLINIC`+`ROLE_DOCTOR` | چندنقشی |
| `secretary_clinic` | `09129000004` | `QaTest@1234` | `ROLE_SECRETARY` | منشیِ کلینیک (نه پزشک) |
| `unclaimed_doctor` | `09129000005` | `QaTest@1234` | `ROLE_UNCLAIMED_DOCTOR` | پروفایل ایمپورت‌شدهٔ تصاحب‌نشده |
| `patient` | `09129000006` | `QaTest@1234` | `ROLE_USER` | کاربر عادی سایت |
| `importer` | `09129000007` | `QaTest@1234` | `ROLE_IMPORTER` | — |
پنج ردیف اول موجودند. **هفت ردیف آخر تا وقتی Phase 0 اجرا نشده وجود ندارند** و
`driver.mjs roles` برایشان `✗` می‌دهد — این دقیقاً چک آمادگی است.
اگر DB ریست شد، پسورد پنج‌تای اول را دوباره ست کن:
```bash
ddev exec php bin/console security:hash-password 'QaTest@1234'
# هش خروجی را در این کوئری بگذار:
ddev mysql -e "UPDATE users SET password_hash='<هش>' \
WHERE mobile_number IN ('09120671756','09127000000','09123456778');"
```
اعتبارسنجی همه نقش‌ها:
```bash
node .claude/skills/qa-clinicpro/driver.mjs roles
```
خروجی واقعی:
```
admin 09120671756 ROLE_USER,ROLE_ADMIN token 15min
clinic 09127000000 ROLE_USER,ROLE_CLINIC token 15min
secretary 09123456778 ROLE_USER,ROLE_SECRETARY token 15min
doctor 09390039833 ROLE_USER,ROLE_DOCTOR token 15min
representation 09124000001 ROLE_USER,ROLE_REPRESENTATION token 15min
```
می‌توانی به‌جای نام نقش، `--as "0912xxxxxxx:password"` هم بدهی.
---
## مسیر اجرا (agent path)
### ۰. Phase 0 — ساخت نقش‌ها، پروفایل‌ها و ماتریس دسترسی (اجباری، قبل از هر تست)
هیچ تستی را قبل از تمام‌شدن این فاز شروع نکن. خروجی این فاز سه چیز است:
**همهٔ پرسوناها موجود** · **پروفایل هرکدام کامل** · **ماتریس دسترسی مکتوب**.
**۰.۱ — کشف نقش‌ها.** لیست بالا را دوباره از روی کد بساز، به آن استناد نکن؛ ممکن است
نقشی اضافه شده باشد:
```bash
grep -rhoE "ROLE_[A-Z_]+" src/ assets/admin/ config/ | sort -u
ddev mysql -e "SELECT roles, COUNT(*) c FROM users GROUP BY roles ORDER BY c DESC;"
grep -n "role_hierarchy" -A 10 config/packages/security.yaml
```
هر نقشی که در کد هست و در جدول پرسوناها نیست را به `ROLES` در `driver.mjs` اضافه کن.
**۰.۲ — چک آمادگی.** ببین کدام پرسونا هنوز نیست:
```bash
node .claude/skills/qa-clinicpro/driver.mjs roles
```
**۰.۳ — ساخت پرسوناهای ناموجود.** برای هرکدام، **اول مسیر واقعی ساخت را در خود اپ پیدا
کن** و از همان استفاده کن — دست‌کاری مستقیم SQL پروفایل ناقص می‌سازد و تست را دروغین
می‌کند. به این ترتیب بگرد:
```bash
ls src/*/Command/ # آیا کامند کنسولی برای ساخت کاربر هست؟
grep -rn "IsGranted" src/Admin/Controller/ # اندپوینت‌های ادمینِ ساخت کاربر
sed -n '1,80p' docs/api/admin.md
```
فقط برای چیزی که هیچ مسیر اپلیکیشنی ندارد (مثلاً ست‌کردن `ROLE_IMPORTER` یا ساختن
`ROLE_UNCLAIMED_DOCTOR`) به `ddev mysql` برگرد، و در گزارش بنویس که کدام پرسونا
دستی ساخته شد.
**ترتیب ساخت مهم است** — وابستگی دارند:
```
کلینیک → doctor_member (عضو همان کلینیک) → secretary_clinic (منشیِ همان کلینیک)
پزشک → secretary (منشیِ همان پزشک)
```
**۰.۴ — کامل‌کردن پروفایل.** یک حسابِ بدون پروفایل، صفحات را خالی نشان می‌دهد و
باگ‌های واقعی را پنهان می‌کند. برای هر پرسونا این‌ها باید پر باشند:
| پرسونا | حداقل پروفایل لازم |
|---|---|
| `doctor_solo` / `doctor_member` / `clinic_doctor` | نام، تخصص، آدرس مطب، برنامهٔ کاری هفتگی، حداقل یک خدمت با تعرفه، حداقل یک بیمه |
| `clinic` | نام کلینیک، شهر، آدرس، حداقل یک پزشک عضو، حداقل یک خدمت |
| `secretary` / `secretary_clinic` | اتصال به پزشک/کلینیک + سطح دسترسی‌اش |
| `representation` | شهر تخصیص‌یافته |
| `patient` | نام، و حداقل یک نوبت رزروشده (برای اینکه صفحات خالی نباشند) |
| `unclaimed_doctor` | پروفایل پزشک بدون کاربرِ تصاحب‌کننده |
بعد از ساخت، پرشدن را تأیید کن — نه با حدس، با درخواست:
```bash
node .claude/skills/qa-clinicpro/driver.mjs api GET /api/v1/doctor/profile --as doctor_solo
```
**۰.۵ — تعیین سطح دسترسی.** ماتریس را از کد دربیاور، نه از ذهنت:
```bash
grep -n "RoleRoute\|allowedRoles\|element=" assets/admin/App.tsx # مسیرهای فرانت
grep -rn "IsGranted" src/*/Controller/ | sed 's/.*IsGranted(//' # گاردهای بک‌اند
```
از این دو، جدول `مسیر → نقش‌های مجاز` را بساز و در گزارش بیاور. بعد برای هر اندپوینت
حساس با `authz` (بخش ۳) تأییدش کن. **اختلاف بین ماتریسِ کد و خروجی `authz` = باگ**،
حتی اگر خروجی `authz` سخت‌گیرانه‌تر باشد.
**۰.۶ — دروازهٔ خروج.** تا وقتی `roles` برای همهٔ پرسوناها توکن برمی‌گرداند و ماتریس
نوشته شده، به فاز بعد نرو. اگر پرسونایی ساخته نشد، طبق بخش «وقتی به مانع خوردی»
خودت رفعش کن؛ رها کردنش یعنی آن نقش اصلاً تست نشده.
### ۱. بازدید از صفحه — اسکرین‌شات + خطاها
```bash
node .claude/skills/qa-clinicpro/driver.mjs visit \
"https://clinic-pro.ddev.site/admin/dashboard" --as admin --out /tmp/qa-dash.png
```
```
✓ screenshot /tmp/qa-dash.png (1440x900, as admin)
LANDING
(none)
CONSOLE ERRORS
(none)
NETWORK FAILURES
(none)
```
**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** نیمی از باگ‌های UI فقط دیدنی‌اند،
نه لاگ‌شدنی — همان یک اسکرین‌شات داشبورد دو باگ i18n لو داد (پایین را ببین).
فلگ‌ها: `--w 1440 --h 900` (ویوپورت)، `--wait 4000` (ms صبر برای رندر)، `--full` (کل صفحه).
**موبایل را جدا تست کن** — پنل RTL و پرجدول است و بیشتر مشکلات آنجاست:
```bash
node .claude/skills/qa-clinicpro/driver.mjs visit \
"https://clinic-pro.ddev.site/admin/dashboard" --as admin --w 390 --h 844 --out /tmp/qa-m.png
```
بخش `LANDING` دو حالتی را می‌گیرد که اسکرین‌شات پنهان می‌کند:
```
⚠ WRONG PAGE: asked /admin/users, landed /admin/dashboard — role likely lacks access (RoleRoute in App.tsx)
```
### ۲. آدیت UI/UX و RTL
```bash
node .claude/skills/qa-clinicpro/driver.mjs ux \
"https://clinic-pro.ddev.site/admin/dashboard" --as admin --w 390 --h 844
```
```
UX FINDINGS (390x844, as admin)
5 tap target(s) under 36px on a mobile viewport
```
چک‌ها: RTL نبودن ریشه، `lang` غلط، سرریز افقی، رقم لاتین داخل متن فارسی، تارگت لمسی
زیر ۳۶px، `<img>` بدون alt، فیلد بدون label، `id` تکراری، جدول خالی بدون empty-state،
و `<select>` نیتیو (استاندارد پروژه `SearchableSelect` است).
### ۳. تست دسترسی نقش‌ها (Security)
همان درخواست با همه نقش‌ها + ناشناس:
```bash
node .claude/skills/qa-clinicpro/driver.mjs authz GET /api/v1/admin/users
```
```
AUTHZ GET /api/v1/admin/users
anonymous 401
admin 200
clinic 403
secretary 403
doctor 403
representation 403
200 for: admin
```
هر ۲۰۰ غیرمنتظره در این جدول = یک باگ Critical. اگر `anonymous` هم ۲۰۰ گرفت، درایور
هشدار می‌دهد.
#### ۳.۱ جاروی کامل ماتریس — اجباری، نه نمونه‌ای
`authz` خودش همهٔ پرسوناها را می‌زند، پس **تست دسترسی نباید روی چند اندپوینت منتخب
بماند**. لیست اندپوینت‌ها را از روتر بگیر و همه را جارو کن:
```bash
ddev exec php bin/console debug:router --format=json \
| node -e 'const r=JSON.parse(require("fs").readFileSync(0));
for (const [n,v] of Object.entries(r))
if (v.path.startsWith("/api/v1") && !v.path.includes("{"))
console.log(v.method.split("|")[0].replace("ANY","GET"), v.path);' \
| while read m p; do
node .claude/skills/qa-clinicpro/driver.mjs authz "$m" "$p"
done | tee /tmp/qa-authz-matrix.txt
```
روی این DB حدود **۱۶۸ مسیر بدون پارامتر** برمی‌گردد و `authz` برای هر مسیر به‌ازای هر
پرسونا دوباره لاگین می‌کند (≈۲۲۰۰ درخواست) — چند دقیقه طول می‌کشد، پس در پس‌زمینه
اجرایش کن و بعد فایل را بخوان. دو تله در خواندن خروجی:
- **۴۲۲ روی مسیرهای POST طبیعی است** (بدنه خالی فرستاده شده) و باگ نیست؛ چیزی که مهم
است تمایز ۴۰۱/۴۰۳ از بقیه است. اگر نقشی به‌جای ۴۰۳ یک ۴۲۲ گرفت، یعنی **گارد بعد از
اعتبارسنجی اجرا شده** — همان هم یافته است.
- **مسیرهای عمومی** (لاگین، ثبت‌نام، لیست شهرها) قاعدتاً برای `anonymous` هم ۲۰۰‌اند؛
اول با `config/packages/security.yaml` تطبیق بده، بعد ادعای نشت کن.
اندپوینت‌های پارامتردار (`{uuid}`) از این حلقه می‌افتند — آن‌ها را دستی و با
**شناسهٔ متعلق به پرسونای دیگر** بزن، چون همان‌جاست که IDOR پیدا می‌شود:
```bash
# uuid پزشکِ دیگری را به پرسونای doctor_solo بده — باید ۴۰۳/۴۰۴ بگیرد، نه ۲۰۰
node .claude/skills/qa-clinicpro/driver.mjs authz GET /api/v1/doctor/<uuid-of-another-doctor>
```
سه الگویی که باید در `/tmp/qa-authz-matrix.txt` دنبالشان بگردی:
| یافته | معنی |
|---|---|
| `anonymous` = ۲۰۰ روی مسیر غیرعمومی | نشت داده — Critical |
| نقشی ۲۰۰ می‌گیرد که در ماتریس ۰.۵ نبود | گارد جا افتاده — Critical |
| ۲۰۰ روی uuidِ مستأجر دیگر | IDOR — Critical |
| نقشی ۴۰۳ می‌گیرد که طبق ماتریس باید ۲۰۰ بگیرد | یا گارد سخت‌گیر است یا ماتریس غلط — بررسی کن |
| ۵۰۰ به‌جای ۴۰۳ | گارد کار می‌کند ولی خطا مدیریت نشده — High |
**بدون این جدولِ کامل، فاز دسترسی تمام‌شده نیست.** خروجی‌اش را در گزارش نهایی بیاور.
### ۴. تست قرارداد API
```bash
node .claude/skills/qa-clinicpro/driver.mjs api GET /api/v1/categorys/state --as admin
```
```
GET /api/v1/categorys/state → 301 12ms (as admin)
ENVELOPE
(none)
BODY
{
"success": false,
"data": null,
"errors": [
{ "code": "ERR_MOVED", "message": "این endpoint منتقل شده. لطفاً از /api/v1/provinces استفاده کنید." }
]
}
```
بخش `ENVELOPE` پاکت `BaseController` را چک می‌کند: نبودِ `success`، پاسخ خطای بدون
`errors`، و دام معروف **double/triple nesting** (`data.data.data`).
POST هم می‌شود: `--body '{"name":"x"}'`.
### ۵. کارایی
```bash
node .claude/skills/qa-clinicpro/driver.mjs perf "https://clinic-pro.ddev.site/admin/doctors" --as admin
```
```
PERF https://clinic-pro.ddev.site/admin/doctors (as admin)
ttfb 12ms
domContentLoaded 232ms
load 233ms
first-paint 180ms
first-contentful-paint 248ms
resources 24 · DOM nodes 1132
SLOWEST API CALLS
18ms 2kb v1/admin/doctors?page=1&limit=25
17ms 1kb v1/admin/doctors/stats
17ms 5kb v1/specialties
13ms 1kb v1/provinces
```
---
## نقش QA و روش کار
وقتی این skill فعال شد، مثل یک **مهندس ارشد تست** رفتار کن، نه فقط اجراکننده دستور:
0. **Phase 0 را تمام کن** (بالا). بدون پرسوناهای کامل، هر تستی نتیجهٔ بی‌معنی می‌دهد.
1. **اول سناریوی واقعی کاربر را بنویس**، بعد اجرا کن. مثال: ورود منشی → لیست نوبت‌ها →
تغییر وضعیت یک نوبت → خروج → ورود مجدد → آیا تغییر ماند؟
**پیمایش با هر پرسونا اجباری است.** بعد از Phase 0، برای *هر* پرسونا در جدول، وارد شو و
مسیرهای مجازش را طبق ماتریس ۰.۵ بگرد — نه فقط با `admin`. برای هر پرسونا حداقل:
```bash
for p in admin clinic doctor_solo doctor_member clinic_doctor \
secretary secretary_clinic representation patient; do
node .claude/skills/qa-clinicpro/driver.mjs visit \
"https://clinic-pro.ddev.site/admin/dashboard" --as "$p" --out "/tmp/qa-$p.png"
done
```
بعد **هر اسکرین‌شات را با Read باز کن و ببین** — و بخش `LANDING` را بخوان تا ریدایرکت
بی‌صدای نقش را نگیری. سه چیزی که فقط با مقایسهٔ بین پرسوناها پیدا می‌شوند:
- **نشت داده بین مستأجرها:** آیا `doctor_solo` دادهٔ بیمار پزشک دیگری را می‌بیند؟ آیا
`clinic` نوبت‌های پزشک غیرعضو را می‌بیند؟ این‌ها همیشه Critical‌اند.
- **صفحهٔ سفید به‌جای «دسترسی ندارید»:** نقشی که نباید ببیند، باید پیام روشن بگیرد.
- **منوی سایدبار در برابر دسترسی واقعی:** آیتمی که نمایش داده می‌شود ولی به ۴۰۳
می‌خورد (یا برعکس: مسیر باز است ولی در منو نیست) باگ است.
2. برای هر بخش این حالت‌ها را پوشش بده:
Happy Path · ورودی نامعتبر · داده خالی · داده خیلی زیاد (لیست ۱۰٬۹۳۲ کاربری) ·
شرایط مرزی · خطای شبکه · **همهٔ پرسوناها** · دسکتاپ ۱۴۴۰ و موبایل ۳۹۰.
3. **هیچ چیز را حدس نزن.** ادعای بدون خروجی دستور، ادعا نیست.
4. **قبل از گزارش، باگ را دوباره تکرار کن.** همان دستور را دوباره بزن؛ اگر تکرار نشد،
flaky بودنش را بنویس نه خودِ باگ را.
5. باگ‌های کوچک UI را هم گزارش کن، ولی باگ‌های Business Logic اولویت بالاترند.
### وقتی به مانع خوردی — رفعش کن، بعد برو تست بعدی
QA اینجا فقط گزارش‌نویس نیست. هر جا اجرای تست گیر کرد، **مثل یک دولوپر ارشد
Symfony/React خودت مشکل را حل کن**، تأیید کن که حل شده، و تست را از همان‌جا ادامه بده.
توقف روی اولین مانع یعنی بقیهٔ نقش‌ها هیچ‌وقت تست نمی‌شوند.
روال ثابت هر مانع:
```
بازتولید → ریشه‌یابی (نه علامت) → اصلاح → اثبات اصلاح → ثبت → ادامهٔ همان تست
```
1. **ریشه را پیدا کن، نه علامت را.** `visit` صفحهٔ سفید داد؟ اول `CONSOLE ERRORS` و
`NETWORK FAILURES`، بعد فایل سورس صفحه (`redesign-page/driver.mjs inspect`)، بعد
کنترلر مربوطه. اصلاح باید در همان لایه‌ای باشد که علت آنجاست.
2. **طبق قواعد پروژه اصلاح کن**، نه با وصلهٔ سریع:
- بک‌اند: `extends BaseController`، خطا با `AppException(ErrorCodes::…)`، کد SOLID،
تغییر entity ⟵ `doctrine:migrations:diff` + `migrate`.
- فرانت: TanStack Query برای دادهٔ سرور، کامپوننت‌های `components/ui/`، توکن‌های
`styles.css` (هیچ hex هاردکد)، رشته‌های فارسی.
- اندپوینت عوض شد ⟵ همان جلسه `docs/api/<domain>.md` را به‌روز کن (قاعدهٔ ثابت پروژه).
3. **اثبات کن.** همان دستوری که شکست خورده بود را دوباره بزن و خروجی سالمش را نشان بده.
بعد `ddev exec php bin/phpunit` و در صورت تغییر فرانت `npx tsc --noEmit` را اجرا کن
تا مطمئن شوی چیزی نشکسته‌ای.
4. **ثبت کن.** هر اصلاح یک ورودی در بخش «Fixes Applied» گزارش نهایی می‌گیرد:
مانع · ریشه · فایل‌های تغییریافته · دستور اثبات.
**مرزهایی که رد نمی‌کنی:**
- **باگ محصول را بی‌صدا رفع نکن.** اگر مانع خودش یک باگ واقعی محصول است، هم Bug Report
را بنویس هم اصلاح را — نه فقط اصلاح. گزارش، خروجی کار است.
- **هرگز برای سبزشدن تست، دسترسی را باز نکن.** اگر نقشی ۴۰۳ می‌گیرد و تو انتظار ۲۰۰
داری، پیش‌فرض این است که **انتظارت غلط است**. `IsGranted` یا `RoleRoute` را فقط وقتی
عوض کن که از روی کد ثابت کرده باشی آن نقش باید دسترسی داشته باشد، و دلیلش را بنویس.
همین قاعده برای حذف اعتبارسنجی ورودی هم هست.
- **دادهٔ تست را با تغییر محصول نساز.** کمبود دادهٔ پرسونا را با seed درست کن، نه با
نرم‌کردن یک قاعدهٔ کسب‌وکار.
- **مهاجرت مخرب نزن.** روی DB لوکالِ پر (۱۰٬۹۳۲ کاربر) `doctrine:schema:drop` یا
مهاجرتی که ستون پرداده را می‌اندازد، ممنوع.
- **اگر اصلاح از تست بزرگ‌تر شد** (بازطراحی معماری، تغییر شکست‌دهندهٔ قرارداد API که
`nobat724_front` و `clinic-pro-tauri` هم مصرفش می‌کنند)، دست نگه دار: باگ را با
اصلاح پیشنهادی گزارش کن، آن یک تست را `SKIPPED` علامت بزن، و **برو تست بعدی**.
### فرمت Bug Report
هر یافته را با این قالب بنویس (فارسی):
```markdown
## Title
<عنوان کوتاه و مشخص>
- **Severity:** Critical | High | Medium | Low
- **Priority:** فوری | مهم | معمولی | کم
- **Environment:** Chrome headless · macOS · ddev · نقش: <role> · ویوپورت: <w>x<h>
### Description
### Steps To Reproduce
1. `node .claude/skills/qa-clinicpro/driver.mjs …` ← دستور دقیق، نه توضیح
2.
### Expected Behavior
### Actual Behavior
### Evidence
<خروجی درایور، مسیر اسکرین‌شات، پاسخ API>
### Impact
### Suggested Fix
<فایل:خط اگر پیدا کردی>
```
برای پیدا کردن فایل سورس یک صفحه از روی URL، از skill خواهر استفاده کن:
```bash
node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.site/admin/doctors"
```
### گزارش نهایی
۱. خلاصه وضعیت کلی · ۲. تعداد باگ‌ها · ۳. لیست بر اساس Severity ·
۴. باگ‌هایی که باید فوری رفع شوند · ۵. پیشنهاد بهبود کیفیت.
به‌علاوه این سه بخش که از قواعد بالا می‌آیند:
**۶. Fixes Applied** — هر مانعی که خودت رفع کردی:
| مانع | ریشه | فایل‌های تغییریافته | دستور اثبات |
|---|---|---|---|
**۷. ماتریس دسترسی** — جدول کامل `مسیر × پرسونا` از بخش ۳.۱، با اختلاف‌های
ماتریسِ کد و رفتار واقعی مشخص‌شده.
**۸. پوشش** — کدام پرسونا چه چیزی تست شد، و هر `SKIPPED` با دلیلش. اگر نقشی تست نشد
باید اینجا صریح بیاید؛ گزارشِ ساکت بدتر از گزارش ناقص است.
---
## Gotchas
- **SPA است، پس `curl` صفحه نمی‌دهد.** `curl /admin/doctors` همیشه همان HTML پوسته را
برمی‌گرداند. هر ادعایی درباره محتوای صفحه باید از `visit` بیاید.
- **ریدایرکت بی‌صدای نقش.** `RoleRoute` در `App.tsx` کاربر بدون دسترسی را بی‌هیچ پیغامی
به `/dashboard` می‌فرستد — اسکرین‌شات کاملاً سالم به‌نظر می‌رسد ولی صفحهٔ اشتباهی است.
همیشه بخش `LANDING` را بخوان.
- **توکن فقط ۱۵ دقیقه اعتبار دارد.** درایور برای هر دستور دوباره لاگین می‌کند، پس مسئله‌ای
نیست؛ ولی اگر خودت توکن را جایی کش کردی، انتظار ۴۰۱ داشته باش.
- **`TEST_USERS.md` دروغ می‌گوید** (بالا). به آن استناد نکن.
- **`CLAUDE.md` هم روی `/api/v1/categorys/{bundle}` منسوخ است** — آن مسیر حالا ۳۰۱ با
`ERR_MOVED` می‌دهد و مسیر واقعی `/api/v1/provinces` است.
- **کد OTP در محیط dev همیشه `12345` است** (`OtpService::sendCode` — در غیر dev کد تصادفی
۵رقمی می‌سازد و SMS می‌کند). پس زنجیرهٔ کامل ورود بدون رمز اسکریپت‌پذیر است:
`POST /api/v1/user/send-code``POST /api/v1/user/verify-code` با `code=12345`
`grant``POST /api/v1/user/otp-login`.
- **`patient` و `unclaimed_doctor` با رمز وارد نمی‌شوند و این باگ نیست.**
`PasswordAuthenticator::onAuthenticationSuccess` هر کاربری که `User::isStaff()` نباشد را
با ۴۰۳ و `ERR_AUTH_006` رد می‌کند (staff = doctor/clinic/secretary/admin/representation/importer).
این دو پرسونا فقط OTP-only هستند؛ درایور خودش به زنجیرهٔ OTP بالا fallback می‌کند.
**گاردش را برای سبزشدن تست باز نکن.**
- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد** (`config/packages/rate_limiter.yaml`).
یک جاروی کامل authz این سقف را می‌سوزاند و بعدش پرسوناهای OTP-only شکست می‌خورند
(`ERR_RATE_LIMIT_001`). راه‌حل بدون دست‌زدن به محصول: توکن را مستقیم با کامند خود اپ بساز —
```bash
ddev exec 'php bin/console lexik:jwt:generate-token 09129000006 --user-class="App\\Auth\\Entity\\User"'
```
همان کلید و همان claimها؛ فقط محدودیت نرخ را دور می‌زند.
- **برای جاروی ماتریس، درایور را در حلقه صدا نزن.** هر فراخوانی دوباره لاگین می‌کند
(۱۶۸ مسیر × ۱۳ پرسونا ≈ ۲۲۰۰ لاگین) — هم چند ده دقیقه طول می‌کشد هم rate limit را می‌سوزاند.
یک‌بار برای هر پرسونا توکن بگیر و همان را در همهٔ مسیرها استفاده کن.
- **گواهی TLS ddev را Node قبول نمی‌کند.** درایور فقط برای هاست‌های `*.ddev.site` /
`localhost` `NODE_TLS_REJECT_UNAUTHORIZED=0` می‌گذارد و وارنینگ نویزی‌اش را خفه می‌کند.
- **خطاهای صفحهٔ لاگین به حساب صفحهٔ تحت تست نوشته نشوند.** درایور بافر خطا را بعد از
seed کردن `localStorage` و قبل از ناوبری به URL هدف پاک می‌کند.
- **دیتای لوکال واقعی و بزرگ است** (۱۰٬۹۳۲ کاربر، ۲۰۲ کلینیک، ۱۷۹ پزشک) — برای تست
«داده زیاد» لازم نیست چیزی seed کنی.
- **CDP روی پورت ۹۴۴۴** است تا با درایور `redesign-page` (پورت ۹۳۳۳) تداخل نکند؛
می‌توانی هر دو را هم‌زمان اجرا کنی. `CDP_PORT` قابل تغییر است.
## Troubleshooting
| نشانه | علت / رفع |
|---|---|
| `login as admin failed: … ERR_AUTH_005` | DB ریست شده؛ پسورد QA را دوباره ست کن (بخش «کاربران تست») |
| `Chrome did not expose CDP on :9444` | `CHROME_BIN` غلط است، یا نمونهٔ قبلی کروم روی همان پورت مانده — `pkill -f clinicpro-qa` |
| `⚠ page text only N chars` | رندر SPA کرش کرده یا کند است؛ اول `--wait 8000` را امتحان کن، بعد `CONSOLE ERRORS` را بخوان |
| `⚠ redirected to /login` | توکن رد شده — با `driver.mjs login <role>` صحتش را چک کن |
| `fetch failed` / `ECONNREFUSED` | ddev بالا نیست: `ddev start` |
## باگ‌های شناخته‌شده (در همین اجرا پیدا شدند)
نمونه‌هایی از خروجی واقعی همین درایور، به‌عنوان مرجعِ اینکه گزارش چطور باشد:
1. **Medium** — در «وضعیت نوبت‌ها»ی داشبورد، برچسب‌های `confirmed` و `expired` انگلیسی
مانده‌اند در حالی که بقیه فارسی‌اند («تکمیل شده»، «لغو پزشک»).
بازتولید: `visit https://clinic-pro.ddev.site/admin/dashboard --as admin`، اسکرین‌شات.
2. **Low** — کارت «درآمد این ماه» کلمهٔ «تومان» را دو بار نشان می‌دهد (یک‌بار کنار عدد،
یک‌بار به‌عنوان زیرنویس کارت). همان اسکرین‌شات.
3. **Low** — در ویوپورت ۳۹۰px داشبورد، ۵ تارگت لمسی زیر ۳۶px هستند.
بازتولید: `ux … --w 390 --h 844`.
4. **Medium (مستندات)** — `TEST_USERS.md` و بخش Category در `CLAUDE.md` هر دو منسوخ‌اند.
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env node
/**
* ClinicPro QA driver — drives the running app the way a real user would, and
* reports what broke. No npm dependencies: Node 22's global WebSocket speaks CDP
* to a headless Chrome directly, so there is no playwright/puppeteer to install.
*
* driver.mjs login <role>
* driver.mjs visit <url> [--as role] [--out f.png] [--w] [--h] [--wait] [--full]
* driver.mjs api <METHOD> <path> [--as role] [--body '{...}']
* driver.mjs authz <METHOD> <path> [--body '{...}']
* driver.mjs ux <url> [--as role]
* driver.mjs perf <url> [--as role]
* driver.mjs roles
*
* `visit` is the workhorse: it logs in over the API, seeds the SPA's auth store
* into localStorage, navigates, then reports console errors, failed network
* requests, the path it actually landed on, and a screenshot.
*/
import { spawn, execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9444);
/**
* Local QA personas — one per distinct authorization identity in the product,
* not merely one per ROLE_* constant: an independent doctor and a clinic-member
* doctor carry the same role but see different data, so each gets its own row.
*
* The first five predate this list and are known to exist; the rest are
* provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
* until they are. TEST_USERS.md is stale — its accounts do not exist.
*/
const ROLES = {
admin: ['09120671756', 'QaTest@1234'],
clinic: ['09127000000', 'QaTest@1234'],
secretary: ['09123456778', 'QaTest@1234'],
doctor: ['09390039833', 'QaTest@1234'],
representation: ['09124000001', 'QaTest@1234'],
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
doctor_member: ['09129000002', 'QaTest@1234'], // member of a clinic
clinic_doctor: ['09129000003', 'QaTest@1234'], // ROLE_CLINIC + ROLE_DOCTOR
secretary_clinic: ['09129000004', 'QaTest@1234'], // secretary of a clinic
unclaimed_doctor: ['09129000005', 'QaTest@1234'], // imported, unclaimed profile
patient: ['09129000006', 'QaTest@1234'], // ROLE_USER only
importer: ['09129000007', 'QaTest@1234'],
};
// ddev serves a locally-signed cert Node's fetch refuses. Relax TLS only for it.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// …which Node then warns about on every run, drowning the actual QA output.
process.removeAllListeners('warning');
process.on('warning', () => {});
}
// ── auth ───────────────────────────────────────────────────────────────────
function creds(role) {
if (ROLES[role]) return ROLES[role];
if (role.includes(':')) return role.split(':'); // "0912...:password"
throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`);
}
/**
* Non-staff accounts (ROLE_USER, ROLE_UNCLAIMED_DOCTOR) are rejected by
* PasswordAuthenticator with ERR_AUTH_006 by design — they are OTP-only.
* In dev the OTP is the fixed '12345' (OtpService::sendCode), so the whole
* send-code → verify-code → otp-login chain is scriptable.
*/
async function otpLogin(mobile) {
const post = async (path, body) => {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return r.json();
};
const sent = await post('/api/v1/user/send-code', { mobile });
if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 200)}`);
const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
const grant = ver?.data?.grant;
if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 200)}`);
return post('/api/v1/user/otp-login', { grant });
}
/** Same signing key and claims as a real login — only skips the rate limiter. */
function mintToken(mobile) {
const out = execSync(
`ddev exec 'php bin/console lexik:jwt:generate-token ${mobile} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd() },
);
const tok = out.trim().split('\n').pop().trim();
if (!tok.startsWith('ey')) throw new Error(`could not mint token for ${mobile}`);
return tok;
}
async function login(role) {
const [mobile_number, password] = creds(role);
const r = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number, password }),
});
const j = await r.json();
if (j.access_token) return j;
// ERR_AUTH_006 here means "staff-only endpoint", not "wrong password".
if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006')) {
const o = await otpLogin(mobile_number).catch((e) => ({ _err: e.message }));
if (o.access_token) return o;
// send-code is capped at 5/hour/IP; once burned, mint the JWT with the app's
// own command rather than loosening a real product limit for a test.
return { access_token: mintToken(mobile_number) };
}
throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
}
/** JWT is unsigned-read here purely to report which roles a token carries. */
function claims(token) {
const s = token.split('.')[1];
return JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
}
// ── CDP plumbing ───────────────────────────────────────────────────────────
async function waitForCdp(timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return (await r.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
}
/** CDP client with both request/response and event subscription. */
function cdp(ws) {
let id = 0;
const pending = new Map();
const listeners = [];
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
} else if (msg.method) {
listeners.forEach((fn) => fn(msg.method, msg.params));
}
});
const send = (method, params = {}, sessionId) =>
new Promise((res, rej) => {
const msgId = ++id;
pending.set(msgId, { resolve: res, reject: rej });
ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
});
send.on = (fn) => listeners.push(fn);
return send;
}
/**
* Boot Chrome, authenticate the SPA, navigate, and hand the page to `fn`.
* Collects console errors and failed requests for the whole session.
*/
async function withPage(url, opts, fn) {
const { access_token, refresh_token } = await login(opts.as);
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
'--ignore-certificate-errors', // ddev's local CA
`--remote-debugging-port=${PORT}`,
`--user-data-dir=/tmp/clinicpro-qa-${process.pid}`,
`--window-size=${opts.w},${opts.h}`,
'about:blank',
], { stdio: 'ignore' });
const errors = [];
const netFails = [];
try {
const ws = new WebSocket(await waitForCdp());
await new Promise((res) => ws.addEventListener('open', res, { once: true }));
const send = cdp(ws);
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => send(m, p, sessionId);
await S('Page.enable');
await S('Runtime.enable');
await S('Log.enable');
await S('Network.enable');
send.on((method, p) => {
if (method === 'Runtime.exceptionThrown') {
errors.push(`uncaught: ${p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text}`);
} else if (method === 'Runtime.consoleAPICalled' && p.type === 'error') {
errors.push('console.error: ' + p.args.map((a) => a.value ?? a.description ?? a.type).join(' '));
} else if (method === 'Log.entryAdded' && p.entry.level === 'error') {
errors.push(`log(${p.entry.source}): ${p.entry.text}`);
} else if (method === 'Network.loadingFailed') {
netFails.push(`request failed: ${p.errorText}`);
} else if (method === 'Network.responseReceived' && p.response.status >= 400) {
netFails.push(`HTTP ${p.response.status} ${p.response.url.replace(BASE, '')}`);
}
});
// localStorage is origin-scoped: load the origin before seeding it.
await S('Page.navigate', { url: `${BASE}/admin/login` });
await new Promise((r) => setTimeout(r, 1500));
const auth = {
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
version: 0,
};
await S('Runtime.evaluate', {
expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('pwa-dismissed','1');`,
});
// Errors before this point belong to the login page, not the page under test.
errors.length = 0; netFails.length = 0;
await S('Page.navigate', { url });
await new Promise((r) => setTimeout(r, opts.wait));
const evalJs = async (expression) => {
const { result, exceptionDetails } = await S('Runtime.evaluate', {
expression, returnByValue: true, awaitPromise: true,
});
if (exceptionDetails) throw new Error(exceptionDetails.text);
return result.value;
};
await fn({ S, evalJs, errors, netFails, url, opts });
ws.close();
} finally {
chrome.kill();
}
}
/** Report the two failure modes a screenshot alone hides: wrong page, blank page. */
async function landingCheck(evalJs, url) {
const v = await evalJs('JSON.stringify({p:location.pathname,t:(document.body.innerText||"").trim().length})');
const { p: landed, t: len } = JSON.parse(v);
const wanted = new URL(url).pathname;
const out = [];
if (landed.includes('/login')) out.push('⚠ redirected to /login — token rejected, expired, or route requires auth');
else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
out.push(`⚠ WRONG PAGE: asked ${wanted}, landed ${landed} — role likely lacks access (RoleRoute in App.tsx)`);
}
if (len < 40) out.push(`⚠ page text only ${len} chars — likely blank / crashed render`);
return out;
}
function report(title, lines) {
console.log(`\n${title}`);
console.log(lines.length ? lines.map((l) => ' ' + l).join('\n') : ' (none)');
}
// ── commands ───────────────────────────────────────────────────────────────
async function cmdVisit(url, opts) {
await withPage(url, opts, async ({ S, evalJs, errors, netFails }) => {
const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.full });
writeFileSync(opts.out, Buffer.from(data, 'base64'));
console.log(`✓ screenshot ${opts.out} (${opts.w}x${opts.h}, as ${opts.as})`);
report('LANDING', await landingCheck(evalJs, url));
report('CONSOLE ERRORS', [...new Set(errors)]);
report('NETWORK FAILURES', [...new Set(netFails)]);
});
}
/**
* DOM heuristics for the recurring UX defects of an RTL Persian admin: layout
* that overflows sideways, Latin digits leaking into Persian copy, tap targets
* too small for the mobile viewport, tables with no empty state.
*/
const UX_PROBE = `(() => {
const out = [];
const de = document.documentElement;
if (de.dir !== 'rtl' && getComputedStyle(de).direction !== 'rtl') out.push('root is not RTL');
if (de.lang !== 'fa') out.push('html lang is "' + de.lang + '", expected "fa"');
if (de.scrollWidth > de.clientWidth + 2)
out.push('horizontal overflow: content ' + de.scrollWidth + 'px > viewport ' + de.clientWidth + 'px');
// Latin digits inside Persian text read as untranslated to a Persian user.
const fa = /[\\u0600-\\u06FF]/, latin = /[0-9]/;
let mixed = 0;
document.querySelectorAll('h1,h2,h3,label,th,button,a').forEach(el => {
const t = (el.textContent||'').trim();
if (t && fa.test(t) && latin.test(t)) mixed++;
});
if (mixed) out.push(mixed + ' element(s) mix Persian text with Latin digits (use Persian numerals)');
// 44px is the usual minimum comfortable touch target.
if (innerWidth < 600) {
let small = 0;
document.querySelectorAll('button,a,[role=button]').forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && (r.height < 36 || r.width < 36)) small++;
});
if (small) out.push(small + ' tap target(s) under 36px on a mobile viewport');
}
document.querySelectorAll('img:not([alt])').forEach(() => {});
const noAlt = document.querySelectorAll('img:not([alt])').length;
if (noAlt) out.push(noAlt + ' <img> without alt');
const noLabel = [...document.querySelectorAll('input,select,textarea')]
.filter(el => !el.labels?.length && !el.getAttribute('aria-label') && !el.placeholder).length;
if (noLabel) out.push(noLabel + ' form field(s) with no label, aria-label, or placeholder');
const ids = {}; let dup = 0;
document.querySelectorAll('[id]').forEach(el => { dup += (ids[el.id] = (ids[el.id]||0) + 1) > 1 ? 1 : 0; });
if (dup) out.push(dup + ' duplicate DOM id(s)');
// A table rendered with zero rows and no empty-state message is a dead end.
document.querySelectorAll('table').forEach((t, i) => {
const rows = t.querySelectorAll('tbody tr').length;
if (rows === 0 && !/(هیچ|یافت نشد|خالی|موردی)/.test(t.parentElement?.textContent||''))
out.push('table #' + (i+1) + ' has 0 rows and no empty-state message');
});
if (document.querySelector('select')) out.push('native <select> present — project standard is SearchableSelect');
return JSON.stringify(out);
})()`;
async function cmdUx(url, opts) {
await withPage(url, opts, async ({ evalJs, errors, netFails }) => {
report('LANDING', await landingCheck(evalJs, url));
report(`UX FINDINGS (${opts.w}x${opts.h}, as ${opts.as})`, JSON.parse(await evalJs(UX_PROBE)));
report('CONSOLE ERRORS', [...new Set(errors)]);
report('NETWORK FAILURES', [...new Set(netFails)]);
});
}
async function cmdPerf(url, opts) {
await withPage(url, opts, async ({ evalJs }) => {
const t = JSON.parse(await evalJs(`JSON.stringify({
nav: performance.getEntriesByType('navigation')[0],
paint: performance.getEntriesByType('paint'),
api: performance.getEntriesByType('resource')
.filter(r => r.name.includes('/api/'))
.map(r => ({ u: r.name.split('/api/')[1], ms: Math.round(r.duration), kb: Math.round(r.transferSize/1024) }))
.sort((a,b) => b.ms - a.ms).slice(0, 12),
res: performance.getEntriesByType('resource').length,
dom: document.querySelectorAll('*').length,
})`));
console.log(`\nPERF ${url} (as ${opts.as})`);
if (t.nav) {
console.log(` ${'ttfb'.padEnd(24)}${Math.round(t.nav.responseStart)}ms`);
console.log(` ${'domContentLoaded'.padEnd(24)}${Math.round(t.nav.domContentLoadedEventEnd)}ms`);
console.log(` ${'load'.padEnd(24)}${Math.round(t.nav.loadEventEnd)}ms`);
}
t.paint.forEach((p) => console.log(` ${p.name.padEnd(24)}${Math.round(p.startTime)}ms`));
console.log(` resources ${t.res} · DOM nodes ${t.dom}`);
report('SLOWEST API CALLS', t.api.map((a) => `${String(a.ms).padStart(5)}ms ${a.kb}kb ${a.u}`));
});
}
async function apiCall(method, path, role, body) {
const { access_token } = await login(role);
const t0 = Date.now();
const r = await fetch(`${BASE}${path.startsWith('/') ? path : '/' + path}`, {
method,
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: body ?? undefined,
});
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch { /* not json */ }
return { status: r.status, ms: Date.now() - t0, json, text };
}
async function cmdApi(method, path, opts) {
const { status, ms, json, text } = await apiCall(method, path, opts.as, opts.body);
console.log(`${method} ${path}${status} ${ms}ms (as ${opts.as})`);
// BaseController's envelope is the contract every client depends on.
const problems = [];
if (!json) problems.push('response is not JSON');
else {
if (!('success' in json)) problems.push('envelope missing "success"');
if (status >= 400 && !json.errors) problems.push('error response has no "errors" array');
if (json?.data?.data?.data) problems.push('triple-nested data — BaseController double-nesting pitfall');
else if (json?.data?.data && !Array.isArray(json.data)) problems.push('double-nested data (client must read data.data.data)');
}
report('ENVELOPE', problems);
console.log('\nBODY\n' + (json ? JSON.stringify(json, null, 2) : text).slice(0, 2000));
}
/** Same request as every role plus anonymous — the access-control matrix. */
async function cmdAuthz(method, path, opts) {
console.log(`AUTHZ ${method} ${path}\n`);
const rows = [];
const anon = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: opts.body ?? undefined });
rows.push(['anonymous', anon.status]);
for (const role of Object.keys(ROLES)) {
try {
const { status } = await apiCall(method, path, role, opts.body);
rows.push([role, status]);
} catch (e) {
rows.push([role, `login failed (${String(e.message).slice(0, 40)})`]);
}
}
rows.forEach(([r, s]) => console.log(` ${r.padEnd(16)} ${s}`));
const leaks = rows.filter(([r, s]) => r === 'anonymous' && s === 200);
if (leaks.length) console.log('\n ⚠ anonymous got 200 — endpoint is public. Intended?');
const allowed = rows.filter(([, s]) => s === 200).map(([r]) => r);
console.log(`\n 200 for: ${allowed.join(', ') || '(nobody)'}`);
}
async function cmdRoles() {
for (const role of Object.keys(ROLES)) {
try {
const j = await login(role);
const c = claims(j.access_token);
const mins = Math.round((c.exp - c.iat) / 60);
console.log(`${role.padEnd(16)} ${creds(role)[0]} ${c.roles.join(',')} token ${mins}min`);
} catch (e) {
console.log(`${role.padEnd(16)}${e.message.slice(0, 90)}`);
}
}
}
// ── CLI ────────────────────────────────────────────────────────────────────
const [cmd, ...argv] = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : d; };
const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--full'));
const opts = {
as: flag('as', 'admin'),
out: flag('out', '/tmp/clinicpro-qa.png'),
w: Number(flag('w', 1440)),
h: Number(flag('h', 900)),
wait: Number(flag('wait', 4000)),
full: argv.includes('--full'),
body: flag('body', null),
};
try {
if (cmd === 'visit' && positional[0]) await cmdVisit(positional[0], opts);
else if (cmd === 'ux' && positional[0]) await cmdUx(positional[0], opts);
else if (cmd === 'perf' && positional[0]) await cmdPerf(positional[0], opts);
else if (cmd === 'api' && positional[1]) await cmdApi(positional[0].toUpperCase(), positional[1], opts);
else if (cmd === 'authz' && positional[1]) await cmdAuthz(positional[0].toUpperCase(), positional[1], opts);
else if (cmd === 'login' && positional[0]) console.log(JSON.stringify(claims((await login(positional[0])).access_token), null, 2));
else if (cmd === 'roles') await cmdRoles();
else {
console.log(`usage (roles: ${Object.keys(ROLES).join(', ')}, or "mobile:password")
driver.mjs roles
driver.mjs login <role>
driver.mjs visit <url> [--as admin] [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
driver.mjs ux <url> [--as admin] [--w] [--h]
driver.mjs perf <url> [--as admin]
driver.mjs api <METHOD> <path> [--as admin] [--body '{"k":1}']
driver.mjs authz <METHOD> <path> [--body '{"k":1}']`);
process.exit(1);
}
} catch (e) {
console.error('✗ ' + e.message);
process.exit(1);
}
+165
View File
@@ -0,0 +1,165 @@
---
name: redesign-page
description: بازطراحی UI/UX یک صفحه از پنل ادمین ClinicPro از روی URL آن — اسکرین‌شات گرفتن از صفحه، نگاشت URL به فایل سورس، آدیت انحراف‌ها از دیزاین‌سیستم، و بازنویسی صفحه با کامپوننت‌ها و توکن‌های موجود. استفاده کن وقتی کاربر یک URL از /admin می‌دهد و می‌گوید «این صفحه ui/ux خوبی ندارد»، «این صفحه را بازطراحی کن»، «redesign this page»، «این قسمت را درست کن»، یا «screenshot این صفحه».
---
# بازطراحی صفحه پنل ادمین ClinicPro
پنل ادمین یک SPA کلاینت‌ساید است (React 19 + Webpack Encore، سرو شده از `/admin/*`).
یعنی `curl` و فلگ `--screenshot` کروم به درد نمی‌خورند: هر دو روی فرم لاگین می‌نشینند،
چون توکن JWT در `localStorage['clinicpro-auth']` است.
درایور این skill آن کار را انجام می‌دهد: با API لاگین می‌کند، `localStorage` را seed
می‌کند، بعد ناوبری و اسکرین‌شات می‌گیرد — با CDP روی `WebSocket` نیتیو Node 22،
**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
مسیرها نسبت به `clinicpro/` هستند.
## پیش‌نیازها
هیچ نصبی لازم نیست. فقط این دو:
```bash
ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
```
کروم در مسیر دیگری است؟ `CHROME_BIN` را ست کن.
## گردش کار
### ۱. اسکرین‌شات صفحه فعلی
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/appointments" --out /tmp/before.png
```
**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** بدون دیدنِ صفحه، بازطراحی
یعنی حدس زدن.
فلگ‌ها: `--w 1440 --h 900` (سایز ویوپورت)، `--wait 4000` (میلی‌ثانیه صبر برای رندر)،
`--full` (کل صفحه، نه فقط ویوپورت).
موبایل هم ببین — این پنل RTL و پرجدول است و بیشتر مشکلات ریسپانسیو آنجاست:
```bash
node .claude/skills/redesign-page/driver.mjs shot \
"https://clinic-pro.ddev.site/admin/appointments" --w 390 --h 844 --out /tmp/mobile.png
```
### ۲. نگاشت URL به سورس + آدیت
```bash
node .claude/skills/redesign-page/driver.mjs inspect \
"https://clinic-pro.ddev.site/admin/clinics/41e325c4-e825-4067-8438-5d828ecaee09"
```
خروجی واقعی:
```
route clinics/:uuid
component ClinicDetailPage
file assets/admin/pages/ClinicDetailPage.tsx
components ConfirmDialog, Modal, PageHeader, SearchableSelect, NotificationMobileCard
lines 1035
AUDIT
assets/admin/pages/ClinicDetailPage.tsx:242 hand-rolled overlay — use the shared <Modal>
```
روی هر فایل دلخواه هم مستقیم:
```bash
node .claude/skills/redesign-page/driver.mjs audit assets/admin/pages/AppointmentsPage.tsx
```
### ۳. قبل از نوشتن کد، دیزاین‌سیستم را بخوان
**منبع حقیقتِ توکن‌ها `assets/admin/styles.css` است** — نه `docs/admin-ui/ui-design-spec.md`
(آن سند قدیمی و پالت بنفشش با کد شیپ‌شده نمی‌خواند).
```bash
sed -n '/^:root/,/^}/p' assets/admin/styles.css | head -60 # توکن‌ها
ls assets/admin/components/ui/ # کامپوننت‌های آماده
```
قانون: **اول کامپوننت موجود، بعد توسعه‌اش، در آخر ساخت کامپوننت جدید** — و دلیلش را بنویس.
### ۴. بازنویسی، سپس مقایسه
بعد از ادیت، دوباره اسکرین‌شات بگیر و با `before.png` مقایسه کن:
```bash
yarn dev # یا: yarn watch
node .claude/skills/redesign-page/driver.mjs shot "<همان url>" --out /tmp/after.png
```
### ۵. تست + تایپ‌چک (بدون این، تسک تمام نیست)
```bash
npx tsc --noEmit -p tsconfig.json
npx vitest run assets/admin/pages/<YourPage>.test.tsx
```
توجه: سوییت کامل همین الان **۲۱ تست از پیش شکسته** دارد (`api.test.ts`، `LoginPage`،
`PatientDetailPage`، …) که ربطی به کار تو ندارند. قبل از شروع یک‌بار `npx vitest run`
بگیر و عدد پایه را یادداشت کن، وگرنه خطاهای موجود را به گردن تغییر خودت می‌اندازی.
## چک‌لیست بازطراحی
درایور موارد گرپ‌شدنی را می‌گیرد؛ این‌ها را باید خودت با چشم ببینی:
- **`.field` در مقابل `.field-block`** — `.field` یک باکس افقی بوردردار است که لیبل
*داخلش* می‌نشیند. اگر `<label>` داخل `.field` بگذاری، لیبل کنار اینپوت می‌چسبد؛ و اگر
`SearchableSelect` داخلش بگذاری، دو باکس تودرتو می‌شود. برای «لیبل بالای فیلد» از
`.field-block` استفاده کن.
- **`className="btn"` بدون واریانت** بی‌رنگ و بدون بوردر رندر می‌شود — عملاً نامرئی.
همیشه `btn primary` / `btn ghost` / `btn soft` / `btn danger`.
- **دکمه‌های فقط-آیکون** → `mini-btn`، نه `btn ghost sm` با پدینگ دستی.
- **توکن مرده** — مثلاً `var(--error)` وجود ندارد (`--danger` درست است). درایور این را می‌گیرد.
- **سلسله‌مراتب** — عنوان صفحه در `PageHeader` بیاید و در کارت زیرش تکرار نشود.
- **RTL/جلالی** — رشته‌های جدید فارسی، تاریخ‌ها جلالی، اعداد با `formatNumber`/`formatRial`.
- **دارک‌مود** — چون توکن استفاده می‌کنی خودکار درست است؛ هگز هاردکد آن را می‌شکند.
## Gotchas
- **ریدایرکت خاموش نقش‌ها.** `RoleRoute` کاربری که نقشش اجازه ندارد را بی‌صدا به
`/admin/dashboard` می‌برد. یعنی یک اسکرین‌شات کاملاً سالم از **صفحهٔ اشتباه** می‌گیری.
درایور مسیر نهایی را با مسیر درخواستی مقایسه می‌کند و هشدار می‌دهد:
```
⚠ WRONG PAGE: asked for /admin/clinics/…, landed on /admin/dashboard
```
کاربر پیش‌فرض (`09390039833`) نقش **doctor** دارد. صفحات ادمین/کلینیک با آن باز نمی‌شوند.
برای آن‌ها `CLINICPRO_USER` / `CLINICPRO_PASS` را ست کن.
- **کاربران تستی ممکن است seed نشده باشند.** `TEST_USERS.md` ادمین `09100000001` با رمز
`Test@1234` را مستند می‌کند، ولی روی این دیتابیس وجود نداشت و لاگین `ERR_AUTH_005` داد.
ساختنشان: `ddev exec php create_test_users.php` (دیتابیس را می‌نویسد — اول بپرس).
- **مودال نصب PWA جلوی صفحه را می‌گیرد.** درایور `localStorage['pwa-dismissed']='1'` را
seed می‌کند. اگر با کروم خام اسکرین‌شات بگیری، این مودال وسط تصویر است.
- **کپچا (altcha) لوکال اجباری نیست.** `POST /api/v1/user/login` بدون فیلد `altcha` هم
توکن می‌دهد؛ درایور به همین تکیه می‌کند. اگر روی محیطی که کپچا را اجبار می‌کند اجرا شود، می‌شکند.
- **سرت ddev را Node رد می‌کند** (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). درایور فقط برای
هاست‌های `*.ddev.site` / `localhost` تأیید TLS را خاموش می‌کند، نه برای هر مبدأ.
- **صفحهٔ نوبت‌ها خودش اسکرول می‌شود** به ساعت جاری، پس ویوپورت وسط تایم‌لاین می‌افتد.
برای دیدن هدر از `--full` استفاده کن.
- **بیلد CSS داخل ddev خطای نیتیو `lightningcss` می‌دهد** — از قبل وجود دارد و جلوی
کامپایل JS/TS را نمی‌گیرد. خطاهای TypeScript همچنان در خروجی `tsc` می‌آیند.
## Troubleshooting
| علامت | علت / راه‌حل |
|---|---|
| `Chrome did not expose CDP on :9333` | نمونهٔ کروم قبلی زنده مانده. `CDP_PORT=9444` بده یا پروسه را بکش. |
| `login failed: … ERR_AUTH_005` | کاربر seed نشده یا رمز فرق دارد. `TEST_USERS.md` را ببین. |
| `⚠ redirected to /login` | توکن رد شد؛ معمولاً یعنی JWT منقضی شده — دوباره اجرا کن. |
| `⚠ page text is only N chars` | صفحه خالی رندر شده. `--wait 8000` بده یا کنسول را چک کن. |
| اسکرین‌شات تغییرات را نشان نمی‌دهد | باندل قدیمی است. `yarn dev` بزن (یا `yarn watch` روشن باشد). |
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* ClinicPro admin page driver — screenshots and audits a page of the React admin
* SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
* CDP directly, so there is no playwright/puppeteer install to babysit).
*
* node .claude/skills/redesign-page/driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
* node .claude/skills/redesign-page/driver.mjs inspect <url>
* node .claude/skills/redesign-page/driver.mjs audit <file.tsx>
*
* `shot` logs in over the API, seeds localStorage['clinicpro-auth'], then
* navigates and captures. Needed because the admin is a client-side
* SPA: Chrome's plain `--screenshot` flag lands on the login form.
* `inspect` maps a URL to the route entry in App.tsx, the page source file, and
* the design-system components it already imports.
* `audit` greps one source file for the anti-patterns this project keeps
* regrowing (native <select>, hardcoded hex, dead tokens, …).
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const USER = process.env.CLINICPRO_USER ?? '09390039833';
const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
const CHROME = process.env.CHROME_BIN
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9333);
// ddev serves a locally-signed cert that Node's fetch refuses. Only relax TLS for
// that local host — never for a real origin someone might point this at.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
// ── CDP plumbing ───────────────────────────────────────────────────────────
/** Chrome needs a moment before /json/version answers; poll instead of sleeping. */
async function waitForCdp(timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return (await r.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
}
/** Minimal CDP client: send(method, params) → Promise<result>. */
function cdp(ws) {
let id = 0;
const pending = new Map();
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve: res, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result);
}
});
return (method, params = {}, sessionId) =>
new Promise((res, reject) => {
const msgId = ++id;
pending.set(msgId, { resolve: res, reject });
ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
});
}
async function login() {
const r = await fetch(`${BASE}/api/v1/user/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: USER, password: PASS }),
});
const j = await r.json();
if (!j.access_token) throw new Error(`login failed: ${JSON.stringify(j).slice(0, 200)}`);
return j;
}
async function shot(url, opts) {
const { access_token, refresh_token } = await login();
const chrome = spawn(CHROME, [
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
'--ignore-certificate-errors', // ddev serves a local CA cert
`--remote-debugging-port=${PORT}`,
`--user-data-dir=/tmp/clinicpro-shot-${process.pid}`,
`--window-size=${opts.w},${opts.h}`,
'about:blank',
], { stdio: 'ignore' });
try {
const ws = new WebSocket(await waitForCdp());
await new Promise((res) => ws.addEventListener('open', res, { once: true }));
const send = cdp(ws);
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const S = (m, p) => send(m, p, sessionId);
await S('Page.enable');
await S('Runtime.enable');
// localStorage is origin-scoped, so the origin must be loaded before seeding.
await S('Page.navigate', { url: `${BASE}/admin/login` });
await new Promise((r) => setTimeout(r, 1500));
const auth = {
state: {
token: access_token, refreshToken: refresh_token, isAuthenticated: true,
},
version: 0,
};
await S('Runtime.evaluate', {
expression: `
localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('pwa-dismissed', '1');
`,
});
await S('Page.navigate', { url });
await new Promise((r) => setTimeout(r, opts.wait));
const { data } = await S('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: opts.full,
});
writeFileSync(opts.out, Buffer.from(data, 'base64'));
console.log(`${opts.out}`);
// The SPA redirects silently: RoleRoute bounces a user whose role lacks access
// straight to /dashboard, so you get a valid-looking screenshot of the WRONG
// page. Compare the landed path against the requested one and say so loudly.
const { result } = await S('Runtime.evaluate', {
expression: 'location.pathname + "|" + (document.body.innerText||"").trim().length',
returnByValue: true,
});
const [landed, len] = String(result.value).split('|');
const wanted = new URL(url).pathname;
if (landed.includes('/login')) {
console.log('⚠ redirected to /login — token rejected or expired');
} else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
console.log(`⚠ WRONG PAGE: asked for ${wanted}, landed on ${landed}`);
console.log(' → the test user\'s role probably lacks access (see RoleRoute in App.tsx).');
console.log(' → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
}
if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
ws.close();
} finally {
chrome.kill();
}
}
// ── Static inspection ──────────────────────────────────────────────────────
/** URL path → the <Route> line in App.tsx → the page component file. */
function inspect(url) {
const path = url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/admin\/?/, '').split('?')[0];
const app = readFileSync(`${REPO}/assets/admin/App.tsx`, 'utf8');
const segs = path.split('/').filter(Boolean);
const routes = [...app.matchAll(/<Route\s+path="([^"]+)"[\s\S]*?element=\{([\s\S]*?)\}\s*\/>/g)]
.map(([, p, el]) => ({ p, comp: (el.match(/<(\w+)\s*\/>/g) ?? []).pop() ?? el.trim() }));
const score = (rp) => {
const rs = rp.split('/').filter(Boolean);
if (rs.length !== segs.length) return -1;
return rs.every((s, i) => s.startsWith(':') || s === segs[i]) ? rs.length : -1;
};
const hit = routes.map((r) => ({ ...r, s: score(r.p) })).filter((r) => r.s >= 0)
.sort((a, b) => b.s - a.s)[0];
if (!hit) {
console.log(`no route matched "${path}". Routes:\n` + routes.map((r) => ' ' + r.p).join('\n'));
return;
}
const comp = hit.comp.replace(/[<>/\s]/g, '');
const imp = app.match(new RegExp(`import\\s+${comp}\\s+from\\s+'([^']+)'`));
const file = imp ? `assets/admin/${imp[1].replace(/^\.\//, '')}.tsx` : '(inline element)';
console.log(`route ${hit.p}`);
console.log(`component ${comp}`);
console.log(`file ${file}`);
const abs = `${REPO}/${file}`;
if (existsSync(abs)) {
const src = readFileSync(abs, 'utf8');
const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
console.log(`lines ${src.split('\n').length}`);
auditSource(file, src);
}
}
/** The anti-patterns this codebase keeps regrowing. Each one is a real past bug. */
function auditSource(label, src) {
const tokens = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
const findings = [];
const push = (re, msg) => {
src.split('\n').forEach((line, i) => { if (re.test(line)) findings.push(`${label}:${i + 1} ${msg}`); });
};
push(/<select\b/, 'native <select> — use SearchableSelect');
push(/className="btn"(?!\s*\+)/, '.btn with no variant — renders borderless/invisible');
push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
push(/className="overlay"/, 'hand-rolled overlay — use the shared <Modal>');
push(/className="field"[\s\S]*?<label/, '<label> inside .field — .field is an inline box; use .field-block');
// var(--x) references that styles.css never defines (e.g. the dead --error).
for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
if (!tokens.includes(`${m[1]}:`)) findings.push(`${label} undefined token ${m[1]}`);
}
console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => ' ' + f).join('\n')
: '\nAUDIT clean');
}
// ── CLI ────────────────────────────────────────────────────────────────────
const [cmd, arg, ...rest] = process.argv.slice(2);
const flag = (n, d) => { const i = rest.indexOf(`--${n}`); return i >= 0 ? rest[i + 1] : d; };
if (cmd === 'shot' && arg) {
await shot(arg, {
out: flag('out', 'page.png'),
w: Number(flag('w', 1440)),
h: Number(flag('h', 900)),
wait: Number(flag('wait', 4000)),
full: rest.includes('--full'),
});
} else if (cmd === 'inspect' && arg) {
inspect(arg);
} else if (cmd === 'audit' && arg) {
auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
} else {
console.log(`usage:
driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
driver.mjs inspect <url>
driver.mjs audit <path/to/File.tsx>`);
process.exit(1);
}
+210 -150
View File
@@ -1,178 +1,238 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code when working in **ClinicPro** — a clinic management & appointment platform. A Symfony 7.4 REST API backend plus a React 19 admin SPA bundled inside Symfony via Webpack Encore.
## Project Overview
- **Local:** `https://clinic-pro.ddev.site`**Admin:** `/admin`**Swagger:** `/api/doc`
- Runs inside **ddev**: prefix commands with `ddev exec`.
- Entire product is Persian/Farsi, **RTL**, Jalali (Shamsi) dates. Keep new strings Persian, dates Jalali.
**ClinicPro** — a clinic management and appointment booking platform migrated from Drupal to Symfony 7. It consists of a Symfony REST API backend and a React 19 admin SPA bundled inside Symfony via Webpack Encore.
- **Local URL:** `https://clinic-pro.ddev.site`
- **Admin panel:** `https://clinic-pro.ddev.site/admin`
- **Swagger UI:** `https://clinic-pro.ddev.site/api/doc`
> **Source of truth for design tokens is `assets/admin/styles.css`**, not `docs/admin-ui/ui-design-spec.md`. That spec doc is an older aspirational draft (purple palette, tiptap, date-io) that does **not** match the shipped code (indigo palette, CKEditor, jalaali-js). Trust the code.
---
## Commands
## Stack & versions
All commands run inside ddev: prefix with `ddev exec` unless noted.
### Backend (`composer.json`)
- **PHP ≥ 8.2**, **Symfony 7.4**, **Doctrine ORM 3.6** + migrations, **MariaDB 11.8** (via ddev)
- Auth: **JWT** (`lexik/jwt-authentication-bundle`)
- Async/scheduled: `symfony/messenger` + `symfony/scheduler` + `symfony/redis-messenger`
- API docs: `nelmio/api-doc-bundle` + `zircote/swagger-php`; CORS: `nelmio/cors-bundle`
- Also: `symfony/uid`, `symfony/rate-limiter`, `altcha-org/altcha`, Twig, `symfony/ux-react`
- PSR-4: `App\``src/`, tests `App\Tests\``tests/`
### First-time setup
```bash
ddev exec composer install
ddev exec php bin/console lexik:jwt:generate-keypair # generate JWT keys
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec yarn install && ddev exec yarn dev
ddev exec php bin/console app:create-admin # create first admin user
```
### Frontend admin SPA (`package.json`)
- **React 19** + **TypeScript 5**, bundled by **Webpack Encore 6** (not Vite)
- **Tailwind CSS v4** (`@tailwindcss/postcss`, CSS-first config)
- Server state: **TanStack Query v5** · Tables: **TanStack Table v8**
- Client state: **Zustand 5** · Forms: **React Hook Form 7 + Zod 3** (`@hookform/resolvers`)
- Routing: **React Router v7** · Icons: **Heroicons v2** · Charts: **Recharts 3**
- Select: `react-select` · Rich text: **CKEditor 5** · Dates: `jalaali-js` · Maps: `leaflet`/`react-leaflet`
- Toasts: `sonner` (+ `react-hot-toast`) · Font: **Vazirmatn** via `@fontsource/vazirmatn`
- Tests: **Vitest** + Testing Library (jsdom)
### Backend (PHP/Symfony)
```bash
ddev exec php bin/console cache:clear
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console doctrine:migrations:diff --no-interaction # generate migration after entity change
ddev exec php bin/console debug:router | grep api
ddev exec php bin/console messenger:consume async # start queue worker (SMS, async jobs)
ddev exec php bin/console messenger:consume scheduler_default # run scheduled tasks (expires unpaid bookings every 1 min)
# Tests
ddev exec php bin/phpunit
ddev exec php bin/phpunit tests/SomeTest.php # single test file
# Static analysis (level 5, with Symfony + Doctrine extensions)
ddev exec php vendor/bin/phpstan analyse
```
### Frontend (React/TypeScript)
```bash
ddev exec yarn dev # one-off dev build (use this to check for errors)
ddev exec yarn watch # watch mode
ddev exec yarn build # production build
# Type check only (faster)
ddev exec npx tsc --noEmit --project tsconfig.json
```
> **Note:** The CSS build has a known `lightningcss.linux-arm64-gnu.node` native module error inside ddev — this is pre-existing and does not block JS/TS compilation. TypeScript errors only appear in TSC output.
### Build entry points (`webpack.config.js`)
Three Encore entries → `public/build/`:
| Entry | Source | Purpose |
|---|---|---|
| `admin` | `assets/admin/index.tsx` | React admin SPA, mounted at `/admin/*` |
| `app` | `assets/app.js` | Stimulus/UX React controllers |
| `home` | `assets/home/index.js` | Public-facing pages |
---
## Architecture
## Commands (prefix with `ddev exec`)
### Backend — `src/`
Domain-driven structure; each domain is its own namespace under `App\<Domain>\`:
```bash
# Backend
php bin/console cache:clear
php bin/console doctrine:migrations:diff --no-interaction # after any entity change
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console debug:router | grep api
php bin/console messenger:consume async # SMS / async jobs
php bin/console messenger:consume scheduler_default # scheduled tasks
php bin/phpunit # tests
php vendor/bin/phpstan analyse # static analysis (level 5)
# Frontend
yarn dev # one-off build (use to check for errors)
yarn watch # watch mode
yarn build # production
npx tsc --noEmit --project tsconfig.json # type check only (faster)
yarn test # vitest
```
src/
Admin/Controller/AdminApiController.php # all admin-only list/stats endpoints
Appointment/ Doctor/ Clinic/
Auth/ Payment/ Rating/
Blog/ Representation/ Secretary/
Category/ Settlement/ Sms/
Shared/Controller/BaseController.php # all controllers extend this
Shared/Constant/ErrorCodes.php
> Known: CSS build has a pre-existing `lightningcss.linux-arm64-gnu.node` native-module error inside ddev; it does not block JS/TS compilation. TypeScript errors still surface in TSC output.
---
## Folder structure & where things go
### Backend — `src/<Domain>/`
Domain-driven; each domain is its own namespace `App\<Domain>\` holding its own layers:
```
src/<Domain>/
Controller/ # HTTP endpoints — extend BaseController
Entity/ # Doctrine entities
Repository/ # Doctrine repositories (DQL / query builders)
Service/ # domain logic
Command/ # console commands (optional)
```
Domains include: `Doctor`, `Patient`, `Appointment`, `Payment`, `Clinic`, `ClinicInvitation`,
`ClinicService`, `DoctorService`, `Secretary`, `Staff`, `Settlement`, `Billing`, `Subscription`,
`Rating`, `Blog`, `Sms`, `Category`, `Location`, `Specialty`, `Insurance`, `Tag`, `Representation`,
`Auth`, `Admin`, `Dashboard`, `UserProfile`, `Config`.
**Every controller extends `BaseController`** which provides four response helpers:
| Method | Shape | When to use |
|--------|-------|-------------|
| `$this->success($data)` | `{ success, data: $data }` | Single resource / action |
| `$this->paginated($items, $total, $page, $limit)` | `{ success, data: $items[], meta: { totalRecords, totalPages, currentPage } }` | Admin list endpoints |
| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` | All error responses |
| `$this->validationError($violations)` | `{ success:false, errors:[{code,field,message}] }` HTTP 422 | Input validation failures |
**Domain exceptions:** throw `AppException(ErrorCodes::ERR_XXX, null, $httpStatus)` anywhere in the domain — `ExceptionSubscriber` catches it and calls `$this->error()` automatically. All error codes and their Persian messages live in `src/Shared/Constant/ErrorCodes.php`.
**Critical pitfall — double-nested responses:**
`$this->success(['data' => $rep->toArray()])` produces `{ data: { data: {...} } }`, so the frontend must extract with `data?.data?.data`. The `paginated()` helper does NOT nest — it returns `data` as a flat array.
Shared infra:
```
src/Shared/Controller/BaseController.php # every controller extends this
src/Shared/Constant/ErrorCodes.php # all error codes + Persian messages
```
Cross-cutting: `migrations/`, `config/`, `templates/`, `docs/api/` (endpoint docs).
### Frontend — `assets/admin/`
Single-page app mounted at `/admin/*`:
```
assets/admin/
App.tsx # React Router routes
pages/ # one file per page
components/
ui/ # DataTable, Modal, ConfirmDialog, PageHeader, StatusBadge, Pagination,
# SearchableSelect, PersianDateInput, PersianCalendar, AppointmentStatusDropdown
layout/ # AdminLayout, Sidebar, Topbar
hooks/ # custom React hooks
lib/api.ts # fetch wrapper (reads JWT from localStorage key: clinicpro-auth)
lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime
types/index.ts # all TypeScript interfaces
stores/
authStore.ts # Zustand auth store (persisted to localStorage)
uiStore.ts # sidebar open/close state
index.tsx # entry: mounts <App/>, QueryClientProvider, <Toaster/> (sonner)
App.tsx # React Router v7 routes
pages/ # one file per page — XxxPage.tsx (~50 pages)
components/
ui/ # shared design-system components (see below)
*.tsx # feature-specific composites (ServiceTariffModal, ImageCropModal, …)
hooks/ # useSubscription, usePaymentConfig, usePwaInstall, …
lib/api.ts # fetch wrapper; reads JWT from localStorage['clinicpro-auth']
lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime
types/index.ts # all shared TypeScript interfaces
stores/ # authStore.ts (persisted), uiStore.ts (sidebar/ui)
styles.css # Tailwind entry + all design tokens
```
**Data fetching pattern:** TanStack Query v5 (`useQuery` / `useMutation`). Query keys use `['resource-name', page, filters]`.
**API response types in `lib/api.ts`:**
- `ApiResponse<T>` — for single-resource responses: extract with `data?.data`
- `PaginatedResponse<T>` — for admin lists: items at `data?.data`, total at `data?.meta?.totalRecords`
**Forms:** React Hook Form + Zod resolver. Schema defined with `z.object()`, type inferred with `z.infer<typeof schema>`.
### Auth
- JWT stored in Zustand store → `localStorage['clinicpro-auth']``state.token`
- `api.ts` reads it automatically for every request
- Admin routes require `ROLE_ADMIN`. `#[IsGranted('ROLE_ADMIN')]` on controller class or method.
- Public endpoints listed in `config/packages/security.yaml` under `public_endpoints` firewall pattern
### Category / Bundle system
Categories are polymorphic via a `bundle` string field. Used for: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`.
City IDs (integer FK to `categories.id` where `bundle='city'`) are stored on entities like `Representation.cityId`. To get the city name, LEFT JOIN the categories table in DQL.
### Database
- MariaDB 11.8 via ddev
- Doctrine ORM with integer Unix timestamps (`createdAt`, `updatedAt`) — **not** DateTime objects
- All admin list queries in `AdminApiController` use DQL array hydration (`.getArrayResult()`) to avoid triggering non-existent getter errors on entities
- Migrations in `migrations/` — always run `doctrine:migrations:diff` after entity changes
---
## Key Patterns
## Styling & design tokens
**Adding a new admin list endpoint (backend):**
1. Add method to `src/Admin/Controller/AdminApiController.php`
2. Use `$this->em->createQueryBuilder()` with `->getArrayResult()` (never use entity getters in admin list queries)
3. Return `$this->paginated($items, $total, $page, $limit)`
Tailwind **v4**, imported in `assets/admin/styles.css`:
```css
@import "tailwindcss";
@import "@fontsource/vazirmatn/{300..800}.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme { --font-sans: "Vazirmatn", ui-sans-serif, system-ui, sans-serif; }
```
**All design tokens are CSS custom properties in the `:root` block of `styles.css`** — reference them via Tailwind arbitrary values (`bg-[var(--surface)]`) or plain CSS, do not hardcode hex:
**Adding a new admin page (frontend):**
1. Create `assets/admin/pages/XxxPage.tsx`
2. Use `PaginatedResponse<YourType>` with `useQuery`
3. Extract: `data?.data` for items, `data?.meta?.totalRecords` for total
4. Add route in `App.tsx`
5. Add UI components: `<DataTable>`, `<Pagination>`, `<Modal>`, `<ConfirmDialog>`
**Category API endpoint pattern:** `GET /api/v1/categorys/{bundle}` (note: typo `categorys` is intentional — existing route). Response is double-nested: extract array with `data?.data?.data ?? []`.
---
## Standing Rule — API Documentation
**Whenever any API endpoint is created or modified** (controller file, route, request/response structure, error code, permission), the corresponding file in `docs/api/` **must be updated in the same session**.
| Changed file | Doc to update |
| Group | Tokens |
|---|---|
| `src/Auth/*` | `docs/api/auth.md` |
| `src/Doctor/*` | `docs/api/doctor.md` |
| `src/Clinic/*` | `docs/api/clinic.md` + `docs/api/clinic-invitation.md` |
| `src/Appointment/Controller/AppointmentController.php` | `docs/api/appointment.md` |
| `src/Appointment/Controller/AppointmentSettings*` | `docs/api/appointment-settings.md` |
| `src/Payment/*` | `docs/api/payment.md` |
| `src/Settlement/*` | `docs/api/settlement.md` |
| `src/Rating/*` | `docs/api/rating.md` |
| `src/Secretary/*` | `docs/api/secretary.md` |
| `src/Representation/*` | `docs/api/representation.md` |
| `src/Sms/*` | `docs/api/sms.md` |
| `src/Blog/*` | `docs/api/blog.md` |
| `src/Admin/*` | `docs/api/admin.md` |
| Category/Province/City controllers | `docs/api/location.md`, `docs/api/specialty.md`, `docs/api/insurance.md`, `docs/api/doctor-service.md`, `docs/api/tag.md` |
| Brand | `--primary:#5559CE` (indigo), `--primary-600/700`, `--primary-soft/soft2`, `--on-primary` |
| Accent | `--accent:#f0682a` (orange, matches clinic-pro-tauri), `--accent-600`, `--accent-bg` |
| Surfaces | `--bg`, `--bg-2`, `--surface`, `--surface-2/3`, `--border`, `--border-2` |
| Text | `--text`, `--text-2`, `--text-3` |
| Status | `--success/-bg`, `--warning/-bg`, `--danger/-bg`, `--info/-bg`, `--violet/-bg` |
| Stat cards | `--stat-{amber,violet,green,pink}-{bg,fg}` |
| Radius | `--r-xs:7 · --r-sm:8 · --r:14 · --r-lg:18 · --r-xl:24 · --r-pill:999` (px) |
| Shadow | `--shadow-sm`, `--shadow`, `--shadow-lg` |
| Layout | `--sidebar-w:243`, `--collapsed-w:90`, `--topbar-h:64`, `--gap:20`, `--card-pad:22`, `--row-h:56` |
| Motion | `--ease: cubic-bezier(.22,.61,.36,1)` |
Theming: **dark mode** overrides via `[data-theme="dark"]`; **compact density** via `[data-density="compact"]`. oklch color versions applied under `@supports` with sRGB fallbacks for old WebKit.
---
## Design system components — `assets/admin/components/ui/`
Reuse these before building new ones:
`DataTable` (sortable, search, skeleton loading, empty state, bulk) · `Modal` · `ConfirmDialog` ·
`PageHeader` (title + breadcrumb + action) · `StatCard` · `StatusBadge` · `Pagination` ·
`SearchableSelect` · `AppointmentStatusDropdown` · `PersianDateInput` / `PersianDatePicker` /
`PersianCalendar` · `MobileInput` · `PriceInput` · `Portal` · `FeatureGate` · `Altcha` ·
`InviteDoctorModal` · `PwaInstallBanner` / `PwaLoginCard` / `NotificationMobileCard`.
Feature composites (not generic) live one level up in `components/*.tsx`.
---
## Conventions
**Naming:** pages `XxxPage.tsx`, components PascalCase, hooks `useXxx.ts`. Backend PSR-4 `App\<Domain>\<Layer>`; entities singular (`Doctor`), tables snake_case plural (`patient_records`).
**Data fetching:** TanStack Query only — `useQuery` / `useMutation`. Query keys `['resource-name', page, filters]`. All HTTP goes through `lib/api.ts`, which injects the JWT automatically.
- `ApiResponse<T>` (single resource): extract with `data?.data`
- `PaginatedResponse<T>` (admin lists): items at `data?.data`, total at `data?.meta?.totalRecords`
**State management:**
- **Server state → TanStack Query** (cache, `staleTime: 30s`, `retry: 1`).
- **Client state → Zustand.** `authStore` (JWT + user, persisted to `localStorage['clinicpro-auth']`), `uiStore` (sidebar/ui flags).
**Forms:** React Hook Form + Zod resolver — `z.object({...})`, type via `z.infer<typeof schema>`.
**Routing:** React Router v7, `<BrowserRouter>` in `index.tsx`, route table in `App.tsx`, SPA served at `/admin/*`.
**Auth:** JWT issued by Symfony. Admin routes guard with `#[IsGranted('ROLE_ADMIN')]` on the controller class/method. Public endpoints are whitelisted in `config/packages/security.yaml`.
---
## Backend endpoint & model patterns
### Response envelope — `BaseController` helpers
Every controller `extends BaseController`:
| Method | Shape |
|---|---|
| `$this->success($data)` | `{ success, data }` |
| `$this->paginated($items, $total, $page, $limit)` | `{ success, data:[], meta:{ totalRecords, totalPages, currentPage } }` (flat — no nesting) |
| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` |
| `$this->validationError($violations)` | `{ success:false, errors:[{code,field,message}] }` HTTP 422 |
- **Errors:** throw `AppException(ErrorCodes::ERR_XXX, null, $httpStatus)` anywhere in a domain; `ExceptionSubscriber` catches it and formats via `$this->error()`. Codes + Persian messages in `src/Shared/Constant/ErrorCodes.php`.
- **Pitfall — double nesting:** `$this->success(['data' => $x])` yields `{ data: { data: x } }`; the frontend must then read `data?.data?.data`. Prefer passing the payload directly.
- **Admin list queries** use `->getArrayResult()` (array hydration), never entity getters, to avoid missing-getter errors.
### Adding an endpoint (one domain)
1. `src/<Domain>/Entity/Foo.php` — Doctrine entity (attributes; see model pattern).
2. `src/<Domain>/Repository/FooRepository.php` — queries.
3. `src/<Domain>/Service/FooService.php` — logic (optional but preferred).
4. `src/<Domain>/Controller/FooController.php``extends BaseController`, route `/api/v1/...`, guard with `#[IsGranted]`, return a response helper.
5. `ddev exec php bin/console doctrine:migrations:diff` then `migrate`.
6. Update the matching `docs/api/*.md` (see Standing Rule).
### Model (entity) pattern
```php
#[ORM\Entity(repositoryClass: FooRepository::class)]
#[ORM\Table(name: 'foos')]
class Foo
{
#[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid; // Uuid::v4()->toRfc4122() in constructor
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt; // Unix timestamp (time()), NOT DateTime
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
private User $user;
}
```
Conventions: integer surrogate `id`; string `uuid` (v4) for external references; **timestamps are `int` Unix**, not DateTime; column names snake_case via `name:`; relations `ManyToOne` / `OneToMany`.
### Category / bundle system
Categories are polymorphic via a `bundle` string: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`. City is an integer FK to `categories.id` where `bundle='city'` (e.g. `Representation.cityId`) — LEFT JOIN `categories` in DQL to get the name. Endpoint: `GET /api/v1/categorys/{bundle}` (typo `categorys` is the real route); its response is double-nested → extract with `data?.data?.data ?? []`.
---
## Standing Rule — API docs
**Whenever any endpoint is created or modified** (route, request/response shape, error code, permission), update the matching file in `docs/api/` **in the same session**. Mapping mirrors `src/<Domain>``docs/api/<domain>.md` (e.g. `src/Doctor/*``docs/api/doctor.md`, `src/Admin/*``docs/api/admin.md`, Category/Location controllers → `docs/api/location.md` + `specialty.md` + `insurance.md` + `tag.md`).
## Project skills
`.claude/skills/`: `add-admin-endpoint`, `add-admin-page`, `sync-db`, `prompt-writer`, `run-prompt`. Prefer them over hand-rolling. Test users: `TEST_USERS.md` (rebuild: `ddev exec php create_test_users.php`).
---
## قواعد غیرقابل‌مذاکره
1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد.
همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس.
3. مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس.
4. هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تست‌ها تمام‌شده نیست.
5. ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را
به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفت‌وگو با من فارسی.
رشته‌های UI فارسی و از فایل i18n.
+93 -92
View File
@@ -2,108 +2,109 @@
**پنل ادمین:** https://clinic-pro.ddev.site/admin
> رمز عبور همه کاربران seed‌شده: `Test@1234`
> رمز عبور همهٔ پرسوناها: `QaTest@1234`
این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را
توصیف می‌کند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل
تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است.
---
## ادمین
## پرسوناها
| فیلد | مقدار |
| ------ | --------------- |
| موبایل | `09100000001` |
| پسورد | `Test@1234` |
| نقش | `ROLE_ADMIN` |
| نام | مدیر سیستم |
واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR`
دارند ولی دادهٔ متفاوتی می‌بینند.
| پرسونا | موبایل | نقش‌ها | تمایز |
|---|---|---|---|
| `admin` | `09120671756` | `ROLE_ADMIN` | — |
| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» |
| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` |
| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک |
| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری |
| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک |
| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» می‌بیند |
| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» |
| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک |
| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — |
| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت |
| `importer` | `09129000007` | `ROLE_IMPORTER` | — |
`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ
ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» می‌گیرند. این باگ نیست:
`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد می‌کند.
## شناسه‌ها
| موجودیت | نام | UUID |
|---|---|---|
| پزشک `doctor_solo` | سارا مستقل | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` |
| پزشک `doctor_member` | رضا عضوکلینیک | `439c9935-77bc-4f72-b73d-2432712bb6f5` |
| پزشک `clinic_doctor` | نیما چندنقشی | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` |
| پزشک `doctor` | کاوه قدیمی | `c3311b98-86b7-4d8e-8538-1390c36c2a90` |
| پروفایل تصاحب‌نشده | تصاحب نشده تست | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` |
| کلینیک تست QA | — | `bcb00726-2343-4d63-90c6-d0175cc74591` |
| کلینیک تست چندنقشی | — | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` |
هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و
برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقه‌ای)
دارند، پس صفحات نوبت‌دهی‌شان خالی نیستند.
## دادهٔ انبوه
`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰
نوبت می‌سازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست.
---
## کلینیک نمونه — تبریز
## بازسازی از صفر
| فیلد | مقدار |
| ----------- | -------------------------------------- |
| موبایل | `09100100000` |
| پسورد | `Test@1234` |
| نقش | `ROLE_CLINIC` |
| نام | کلینیک تخصصی امید تبریز |
| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
| شهر | تبریز (id: 101) |
---
## دکتر نمونه کامل — تبریز
| فیلد | مقدار |
| ----------- | -------------------------------------- |
| موبایل | `09100100001` |
| پسورد | `Test@1234` |
| نقش | `ROLE_DOCTOR` |
| نام | دکتر آرمان رضایی |
| تخصص | قلب و عروق + داخلی |
| UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` |
| کلینیک | کلینیک تخصصی امید تبریز (عضو) |
| آدرس مطب | تبریز، خیابان آزادی |
| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ |
| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران |
---
## منشی دکتر نمونه
| فیلد | مقدار |
| ------ | ------------------- |
| موبایل | `09100100002` |
| پسورد | `Test@1234` |
| نقش | `ROLE_SECRETARY` |
| نام | خانم نرگس صادقی |
| مرتبط | دکتر آرمان رضایی |
---
## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر)
هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص:
| شهر | شروع موبایل | تعداد |
| ---------- | ------------- | ----- |
| تبریز | `09100100003` | ۱۰۰ |
| ارومیه | `09100100103` | ۱۰۰ |
| اردبیل | `09100100203` | ۱۰۰ |
| اصفهان | `09100100303` | ۱۰۰ |
| کرج | `09100100403` | ۱۰۰ |
| تهران | `09100100503` | ۱۰۰ |
| مشهد | `09100100603` | ۱۰۰ |
| اهواز | `09100100703` | ۱۰۰ |
| شیراز | `09100100803` | ۱۰۰ |
| کرمان | `09100100903` | ۱۰۰ |
| کرمانشاه | `09100101003` | ۱۰۰ |
| رشت | `09100101103` | ۱۰۰ |
| ساری | `09100101203` | ۱۰۰ |
| همدان | `09100101303` | ۱۰۰ |
| یزد | `09100101403` | ۱۰۰ |
توزیع تخصص در هر شهر:
- ۱۵ پزشک عمومی
- ۱۰ داخلی + قلب
- ۸ جراحی عمومی
- ۸ زنان و زایمان
- ۸ اطفال
- ۷ ارتوپدی
- ۶ گوارش، نورولوژی، پوست، چشم‌پزشکی، دندانپزشکی (هر کدام ۶)
- ۵ ENT، روانپزشکی (هر کدام ۵)
- ۴ اورولوژی
---
## ساخت مجدد
اگر دیتابیس ریست شد، ادمین را مستقیم در دیتابیس بساز:
ترتیب اجباری است — وابستگی‌ها چرخه‌ای‌اند:
```bash
ddev mysql -e "INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (UUID(), '09100000001', '\$2y\$13\$8.5nFvKRxQGAXfJMXPB6sO.eR.c1RNxj0nJalcQfhzFTiEXnfaJbG', 'مدیر سیستم', '[\"ROLE_USER\",\"ROLE_ADMIN\"]', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());"
ddev exec php bin/console doctrine:schema:drop --full-database --force
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234'
# نماینده‌ها باید قبل از شهرها باشند: data/seed/cities.json به representation_id
# های ۱ تا ۳ ارجاع می‌دهد و app:seed-categories اعتبارسنجی‌شان می‌کند.
# POST /api/v1/representation ×۳ (با توکن ادمین)
ddev exec php bin/console app:seed-categories --no-interaction
ddev exec php bin/console app:seed-sms-message-templates --no-interaction
ddev exec php bin/console app:seed-demo-data --purge --no-interaction
```
برای seed دیتای واقعی دکتران و کلینیک:
`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نماینده‌های مرحلهٔ قبل را
purge و بازسازی می‌کند؛ بعد از آن `cities.representation_id` به شناسه‌های قدیمی اشاره
می‌کند و باید به شناسه‌های جدید نگاشت شود.
```bash
ddev exec php seed_realistic_data.php
```
سپس پرسوناها از راه اندپوینت‌های خود اپ ساخته می‌شوند:
`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` ·
`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` ·
`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` ·
`send-code → verify-code → register` برای `patient`.
`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش
در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient`
را می‌شناسد، پس این دو با SQL مستقیم ست می‌شوند.
## نکته‌ها
- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و
ثبت‌نام اسکریپتی رد می‌شود. از پنل ادمین یا
`PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن.
- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`).
- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد.** برای تست‌های انبوه توکن را
مستقیم بساز:
`ddev exec 'php bin/console lexik:jwt:generate-token <mobile> --user-class="App\\Auth\\Entity\\User"'`
- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'`
و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors`
یا `/api/v1/admin/clinic` ساخته می‌شوند رمز نمی‌گیرند.
## نام پزشک بدون عنوان
نام ذخیره‌شدهٔ پزشک **هرگز** پیشوند «دکتر» ندارد؛ همهٔ مسیرهای ثبت و ویرایش آن را با
`PersianText::stripDoctorTitle()` حذف می‌کنند. افزودن عنوان کار لایهٔ نمایش است —
`nobat724_front` برای عنوان صفحه و JSON-LD از `doctorTitle()` استفاده می‌کند (idempotent)،
و پنل ادمین اصلاً عنوان اضافه نمی‌کند.
پاک‌سازی دادهٔ قدیمی: `php bin/console app:doctors:fix-irimc-names --all --dry-run`
+63 -6
View File
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import { usePermissions } from './hooks/usePermissions';
import AdminLayout from './components/layout/AdminLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
@@ -13,7 +14,10 @@ import DoctorFormPage from './pages/DoctorFormPage';
import ClinicsPage from './pages/ClinicsPage';
import ClinicDetailPage from './pages/ClinicDetailPage';
import AppointmentsPage from './pages/AppointmentsPage';
import AppointmentCreatePage from './pages/AppointmentCreatePage';
import AppointmentDetailPage from './pages/AppointmentDetailPage';
import AppointmentEditPage from './pages/AppointmentEditPage';
import ReserveAppointmentsPage from './pages/ReserveAppointmentsPage';
import PaymentsPage from './pages/PaymentsPage';
import PaymentDetailPage from './pages/PaymentDetailPage';
import SettlementsPage from './pages/SettlementsPage';
@@ -28,7 +32,7 @@ import CategoriesPage from './pages/CategoriesPage';
import BlogsPage from './pages/BlogsPage';
import BlogFormPage from './pages/BlogFormPage';
import SecretariesPage from './pages/SecretariesPage';
import MyClinicPage from './pages/MyClinicPage';
import ClinicDoctorsPage from './pages/ClinicDoctorsPage';
import SettingsPage from './pages/SettingsPage';
import FinancialReportPage from './pages/FinancialReportPage';
import RepresentationSettlementPage from './pages/RepresentationSettlementPage';
@@ -36,19 +40,36 @@ import RepresentationFinancePage from './pages/RepresentationFinancePage';
import RepresentationProfilePage from './pages/RepresentationProfilePage';
import DoctorProfilePage from './pages/DoctorProfilePage';
import MyPatientsPage from './pages/MyPatientsPage';
import MyPaymentsPage from './pages/MyPaymentsPage';
import MyPaymentDetailPage from './pages/MyPaymentDetailPage';
import NewSessionPage from './pages/NewSessionPage';
import EditSessionPage from './pages/EditSessionPage';
import SessionPaymentPage from './pages/SessionPaymentPage';
import InsurancePricingPage from './pages/InsurancePricingPage';
import ClaimsPage from './pages/ClaimsPage';
import ClaimPatientDetailPage from './pages/ClaimPatientDetailPage';
import DoctorClaimsPage from './pages/DoctorClaimsPage';
import MyFinancialPage from './pages/MyFinancialPage';
import ClinicFormPage from './pages/ClinicFormPage';
import PreRegistrationsPage from './pages/PreRegistrationsPage';
import StaffPage from './pages/StaffPage';
import SubscriptionPage from './pages/SubscriptionPage';
import DiscountsPage from './pages/DiscountsPage';
import ClinicServicesPage from './pages/ClinicServicesPage';
import ServiceDetailPage from './pages/ServiceDetailPage';
import SmsWalletPage from './pages/SmsWalletPage';
import MySecretariesPage from './pages/MySecretariesPage';
import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
import SettingsMenuPage from './pages/SettingsMenuPage';
import AccountSettingsPage from './pages/AccountSettingsPage';
import TagsSettingsPage from './pages/TagsSettingsPage';
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import PatientRecordFormPage from './pages/PatientRecordFormPage';
import PatientDetailPage from './pages/PatientDetailPage';
import PaymentSuccessPage from './pages/PaymentSuccessPage';
import PwaInstallBanner from './components/ui/PwaInstallBanner';
// ── Guards ──────────────────────────────────────────────────────────────────
@@ -97,14 +118,23 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
}
function RoleRoute({ roles, blockClinicScope, children }: { roles: string[]; blockClinicScope?: boolean; children: React.ReactNode }) {
function RoleRoute({ roles, blockClinicScope, permission, children }: {
roles: string[];
blockClinicScope?: boolean;
/** [resource, action] — پزشکِ مهمان با داشتن این مجوز از blockClinicScope مستثنا می‌شود. */
permission?: [string, string];
children: React.ReactNode;
}) {
const primaryRole = useAuthStore((s) => s.primaryRole);
const context = useAuthStore((s) => s.context);
const { can } = usePermissions();
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
// پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد.
// پزشکِ مهمان در محیط کلینیک فقط تا جایی که کلینیک مجوز داده دسترسی دارد.
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
return <Navigate to="/admin/dashboard" replace />;
if (!permission || !can(permission[0], permission[1])) {
return <Navigate to="/admin/dashboard" replace />;
}
}
return <>{children}</>;
}
@@ -143,7 +173,10 @@ export default function App() {
{/* نوبت‌ها — همه نقش‌ها به‌جز نماینده */}
<Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
<Route path="appointments/reserve" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><ReserveAppointmentsPage /></RoleRoute>} />
<Route path="appointments/new" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentCreatePage /></RoleRoute>} />
<Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></RoleRoute>} />
<Route path="appointments/:uuid/edit" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentEditPage /></RoleRoute>} />
{/* فقط ادمین */}
<Route path="users" element={<RoleRoute roles={['admin']}><UsersPage /></RoleRoute>} />
@@ -167,8 +200,11 @@ export default function App() {
<Route path="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
{/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */}
<Route path="settings/clinic-doctors" element={<RoleRoute roles={['clinic']} blockClinicScope><ClinicDoctorsPage /></RoleRoute>} />
<Route path="settings/appointment-settings" element={<RoleRoute roles={['clinic']}><ClinicAppointmentSettingsPage /></RoleRoute>} />
{/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */}
<Route path="my-clinic" element={<Navigate to="/admin/settings/clinic-doctors" replace />} />
{/* ادمین + کلینیک */}
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'representation']}><ClinicDetailPage /></RoleRoute>} />
@@ -185,15 +221,36 @@ export default function App() {
{/* دکتر / منشی / کلینیک */}
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPatientsPage /></RoleRoute>} />
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentsPage /></RoleRoute>} />
<Route path="my-payments/:patientUuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentDetailPage /></RoleRoute>} />
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientsListPage /></RoleRoute>} />
<Route path="patients/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'create']}><PatientRecordFormPage /></RoleRoute>} />
<Route path="patients/:uuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'update']}><PatientRecordFormPage /></RoleRoute>} />
<Route path="patients/:uuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientDetailPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'create']}><NewSessionPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/:sessionUuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><EditSessionPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/:sessionUuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><EditSessionPage /></RoleRoute>} />
<Route path="insurance-pricing" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InsurancePricingPage /></RoleRoute>} />
<Route path="claims" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClaimsPage /></RoleRoute>} />
<Route path="claims/:patientUuid" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClaimPatientDetailPage /></RoleRoute>} />
<Route path="my-financial" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyFinancialPage /></RoleRoute>} />
{/* فاز ۲ — دکتر / کلینیک */}
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><StaffPage /></RoleRoute>} />
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><TagsSettingsPage /></RoleRoute>} />
<Route path="appointment-settings" element={<RoleRoute roles={['doctor']} blockClinicScope><AppointmentSettingsPage /></RoleRoute>} />
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SubscriptionPage /></RoleRoute>} />
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><DiscountsPage /></RoleRoute>} />
<Route path="subscription/success" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><PaymentSuccessPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InventoryPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SmsWalletPage /></RoleRoute>} />
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import AppointmentActionsMenu from './AppointmentActions';
import type { Appointment } from '../types';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const appt: Appointment = {
uuid: 'ap1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874',
doctor_uuid: 'd1', doctor_name: 'دکتر احمدی',
slot_start: 1735639200, slot_end: 1735641900, // 45 min
appointment_date: '2024-12-31', appointment_time: '09:00', end_time: '09:45',
status: 'confirmed', version: 3, created_at: '',
service_section: { uuid: 's1', name: 'زیبایی' },
service_item: { uuid: 'i1', name: 'لیزر توتال' },
staff: { uuid: 'st1', full_name: 'دکتر حمیدی' },
};
beforeEach(() => {
get.mockReset(); patch.mockReset();
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1' }] });
if (url === '/api/v1/patient/rec1/wallet') return Promise.resolve({ success: true, data: { balance_rials: 500000, recent_transactions: [] } });
return Promise.resolve({ success: true, data: [] });
});
patch.mockResolvedValue({ success: true, data: {} });
});
function openMenu() {
renderWithProviders(<AppointmentActionsMenu appointment={appt} queryKey={['appts']} />);
fireEvent.click(screen.getByRole('button', { name: 'عملیات' }));
}
describe('AppointmentActionsMenu (عملیات نوبت)', () => {
it('lists all six actions from the Figma menu', () => {
openMenu();
for (const label of ['ویرایش', 'ثبت سرویس', 'مشاهده', 'جا به جایی نوبت', 'انتقال به لیست رزرو', 'جایگزینی نوبت']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
});
it('info modal shows appointment details and patient wallet balance', async () => {
openMenu();
fireEvent.click(screen.getByText('مشاهده'));
expect(await screen.findByText('ساعت شروع:')).toBeInTheDocument();
expect(screen.getByText('۴۵ دقیقه')).toBeInTheDocument();
expect(screen.getByText('لیزر توتال')).toBeInTheDocument();
expect(screen.getByText('دکتر حمیدی')).toBeInTheDocument();
// wallet resolved through record search → balance shown (rial → toman)
expect(await screen.findByText(/تومان/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'مشاهده پرونده' })).toBeInTheDocument();
});
it('move modal patches new slot times', async () => {
openMenu();
fireEvent.click(screen.getByText('جا به جایی نوبت'));
expect(await screen.findByText('اعمال تغییرات')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('ساعت شروع'), { target: { value: '15:00' } });
fireEvent.change(screen.getByLabelText('ساعت پایان'), { target: { value: '16:00' } });
fireEvent.click(screen.getByText('اعمال تغییرات'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
slot_start: Math.floor(new Date('2024-12-31T15:00').getTime() / 1000),
slot_end: Math.floor(new Date('2024-12-31T16:00').getTime() / 1000),
version: 3,
})));
});
it('transfer modal flips is_reserve with a day-level slot', async () => {
openMenu();
fireEvent.click(screen.getByText('انتقال به لیست رزرو'));
expect(await screen.findByText(/به لیست نوبت های رزرو شده منتقل می شود/)).toBeInTheDocument();
fireEvent.click(screen.getByText('انتقال و حذف از لیست'));
const day = Math.floor(new Date('2024-12-31T00:00').getTime() / 1000);
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
is_reserve: true, slot_start: day, slot_end: day, version: 3,
})));
});
it('replace modal swaps the patient and keeps the slot locked', async () => {
openMenu();
fireEvent.click(screen.getByText('جایگزینی نوبت'));
expect(await screen.findByPlaceholderText('نام و نام خانوادگی')).toBeInTheDocument();
// the original slot is shown read-only
expect(screen.getByDisplayValue('2024-12-31')).toBeDisabled();
expect(screen.getByDisplayValue('09:00')).toBeDisabled();
// prefilled from the appointment's current specs (react-select single value)
expect(screen.getByText('قطعی شده')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی'), { target: { value: 'ساغر صابری' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس'), { target: { value: '09356619438' } });
fireEvent.click(screen.getByText('ثبت نوبت'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
patient_name: 'ساغر صابری', patient_mobile: '09356619438',
service_section_uuid: 's1', service_item_uuid: 'i1', staff_uuid: 'st1',
version: 3,
})));
});
it('replace modal picks an existing patient from the record search', async () => {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [
{ uuid: 'rec9', user_name: 'پریسا همتی', user_mobile: '09120009999' },
] });
return Promise.resolve({ success: true, data: [] });
});
openMenu();
fireEvent.click(screen.getByText('جایگزینی نوبت'));
fireEvent.change(await screen.findByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'پریسا' } });
fireEvent.click(await screen.findByText('پریسا همتی'));
fireEvent.click(screen.getByText('ثبت نوبت'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
patient_name: 'پریسا همتی', patient_mobile: '09120009999',
})));
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from './AppointmentFiltersModal';
import type { Appointment } from '../types';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
get.mockResolvedValue({ success: true, data: [] });
});
const mk = (over: Partial<Appointment>): Appointment => ({
uuid: Math.random().toString(36), patient_name: 'x', patient_mobile: '0912',
doctor_uuid: 'd', doctor_name: 'دکتر', slot_start: 0, slot_end: 0,
appointment_date: '', appointment_time: '', end_time: '',
status: 'pending', version: 1, created_at: '', ...over,
});
describe('applyAppointmentFilters', () => {
const items = [
mk({ patient_name: 'مریم اسکندری', patient_national_code: '001', status: 'completed', patient_gender: 'female', service_section: { uuid: 's1', name: 'زیبایی' } }),
mk({ patient_name: 'مازیار عزیزی', patient_national_code: '002', status: 'cancelled_by_user', patient_gender: 'male' }),
mk({ patient_name: 'پریسا همتی', status: 'salon', patient_gender: 'female' }),
];
it('filters by name, national code, section, status group and gender', () => {
expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, name: 'مریم' })).toHaveLength(1);
expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, nationalCode: '002' })).toHaveLength(1);
expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, sectionUuid: 's1' })).toHaveLength(1);
// «لغو شده» covers both cancelled_by_* statuses
expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, statuses: ['cancelled'] })).toHaveLength(1);
expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, statuses: ['salon', 'completed'] })).toHaveLength(2);
expect(applyAppointmentFilters(items, { ...EMPTY_FILTERS, gender: 'female' })).toHaveLength(2);
expect(applyAppointmentFilters(
items.map((a, i) => ({ ...a, staff: i === 0 ? { uuid: 'st1', full_name: 'x' } : null })),
{ ...EMPTY_FILTERS, staffUuid: 'st1' },
)).toHaveLength(1);
expect(applyAppointmentFilters(items, EMPTY_FILTERS)).toHaveLength(3);
});
});
describe('AppointmentFiltersModal (فیلترها)', () => {
it('renders the design controls and applies the chosen filters', () => {
const onApply = vi.fn();
renderWithProviders(<AppointmentFiltersModal value={EMPTY_FILTERS} onApply={onApply} onClose={() => {}} />);
expect(screen.getByText('حذف همه')).toBeInTheDocument();
expect(screen.getByText('وضعیت نوبت')).toBeInTheDocument();
expect(screen.getByText('جنسیت')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('نام مراجعه کننده را وارد کنید...'), { target: { value: 'مریم' } });
fireEvent.click(screen.getByLabelText('ویزیت شده'));
fireEvent.click(screen.getByLabelText('خانم'));
fireEvent.click(screen.getByText('اعمال تغییرات'));
expect(onApply).toHaveBeenCalledWith(expect.objectContaining({
name: 'مریم', statuses: ['completed'], gender: 'female',
}));
});
it('«حذف همه» resets to the empty filter set', () => {
const onApply = vi.fn();
renderWithProviders(<AppointmentFiltersModal
value={{ ...EMPTY_FILTERS, name: 'x', statuses: ['salon'] }} onApply={onApply} onClose={() => {}} />);
fireEvent.click(screen.getByText('حذف همه'));
fireEvent.click(screen.getByText('اعمال تغییرات'));
expect(onApply).toHaveBeenCalledWith(EMPTY_FILTERS);
});
});
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Appointment } from '../types';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface Option { uuid: string; name?: string }
export interface AppointmentFilters {
name: string;
nationalCode: string;
sectionUuid: string;
itemUuid: string;
/** toolbar «پرسنل را انتخاب کنید...» select — not part of the modal */
staffUuid: string;
statuses: string[];
gender: 'female' | 'male' | 'both';
}
export const EMPTY_FILTERS: AppointmentFilters = {
name: '', nationalCode: '', sectionUuid: '', itemUuid: '', staffUuid: '', statuses: [], gender: 'both',
};
// design's 6 checkboxes; لغو شده covers both cancel reasons
const STATUS_OPTIONS: [string, string][] = [
['pending', 'ثبت شده'],
['confirmed', 'قطعی شده'],
['following_up', 'در حال پیگیری'],
['salon', 'سالن'],
['completed', 'ویزیت شده'],
['cancelled', 'لغو شده'],
];
/** Pure client-side filter of the loaded day's appointments (Figma فیلترها). */
export function applyAppointmentFilters(items: Appointment[], f: AppointmentFilters): Appointment[] {
return items.filter(a => {
if (f.name && !(a.patient_name ?? '').includes(f.name)) return false;
if (f.nationalCode && !(a.patient_national_code ?? '').includes(f.nationalCode)) return false;
if (f.sectionUuid && a.service_section?.uuid !== f.sectionUuid) return false;
if (f.itemUuid && a.service_item?.uuid !== f.itemUuid) return false;
if (f.staffUuid && a.staff?.uuid !== f.staffUuid) return false;
if (f.statuses.length) {
const matches = f.statuses.some(s =>
s === 'cancelled' ? a.status.startsWith('cancelled') : a.status === s);
if (!matches) return false;
}
if (f.gender !== 'both' && (a.patient_gender ?? '') !== f.gender) return false;
return true;
});
}
/** فیلترها (filter-desktop.pdf) — name/national-code search, بخش/سرویس, status checkboxes, gender. */
export default function AppointmentFiltersModal({ value, onApply, onClose }: {
value: AppointmentFilters; onApply: (f: AppointmentFilters) => void; onClose: () => void;
}) {
const [f, setF] = useState<AppointmentFilters>(value);
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-items', f.sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${f.sectionUuid}`),
enabled: !!f.sectionUuid,
});
const toggleStatus = (s: string) => setF(v => ({
...v,
statuses: v.statuses.includes(s) ? v.statuses.filter(x => x !== s) : [...v.statuses, s],
}));
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
return (
<Modal open title="فیلترها" onClose={onClose}>
<div>
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 8 }}>
<button className="btn sm ghost" style={{ color: 'var(--accent)' }} onClick={() => setF(EMPTY_FILTERS)}>
<XMarkIcon style={{ width: 14 }} /> حذف همه
</button>
</div>
<label style={label}>جستجو براساس نام</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={f.name} onChange={e => setF(v => ({ ...v, name: e.target.value }))} placeholder="نام مراجعه کننده را وارد کنید..." />
</div>
<label style={label}>جستجو براساس کد ملی</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={f.nationalCode} onChange={e => setF(v => ({ ...v, nationalCode: digitsOnly(e.target.value, 10) }))} placeholder="کد ملی مراجعه کننده را وارد کنید..." inputMode="numeric" dir="ltr" />
</div>
<label style={label}>بخش</label>
<div style={{ margin: '6px 0 12px' }}>
<SearchableSelect
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={f.sectionUuid || null}
onChange={v => setF(prev => ({ ...prev, sectionUuid: v ? String(v) : '', itemUuid: '' }))}
placeholder="انتخاب بخش"
isLoading={sectionsQ.isLoading}
isClearable
height={38}
/>
</div>
<label style={label}>سرویس</label>
<div style={{ margin: '6px 0 14px' }}>
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={f.itemUuid || null}
onChange={v => setF(prev => ({ ...prev, itemUuid: v ? String(v) : '' }))}
placeholder="انتخاب سرویس"
isDisabled={!f.sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{STATUS_OPTIONS.map(([v, l]) => (
<label key={v} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.statuses.includes(v)} onChange={() => toggleStatus(v)} /> {l}
</label>
))}
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginBottom: 16 }}>
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>جنسیت</div>
{([['female', 'خانم'], ['male', 'آقا'], ['both', 'هر دو']] as const).map(([v, l]) => (
<label key={v} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', marginBottom: 6 }}>
<input type="radio" name="gender" checked={f.gender === v} onChange={() => setF(x => ({ ...x, gender: v }))} /> {l}
</label>
))}
</div>
<button className="btn primary" style={{ width: '100%' }} onClick={() => { onApply(f); onClose(); }}>
اعمال تغییرات
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import AppointmentTurnCard, { type AppointmentCardData } from './AppointmentTurnCard';
const base: AppointmentCardData = {
uuid: 'a1', starts_at: 1754000000, status: 'confirmed', version: 1,
doctor_name: 'دکتر ژیلا فتحی', service_name: null,
};
describe('AppointmentTurnCard', () => {
it('renders title, date/time labels, doctor and the live status label', () => {
renderWithProviders(<AppointmentTurnCard appointment={base} queryKey={['x']} />);
expect(screen.getByText('نوبت')).toBeInTheDocument(); // no service → generic title
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
expect(screen.getByText('ساعت:')).toBeInTheDocument();
expect(screen.getByText('پرسنل:')).toBeInTheDocument();
expect(screen.getByText('دکتر ژیلا فتحی')).toBeInTheDocument();
expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // confirmed via STATUS_META
});
it('falls back to «—» when the doctor is missing', () => {
renderWithProviders(<AppointmentTurnCard appointment={{ ...base, doctor_name: null }} queryKey={['x']} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('prefers the service name as the title when present', () => {
renderWithProviders(<AppointmentTurnCard appointment={{ ...base, service_name: 'لیزر' }} queryKey={['x']} />);
expect(screen.getByText('لیزر')).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import type { CSSProperties, ReactNode } from 'react';
import { formatDate, formatTime } from '../lib/utils';
import {
FilesServiceSuccess, FilesServiceMore, CalendarD, ClockP, UserD, StatusGlobe,
} from './icons/FilesServiceIcons';
import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown';
export interface AppointmentCardData {
uuid: string;
starts_at: number;
status: string;
version: number;
doctor_name?: string | null;
service_name?: string | null;
}
/**
* label/value row inside the turn card — mirrors tauri ServiceInfoRow
* (separatedValues variant): icon+label on one side, value (optionally chip) on
* the other.
*/
function InfoRow({ icon, label, value, chip = false, valueStyle }: {
icon: ReactNode; label: string; value: ReactNode; chip?: boolean; valueStyle?: CSSProperties;
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{icon}
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#616161' }}>{label}:</span>
</div>
<span
className={chip ? 'dark:bg-[#404040] dark:text-[#D7D8ED]' : 'dark:text-[#D7D8ED]'}
style={{
fontSize: 12, color: '#525252', textAlign: 'left',
...(chip ? { background: '#efefef', borderRadius: 8, padding: '2px 8px', color: '#2f2f2f' } : {}),
...valueStyle,
}}
>
{value}
</span>
</div>
);
}
/**
* A patient «نوبت» card — ported pixel-for-pixel from tauri
* files/services/TurnsCard. The status uses the admin's live
* AppointmentStatusDropdown (backed by PATCH /appointment/{uuid}/status)
* instead of the tauri mock.
*/
export default function AppointmentTurnCard({ appointment, queryKey }: {
appointment: AppointmentCardData;
queryKey: unknown[];
}) {
const title = appointment.service_name || 'نوبت';
return (
<div
className="bg-white dark:bg-[#222433] border border-[#EDEDED] dark:border-[#35343D]"
style={{ width: 277, maxWidth: '100%', minHeight: 249, borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 10, boxShadow: '0 1px 6px rgba(15,23,42,0.06)' }}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<FilesServiceSuccess />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3, minWidth: 0 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>{title}</span>
</div>
</div>
<FilesServiceMore style={{ color: '#9CA3AF', flexShrink: 0 }} />
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
{/* Middle: date & time chips */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<InfoRow icon={<CalendarD />} label="تاریخ" chip value={formatDate(appointment.starts_at)} />
<InfoRow icon={<ClockP />} label="ساعت" chip value={formatTime(appointment.starts_at)} />
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
{/* Bottom: personnel & status */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<InfoRow icon={<UserD size={19} />} label="پرسنل" value={appointment.doctor_name || '—'} valueStyle={{ fontWeight: 500 }} />
<InfoRow
icon={<StatusGlobe size={19} />}
label="وضعیت"
value={<AppointmentStatusDropdown uuid={appointment.uuid} currentStatus={appointment.status} version={appointment.version} queryKey={queryKey} />}
/>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import ClinicDoctorsManager from './ClinicDoctorsManager';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: [
{ id: '1', uuid: 'doc-uuid-1', name: 'دکتر رضایی', gender: null, degree: null,
img: [], specialties: [{ id: '2', name: 'قلب' }], active: true },
] });
if (url.includes('/invitations')) return Promise.resolve({ success: true, data: [
{ uuid: 'inv-1', mobile: '09120000000', invited_name: 'دکتر مهمان', invited_specialty: null,
status: 'pending', token_used: false, invited_at: 1, expires_at: 9_999_999_999,
responded_at: null, doctor: null },
], meta: { totalRecords: 1, totalPages: 1, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
});
describe('ClinicDoctorsManager', () => {
it('lists clinic doctors and shows management controls by default', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.getByText('قلب')).toBeInTheDocument();
// manager controls
expect(screen.getByText('دعوت پزشک')).toBeInTheDocument();
expect(screen.getByTitle('جداسازی از کلینیک')).toBeInTheDocument();
});
it('hides every mutating control when readOnly', async () => {
renderWithProviders(<ClinicDoctorsManager clinicUuid="clinic-1" readOnly />, { route: '/admin/settings/clinic-doctors' });
expect(await screen.findByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.queryByText('دعوت پزشک')).not.toBeInTheDocument();
expect(screen.queryByTitle('جداسازی از کلینیک')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,308 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, EyeIcon, ShieldCheckIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from './ui/ConfirmDialog';
import InviteDoctorModal from './ui/InviteDoctorModal';
import DoctorPermissionsModal from './ui/DoctorPermissionsModal';
const HUES_LIST = [256, 205, 162, 295, 272];
export interface ClinicDoctorItem {
id: string; uuid: string; name: string;
gender: string | null; degree: string | null;
img: { url: string }[];
specialties: { id: string; name: string }[];
active: boolean;
}
export interface ClinicInvitation {
uuid: string;
mobile: string;
invited_name: string | null;
invited_specialty: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed';
token_used: boolean;
invited_at: number;
expires_at: number;
responded_at: number | null;
doctor: { uuid: string; name: string } | null;
}
const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: 'در انتظار', cls: 'amber' },
accepted: { label: 'پذیرفته‌شده', cls: 'green' },
rejected: { label: 'رد شده', cls: 'gray' },
suspended: { label: 'تعلیق', cls: 'violet' },
removed: { label: 'حذف‌شده', cls: 'gray' },
};
/**
* ClinicDoctorsManager — self-contained management of a clinic's doctors and
* pending invitations (list, invite, resend, suspend, delete invitation, detach
* doctor). Reused by both the admin ClinicDetailPage and the clinic-owner
* settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control.
*/
export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
clinicUuid: string;
readOnly?: boolean;
}) {
const navigate = useNavigate();
const qc = useQueryClient();
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
const [inviteOpen, setInviteOpen] = useState(false);
const [detachDoctorConfirm, setDetachDoctorConfirm] = useState<ClinicDoctorItem | null>(null);
const [permissionsFor, setPermissionsFor] = useState<ClinicDoctorItem | null>(null);
const doctorsQ = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const invitationsQ = useQuery({
queryKey: ['clinic-invitations', clinicUuid],
queryFn: () => api.get<PaginatedResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invitations?limit=50`),
enabled: !!clinicUuid,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
const resendInvMut = useMutation({
mutationFn: (invUuid: string) => api.post<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/resend`, {}),
onSuccess: () => { toast.success('پیامک مجدداً ارسال شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const changeInvStatusMut = useMutation({
mutationFn: ({ invUuid, status }: { invUuid: string; status: string }) =>
api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/invitation/${invUuid}/status`, { status }),
onSuccess: (_d, v) => {
toast.success(v.status === 'pending' ? 'دعوتنامه فعال و پیامک مجدداً ارسال شد' : 'دعوتنامه تعلیق شد');
qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteInvMut = useMutation({
mutationFn: (invUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/invitation/${invUuid}`),
onSuccess: () => { toast.success('دعوتنامه حذف شد'); qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); },
onError: (e: Error) => toast.error(e.message),
});
const detachDoctorMut = useMutation({
mutationFn: (doctorUuid: string) =>
api.delete<ApiResponse<{ message: string }>>(`/api/v1/admin/clinic/${clinicUuid}/doctor/${doctorUuid}`),
onSuccess: () => {
toast.success('پزشک از کلینیک جدا شد');
qc.invalidateQueries({ queryKey: ['clinic-doctors', clinicUuid] });
qc.invalidateQueries({ queryKey: ['clinic-detail', clinicUuid] });
},
onError: (e: Error) => toast.error(e.message),
});
return (
<>
<div className="card card-pad">
{/* Card header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div className="seg">
<button className={doctorsTab === 'doctors' ? 'active' : ''} onClick={() => setDoctorsTab('doctors')}>
پزشکان ({formatNumber(doctorList.length)})
</button>
<button className={doctorsTab === 'invitations' ? 'active' : ''} onClick={() => setDoctorsTab('invitations')}>
دعوتنامهها ({formatNumber(invitationList.length)})
</button>
</div>
{!readOnly && (
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button>
)}
</div>
{/* Doctors tab */}
{doctorsTab === 'doctors' && (
doctorList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{doctorList.map(doc => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
<button
className="mini-btn"
title="مشاهده پروفایل"
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<>
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)
)}
{/* Invitations tab */}
{doctorsTab === 'invitations' && (
invitationList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<EnvelopeIcon style={{ width: 30, height: 30 }} />
<p className="muted">هیچ دعوتنامهای ارسال نشده</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{invitationList.map(inv => {
const statusInfo = INV_STATUS_MAP[inv.status] ?? { label: inv.status, cls: 'gray' };
const isExpired = !inv.token_used && inv.status === 'pending' && Date.now() / 1000 > inv.expires_at;
return (
<div key={inv.uuid} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{inv.invited_name ?? inv.mobile}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{inv.mobile}</span>
{inv.invited_specialty && (
<span className="muted" style={{ fontSize: 11 }}>{inv.invited_specialty}</span>
)}
{inv.doctor && (
<button
className="badge blue"
style={{ fontSize: 11, cursor: 'pointer', border: 'none', background: 'none', padding: 0 }}
onClick={() => navigate(`/admin/doctors/${inv.doctor!.uuid}`)}
>
<span className="bdot" />{inv.doctor.name}
</button>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span>
{!readOnly && (
<div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && (
<button
className="mini-btn"
title="ارسال مجدد"
disabled={resendInvMut.isPending}
onClick={() => resendInvMut.mutate(inv.uuid)}
>
<ArrowPathIcon style={{ width: 13, height: 13 }} />
</button>
)}
{inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title={inv.status === 'suspended' ? 'فعال‌سازی و ارسال مجدد پیامک' : 'تعلیق'}
disabled={changeInvStatusMut.isPending}
onClick={() => changeInvStatusMut.mutate({ invUuid: inv.uuid, status: inv.status === 'suspended' ? 'pending' : 'suspended' })}
>
<NoSymbolIcon style={{ width: 13, height: 13 }} />
</button>
)}
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
</div>
)}
</div>
</div>
);
})}
</div>
)
)}
</div>
{/* Detach Doctor From Clinic Confirm */}
<ConfirmDialog
open={detachDoctorConfirm !== null}
title="جداسازی پزشک از کلینیک"
message={`آیا پزشک "${detachDoctorConfirm?.name ?? ''}" از این کلینیک جدا شود؟ این کار فقط ارتباط پزشک با این کلینیک را حذف می‌کند.`}
confirmLabel="جداسازی"
danger
loading={detachDoctorMut.isPending}
onConfirm={() => {
if (detachDoctorConfirm) detachDoctorMut.mutate(detachDoctorConfirm.uuid);
setDetachDoctorConfirm(null);
}}
onCancel={() => setDetachDoctorConfirm(null)}
/>
{/* Per-doctor clinic permissions */}
{permissionsFor && (
<DoctorPermissionsModal
clinicUuid={clinicUuid}
doctorUuid={permissionsFor.uuid}
doctorName={permissionsFor.name}
onClose={() => setPermissionsFor(null)}
/>
)}
{/* Invite doctor modal */}
{inviteOpen && clinicUuid && (
<InviteDoctorModal
clinicUuid={clinicUuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', clinicUuid] }); setDoctorsTab('invitations'); }}
/>
)}
</>
);
}
+341
View File
@@ -0,0 +1,341 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { DiscountRule, DiscountRuleType } from '../types';
import { formatRial, formatDate, tomanToRial, rialToToman, tehranWallClockToUnix } from '../lib/utils';
import Modal from './ui/Modal';
import ConfirmDialog from './ui/ConfirmDialog';
import SearchableSelect from './ui/SearchableSelect';
import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils';
const TYPE_LABELS: Record<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار',
invoice_amount: 'مبلغ فاکتور',
specific_patient: 'بیمار خاص',
occasion: 'مناسبتی',
service: 'سرویس',
visit_count: 'تعداد مراجعات',
};
interface Option { uuid: string; name?: string }
const unixToIso = (u: number | null): string => {
if (!u) return '';
const d = new Date(u * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const isoToUnix = (iso: string): number | null => (iso ? tehranWallClockToUnix(iso, '00:00') : null);
interface FormState {
name: string;
type: DiscountRuleType;
discount_type: 'percent' | 'fixed';
value: number; // percent (0..100) or toman (fixed)
priority: number;
combinable: boolean;
active: boolean;
valid_from: string; // iso
valid_to: string; // iso
target_tag_uuid: string;
target_record_uuid: string;
target_service_item_uuid: string;
min_amount_toman: number;
min_visit_count: number;
occasion_kind: '' | 'birthday';
}
const emptyForm = (): FormState => ({
name: '', type: 'invoice_amount', discount_type: 'percent', value: 0, priority: 0,
combinable: false, active: true, valid_from: '', valid_to: '',
target_tag_uuid: '', target_record_uuid: '', target_service_item_uuid: '',
min_amount_toman: 0, min_visit_count: 0, occasion_kind: '',
});
const fromRule = (r: DiscountRule): FormState => ({
name: r.name, type: r.type, discount_type: r.discount_type,
value: r.discount_type === 'fixed' ? rialToToman(r.value) : r.value,
priority: r.priority, combinable: r.combinable, active: r.active,
valid_from: unixToIso(r.valid_from), valid_to: unixToIso(r.valid_to),
target_tag_uuid: r.target_tag_uuid ?? '', target_record_uuid: r.target_record_uuid ?? '',
target_service_item_uuid: r.target_service_item_uuid ?? '',
min_amount_toman: r.min_amount_rials ? rialToToman(r.min_amount_rials) : 0,
min_visit_count: r.min_visit_count ?? 0,
occasion_kind: r.occasion_kind === 'birthday' ? 'birthday' : '',
});
function labelStyle(): React.CSSProperties { return { fontSize: 12.5, color: 'var(--text-3)', display: 'block', marginBottom: 6 }; }
export default function DiscountTab() {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | DiscountRule | null>(null);
const [toDelete, setToDelete] = useState<DiscountRule | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-discount-rules'],
queryFn: () => api.get<ApiResponse<DiscountRule[]>>('/api/v1/admin/discount-rules'),
});
const rules: DiscountRule[] = (data?.data as any)?.data ?? (data?.data as any) ?? [];
const removeMut = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/admin/discount-rules/${uuid}`),
onSuccess: () => { toast.success('قانون حذف شد'); setToDelete(null); qc.invalidateQueries({ queryKey: ['admin-discount-rules'] }); },
onError: (e: Error) => toast.error(e.message),
});
const discountDisplay = (r: DiscountRule) =>
r.discount_type === 'percent' ? `${r.value}٪` : formatRial(r.value);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>قوانین تخفیف عمومی بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه</span>
<button className="btn primary sm" onClick={() => setModal('create')}>
<PlusIcon style={{ width: 15 }} /> قانون جدید
</button>
</div>
{isLoading ? (
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : rules.length === 0 ? (
<div className="card" style={{ padding: 28, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>هنوز قانونی تعریف نشده است.</div>
) : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface-2)', textAlign: 'right' }}>
<th style={{ padding: 10 }}>نام</th>
<th style={{ padding: 10 }}>نوع</th>
<th style={{ padding: 10 }}>تخفیف</th>
<th style={{ padding: 10 }}>اولویت</th>
<th style={{ padding: 10 }}>ترکیبپذیر</th>
<th style={{ padding: 10 }}>وضعیت</th>
<th style={{ padding: 10 }}></th>
</tr>
</thead>
<tbody>
{rules.map((r) => (
<tr key={r.uuid} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: 10 }}>{r.name}</td>
<td style={{ padding: 10 }}>{TYPE_LABELS[r.type]}</td>
<td style={{ padding: 10 }}>{discountDisplay(r)}</td>
<td style={{ padding: 10 }}>{r.priority}</td>
<td style={{ padding: 10 }}>{r.combinable ? 'بله' : 'خیر'}</td>
<td style={{ padding: 10 }}>
<span className={`badge ${r.active ? 'green' : ''}`}>{r.active ? 'فعال' : 'غیرفعال'}</span>
</td>
<td style={{ padding: 10, textAlign: 'left', whiteSpace: 'nowrap' }}>
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button>
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{modal && (
<RuleModal
initial={modal === 'create' ? null : modal}
onClose={() => setModal(null)}
onSaved={() => { setModal(null); qc.invalidateQueries({ queryKey: ['admin-discount-rules'] }); }}
/>
)}
<ConfirmDialog
open={!!toDelete}
title="حذف قانون تخفیف"
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
confirmLabel="حذف"
danger
loading={removeMut.isPending}
onConfirm={() => toDelete && removeMut.mutate(toDelete.uuid)}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null; onClose: () => void; onSaved: () => void }) {
const [f, setF] = useState<FormState>(initial ? fromRule(initial) : emptyForm());
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => setF((s) => ({ ...s, [k]: v }));
const tagsQ = useQuery({
queryKey: ['tenant-tags'], queryFn: () => api.get<ApiResponse<Option[]>>('/api/v1/tenant-tags'),
enabled: f.type === 'patient_tag',
});
const sectionsQ = useQuery({
queryKey: ['service-sections'], queryFn: () => api.get<ApiResponse<Option[]>>('/api/v1/service-sections'),
enabled: f.type === 'service',
});
const [sectionUuid, setSectionUuid] = useState('');
const itemsQ = useQuery({
queryKey: ['service-items', sectionUuid], queryFn: () => api.get<ApiResponse<Option[]>>(`/api/v1/service-items/${sectionUuid}`),
enabled: f.type === 'service' && !!sectionUuid,
});
const tags = (tagsQ.data?.data as any)?.data ?? (tagsQ.data?.data as any) ?? [];
const sections = (sectionsQ.data?.data as any)?.data ?? (sectionsQ.data?.data as any) ?? [];
const items = (itemsQ.data?.data as any)?.data ?? (itemsQ.data?.data as any) ?? [];
const save = useMutation({
mutationFn: () => {
const body: Record<string, unknown> = {
name: f.name.trim(),
type: f.type,
discount_type: f.discount_type,
value: f.discount_type === 'fixed' ? tomanToRial(f.value) : f.value,
priority: f.priority,
combinable: f.combinable,
active: f.active,
valid_from: isoToUnix(f.valid_from),
valid_to: isoToUnix(f.valid_to),
target_tag_uuid: f.type === 'patient_tag' ? (f.target_tag_uuid || null) : null,
target_record_uuid: f.type === 'specific_patient' ? (f.target_record_uuid.trim() || null) : null,
target_service_item_uuid: f.type === 'service' ? (f.target_service_item_uuid || null) : null,
min_amount_rials: f.type === 'invoice_amount' ? tomanToRial(f.min_amount_toman) : null,
min_visit_count: f.type === 'visit_count' ? f.min_visit_count : null,
occasion_kind: f.type === 'occasion' ? (f.occasion_kind || null) : null,
};
return initial
? api.patch(`/api/v1/admin/discount-rules/${initial.uuid}`, body)
: api.post('/api/v1/admin/discount-rules', body);
},
onSuccess: () => { toast.success('قانون ذخیره شد'); onSaved(); },
onError: (e: Error) => toast.error(e.message),
});
const canSave = f.name.trim().length > 0 && f.value >= 0;
return (
<Modal open title={initial ? 'ویرایش قانون تخفیف' : 'قانون تخفیف جدید'} onClose={onClose}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={labelStyle()}>نام قانون</label>
<input className="cp-input" value={f.name} onChange={(e) => set('name', e.target.value)} placeholder="مثال: بیماران VIP" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle()}>نوع قانون</label>
<SearchableSelect
options={(Object.keys(TYPE_LABELS) as DiscountRuleType[]).map((t) => ({ value: t, label: TYPE_LABELS[t] }))}
value={f.type} onChange={(v) => set('type', (v as DiscountRuleType) || 'invoice_amount')} height={38}
/>
</div>
<div>
<label style={labelStyle()}>نوع تخفیف</label>
<SearchableSelect
options={[{ value: 'percent', label: 'درصدی' }, { value: 'fixed', label: 'مبلغ ثابت' }]}
value={f.discount_type} onChange={(v) => set('discount_type', (v as 'percent' | 'fixed') || 'percent')} height={38}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label>
{f.discount_type === 'percent'
? <input className="cp-input" style={{ width: '100%' }} type="text" inputMode="numeric" dir="ltr" value={f.value} onChange={(e) => set('value', Number(digitsOnly(e.target.value, 3)) || 0)} />
: <PriceInput className="cp-input" style={{ width: '100%' }} value={f.value} onChange={(v) => set('value', v)} />}
</div>
<div>
<label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label>
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.priority} onChange={(e) => set('priority', Number(digitsOnly(e.target.value)) || 0)} />
</div>
</div>
{/* target فیلد پویا بر اساس نوع */}
{f.type === 'patient_tag' && (
<div>
<label style={labelStyle()}>تگ بیمار</label>
<SearchableSelect
options={tags.map((t: Option) => ({ value: t.uuid, label: t.name ?? '' }))}
value={f.target_tag_uuid || null} onChange={(v) => set('target_tag_uuid', v ? String(v) : '')}
placeholder="انتخاب تگ" isLoading={tagsQ.isLoading} isClearable height={38}
/>
</div>
)}
{f.type === 'invoice_amount' && (
<div>
<label style={labelStyle()}>حداقل مبلغ فاکتور (تومان)</label>
<PriceInput className="cp-input" style={{ width: '100%' }} value={f.min_amount_toman} onChange={(v) => set('min_amount_toman', v)} />
</div>
)}
{f.type === 'specific_patient' && (
<div>
<label style={labelStyle()}>شناسهی پروندهی بیمار (uuid)</label>
<input className="cp-input" dir="ltr" value={f.target_record_uuid} onChange={(e) => set('target_record_uuid', e.target.value)} placeholder="record uuid" />
</div>
)}
{f.type === 'service' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle()}>بخش</label>
<SearchableSelect
options={sections.map((s: Option) => ({ value: s.uuid, label: s.name ?? '' }))}
value={sectionUuid || null} onChange={(v) => { setSectionUuid(v ? String(v) : ''); set('target_service_item_uuid', ''); }}
placeholder="انتخاب بخش" isLoading={sectionsQ.isLoading} isClearable height={38}
/>
</div>
<div>
<label style={labelStyle()}>سرویس</label>
<SearchableSelect
options={items.map((s: Option) => ({ value: s.uuid, label: s.name ?? '' }))}
value={f.target_service_item_uuid || null} onChange={(v) => set('target_service_item_uuid', v ? String(v) : '')}
placeholder="انتخاب سرویس" isDisabled={!sectionUuid} isLoading={itemsQ.isLoading} isClearable height={38}
/>
</div>
</div>
)}
{f.type === 'visit_count' && (
<div>
<label style={labelStyle()}>حداقل تعداد مراجعه</label>
<input className="cp-input" type="text" inputMode="numeric" dir="ltr" value={f.min_visit_count} onChange={(e) => set('min_visit_count', Number(digitsOnly(e.target.value)) || 0)} />
</div>
)}
{f.type === 'occasion' && (
<div>
<label style={labelStyle()}>زیرنوع مناسبت</label>
<SearchableSelect
options={[{ value: '', label: 'بازه‌ی زمانی' }, { value: 'birthday', label: 'تولد بیمار' }]}
value={f.occasion_kind} onChange={(v) => set('occasion_kind', (v as '' | 'birthday'))} height={38}
/>
</div>
)}
{/* بازه‌ی اعتبار (اختیاری؛ برای مناسبتی/موقت) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle()}>اعتبار از (اختیاری)</label>
<PersianDateInput value={f.valid_from} onChange={(v) => set('valid_from', v)} />
</div>
<div>
<label style={labelStyle()}>اعتبار تا (اختیاری)</label>
<PersianDateInput value={f.valid_to} onChange={(v) => set('valid_to', v)} />
</div>
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.combinable} onChange={(e) => set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.active} onChange={(e) => set('active', e.target.checked)} /> فعال
</label>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
<button className="btn primary sm" disabled={!canSave || save.isPending} onClick={() => save.mutate()}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import FreeVisitPrice from './FreeVisitPrice';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
const pricing = (priceRials: number, require: boolean) => ({
success: true,
data: { free_visit_price_rials: priceRials, require_visit_price: require },
});
beforeEach(() => {
get.mockReset();
put.mockReset();
put.mockResolvedValue({ success: true });
});
describe('FreeVisitPrice — الزامی کردن هزینه ویزیت', () => {
it('toggle فعال + قیمت صفر → خطای inline و عدم ارسال درخواست', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.click(screen.getByText('ذخیره'));
expect(await screen.findByText('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است')).toBeInTheDocument();
expect(put).not.toHaveBeenCalled();
});
it('toggle فعال + قیمت معتبر → PUT با هر دو کلید (تومان → ریال)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
fireEvent.click(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: '50000' } });
fireEvent.click(screen.getByText('ذخیره'));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
free_visit_price_rials: 500_000,
require_visit_price: true,
}));
});
it('toggle غیرفعال + قیمت صفر → رفتار قبلی حفظ می‌شود (ارسال مجاز)', async () => {
get.mockResolvedValue(pricing(0, false));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('0'));
fireEvent.click(screen.getByText('ذخیره'));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/insurance-pricing', {
free_visit_price_rials: 0,
require_visit_price: false,
}));
});
it('فلگ ذخیره‌شده true → سوییچ روشن و ستاره روی label قیمت', async () => {
get.mockResolvedValue(pricing(500_000, true));
renderWithProviders(<FreeVisitPrice />);
await waitFor(() => expect(screen.getByRole('switch', { name: 'الزامی کردن هزینه ویزیت' })).toBeChecked());
expect(screen.getByText('قیمت (تومان)').querySelector('span')?.textContent).toContain('*');
expect(screen.getByRole('textbox')).toHaveValue('50000');
});
});
+65 -13
View File
@@ -3,32 +3,54 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
interface Pricing { free_visit_price_rials: number }
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
export default function FreeVisitPrice() {
/** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */
export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) {
const qc = useQueryClient();
const [value, setValue] = useState('');
const [required, setRequired] = useState(false);
const [error, setError] = useState('');
const { data } = useQuery<{ data: Pricing }>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
queryKey: ['insurance-pricing', doctorUuid ?? 'self'],
queryFn: () => api.get(doctorUuid
? `/api/v1/insurance-pricing?doctor_uuid=${doctorUuid}`
: '/api/v1/insurance-pricing'),
});
const pricing = (data as any)?.data as Pricing | undefined;
useEffect(() => {
if (pricing) setValue(String(rialToToman(pricing.free_visit_price_rials ?? 0)));
if (pricing) {
setValue(String(rialToToman(pricing.free_visit_price_rials ?? 0)));
setRequired(!!pricing.require_visit_price);
}
}, [pricing]);
const saveMut = useMutation({
mutationFn: () => api.put('/api/v1/insurance-pricing', { free_visit_price_rials: tomanToRial(Number(value) || 0) }),
mutationFn: () => api.put('/api/v1/insurance-pricing', {
free_visit_price_rials: tomanToRial(Number(value) || 0),
require_visit_price: required,
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
}),
onSuccess: () => {
toast.success('قیمت ویزیت ذخیره شد');
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
qc.invalidateQueries({ queryKey: ['insurance-pricing', doctorUuid ?? 'self'] });
},
onError: (e: Error) => toast.error(e.message),
});
const save = () => {
if (required && (Number(value) || 0) <= 0) {
setError('با فعال بودن «الزامی کردن هزینه ویزیت»، قیمت ویزیت آزاد الزامی است');
return;
}
setError('');
saveMut.mutate();
};
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 4px' }}>قیمت ویزیت آزاد</h2>
@@ -37,19 +59,49 @@ export default function FreeVisitPrice() {
</p>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>قیمت (تومان)</label>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>
قیمت (تومان){required && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<input
type="number" min={0} dir="ltr" className="input" style={{ width: 200 }}
value={value} onChange={(e) => setValue(e.target.value)}
type="text" inputMode="numeric" dir="ltr" className="input" style={{ width: 200 }}
aria-invalid={!!error}
value={value} onChange={(e) => { setValue(digitsOnly(e.target.value)); setError(''); }}
/>
</div>
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
{value !== '' && (
<span style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>{formatRial(tomanToRial(Number(value) || 0))}</span>
)}
</div>
{error && (
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '6px 0 0' }}>{error}</p>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 13, cursor: 'pointer', marginTop: 16 }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: required ? 'var(--primary)' : '#c4c4c4', transition: 'background .2s',
}}>
<input
type="checkbox" checked={required} role="switch" aria-label="الزامی کردن هزینه ویزیت"
onChange={(e) => { setRequired(e.target.checked); setError(''); }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: required ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: '#fff', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
الزامی کردن هزینه ویزیت
</label>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '6px 0 0', lineHeight: 1.7 }}>
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
<div style={{ display: 'flex', marginTop: 16 }}>
<button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
import InsuranceModal, {
buildInsurancePayload, contractToForm, EMPTY_FORM, type Contract, type InsuranceOption,
} from './InsuranceModal';
const mkContract = (over: Partial<Contract> = {}): Contract => ({
uuid: 'c-1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 500_000,
annual_ceiling_rials: 20_000_000, kind: 'basic', effective_from: 1_700_000_000,
effective_to: null, ...over,
});
const options: InsuranceOption[] = [
{ insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' },
{ insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' },
];
describe('buildInsurancePayload', () => {
it('converts toman → rials, percent, and Y-m-d → unix', () => {
const payload = buildInsurancePayload({
...EMPTY_FORM, insuranceId: '3', kind: 'supplementary',
coverage: '80', franchise: '50000', ceiling: '2000000',
effectiveFrom: '2024-01-01', effectiveTo: '2025-01-01',
});
expect(payload.insurance_id).toBe(3);
expect(payload.kind).toBe('supplementary');
expect(payload.coverage_percent).toBe(80);
expect(payload.franchise_rials).toBe(500_000); // 50000 toman × 10
expect(payload.annual_ceiling_rials).toBe(20_000_000);
expect(typeof payload.effective_from).toBe('number');
expect(payload.effective_to).toBeGreaterThan(payload.effective_from!);
});
it('empty ceiling → null (بی‌نهایت), empty dates → null', () => {
const payload = buildInsurancePayload({ ...EMPTY_FORM, insuranceId: '3', coverage: '50' });
expect(payload.annual_ceiling_rials).toBeNull();
expect(payload.effective_from).toBeNull();
expect(payload.effective_to).toBeNull();
});
});
describe('contractToForm', () => {
it('maps rials → toman and uses contract kind', () => {
const form = contractToForm(mkContract({ franchise_rials: 300_000, kind: 'supplementary' }));
expect(form.franchise).toBe('30000');
expect(form.kind).toBe('supplementary');
expect(form.coverage).toBe('70');
});
});
describe('InsuranceModal', () => {
it('renders the fields in add mode with no manual kind select', () => {
renderWithProviders(
<InsuranceModal open editContract={null} options={options} kind="basic" onClose={() => {}} onSubmit={() => {}} />,
);
expect(screen.getByText('افزودن بیمه')).toBeInTheDocument();
expect(screen.getByText('نام بیمه')).toBeInTheDocument();
// Manual "نوع بیمه" select is gone; kind is shown as a read-only chip from the tab.
expect(screen.queryByText('نوع بیمه')).not.toBeInTheDocument();
expect(screen.getByText('پایه')).toBeInTheDocument();
expect(screen.getByText('تاریخ شروع قرارداد')).toBeInTheDocument();
expect(screen.getByText('تاریخ پایان قرارداد')).toBeInTheDocument();
expect(screen.getByText('درصد پوشش')).toBeInTheDocument();
expect(screen.getByText('فرانشیز (تومان)')).toBeInTheDocument();
expect(screen.getByText('سقف تعهد (تومان)')).toBeInTheDocument();
expect(screen.getByText('ثبت بیمه')).toBeInTheDocument();
});
it('shows the tab kind chip and carries it into a new payload', () => {
const onSubmit = vi.fn();
renderWithProviders(
<InsuranceModal open editContract={null} options={options} kind="supplementary" onClose={() => {}} onSubmit={onSubmit} />,
);
expect(screen.getByText('تکمیلی')).toBeInTheDocument();
});
it('submits the built payload for an edited contract', () => {
const onSubmit = vi.fn();
renderWithProviders(
<InsuranceModal open editContract={mkContract()} options={options} kind="basic" onClose={() => {}} onSubmit={onSubmit} />,
);
fireEvent.click(screen.getByText('ثبت بیمه'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
insurance_id: 3, coverage_percent: 70, franchise_rials: 500_000, kind: 'basic',
}));
});
});
+179
View File
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import PersianDateInput from './ui/PersianDateInput';
import { isoToUnix, rialToToman, tomanToRial, unixToIso } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
export interface InsuranceOption {
insurance_id: number;
insurance_name: string;
type: string;
}
export interface Contract {
uuid: string;
insurance_id: number;
insurance_name: string | null;
insurance_kind: string | null;
version: number;
is_active: boolean;
coverage_percent: number;
franchise_rials: number;
annual_ceiling_rials: number | null;
kind: string | null;
effective_from: number;
effective_to: number | null;
}
export interface InsuranceFormValues {
insuranceId: string;
kind: string;
effectiveFrom: string; // Y-m-d
effectiveTo: string; // Y-m-d
coverage: string;
franchise: string; // toman
ceiling: string; // toman
}
export const KIND_LABEL: Record<string, string> = {
basic: 'پایه',
supplementary: 'تکمیلی',
};
export const EMPTY_FORM: InsuranceFormValues = {
insuranceId: '', kind: 'basic', effectiveFrom: '', effectiveTo: '',
coverage: '', franchise: '', ceiling: '',
};
/** Map a contract to editable form values (rials → toman, unix → Y-m-d). */
export function contractToForm(c: Contract): InsuranceFormValues {
return {
insuranceId: String(c.insurance_id),
kind: c.kind ?? c.insurance_kind ?? 'basic',
effectiveFrom: unixToIso(c.effective_from),
effectiveTo: unixToIso(c.effective_to),
coverage: String(c.coverage_percent ?? ''),
franchise: c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '',
ceiling: c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '',
};
}
/** Build the API payload from form values (toman → rials, Y-m-d → unix). */
export function buildInsurancePayload(v: InsuranceFormValues) {
return {
insurance_id: Number(v.insuranceId),
kind: v.kind || null,
coverage_percent: Number(v.coverage) || 0,
franchise_rials: tomanToRial(Number(v.franchise) || 0),
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
effective_from: isoToUnix(v.effectiveFrom),
effective_to: isoToUnix(v.effectiveTo),
};
}
interface Props {
open: boolean;
editContract: Contract | null;
/** Insurance catalog options; in edit mode all are shown, in add mode only the available ones. */
options: InsuranceOption[];
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
kind: string;
onClose: () => void;
onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
isPending?: boolean;
}
/**
* Add/edit insurance contract modal (افزودن/ویرایش بیمه). Presentational: owns form
* state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه"
* modal plus the injected coverage/franchise/ceiling controls.
*/
export default function InsuranceModal({ open, editContract, options, kind, onClose, onSubmit, isPending }: Props) {
const [form, setForm] = useState<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => {
if (!open) return;
setForm(editContract ? contractToForm(editContract) : { ...EMPTY_FORM, kind });
}, [open, editContract, kind]);
const set = (patch: Partial<InsuranceFormValues>) => setForm((f) => ({ ...f, ...patch }));
const isEdit = editContract !== null;
const submit = () => {
if (!form.insuranceId) return;
onSubmit(buildInsurancePayload(form));
};
const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 };
const label = { fontSize: 12, fontWeight: 600, color: 'var(--text-2)' };
return (
<Modal
open={open}
title={isEdit ? 'ویرایش بیمه' : 'افزودن بیمه'}
size="md"
onClose={onClose}
footer={
<>
<button type="button" className="btn ghost" onClick={onClose}>لغو</button>
<button
type="button"
className="btn primary"
disabled={!form.insuranceId || isPending}
onClick={submit}
>
{isPending ? '...' : 'ثبت بیمه'}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={field}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<label style={label}>نام بیمه</label>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 10px', borderRadius: 'var(--r-pill)',
background: 'var(--primary-soft)', color: 'var(--primary)',
}}>
{KIND_LABEL[form.kind] ?? form.kind}
</span>
</div>
<SearchableSelect
options={options.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }))}
value={form.insuranceId}
onChange={(v) => set({ insuranceId: v ? String(v) : '' })}
isDisabled={isEdit}
placeholder="انتخاب کنید..."
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>تاریخ شروع قرارداد</label>
<PersianDateInput value={form.effectiveFrom} onChange={(v) => set({ effectiveFrom: v })} placeholder="انتخاب" />
</div>
<div style={field}>
<label style={label}>تاریخ پایان قرارداد</label>
<PersianDateInput value={form.effectiveTo} onChange={(v) => set({ effectiveTo: v })} placeholder="انتخاب" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div style={field}>
<label style={label}>درصد پوشش</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.coverage} onChange={(e) => set({ coverage: digitsOnly(e.target.value, 3) })} />
</div>
<div style={field}>
<label style={label}>فرانشیز (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" value={form.franchise} onChange={(e) => set({ franchise: digitsOnly(e.target.value) })} />
</div>
<div style={field}>
<label style={label}>سقف تعهد (تومان)</label>
<input type="text" inputMode="numeric" dir="ltr" className="input" placeholder="بی‌نهایت" value={form.ceiling} onChange={(e) => set({ ceiling: digitsOnly(e.target.value) })} />
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import InvoiceSummaryModal from './InvoiceSummaryModal';
const get = api.get as ReturnType<typeof vi.fn>;
const baseInvoice = {
uuid: 'iv1', status: 'finalized', issued_at: 1700000000, total_rials: 2_400_000,
base_insurance_rials: 0, supplementary_rials: 0, patient_rials: 900_000,
items: [{ uuid: 'it1', title: 'فول بادی', quantity: 1, total_rials: 2_400_000, patient_rials: 900_000 }],
};
const fullSession = {
session_at: 1700000000, paid_at: 1700100000,
services_total_rials: 2_400_000, consumables_total_rials: 40_000,
discount_rials: 200_000, final_price_rials: 2_240_000, paid_total_rials: 1_500_000,
// API واقعی همیشه این را می‌فرستد: max(0, final discount paid)
remaining_rials: 540_000,
payments: [
{ uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, created_by_name: 'منشی تست' },
],
consumables: [
{ uuid: 'c1', item_name: 'عینک', quantity: 2, line_total_rials: 40_000 },
],
};
beforeEach(() => {
get.mockReset();
});
function mockInvoice(invoice: object) {
get.mockResolvedValue({ success: true, data: { data: invoice } });
}
describe('InvoiceSummaryModal', () => {
it('با session کامل: جدول کالای مصرفی، پرداختی‌ها و تخفیف را نشان می‌دهد', async () => {
mockInvoice({ ...baseInvoice, session: fullSession });
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
// کالای مصرفی
expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument();
expect(screen.getByText('عینک')).toBeInTheDocument();
// پرداختی‌ها با label فارسی روش و ثبت‌کننده
expect(screen.getByText('پرداختی ها')).toBeInTheDocument();
expect(screen.getByText('پرداخت از کیف پول')).toBeInTheDocument();
expect(screen.getByText('منشی تست')).toBeInTheDocument();
// خلاصه مالی با ستون تخفیف و جمع کالا
expect(screen.getByText('تخفیف')).toBeInTheDocument();
expect(screen.getByText('جمع مبلغ کالا')).toBeInTheDocument();
// وضعیت — مبالغ واقعی (نمایش تومان = ریال ÷ ۱۰):
// پرداخت‌شده ۱۵۰٬۰۰۰ (هم در جدول پرداختی‌ها هم وضعیت) و باقی‌مانده ۵۴٬۰۰۰
// (۲۲۴۰۰۰۰ − ۲۰۰۰۰۰ تخفیف − ۱۵۰۰۰۰۰ پرداختی = ۵۴۰۰۰۰ ریال؛ final_price پیش از تخفیف است)
expect(screen.getAllByText(/۱۵۰٬۰۰۰/).length).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/۵۴٬۰۰۰/)).toBeInTheDocument();
});
it('بدون session (فاکتور قدیمی): رفتار قبلی حفظ می‌شود', async () => {
mockInvoice({ ...baseInvoice, session: null });
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
// جدول‌های session-محور رندر نمی‌شوند
expect(screen.queryByText('اطلاعات کالای مصرفی')).not.toBeInTheDocument();
expect(screen.queryByText('پرداختی ها')).not.toBeInTheDocument();
// خلاصه مالی قدیمی با سهم بیمار
expect(screen.getByText('سهم بیمار')).toBeInTheDocument();
});
it('session با payments/consumables خالی: ردیف خط تیره', async () => {
mockInvoice({
...baseInvoice,
session: { ...fullSession, payments: [], consumables: [], paid_total_rials: 0, discount_rials: 0 },
});
renderWithProviders(<InvoiceSummaryModal invoiceUuid="iv1" onClose={() => {}} />);
await waitFor(() => expect(screen.getByText('اطلاعات فاکتور')).toBeInTheDocument());
expect(screen.getByText('اطلاعات کالای مصرفی')).toBeInTheDocument();
expect(screen.getByText('پرداختی ها')).toBeInTheDocument();
// ردیف‌های '-' برای هر دو جدول خالی + ستون تخفیف صفر
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(8);
});
it('invoiceUuid=null: مودال بسته و بدون fetch', () => {
renderWithProviders(<InvoiceSummaryModal invoiceUuid={null} onClose={() => {}} />);
expect(get).not.toHaveBeenCalled();
expect(screen.queryByText('خلاصه فاکتور')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,161 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
import { METHOD_LABELS } from './session/PaymentStep';
interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number }
interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null }
interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number }
interface SessionData {
session_at: number | null; paid_at: number | null;
services_total_rials: number; consumables_total_rials: number;
discount_rials: number; final_price_rials: number; paid_total_rials: number;
gross_total_rials?: number; base_insurance_rials?: number;
supplementary_insurance_rials?: number; patient_share_rials?: number;
remaining_rials?: number;
payments: SessionPayment[]; consumables: SessionConsumable[];
}
interface Invoice {
uuid: string; status: string; issued_at: number; total_rials: number;
base_insurance_rials: number; supplementary_rials: number; patient_rials: number;
items: InvoiceItem[];
session?: SessionData | null;
}
const STATUS_LABEL: Record<string, string> = { paid: 'پرداخت شده', finalized: 'بدهکار', draft: 'پیش‌نویس', void: 'باطل' };
/** A titled table block — mirrors tauri InvoiceSummary `SectionTable`. */
function SectionTable({ title, cols, rows }: { title: string; cols: string[]; rows: React.ReactNode[][] }) {
return (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)', marginBottom: 12 }}>{title}</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
{cols.map((c, i) => (
<th key={i} style={{ textAlign: 'center', fontWeight: 600, padding: '12px 16px', background: 'var(--info-bg, #ebf5ff)', borderBottom: '1px solid var(--border)', color: 'var(--text-2)' }}>{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ background: i % 2 === 1 ? 'var(--surface-2)' : 'transparent' }}>
{row.map((cell, j) => (
<td key={j} style={{ textAlign: 'center', padding: '12px 16px', borderBottom: i === rows.length - 1 ? 'none' : '1px solid var(--border)', color: 'var(--text)' }}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/** خلاصه فاکتور — invoice summary, ported pixel-for-pixel from tauri InvoiceSummary. */
export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceUuid: string | null; onClose: () => void }) {
const { data, isLoading } = useQuery<ApiResponse<any>>({
queryKey: ['invoice', invoiceUuid],
queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`),
enabled: !!invoiceUuid,
});
// billing show wraps as { data: { data: invoice } }
const inv = ((data?.data as any)?.data ?? data?.data ?? null) as Invoice | null;
const session = inv?.session ?? null;
const paid = inv?.status === 'paid';
// مانده را سرور می‌دهد (session.remaining_rials)؛ محاسبه‌ی محلی با صفحه‌ی پرداخت واگرا می‌شد.
const remaining = session
? session.remaining_rials ?? Math.max(0, session.final_price_rials - (session.discount_rials ?? 0) - session.paid_total_rials)
: inv ? (paid ? 0 : inv.patient_rials) : 0;
const paidAmount = session ? session.paid_total_rials : inv ? inv.total_rials - remaining : 0;
// یک شکل واحد برای خلاصه‌ی مالی؛ با session از خود مراجعه، بدون آن از فاکتور.
const summary = {
services: session ? session.services_total_rials : inv?.total_rials ?? 0,
consumables: session?.consumables_total_rials ?? 0,
discount: session?.discount_rials ?? 0,
baseInsurance: session?.base_insurance_rials ?? inv?.base_insurance_rials ?? 0,
suppInsurance: session?.supplementary_insurance_rials ?? inv?.supplementary_rials ?? 0,
patientShare: session?.patient_share_rials ?? session?.final_price_rials ?? inv?.patient_rials ?? 0,
gross: session?.gross_total_rials ?? inv?.total_rials ?? 0,
};
// وضعیت واقعی پرداخت (مستقل از وضعیت فریزشده‌ی فاکتور): تسویه‌شده اگر مانده صفر.
const statusLabel = session ? (remaining <= 0 ? 'تسویه شده' : 'بدهکار') : (inv ? (STATUS_LABEL[inv.status] ?? inv.status) : '');
return (
<Modal open={!!invoiceUuid} onClose={onClose} title="خلاصه فاکتور" size="xl">
{isLoading || !inv ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری</div>
) : (
<div dir="rtl">
<SectionTable
title="اطلاعات فاکتور"
cols={['تاریخ سرویس', 'تاریخ پرداخت', 'وضعیت پرداخت']}
rows={[[
formatDate(session?.session_at ?? inv.issued_at),
session?.paid_at ? formatDate(session.paid_at) : paid ? formatDate(inv.issued_at) : '—',
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{statusLabel}</span>,
]]}
/>
<SectionTable
title="اطلاعات سرویس"
cols={['سرویس', 'تعداد', 'مبلغ']}
rows={inv.items.length ? inv.items.map((it) => [it.title, it.quantity, formatRial(it.total_rials)]) : [['—', '—', '—']]}
/>
{session && (
<SectionTable
title="اطلاعات کالای مصرفی"
cols={['کالای مصرفی', 'تعداد', 'مبلغ']}
rows={session.consumables.length
? session.consumables.map((c) => [c.item_name, c.quantity, formatRial(c.line_total_rials)])
: [['-', '-', '-']]}
/>
)}
<SectionTable
title="خلاصه مالی"
cols={['جمع مبلغ سرویس', 'جمع مبلغ کالا', 'سهم بیمه پایه', 'سهم بیمه تکمیلی', 'سهم بیمار', 'تخفیف', 'مبلغ نهایی']}
rows={[[
formatRial(summary.services),
summary.consumables > 0 ? formatRial(summary.consumables) : '-',
formatRial(summary.baseInsurance),
formatRial(summary.suppInsurance),
formatRial(summary.patientShare),
summary.discount > 0 ? formatRial(summary.discount) : '-',
formatRial(Math.max(0, summary.patientShare - summary.discount)),
]]}
/>
{session && (
<SectionTable
title="پرداختی ها"
cols={['ردیف', 'شیوه پرداخت', 'مبلغ', 'تاریخ و ساعت', 'ثبت‌کننده']}
rows={session.payments.length
? [
...session.payments.map((p, i) => [
i + 1,
METHOD_LABELS[p.method] ?? p.method,
formatRial(p.amount_rials),
p.paid_at ? formatDateTime(p.paid_at) : '-',
p.created_by_name ?? '-',
]),
['', <span style={{ fontWeight: 700 }}>مجموع پرداختیها</span>, <span style={{ fontWeight: 700 }}>{formatRial(session.paid_total_rials)}</span>, '', ''],
]
: [['-', '-', '-', '-', '-']]}
/>
)}
<SectionTable
title="وضعیت"
cols={['مبلغ کل پرداخت شده', 'مبلغ باقی مانده']}
rows={[[
formatRial(paidAmount),
<span style={{ color: remaining > 0 ? '#d32f2f' : '#388e3c', fontWeight: 600 }}>{formatRial(remaining)}</span>,
]]}
/>
</div>
)}
</Modal>
);
}
@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
/** react-select (SearchableSelect) را با placeholder پیدا و گزینه را با متن انتخاب می‌کند. */
async function pickSelect(placeholder: string, optionLabel: string) {
const ph = await screen.findByText(placeholder);
const control = ph.closest('div[class*="control"]') as HTMLElement;
const input = control.querySelector('input') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText(optionLabel));
}
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import NewAppointmentDrawer from './NewAppointmentDrawer';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
beforeEach(() => {
get.mockReset(); post.mockReset();
get.mockImplementation((url: string) => {
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
if (url.startsWith('/api/v1/service-items/sec1')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر توتال' }] });
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] });
if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'ساغر صابری', user_mobile: '09356619438', user_national_code: '1234567891' }] });
return Promise.resolve({ success: true, data: [] });
});
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
});
function renderDrawer() {
return renderWithProviders(
<NewAppointmentDrawer doctorUuid="d1" defaultDate="2026-08-01" queryKey={['appts']} onClose={() => {}} />,
);
}
describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => {
it('renders the Figma sections and disables submit until valid', async () => {
renderDrawer();
expect(screen.getByText('اطلاعات مراجعه کننده:')).toBeInTheDocument();
expect(screen.getByText('مشخصات سرویس:')).toBeInTheDocument();
expect(screen.getByText('زمان نوبت:')).toBeInTheDocument();
expect(screen.getByText('بیعانه:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
});
it('end time follows start + default duration', () => {
renderDrawer();
fireEvent.change(screen.getByLabelText('ساعت شروع'), { target: { value: '09:00' } });
fireEvent.change(screen.getByLabelText('زمان پیش فرض'), { target: { value: '45' } });
expect((screen.getByLabelText('ساعت پایان') as HTMLInputElement).value).toBe('09:45');
});
it('posts the extended payload for a new patient with service specs', async () => {
renderDrawer();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } });
await pickSelect('انتخاب بخش', 'زیبایی');
await pickSelect('انتخاب سرویس', 'لیزر توتال');
await pickSelect('انتخاب...', 'سحر ایمانی');
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
doctor_uuid: 'd1',
patient_name: 'مریم خلیلی',
patient_mobile: '09136549874',
patient_national_code: '1234567891',
service_section_uuid: 'sec1',
service_item_uuid: 'it1',
staff_uuid: 'st1',
is_reserve: false,
})));
});
it('picks an existing patient from the search results', async () => {
renderDrawer();
fireEvent.change(screen.getByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'ساغر' } });
fireEvent.click(await screen.findByText('ساغر صابری'));
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
patient_name: 'ساغر صابری', patient_mobile: '09356619438', patient_national_code: '1234567891',
})));
});
it('deposit toggle reveals the amount field and the wallet-charge link', async () => {
renderDrawer();
fireEvent.click(screen.getByLabelText('بیعانه مورد نیاز است.'));
expect(await screen.findByText('مبلغ بیعانه (تومان)')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /شارژ کیف پول/ })).toBeInTheDocument();
});
it('service mode: picks a suggested time and posts service_item_uuids', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] });
if (url.startsWith('/api/v1/service-items/sec1')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر توتال' }] });
if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [] });
if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [] });
// پزشک در حالت نوبت‌دهی سرویسی
if (url.startsWith('/api/v1/appointment-settings/weekly-schedule/'))
return Promise.resolve({ success: true, data: { data: { meta: { booking_mode: 'service', buffer_minutes: 5 } } } });
if (url.startsWith('/api/v1/appointment-service-slots'))
return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } });
return Promise.resolve({ success: true, data: [] });
});
renderDrawer();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } });
// حالت سرویس: منوی زمان‌دهیِ دستی نباید باشد
await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull());
await pickSelect('انتخاب بخش', 'زیبایی');
await pickSelect('افزودن سرویس', 'لیزر توتال');
// زمانِ خالیِ پیشنهادی ظاهر می‌شود؛ انتخاب می‌کنیم
const slotBtn = await screen.findByRole('button', { name: '15:00' });
fireEvent.click(slotBtn);
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
doctor_uuid: 'd1',
slot_start: 1754000000,
slot_end: 1754001800,
service_item_uuids: ['it1'],
is_reserve: false,
})));
});
it('reserve mode hides time fields and posts a day-level entry', async () => {
renderWithProviders(
<NewAppointmentDrawer doctorUuid="d1" defaultDate="2026-08-01" queryKey={['r']} onClose={() => {}} isReserve />,
);
expect(screen.getByText('اضافه کردن نوبت رزرو')).toBeInTheDocument();
expect(screen.queryByLabelText('ساعت شروع')).toBeNull();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
const day = Math.floor(new Date('2026-08-01T00:00').getTime() / 1000);
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
is_reserve: true, slot_start: day, slot_end: day,
})));
});
});
@@ -0,0 +1,397 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useClinicContext } from '../hooks/useClinicContext';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import { WalletChargeLink } from './AppointmentActions';
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
const addMinutes = (time: string, min: number) => {
const [h, m] = time.split(':').map(Number);
const t = h * 60 + m + min;
return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
};
/**
* اضافه کردن نوبت جدید (Figma add.pdf) — rich create form: patient
* search-or-new, بخش/سرویس/پرسنل, date + default-duration + start/end time,
* deposit toggle, status and notes. POSTs the extended /my/appointment.
*/
export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey, onClose, isReserve = false }: {
doctorUuid: string;
/** ISO Y-m-d — the currently viewed day. */
defaultDate: string;
queryKey: unknown[];
onClose: () => void;
/** true → «اضافه کردن نوبت رزرو» (day-level entry, no time fields). */
isReserve?: boolean;
}) {
const qc = useQueryClient();
const clinicUuid = useClinicContext();
// ── patient: pick an existing record or enter a new person ────────────────
const [patientSearch, setPatientSearch] = useState('');
const [pickedPatient, setPickedPatient] = useState<PatientRow | null>(null);
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const [nationalCode, setNationalCode] = useState('');
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
queryKey: ['drawer-patients', patientSearch],
queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`),
enabled: patientSearch.trim().length >= 2,
});
// ── service specs ──────────────────────────────────────────────────────────
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [staffUuid, setStaffUuid] = useState('');
// روش نوبت‌دهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد می‌شود.
const scheduleQ = useQuery<ApiResponse<any>>({
queryKey: ['drawer-schedule', doctorUuid, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`
+ (clinicUuid ? `?clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: !!doctorUuid,
});
const bookingMode: 'slot' | 'service' =
((scheduleQ.data?.data as any)?.data?.meta ?? (scheduleQ.data?.data as any)?.meta)?.booking_mode === 'service'
? 'service' : 'slot';
const serviceMode = bookingMode === 'service' && !isReserve;
const sectionsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
});
const itemsQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['service-items', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const staffQ = useQuery<ApiResponse<Option[]>>({
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
});
// ── timing ─────────────────────────────────────────────────────────────────
const [date, setDate] = useState(defaultDate);
const [duration, setDuration] = useState(40);
const [start, setStart] = useState('15:00');
const [end, setEnd] = useState(addMinutes('15:00', 40));
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
// ── service-mode: چند سرویس + زمان‌های خالیِ پیشنهادی ─────────────────────────
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
const [svcNames, setSvcNames] = useState<Record<string, string>>({});
const [pickedSlot, setPickedSlot] = useState<{ start: number; end: number } | null>(null);
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]);
const svcSlotsQ = useQuery<ApiResponse<any>>({
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
),
enabled: serviceMode && !!date && serviceUuids.length > 0,
});
const svcSlots = ((svcSlotsQ.data?.data as any)?.start_times ?? []) as Array<{ start: number; end: number; start_time: string }>;
const totalMinutes = (svcSlotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
// ── deposit / status / notes ───────────────────────────────────────────────
const [depositRequired, setDepositRequired] = useState(false);
const [depositToman, setDepositToman] = useState(0);
const [status, setStatus] = useState('pending');
const [note, setNote] = useState('');
// ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (فیلد UI تومان،
// API ریالی). بدون این مقدار وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد.
const pricingQ = useQuery<ApiResponse<{ free_visit_price_rials: number; require_visit_price: boolean }>>({
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0;
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
const effectiveName = pickedPatient?.user_name || name.trim();
const effectiveMobile = pickedPatient?.user_mobile || mobile.trim();
const effectiveNationalCode = (pickedPatient?.user_national_code || nationalCode).replace(/\D/g, '');
const timingValid = isReserve
? true
: serviceMode
? (serviceUuids.length > 0 && !!pickedSlot)
: (!!start && !!end);
const visitPriceValid = !requireVisit || visitPriceToman > 0;
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
&& effectiveNationalCode.length === 10 && timingValid && visitPriceValid;
const create = useMutation({
mutationFn: async () => {
const slotStart = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.start : toEpoch(date, start);
const slotEnd = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.end : toEpoch(date, end);
const payload: Record<string, unknown> = {
doctor_uuid: doctorUuid,
slot_start: slotStart,
slot_end: slotEnd,
patient_name: effectiveName,
patient_mobile: effectiveMobile,
patient_national_code: effectiveNationalCode,
is_reserve: isReserve,
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
// حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری).
...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
...(note.trim() ? { note: note.trim() } : {}),
};
const res: any = await api.post('/api/v1/my/appointment', payload);
// POST creates a pending booking; apply the picked status afterwards.
if (status !== 'pending' && res?.data?.uuid) {
await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 });
}
return res;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey });
toast.success(isReserve ? 'نوبت رزرو ثبت شد' : 'نوبت با موفقیت ثبت شد');
onClose();
},
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
});
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
return (
<Modal open title={isReserve ? 'اضافه کردن نوبت رزرو' : 'اضافه کردن نوبت جدید'} onClose={onClose}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 10 }}>اطلاعات مراجعه کننده:</div>
<label style={label}>انتخاب مراجعه کننده</label>
<div className="field" style={{ margin: '6px 0 8px' }}>
<input value={pickedPatient ? `${pickedPatient.user_name ?? ''}${pickedPatient.user_mobile ?? ''}` : patientSearch}
onChange={e => { setPickedPatient(null); setPatientSearch(e.target.value); }}
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
</div>
{!pickedPatient && patients.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden' }}>
{patients.map(p => (
<button key={p.uuid} onClick={() => setPickedPatient(p)} style={{
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
</button>
))}
</div>
)}
{pickedPatient === null && (
<>
<div style={{ margin: '4px 0 10px' }}>
<span className="badge" style={{ color: 'var(--primary)', border: '1px solid var(--primary)', borderRadius: 'var(--r-sm)', padding: '5px 10px', fontSize: 12, display: 'inline-flex', gap: 5, alignItems: 'center' }}>
<PlusIcon style={{ width: 13 }} /> مراجعه کننده جدید
</span>
</div>
<label style={label}>نام و نام خانوادگی مراجعه کننده</label>
<div className="field" style={{ margin: '6px 0 10px' }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده را وارد نمایید" />
</div>
<label style={label}>شماره تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={mobile} onChange={e => setMobile(sanitizeMobileInput(e.target.value))} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={11} />
</div>
<label style={label}>کد ملی</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={nationalCode} onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
</div>
</>
)}
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>مشخصات سرویس:</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={sectionUuid || null}
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
placeholder="انتخاب بخش"
isLoading={sectionsQ.isLoading}
isClearable
height={38}
/>
</div>
</div>
<div>
<label style={label}>سرویس{serviceMode ? ' (یک یا چند)' : ''}</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
value={serviceMode ? null : (itemUuid || null)}
isDisabled={!sectionUuid}
isLoading={itemsQ.isLoading}
placeholder={serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'}
onChange={v => {
const uuid = v ? String(v) : '';
if (!uuid) { if (!serviceMode) setItemUuid(''); return; }
if (serviceMode) {
const name = (itemsQ.data?.data ?? []).find(o => o.uuid === uuid)?.name ?? '';
setServiceUuids(prev => prev.includes(uuid) ? prev : [...prev, uuid]);
setSvcNames(prev => ({ ...prev, [uuid]: name }));
} else {
setItemUuid(uuid);
}
}}
height={38}
/>
</div>
</div>
</div>
{serviceMode && serviceUuids.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
{serviceUuids.map(uuid => (
<span key={uuid} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '4px 8px' }}>
{svcNames[uuid] ?? uuid}
<button type="button" aria-label="حذف سرویس" onClick={() => setServiceUuids(prev => prev.filter(u => u !== uuid))}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-3)', fontSize: 14, lineHeight: 1 }}>×</button>
</span>
))}
{totalMinutes != null && <span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'center' }}>مدت کل: {totalMinutes} دقیقه</span>}
</div>
)}
<label style={label}>انتخاب پرسنل</label>
<div style={{ margin: '6px 0 12px' }}>
<SearchableSelect
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
value={staffUuid || null}
onChange={v => setStaffUuid(v ? String(v) : '')}
placeholder="انتخاب..."
isLoading={staffQ.isLoading}
isClearable
height={38}
/>
</div>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>زمان نوبت:</div>
<label style={label}>انتخاب تاریخ</label>
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
{serviceMode ? (
<div style={{ marginBottom: 12 }}>
<label style={label}>زمانهای خالی پیشنهادی</label>
{serviceUuids.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>ابتدا سرویس را انتخاب کنید.</div>
) : svcSlotsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>در حال محاسبه...</div>
) : svcSlots.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--danger)', marginTop: 6 }}>برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
{svcSlots.map(s => {
const active = pickedSlot?.start === s.start;
return (
<button key={s.start} type="button" dir="ltr" onClick={() => setPickedSlot({ start: s.start, end: s.end })}
style={{ fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)' }}>
{s.start_time}
</button>
);
})}
</div>
)}
</div>
) : (
<>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ margin: '6px 0 10px' }}>
<input aria-label="زمان پیش فرض" type="text" inputMode="numeric" value={duration} onChange={e => setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
</div>
{!isReserve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
)}
</>
)}
{!isReserve && (
<>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
</div>
{depositRequired && (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput value={depositToman} onChange={setDepositToman} />
</div>
</div>
<WalletChargeLink mobile={effectiveMobile} />
</div>
)}
</>
)}
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>هزینه ویزیت:</div>
<label style={label}>
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
</label>
<div style={{ margin: '6px 0 4px' }}>
<PriceInput value={visitPriceToman} onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }} />
</div>
{requireVisit && visitPriceToman <= 0 && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 12 }}>هزینه ویزیت الزامی است</div>
)}
<label style={label}>انتخاب وضعیت</label>
<div style={{ margin: '6px 0 12px' }}>
<SearchableSelect
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
value={status || null}
onChange={v => setStatus(v ? String(v) : '')}
placeholder="انتخاب وضعیت"
height={38}
/>
</div>
<div className="field" style={{ height: 'auto', marginBottom: 16 }}>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
</div>
<button className="btn primary" style={{ width: '100%' }} disabled={!valid || create.isPending} onClick={() => create.mutate()}>
ثبت نوبت
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,100 @@
import { Link } from 'react-router-dom';
import { formatDate } from '../lib/utils';
import {
ArrowLeftPH, ArrowLeftD, FilesServicePhone, FilesServiceCalendar,
FilesServiceNotification, FilesServiceMessage,
} from './icons/FilesServiceIcons';
interface Tag { uuid: string; name: string; color: string }
/** پرونده > {name} breadcrumb, ported from tauri BreadcrumbHeader. */
export function Breadcrumb({ name, backTo }: { name: string; backTo: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#6B7280', fontSize: 12, marginBottom: 14 }}>
<Link to={backTo} className="bg-white dark:bg-[#222433]" style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 8px', borderRadius: 12, cursor: 'pointer', color: '#6B7280', textDecoration: 'none' }}>
<ArrowLeftPH />
<span>بازگشت</span>
</Link>
<ArrowLeftD />
<span>پرونده</span>
<ArrowLeftD />
<span className="dark:text-[#D7D8ED]" style={{ color: '#111827' }}>{name}</span>
</div>
);
}
function TagDots({ tags }: { tags?: Tag[] }) {
if (!tags || tags.length === 0) return <span style={{ fontSize: 13, color: 'var(--text-3)' }}></span>;
return (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{tags.slice(0, 4).map((t, i) => (
<span key={t.uuid} title={t.name} style={{ width: 16, height: 16, borderRadius: '50%', background: t.color, border: '2px solid var(--surface)', marginInlineStart: i === 0 ? 0 : -6 }} />
))}
</span>
);
}
const InfoLine = ({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{icon}
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#2f2f2f' }}>{label}</span>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#2f2f2f' }} dir="ltr">{value}</span>
</div>
);
/**
* The patient case-file banner — ported pixel-for-pixel from tauri
* FileServicesHeader (name + status chip, file number, tags, contact/date,
* next appointment, یادداشت button).
*/
export default function PatientCaseBanner({ name, recordNumber, mobile, createdAt, tags, nextAppointment, hasDebt, onAddNote }: {
name: string;
recordNumber?: string | null;
mobile?: string | null;
createdAt?: number;
tags?: Tag[];
nextAppointment?: number | null;
hasDebt?: boolean;
onAddNote: () => void;
}) {
const complete = !hasDebt;
return (
<div
className="bg-white dark:bg-[#222433] border border-[#EDEDED] dark:border-[#35343D]"
style={{ borderRadius: 16, padding: '14px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, marginBottom: 16, minHeight: 140, flexWrap: 'wrap' }}
>
{/* right — name + tags */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className="dark:text-[#D7D8ED]" style={{ fontWeight: 700, fontSize: 20, color: '#111827' }}>{name}</span>
<span style={{
height: 29, minWidth: 95, borderRadius: 8, padding: '0 10px', fontSize: 13, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: complete ? '#ECFDF3' : 'rgba(255,192,81,0.15)', color: complete ? '#3C9A4F' : '#f59e0b',
}}>{complete ? 'تکمیل شده' : 'تکمیل نشده'}</span>
</div>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 16, color: '#525252' }}>شماره پرونده: {recordNumber || '—'}</span>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 16, color: '#6B7280' }}>برچسب ها:</span>
<TagDots tags={tags} />
</div>
</div>
{/* middle — contact + file date */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 28 }}>
<InfoLine icon={<FilesServicePhone />} label="شماره تماس:" value={mobile || '—'} />
<InfoLine icon={<FilesServiceCalendar />} label="تاریخ تشکیل پرونده:" value={createdAt ? formatDate(createdAt) : '—'} />
</div>
{/* left — next appointment + note */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 28 }}>
<InfoLine icon={<FilesServiceNotification />} label="نوبت بعدی:" value={nextAppointment ? formatDate(nextAppointment) : '—'} />
<button
type="button" onClick={onAddNote}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: '#f17732', color: '#fff', border: 'none', borderRadius: 10, padding: '0 18px', height: 36, fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
<FilesServiceMessage /> یادداشت
</button>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PatientRecordInfoForm, {
type PatientFormValues,
type PatientFormOptions,
} from '@/components/PatientRecordInfoForm';
const options: PatientFormOptions = {
gender: [{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }],
marital: [{ value: 'single', label: 'مجرد' }],
education: [{ value: 'bachelor', label: 'کارشناسی' }],
insurance: [{ value: 3, label: 'تأمین اجتماعی' }],
supplementary: [{ value: 5, label: 'بیمه دانا' }],
province: [{ value: 8, label: 'یزد' }],
city: [{ value: 42, label: 'یزد' }],
referral: [{ value: 'instagram', label: 'اینستاگرام' }],
};
const baseValues: PatientFormValues = {
name: 'ساغر صابری نژاد',
mobile: '',
national_code: '',
gender: null,
birth_date: '',
fathers_name: '',
marital_status: null,
basic_insurance_id: null,
supplementary_insurance_id: null,
field_of_study: '',
education: null,
job: '',
province_id: null,
city_id: null,
home_phone: '',
address: '',
postal_code: '',
referral_source: null,
description: '',
};
function setup(overrides: Partial<PatientFormValues> = {}) {
const onSubmit = vi.fn();
render(
<PatientRecordInfoForm
defaultValues={{ ...baseValues, ...overrides }}
recordNumber="123456789"
options={options}
onSubmit={onSubmit}
/>,
);
return { onSubmit };
}
describe('PatientRecordInfoForm', () => {
it('برچسب‌های اصلی و شماره پروندهٔ read-only را رندر می‌کند', () => {
setup();
expect(screen.getByText('نام و نام خانوادگی مراجعه کننده')).toBeInTheDocument();
expect(screen.getByText('بیمه پایه')).toBeInTheDocument();
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
expect(screen.getByText('نحوه آشنایی')).toBeInTheDocument();
expect((screen.getByDisplayValue('123456789') as HTMLInputElement).readOnly).toBe(true);
});
it('نام خالی → خطای الزامی و onSubmit صدا نمی‌خورد', async () => {
const { onSubmit } = setup({ name: '' });
await userEvent.click(screen.getByRole('button', { name: /ثبت اطلاعات/ }));
expect(await screen.findByText('نام و نام خانوادگی الزامی است')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('کدملی نامعتبر → خطای ۱۰ رقم', async () => {
setup({ national_code: '123' });
await userEvent.click(screen.getByRole('button', { name: /ثبت اطلاعات/ }));
expect(await screen.findByText('کد ملی باید ۱۰ رقم باشد')).toBeInTheDocument();
});
it('ورودی معتبر → onSubmit با مقادیر فرم', async () => {
const { onSubmit } = setup({ national_code: '0012345678' });
await userEvent.click(screen.getByRole('button', { name: /ثبت اطلاعات/ }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({
name: 'ساغر صابری نژاد',
national_code: '0012345678',
});
});
});
@@ -0,0 +1,210 @@
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Field from './ui/Field';
import Input from './ui/Input';
import MobileInput from './ui/MobileInput';
import SearchableSelect from './ui/SearchableSelect';
import type { SelectOption } from './ui/SearchableSelect';
import PersianDatePicker from './ui/PersianDatePicker';
import { iranNationalCodeOptionalSchema, iranMobileOptionalSchema } from '../lib/utils';
/**
* اسکیمای فرم «اطلاعات پرونده». مطابق قواعد بک‌اند:
* نام الزامی؛ موبایل در صورت پرشدن باید ^09\d{9}$؛ کدملی در صورت پرشدن ۱۰ رقم.
* `birth_date` رشتهٔ میلادی YYYY-MM-DD است؛ تبدیل به timestamp در لایهٔ صفحه انجام می‌شود.
*/
export const patientFormSchema = z.object({
name: z.string().trim().min(1, 'نام و نام خانوادگی الزامی است'),
mobile: iranMobileOptionalSchema,
national_code: iranNationalCodeOptionalSchema,
gender: z.string().nullable(),
birth_date: z.string(),
fathers_name: z.string(),
marital_status: z.string().nullable(),
basic_insurance_id: z.union([z.number(), z.string(), z.null()]),
supplementary_insurance_id: z.union([z.number(), z.string(), z.null()]),
field_of_study: z.string(),
education: z.string().nullable(),
job: z.string(),
province_id: z.union([z.number(), z.string(), z.null()]),
city_id: z.union([z.number(), z.string(), z.null()]),
home_phone: z.string(),
address: z.string(),
postal_code: z.string(),
referral_source: z.string().nullable(),
description: z.string(),
});
export type PatientFormValues = z.infer<typeof patientFormSchema>;
export interface PatientFormOptions {
gender: SelectOption[];
marital: SelectOption[];
education: SelectOption[];
insurance: SelectOption[];
supplementary: SelectOption[];
province: SelectOption[];
city: SelectOption[];
referral: SelectOption[];
}
interface Props {
defaultValues: PatientFormValues;
recordNumber?: string | null;
options: PatientFormOptions;
onSubmit: (values: PatientFormValues) => void;
isSubmitting?: boolean;
/** برای بارگذاری وابستهٔ شهرها هنگام تغییر استان (منطق در صفحه). */
onProvinceChange?: (provinceId: number | null) => void;
}
const grid: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 'var(--gap)',
};
export default function PatientRecordInfoForm({
defaultValues,
recordNumber,
options,
onSubmit,
isSubmitting,
onProvinceChange,
}: Props) {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<PatientFormValues>({
resolver: zodResolver(patientFormSchema),
defaultValues,
});
const sel = (name: keyof PatientFormValues, label: string, opts: SelectOption[], placeholder = 'انتخاب کنید...') => (
<Field label={label} error={errors[name]?.message as string | undefined}>
<Controller
name={name}
control={control}
render={({ field }) => (
<SearchableSelect
options={opts}
value={field.value as string | number | null}
onChange={field.onChange}
placeholder={placeholder}
/>
)}
/>
</Field>
);
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div style={grid}>
<Field label="نام و نام خانوادگی مراجعه کننده" error={errors.name?.message}>
<Input {...register('name')} hasError={!!errors.name} placeholder="نام و نام خانوادگی" />
</Field>
<Field label="شماره پرونده">
<Input value={recordNumber ?? '—'} readOnly dir="ltr" />
</Field>
{sel('gender', 'جنسیت', options.gender)}
<Field label="کدملی" error={errors.national_code?.message}>
<Input {...register('national_code')} numeric hasError={!!errors.national_code} maxLength={10} placeholder="کد ملی" />
</Field>
<Field label="شماره تماس" error={errors.mobile?.message}>
<Controller
name="mobile"
control={control}
render={({ field }) => (
<MobileInput value={field.value} onChange={field.onChange} hasError={!!errors.mobile} />
)}
/>
</Field>
<Field label="تاریخ تولد" error={errors.birth_date?.message}>
<Controller
name="birth_date"
control={control}
render={({ field }) => (
<PersianDatePicker value={field.value} onChange={field.onChange} placeholder="تاریخ تولد" enableYearPicker />
)}
/>
</Field>
<Field label="نام پدر">
<Input {...register('fathers_name')} placeholder="نام پدر را وارد نمایید" />
</Field>
{sel('marital_status', 'وضعیت تاهل', options.marital)}
{sel('basic_insurance_id', 'بیمه پایه', options.insurance)}
{sel('supplementary_insurance_id', 'بیمه تکمیلی', options.supplementary)}
<Field label="رشته تحصیلی">
<Input {...register('field_of_study')} placeholder="رشته تحصیلی را وارد نمایید" />
</Field>
{sel('education', 'مقطع تحصیلی', options.education)}
<Field label="شغل">
<Input {...register('job')} placeholder="شغل را وارد نمایید" />
</Field>
<Field label="استان" error={errors.province_id?.message as string | undefined}>
<Controller
name="province_id"
control={control}
render={({ field }) => (
<SearchableSelect
options={options.province}
value={field.value as string | number | null}
onChange={(v) => { field.onChange(v); onProvinceChange?.(v === null ? null : Number(v)); }}
placeholder="انتخاب کنید..."
/>
)}
/>
</Field>
{sel('city_id', 'شهر', options.city)}
<Field label="تلفن ثابت">
<Input {...register('home_phone')} dir="ltr" placeholder="تلفن ثابت را وارد نمایید..." />
</Field>
<Field label="آدرس" style={{ gridColumn: 'span 2' }}>
<Input {...register('address')} placeholder="آدرس محل سکونت را وارد نمایید..." />
</Field>
<Field label="کد پستی">
<Input {...register('postal_code')} numeric maxLength={10} placeholder="کدپستی محل سکونت را وارد نمایید..." />
</Field>
{sel('referral_source', 'نحوه آشنایی', options.referral)}
</div>
<div style={{ marginTop: 'var(--gap)' }}>
<Field label="توضیحات">
<textarea
{...register('description')}
className="cp-input"
rows={4}
style={{ resize: 'vertical', minHeight: 96 }}
placeholder="توضیحات"
/>
</Field>
</div>
<div style={{ marginTop: 'var(--gap)' }}>
<button type="submit" className="cp-btn cp-btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'در حال ثبت...' : 'ثبت اطلاعات'}
</button>
</div>
</form>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatNumber } from '../lib/utils';
import type { PatientRecord } from '../types';
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
/**
* The برچسب‌ها cell for a patient row: shows the record's tag dots and, on click,
* opens a popover to assign/remove tenant tags inline. Assignment PATCHes the
* record's full `tags` array (the backend replaces the set) and refreshes the list.
*/
export default function PatientTagsCell({ record }: { record: PatientRecord }) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const current = record.tags ?? [];
const currentUuids = current.map((t) => t.uuid);
const { data: tagsData, isLoading } = useQuery<ApiResponse<TenantTag[]>>({
queryKey: ['tenant-tags'],
queryFn: () => api.get('/api/v1/tenant-tags'),
enabled: open,
});
const allTags = tagsData?.data ?? [];
const mutate = useMutation({
mutationFn: (uuids: string[]) => api.patch(`/api/v1/patient/${record.uuid}`, { tags: uuids }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['patients'] }),
onError: (e: any) => toast.error(e?.message || 'خطا در به‌روزرسانی برچسب‌ها'),
});
const toggle = (uuid: string) => {
const next = currentUuids.includes(uuid) ? currentUuids.filter((u) => u !== uuid) : [...currentUuids, uuid];
mutate.mutate(next);
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}>
{current.length === 0 ? (
<button type="button" onClick={() => setOpen((v) => !v)} className="btn sm ghost" style={{ color: 'var(--primary)', fontSize: 12.5, gap: 3 }}>
<PlusIcon style={{ width: 14 }} /> اضافه کردن
</button>
) : (
<button type="button" onClick={() => setOpen((v) => !v)} style={{ display: 'inline-flex', alignItems: 'center', background: 'none', border: 'none', cursor: 'pointer', padding: 4 }} aria-label="ویرایش برچسب‌ها">
{current.slice(0, 3).map((t, i) => (
<span key={t.uuid} title={t.name} style={{
width: 16, height: 16, borderRadius: '50%', background: t.color,
border: '2px solid var(--surface)', marginInlineStart: i === 0 ? 0 : -6,
}} />
))}
{current.length > 3 && <span style={{ fontSize: 11, color: 'var(--text-3)', marginInlineStart: 4 }}>+{formatNumber(current.length - 3)}</span>}
</button>
)}
{open && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 60 }} onClick={() => setOpen(false)} />
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', insetInlineStart: 0, zIndex: 61,
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
boxShadow: 'var(--shadow)', padding: 10, minWidth: 200, maxWidth: 300,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>برچسبها</span>
<button type="button" onClick={() => setOpen(false)} className="mini-btn" aria-label="بستن"><XMarkIcon style={{ width: 15 }} /></button>
</div>
{isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '6px 0' }}>در حال بارگذاری</div>
) : allTags.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>برچسبی تعریف نشده است.</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{allTags.map((tag) => {
const assigned = currentUuids.includes(tag.uuid);
return (
<button
key={tag.uuid}
type="button"
disabled={mutate.isPending}
onClick={() => toggle(tag.uuid)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer',
fontSize: 12, height: 24, padding: '0 8px', borderRadius: 999,
border: '1px solid var(--border)',
background: assigned ? tag.color : 'var(--surface-2)',
color: assigned ? '#fff' : 'var(--text-2)',
}}
>
{assigned ? <XMarkIcon style={{ width: 12 }} /> : <PlusIcon style={{ width: 12 }} />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
</>
)}
</span>
);
}
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import SearchableSelect from './ui/SearchableSelect';
export interface PatientFilters {
gender?: string; // male | female
insurance_id?: string;
admitted_from?: string; // gregorian Y-m-d
admitted_to?: string;
service_status?: string; // pending | completed
has_debt?: boolean;
tags?: string[]; // tenant-tag uuids
}
interface TenantTag { uuid: string; name: string; color: string }
interface PricingInsurance { insurance_id: number; insurance_name: string; type: string }
const EMPTY: PatientFilters = {};
function Segmented({ value, onChange, options }: { value: string | undefined; onChange: (v: string) => void; options: { label: string; value: string }[] }) {
return (
<div style={{ display: 'inline-flex', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', overflow: 'hidden' }}>
{options.map((o) => {
const active = (value ?? '') === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
style={{
padding: '7px 14px', border: 'none', cursor: 'pointer', fontSize: 13,
background: active ? 'var(--primary-soft)' : 'var(--surface)',
color: active ? 'var(--primary)' : 'var(--text-2)',
}}
>
{o.label}
</button>
);
})}
</div>
);
}
/** فیلترها — advanced patient-list filter modal, ported from tauri FilesFilterModal. */
export default function PatientsFilterModal({ open, onClose, value, onApply }: {
open: boolean; onClose: () => void; value: PatientFilters; onApply: (f: PatientFilters) => void;
}) {
const [f, setF] = useState<PatientFilters>(value);
useEffect(() => { if (open) setF(value); }, [open, value]);
const { data: tagsData } = useQuery<ApiResponse<TenantTag[]>>({
queryKey: ['tenant-tags'], queryFn: () => api.get('/api/v1/tenant-tags'), enabled: open,
});
const tags = tagsData?.data ?? [];
const { data: pricingData } = useQuery<ApiResponse<{ insurances: PricingInsurance[] }>>({
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), enabled: open,
});
const insurances = (pricingData?.data?.insurances ?? []).filter((i) => i.type === 'basic');
const set = <K extends keyof PatientFilters>(k: K, v: PatientFilters[K]) => setF((p) => ({ ...p, [k]: v }));
const toggleTag = (uuid: string) => {
const cur = f.tags ?? [];
set('tags', cur.includes(uuid) ? cur.filter((u) => u !== uuid) : [...cur, uuid]);
};
const label: React.CSSProperties = { fontSize: 13, fontWeight: 700, color: 'var(--text-2)', marginBottom: 8, display: 'block' };
const selectedTags = f.tags ?? [];
return (
<Modal open={open} onClose={onClose} title="فیلترها" size="md" footer={
<>
<button className="btn ghost sm" onClick={() => setF(EMPTY)} style={{ color: 'var(--accent)' }}>حذف همه</button>
<button className="btn primary sm" onClick={() => onApply(f)}>اعمال تغییرات</button>
</>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<label style={label}>تاریخ پذیرش</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ flex: 1 }}><PersianDateInput value={f.admitted_from ?? ''} onChange={(v) => set('admitted_from', v)} placeholder="از تاریخ" /></div>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>تا</span>
<div style={{ flex: 1 }}><PersianDateInput value={f.admitted_to ?? ''} onChange={(v) => set('admitted_to', v)} placeholder="تا تاریخ" /></div>
</div>
</div>
<div>
<label style={label}>نوع بیمه</label>
<SearchableSelect
options={insurances.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }))}
value={f.insurance_id ?? null}
onChange={(v) => set('insurance_id', v ? String(v) : undefined)}
placeholder="همه بیمه‌ها"
isClearable
height={38}
/>
</div>
<div>
<label style={label}>وضعیت سرویس</label>
<Segmented
value={f.service_status ?? ''}
onChange={(v) => set('service_status', v || undefined)}
options={[{ label: 'همه', value: '' }, { label: 'تکمیل نشده', value: 'pending' }, { label: 'تکمیل شده', value: 'completed' }]}
/>
</div>
<div>
<label style={label}>وضعیت پرونده</label>
<label className="switch" title="فقط پرونده‌های دارای بدهی" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" checked={!!f.has_debt} onChange={(e) => set('has_debt', e.target.checked)} aria-label="فقط پرونده‌های دارای بدهی" />
<span className="switch-track"><span className="switch-thumb" /></span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>فقط پروندههای دارای بدهی</span>
</label>
</div>
<div>
<label style={label}>جنسیت بیمار</label>
<Segmented
value={f.gender ?? ''}
onChange={(v) => set('gender', v || undefined)}
options={[{ label: 'هر دو', value: '' }, { label: 'آقا', value: 'male' }, { label: 'خانم', value: 'female' }]}
/>
</div>
<div>
<label style={label}>برچسب</label>
{tags.length === 0 ? (
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>برچسبی تعریف نشده است.</span>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tags.map((t) => {
const on = selectedTags.includes(t.uuid);
return (
<button
key={t.uuid} type="button" onClick={() => toggleTag(t.uuid)}
style={{
fontSize: 12, height: 26, padding: '0 10px', borderRadius: 999, cursor: 'pointer',
border: '1px solid var(--border)',
background: on ? t.color : 'var(--surface-2)', color: on ? '#fff' : 'var(--text-2)',
}}
>
{t.name}
</button>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import ServiceInsuranceModal from './ServiceInsuranceModal';
import type { ServiceItem } from '../types';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
const item = { uuid: 'svc-1', name: 'سرم ۵۰۰cc' } as ServiceItem;
beforeEach(() => {
vi.clearAllMocks();
get.mockImplementation((url: string) => {
if (url.includes('service-coverage')) return Promise.resolve({ data: { data: [] } });
return Promise.resolve({
data: { data: [{ uuid: 'ins-1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 }] },
});
});
put.mockResolvedValue({ success: true });
});
const percentInput = async () => {
const el = await screen.findByPlaceholderText('ارث از قرارداد');
return el as HTMLInputElement;
};
describe('ServiceInsuranceModal — فیلد درصد پوشش', () => {
it('رقم فارسی را می‌پذیرد و NaN نمایش نمی‌دهد', async () => {
renderWithProviders(<ServiceInsuranceModal item={item} onClose={() => {}} />);
const input = await percentInput();
fireEvent.change(input, { target: { value: '۲۵' } });
expect(input.value).toBe('25');
expect(input.value).not.toContain('NaN');
});
it('مقدار ذخیره‌شده عدد معتبر است', async () => {
renderWithProviders(<ServiceInsuranceModal item={item} onClose={() => {}} />);
fireEvent.change(await percentInput(), { target: { value: '۳۰' } });
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
await waitFor(() => expect(put).toHaveBeenCalled());
expect(put.mock.calls[0][1]).toMatchObject({ service_item_uuid: 'svc-1', coverage_percent: 30 });
});
it('بیش از ۱۰۰ به ۱۰۰ محدود می‌شود', async () => {
renderWithProviders(<ServiceInsuranceModal item={item} onClose={() => {}} />);
fireEvent.change(await percentInput(), { target: { value: '۱۵۰' } });
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
await waitFor(() => expect(put).toHaveBeenCalled());
expect(put.mock.calls[0][1]).toMatchObject({ coverage_percent: 100 });
});
it('فیلد خالی → null (ارث از قرارداد)', async () => {
renderWithProviders(<ServiceInsuranceModal item={item} onClose={() => {}} />);
const input = await percentInput();
fireEvent.change(input, { target: { value: '۴۰' } });
fireEvent.change(input, { target: { value: '' } });
expect(input.value).toBe('');
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
await waitFor(() => expect(put).toHaveBeenCalled());
expect(put.mock.calls[0][1]).toMatchObject({ coverage_percent: null });
});
it('حروف نامعتبر وارد نمی‌شود', async () => {
renderWithProviders(<ServiceInsuranceModal item={item} onClose={() => {}} />);
const input = await percentInput();
fireEvent.change(input, { target: { value: 'ابج' } });
expect(input.value).toBe('');
});
});
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheckIcon, CheckIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { digitsOnly, parseUserNumberClamped } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import type { ServiceItem } from '../types';
@@ -46,6 +47,8 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
const existing = rows?.find((r) => r.service_item_uuid === item.uuid);
const [draft, setDraft] = useState<Draft>({ covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
// متن خام فیلد درصد جدا از مقدار عددی نگه داشته می‌شود تا کاربر بتواند فیلد را خالی کند.
const [percentText, setPercentText] = useState('');
useEffect(() => {
setDraft(existing
@@ -56,6 +59,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
ceiling_rials: existing.ceiling_rials,
}
: { covered: true, coverage_percent: null, franchise_rials: null, ceiling_rials: null });
setPercentText(existing?.coverage_percent == null ? '' : String(existing.coverage_percent));
}, [existing]);
const saveMut = useMutation({
@@ -85,7 +89,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
}}>
<div style={{
width: 32, height: 32, borderRadius: 9, flexShrink: 0,
display: 'grid', placeItems: 'center', background: 'var(--primary-subtle)',
display: 'grid', placeItems: 'center', background: 'var(--primary-soft)',
}}>
<ShieldCheckIcon style={{ width: 18, color: 'var(--primary)' }} />
</div>
@@ -127,11 +131,16 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
<div style={{ minWidth: 0 }}>
<label className="field-label">درصد پوشش</label>
<input
type="number" min={0} max={100} dir="ltr" className="input"
type="text" inputMode="numeric" dir="ltr" className="input"
style={{ height: 40, textAlign: 'left' }}
value={draft.coverage_percent ?? ''}
placeholder="ارث"
onChange={(e) => setDraft((d) => ({ ...d, coverage_percent: e.target.value === '' ? null : Number(e.target.value) }))}
value={percentText}
placeholder="ارث از قرارداد"
onChange={(e) => {
const digits = digitsOnly(e.target.value, 3);
setPercentText(digits);
setDraft((d) => ({ ...d, coverage_percent: parseUserNumberClamped(digits, 0, 100) }));
}}
onBlur={() => setPercentText(draft.coverage_percent == null ? '' : String(draft.coverage_percent))}
/>
</div>
<div style={{ minWidth: 0 }}>
@@ -172,7 +181,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
{!draft.covered && !isLoading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '12px 14px' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>این خدمت تحت این بیمه پوشش ندارد</span>
<button className="btn sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
@@ -195,7 +204,7 @@ export default function ServiceInsuranceModal({ item, onClose }: { item: Service
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
background: 'var(--primary-soft)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
}}>
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, marginTop: 1, color: 'var(--primary)' }} />
<span>درصد پوشش، فرانشیز و سقف هر بیمهگر برای این خدمت تعیین میشود. این تنظیمات مبنای محاسبهی سهم بیمار و ساخت مطالبات بیمه است.</span>
@@ -0,0 +1,322 @@
import { useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ServiceItem, ClinicStaff } from '../types';
import type { InventoryPackage, InventoryItem } from '../hooks/useInventory';
import { rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'),
staff_uuids: z.array(z.string()).optional(),
duration_minutes: z.coerce.number().min(0).optional(),
bookable: z.boolean().optional(),
/** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */
inventory_package_uuid: z.string().optional(),
/** اقلام کالای تکی — مستقل از پکیج. */
consumables: z.array(z.object({ item_uuid: z.string(), amount: z.coerce.number().min(1) })).optional(),
});
type ItemForm = z.infer<typeof itemSchema>;
const EMPTY_FORM: ItemForm = {
name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined,
bookable: false, inventory_package_uuid: '', consumables: [],
};
interface Props {
/** `'create'` برای سرویس جدید، شیء سرویس برای ویرایش، `null` یعنی بسته. */
item: 'create' | ServiceItem | null;
/** بخش مقصد؛ برای حالت ایجاد الزامی است. */
sectionUuid: string | null;
onClose: () => void;
/** برای باز کردن مودال پوشش بیمه از داخل فرم (فقط در حالت ویرایش). */
onManageInsurance?: (item: ServiceItem) => void;
}
/**
* فرم ایجاد/ویرایش سرویس — مشترک بین فهرست سرویس‌ها و صفحه‌ی جزئیات سرویس.
*
* تنظیمات بیمه اینجا نیست: پوشش هر بیمه‌گر تنها در «پوشش بیمه» مدیریت می‌شود و
* پرچم `insurance_covered` سمت سرور از همان‌جا همگام می‌شود.
*/
export default function ServiceItemFormModal({ item, sectionUuid, onClose, onManageInsurance }: Props) {
const qc = useQueryClient();
const editing = item !== null && typeof item === 'object' ? item : null;
const { data: staffData } = useQuery<ApiResponse<ClinicStaff[]>>({
queryKey: ['staff'],
queryFn: () => api.get('/api/v1/staff'),
enabled: item !== null,
});
const allStaff = staffData?.data ?? [];
const { data: packagesData } = useQuery<ApiResponse<InventoryPackage[]>>({
queryKey: ['inventory-packages'],
queryFn: () => api.get('/api/v1/inventory-packages'),
enabled: item !== null,
});
const packages = packagesData?.data ?? [];
// این endpoint پاسخ را در { items, stats } می‌پیچد.
const { data: inventoryData } = useQuery<ApiResponse<{ items: InventoryItem[] }>>({
queryKey: ['inventory-items'],
queryFn: () => api.get('/api/v1/inventory-items'),
enabled: item !== null,
});
const inventoryItems = inventoryData?.data?.items ?? [];
const form = useForm<ItemForm>({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM });
useEffect(() => {
if (item === null) return;
form.reset(editing
? {
name: editing.name,
price_rials: rialToToman(editing.price_rials),
staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid),
duration_minutes: editing.duration_minutes ?? undefined,
bookable: editing.bookable ?? false,
inventory_package_uuid: editing.inventory_package_uuid ?? '',
consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })),
}
: EMPTY_FORM);
}, [item]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['service-items'] });
qc.invalidateQueries({ queryKey: ['service-sections'] });
if (editing) qc.invalidateQueries({ queryKey: ['service-item', editing.uuid] });
};
// رشته‌ی خالی در payload یعنی «بدون پکیج»؛ بک‌اند null می‌خواهد.
const toPayload = (body: ItemForm) => ({
...body,
price_rials: tomanToRial(body.price_rials),
inventory_package_uuid: body.inventory_package_uuid || null,
});
const createItem = useMutation({
mutationFn: (body: ItemForm) => api.post('/api/v1/service-item', {
...toPayload(body),
section_uuid: sectionUuid,
}),
onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ایجاد شد'); },
onError: (e: Error) => toast.error(e.message),
});
const editItem = useMutation({
mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, toPayload(body)),
onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ویرایش شد'); },
onError: (e: Error) => toast.error(e.message),
});
const saving = createItem.isPending || editItem.isPending;
const selectedStaffUuids = form.watch('staff_uuids') ?? [];
const editingMembers = editing ? (editing.staff_members ?? (editing.staff ? [editing.staff] : [])) : [];
const staffOptions = allStaff
.filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid))
.filter((s) => !selectedStaffUuids.includes(s.uuid))
.map((s) => ({ value: s.uuid, label: s.active ? s.full_name : `${s.full_name} (غیرفعال)` }));
const consumables = form.watch('consumables') ?? [];
const inventoryOptions = inventoryItems
.filter((i) => !consumables.some((c) => c.item_uuid === i.uuid))
.map((i) => ({ value: i.uuid, label: `${i.name} (${i.unit})` }));
const inventoryNameOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.name ?? uuid;
const inventoryUnitOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.unit ?? '';
const addConsumable = (uuid: string) =>
form.setValue('consumables', [...consumables, { item_uuid: uuid, amount: 1 }]);
const staffNameOf = (uuid: string) =>
allStaff.find((s) => s.uuid === uuid)?.full_name
?? editingMembers.find((m) => m.uuid === uuid)?.full_name
?? uuid;
return (
<Modal
open={item !== null}
onClose={onClose}
title={editing ? 'ویرایش سرویس' : 'سرویس جدید'}
size="md"
footer={
<>
<button type="button" className="btn" onClick={onClose}>انصراف</button>
<button type="submit" form="service-item-form" className="btn primary" disabled={saving}>
{saving ? 'در حال ذخیره...' : 'ذخیره سرویس'}
</button>
</>
}
>
<form
id="service-item-form"
onSubmit={form.handleSubmit((d) => (editing ? editItem : createItem).mutate(d))}
style={{ display: 'flex', flexDirection: 'column', gap: 20 }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className="field-label">نام سرویس *</label>
<div className="field" style={form.formState.errors.name ? { borderColor: 'var(--danger)' } : undefined}>
<input {...form.register('name')} placeholder="مثلاً: سرم ۵۰۰cc" autoFocus />
</div>
{form.formState.errors.name && (
<span className="field-error">{form.formState.errors.name.message}</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="field-label">قیمت پایه (تومان) *</label>
<div className="field">
<PriceInput
value={form.watch('price_rials') ?? 0}
onChange={(v) => form.setValue('price_rials', v)}
placeholder="۸۵,۰۰۰"
min={0}
suffix="تومان"
/>
</div>
{form.formState.errors.price_rials && (
<span className="field-error">{form.formState.errors.price_rials.message}</span>
)}
</div>
<div>
<label className="field-label">پرسنل مسئول</label>
<SearchableSelect
options={staffOptions}
value={''}
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
placeholder="افزودن پرسنل (اختیاری)"
noOptionsMessage="پرسنلی باقی نمانده"
height={42}
/>
{selectedStaffUuids.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selectedStaffUuids.map((uuid) => (
<span key={uuid} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12.5, color: 'var(--text-2)', background: 'var(--surface-2)',
border: '1px solid var(--border)', borderRadius: 999, padding: '4px 6px 4px 10px',
}}>
{staffNameOf(uuid)}
<button
type="button" aria-label={`حذف ${staffNameOf(uuid)}`}
onClick={() => form.setValue('staff_uuids', selectedStaffUuids.filter((u) => u !== uuid))}
style={{ display: 'grid', placeItems: 'center', width: 16, height: 16, border: 'none', cursor: 'pointer', borderRadius: '50%', background: 'var(--surface-3)', color: 'var(--text-3)' }}
>
<XMarkIcon style={{ width: 11 }} />
</button>
</span>
))}
</div>
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
<div>
<label className="field-label">زمان متوسط (دقیقه)</label>
<div className="field">
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
<span className="switch">
<input
type="checkbox"
checked={form.watch('bookable') ?? false}
onChange={(e) => form.setValue('bookable', e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
</label>
</div>
<div>
<label className="field-label">پکیج کالای مصرفی</label>
<SearchableSelect
options={packages.map((p) => ({ value: p.uuid, label: p.title }))}
value={form.watch('inventory_package_uuid') || null}
onChange={(v) => form.setValue('inventory_package_uuid', v == null ? '' : String(v))}
placeholder="بدون پکیج (اختیاری)"
noOptionsMessage="پکیجی تعریف نشده است"
isClearable
height={42}
/>
</div>
{/* کالای تکی — مستقل از پکیج و قابل استفاده هم‌زمان با آن. */}
<div>
<label className="field-label">کالاهای تکی</label>
<SearchableSelect
options={inventoryOptions}
value={''}
onChange={(v) => { if (v != null) addConsumable(String(v)); }}
placeholder="افزودن کالا (اختیاری)"
noOptionsMessage="کالایی باقی نمانده"
height={42}
/>
{consumables.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
{consumables.map((line, i) => (
<div key={line.item_uuid} style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px',
borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)',
}}>
<span style={{ flex: 1, minWidth: 0, fontSize: 13 }}>{inventoryNameOf(line.item_uuid)}</span>
<input
{...numericField(form.register(`consumables.${i}.amount` as const))}
aria-label={`تعداد ${inventoryNameOf(line.item_uuid)}`}
style={{ width: 64, height: 32, textAlign: 'center' }}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)', minWidth: 34 }}>
{inventoryUnitOf(line.item_uuid)}
</span>
<button
type="button" aria-label={`حذف ${inventoryNameOf(line.item_uuid)}`}
onClick={() => form.setValue('consumables', consumables.filter((c) => c.item_uuid !== line.item_uuid))}
style={{ display: 'grid', placeItems: 'center', width: 20, height: 20, border: 'none', cursor: 'pointer', borderRadius: '50%', background: 'var(--surface-3)', color: 'var(--text-3)' }}
>
<XMarkIcon style={{ width: 12 }} />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت می‌شود تا داده‌ی تکراری ساخته نشود. */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '11px 13px',
borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, color: 'var(--primary)' }} />
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7 }}>
پوشش بیمهی این خدمت درصد، فرانشیز و سقف هر بیمهگر در بخش «پوشش بیمه» تنظیم میشود.
</span>
{editing && onManageInsurance && (
<button
type="button"
className="btn sm"
style={{ flexShrink: 0 }}
onClick={() => { const it = editing; onClose(); onManageInsurance(it); }}
>
پوشش بیمه
</button>
)}
</div>
</form>
</Modal>
);
}
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatNumber, rialToToman, tomanToRial } from '../lib/utils';
import { formatRial, formatYear, rialToToman, tomanToRial } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
@@ -46,7 +46,7 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte
const opts: { value: number; label: string }[] = [];
for (let y = currentYear + 2; y >= currentYear - 5; y--) {
if (used.has(y)) continue;
opts.push({ value: y, label: y === currentYear ? `${formatNumber(y)} (سال جاری)` : formatNumber(y) });
opts.push({ value: y, label: y === currentYear ? `${formatYear(y)} (سال جاری)` : formatYear(y) });
}
return opts;
}, [currentYear, tariffs]);
@@ -85,7 +85,7 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
}}>
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatNumber(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
</div>
)}
@@ -131,7 +131,7 @@ export default function ServiceTariffModal({ item, onClose }: { item: ServiceIte
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
}}>
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
سال {formatNumber(t.year)}
سال {formatYear(t.year)}
{isCurrent && <span className="badge green" style={{ fontSize: 9.5, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
</span>
@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import SessionPaymentAccordion, { type SessionPaymentData } from './SessionPaymentAccordion';
const paid: SessionPaymentData = {
uuid: 's1', services: [{ service_name: 'روکش' }], final_price_rials: 2500000,
doctor_name: 'دکتر فتحی', payment_method: 'cash', is_paid: true,
created_at: 1700000000, updated_at: 1700003600,
};
const unpaid: SessionPaymentData = {
uuid: 's2', services: [{ service_name: 'اسکیلینگ' }], final_price_rials: 1800000,
doctor_name: 'دکتر راد', payment_method: 'pending', is_paid: false, created_at: 1700000000,
};
describe('SessionPaymentAccordion', () => {
it('shows the settled badge + settlement line (method/personnel) when expanded', () => {
render(<SessionPaymentAccordion session={paid} expanded onToggle={() => {}} />);
expect(screen.getByText('روکش')).toBeInTheDocument();
expect(screen.getByText('پرداخت شده')).toBeInTheDocument();
expect(screen.getByText('نحوه پرداخت:')).toBeInTheDocument();
expect(screen.getByText('نقدی')).toBeInTheDocument(); // cash → نقدی
expect(screen.getByText('دکتر فتحی')).toBeInTheDocument(); // personnel = doctor
});
it('shows the unsettled badge + empty message for an unpaid session', () => {
render(<SessionPaymentAccordion session={unpaid} expanded onToggle={() => {}} />);
expect(screen.getByText('تسویه نشده')).toBeInTheDocument();
expect(screen.getByText('هیچ پرداختی ثبت نشده است.')).toBeInTheDocument();
});
it('hides the details when collapsed', () => {
render(<SessionPaymentAccordion session={paid} expanded={false} onToggle={() => {}} />);
expect(screen.getByText('پرداخت شده')).toBeInTheDocument(); // header still visible
expect(screen.queryByText('نحوه پرداخت:')).not.toBeInTheDocument();
});
it('calls onToggle when the summary is clicked', () => {
const onToggle = vi.fn();
render(<SessionPaymentAccordion session={paid} expanded={false} onToggle={onToggle} />);
screen.getByRole('button').click();
expect(onToggle).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,105 @@
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { formatDate, formatRial } from '../lib/utils';
import { PAYMENT_METHOD_LABELS } from '../lib/paymentMethods';
import { FilesServicePaymentsCheck } from './icons/FilesServiceIcons';
export interface SessionPaymentData {
uuid: string;
services?: Array<{ service_name?: string; name?: string }>;
visit_price_rials?: number;
doctor_name?: string | null;
final_price_rials?: number;
payment_method?: string;
is_paid?: boolean;
created_at?: number;
updated_at?: number;
}
const PAYMENT_LABELS = PAYMENT_METHOD_LABELS;
/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */
function VDivider({ h = 24 }: { h?: number }) {
return <span className="dark:bg-[#35343D]" style={{ width: 1, height: h, background: '#E5E7EB', flexShrink: 0, margin: '0 16px', alignSelf: 'center' }} />;
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>{label}:</span>
<span className="dark:text-[#D7D8ED]" style={{ color: '#525252', fontWeight: 500 }}>{value}</span>
</span>
);
}
/**
* A patient «پرداخت‌ها» accordion — one settled/unsettled مراجعه (session) —
* ported from tauri files/tabs/PaymentsSection. Header shows the service, date,
* final price and settlement badge; the body lists the session's settlement
* (real model = one payment_method per session), or the empty message.
*/
export default function SessionPaymentAccordion({ session, expanded, onToggle }: {
session: SessionPaymentData;
expanded: boolean;
onToggle: () => void;
}) {
const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
if ((session.visit_price_rials ?? 0) > 0) names.unshift('ویزیت');
const name = names.length ? names.join(' - ') : 'ویزیت';
const paid = !!session.is_paid;
const finalPrice = formatRial(session.final_price_rials ?? 0);
return (
<div className="dark:border-[#35343D]" style={{ border: '1px solid #E5E7EB', borderRadius: 8, marginBottom: 16, overflow: 'hidden' }}>
{/* summary */}
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="bg-white dark:bg-[#2B2D3A]"
style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%', padding: '12px 16px', border: 'none', cursor: 'pointer', textAlign: 'start', flexWrap: 'wrap' }}
>
<span className="dark:text-[#D7D8ED]" style={{ minWidth: 150, color: '#2f2f2f', fontWeight: 500, fontSize: 14 }}>{name}</span>
<VDivider />
<Meta label="تاریخ" value={session.created_at ? formatDate(session.created_at) : '—'} />
<VDivider />
<Meta label="مبلغ نهایی" value={finalPrice} />
<span style={{ flex: 1 }} />
<VDivider />
<span style={{
color: '#fff', padding: '3px 16px', borderRadius: 4, fontSize: 12, fontWeight: 600,
textAlign: 'center', minWidth: 90, background: paid ? '#10B981' : '#F59E0B',
}}>
{paid ? 'پرداخت شده' : 'تسویه نشده'}
</span>
<ChevronDownIcon className="dark:text-[#D7D8ED]" style={{ width: 20, height: 20, color: '#525252', marginInlineStart: 8, transition: 'transform .2s', transform: expanded ? 'rotate(180deg)' : 'none' }} />
</button>
{/* details */}
{expanded && (
<div className="dark:border-[#35343D] dark:bg-[#222433]" style={{ borderTop: '1px solid #E5E7EB' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px 8px' }}>
<FilesServicePaymentsCheck color="#6B7280" size={18} style={{ transform: 'rotate(180deg)' }} />
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280', fontWeight: 600, fontSize: 13 }}>پرداختیها</span>
</div>
<div style={{ padding: '0 16px 16px' }}>
{paid ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 0', flexWrap: 'wrap' }}>
<FilesServicePaymentsCheck color="#6B7280" size={20} />
<Meta label="تاریخ" value={formatDate(session.updated_at ?? session.created_at ?? 0)} />
<VDivider h={32} />
<Meta label="مبلغ" value={finalPrice} />
<VDivider h={32} />
<Meta label="نحوه پرداخت" value={PAYMENT_LABELS[session.payment_method ?? ''] ?? session.payment_method ?? '—'} />
<VDivider h={32} />
<Meta label="پرسنل" value={session.doctor_name || '—'} />
</div>
) : (
<div className="dark:text-[#A1A1A1]" style={{ padding: 16, textAlign: 'center', color: '#6B7280', fontSize: 13 }}>هیچ پرداختی ثبت نشده است.</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { formatDate, formatRial } from '../lib/utils';
import { FilesServiceSuccess, FilesServiceMore } from './icons/FilesServiceIcons';
export interface SessionPaymentEntry {
uuid: string;
method: string;
amount_rials: number;
paid_at?: number;
created_at?: number;
created_by_name?: string | null;
}
export interface SessionCardData {
uuid: string;
services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>;
// مطابق SessionConsumable::toArray در بک‌اند
consumables?: Array<{ uuid?: string; inventory_item_uuid?: string; item_name?: string; unit?: string; price_rials?: number; quantity?: number; line_total_rials?: number }>;
visit_price_rials?: number;
services_total_rials?: number;
session_at?: number | null;
insurance_base_id?: number | null;
insurance_supplementary_id?: number | null;
base_insurance_discount_percent?: number;
supplementary_discount_percent?: number;
doctor_name?: string | null;
final_price_rials?: number;
// تفکیک بیمه — سرور محاسبه می‌کند (PatientSession::applyShares)
gross_total_rials?: number;
base_insurance_rials?: number;
supplementary_insurance_rials?: number;
patient_share_rials?: number;
remaining_rials?: number;
patient_debt_rials?: number;
is_paid?: boolean;
invoice_uuid?: string | null;
notes?: string | null;
archived?: boolean;
created_at?: number;
// تسویه چندتکه + تخفیف (backend session settlement fields)
discount_type?: 'percent' | 'fixed' | null;
discount_value?: number;
discount_rials?: number;
applied_discount_rule_label?: string | null;
paid_total_rials?: number;
paid_at?: number | null;
payments?: SessionPaymentEntry[];
}
/** label:value row inside the service card (mirrors tauri ServiceInfoRow). */
function Row({ label, value, valueStyle }: { label: string; value: string; valueStyle?: CSSProperties }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 6, margin: '6px 0' }}>
<span style={{ fontSize: 12, color: '#616161' }} className="dark:text-[#A1A1A1]">{label}:</span>
<span style={{ fontSize: 14, color: '#525252', textAlign: 'left', ...valueStyle }} className="dark:text-[#D7D8ED]">{value}</span>
</div>
);
}
/**
* A patient «مراجعه» (session) card — visit + performed services — ported
* pixel-for-pixel from tauri files/services/ServiceCard. Paid cards show
* «مشاهده فاکتور», unpaid ones «تکمیل پرداخت».
*/
export default function SessionServiceCard({ session, onSettle, onViewInvoice, onArchive, onEdit, onViewAudit, settling, issuing }: {
session: SessionCardData;
onSettle: (uuid: string) => void;
/** کل session پاس می‌شود؛ اگر invoice_uuid نداشت، caller فاکتور را می‌سازد. */
onViewInvoice: (session: SessionCardData) => void;
/** آرشیو/خروج از آرشیو مراجعه. */
onArchive?: (session: SessionCardData, archived: boolean) => void;
/** ویرایش سرویس‌های مراجعه. */
onEdit?: (session: SessionCardData) => void;
/** نمایش تاریخچه‌ی تغییرات (Audit Log). */
onViewAudit?: (session: SessionCardData) => void;
settling?: boolean;
/** true وقتی صدور فاکتور همین لحظه در جریان است (دکمه قفل می‌شود). */
issuing?: boolean;
}) {
const paid = !!session.is_paid;
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const onDoc = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [menuOpen]);
const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
if ((session.visit_price_rials ?? 0) > 0) names.unshift('ویزیت');
// A «مراجعه» = the visit plus the services performed in it.
const title = 'مراجعه';
const subtitle = names.length ? names.join(' - ') : 'ویزیت';
const debt = session.patient_debt_rials ?? 0;
return (
<div
className="bg-white dark:bg-[#222433] border border-[#EDEDED] dark:border-[#35343D]"
style={{ minWidth: 277, minHeight: 331, borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden', boxShadow: '0 1px 6px rgba(15,23,42,0.06)' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<FilesServiceSuccess />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3, minWidth: 0 }}>
<span className="dark:text-[#D7D8ED]" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>
{title}
{session.archived && <span className="badge" style={{ fontSize: 10, color: '#9CA3AF', border: '1px solid #E0E0E0', borderRadius: 4, padding: '1px 6px' }}>آرشیو</span>}
</span>
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#6B7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 200 }}>{subtitle}</span>
</div>
</div>
<div ref={menuRef} style={{ position: 'relative', flexShrink: 0 }}>
<button type="button" aria-label="عملیات" onClick={() => setMenuOpen((o) => !o)}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
<FilesServiceMore style={{ color: '#9CA3AF' }} />
</button>
{menuOpen && (
<div className="card dark:bg-[#222433] dark:border-[#35343D]" style={{ position: 'absolute', insetInlineEnd: 0, top: '100%', marginTop: 4, minWidth: 150, zIndex: 20, padding: 4, background: '#fff', border: '1px solid var(--border)', borderRadius: 8, boxShadow: 'var(--shadow-lg)' }}>
<button type="button" onClick={() => { setMenuOpen(false); onViewInvoice(session); }}
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
مشاهده فاکتور
</button>
{onEdit && (
<button type="button" onClick={() => { setMenuOpen(false); onEdit(session); }}
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
ویرایش
</button>
)}
{onViewAudit && (
<button type="button" onClick={() => { setMenuOpen(false); onViewAudit(session); }}
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
تاریخچه تغییرات
</button>
)}
{onArchive && (
<button type="button" onClick={() => { setMenuOpen(false); onArchive(session, !session.archived); }}
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: session.archived ? 'var(--text)' : '#EF4444', fontFamily: 'inherit' }}>
{session.archived ? 'خروج از آرشیو' : 'آرشیو'}
</button>
)}
</div>
)}
</div>
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'hidden', lineHeight: 1.9 }}>
<Row label="انجام دهنده" value={session.doctor_name || '—'} />
<Row label="تاریخ" value={session.created_at ? formatDate(session.created_at) : '—'} />
<Row label="توضیحات" value={session.notes || '—'} valueStyle={{ display: 'flex', textAlign: 'right', width: '100%' }} />
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Row label="هزینه" value={formatRial(session.final_price_rials ?? 0)} valueStyle={{ fontWeight: 500 }} />
{!paid && <Row label="مانده بدهی" value={formatRial(debt)} valueStyle={{ color: '#EF4444', fontWeight: 500 }} />}
</div>
<div style={{ marginTop: 'auto' }}>
{paid ? (
<button
type="button"
disabled={issuing}
onClick={() => onViewInvoice(session)}
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: '#4F46E5', background: 'transparent', border: '1px solid #5559ce' }}
>
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button>
) : (
<button
type="button"
disabled={settling}
onClick={() => onSettle(session.uuid)}
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: '#fff', background: '#5559ce', border: '1px solid #5559ce' }}
>
تکمیل پرداخت
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import SessionStepper from './SessionStepper';
const STEPS = ['ایجاد سرویس', 'پرداخت', 'جزییات'];
describe('SessionStepper', () => {
it('برچسب همه گام‌ها و یک کانکتور به‌ازای هر گام غیر از اولی', () => {
render(<SessionStepper steps={STEPS} activeStep={0} />);
STEPS.forEach((l) => expect(screen.getByText(l)).toBeInTheDocument());
expect(screen.getByTestId('step-connector-1')).toBeInTheDocument();
expect(screen.getByTestId('step-connector-2')).toBeInTheDocument();
expect(screen.queryByTestId('step-connector-0')).not.toBeInTheDocument();
});
it('کانکتور سمت گامِ قبلی (راست در RTL) کشیده می‌شود، نه بیرون از لبه چپ', () => {
render(<SessionStepper steps={STEPS} activeStep={0} />);
const c = screen.getByTestId('step-connector-2');
// RTL: امتداد به سمت راست (گام قبلی) → left مثبت از مرکز، right منفی
expect(c.style.left).toBe('calc(50% + 40px)');
expect(c.style.right).toBe('calc(-50% + 40px)');
});
it('کانکتور گام‌های طی‌شده بنفش و بقیه خاکستری', () => {
render(<SessionStepper steps={STEPS} activeStep={1} />);
// jsdom رنگ hex را به rgb نرمال می‌کند
expect(screen.getByTestId('step-connector-1').style.background).toContain('rgb(85, 89, 206)');
expect(screen.getByTestId('step-connector-2').style.background).toContain('rgb(234, 234, 240)');
});
it('استپر خالی: بدون کرش و بدون کانکتور', () => {
const { container } = render(<SessionStepper steps={[]} activeStep={0} />);
expect(container.querySelectorAll('[data-testid^="step-connector"]').length).toBe(0);
});
});
@@ -0,0 +1,64 @@
import {
FilesServiceAddServiceStep,
FilesAddServicePaymentStep,
FilesServiceDetailsStep,
} from './icons/FilesServiceIcons';
/**
* استپر صفحهی سرویس/تسویه پورت tauri files/services/CustomizedStepper
* (کانکتور بنفش، آیکون دایرهای هر گام، تیک سبز برای گامهای کاملشده).
*/
// هر برچسب گام آیکون ثابت خودش را دارد تا در حالت payment-only هم درست بماند.
const ICON_BY_LABEL: Record<string, number> = {
'ایجاد سرویس': 1,
'ویرایش': 1,
'پرداخت': 2,
'جزییات': 3,
};
function StepIcon({ index, active, completed }: { index: number; active: boolean; completed: boolean }) {
if (completed && !active) {
// دایره سفید با تیک سبز (tauri ColorlibStepIconRoot completed state)
return (
<div style={{ width: 52, height: 52, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="#22C55E" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</div>
);
}
const shadow = active ? { boxShadow: '0 4px 10px 0 rgba(85, 89, 206, 0.25)', borderRadius: '50%' } : undefined;
switch (index) {
case 1: return <div style={shadow}><FilesServiceAddServiceStep active={active} /></div>;
case 2: return <div style={shadow}><FilesAddServicePaymentStep active={active} /></div>;
default: return <div style={shadow}><FilesServiceDetailsStep active={active} /></div>;
}
}
export default function SessionStepper({ activeStep = 0, steps = [] }: { activeStep?: number; steps?: string[] }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', width: '100%', marginBottom: 24 }}>
{steps.map((label, i) => (
<div key={label} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
{/* connector to previous step (tauri ColorlibConnector: top 22, h 3, marginX 40).
صفحه RTL است و گامِ قبلی سمت راست میافتد؛ MUI در tauri این را خودش flip میکرد. */}
{i > 0 && (
<div
data-testid={`step-connector-${i}`}
style={{
position: 'absolute', top: 22, left: 'calc(50% + 40px)', right: 'calc(-50% + 40px)',
height: 3, borderRadius: 1,
background: i <= activeStep ? 'linear-gradient(95deg, #5559ce 0%, #5559ce 100%)' : '#eaeaf0',
}}
/>
)}
<StepIcon index={ICON_BY_LABEL[label] ?? i + 1} active={i === activeStep} completed={i < activeStep} />
<span className="dark:text-[#D7D8ED]" style={{ marginTop: 8, fontSize: '0.875rem', fontWeight: 600, color: '#3b3b3b' }}>
{label}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import TenantInsuranceContracts, { filterInsurances, contractSummary } from './TenantInsuranceContracts';
import type { Contract } from './InsuranceModal';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const mk = (over: Partial<Contract>): Contract => ({
uuid: 'u', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic',
version: 1, is_active: true, coverage_percent: 70, franchise_rials: 0,
annual_ceiling_rials: null, kind: 'basic', effective_from: 0, effective_to: null, ...over,
});
describe('filterInsurances', () => {
const list = [
mk({ uuid: 'a', insurance_id: 3, insurance_name: 'بیمه ایران' }),
mk({ uuid: 'b', insurance_id: 5, insurance_name: 'بیمه آسیا' }),
];
it('filters by name', () => {
expect(filterInsurances(list, 'آسیا')).toHaveLength(1);
expect(filterInsurances(list, 'آسیا')[0].uuid).toBe('b');
});
it('filters by code (insurance_id)', () => {
expect(filterInsurances(list, '5')).toHaveLength(1);
});
it('empty query returns all', () => {
expect(filterInsurances(list, ' ')).toHaveLength(2);
});
});
describe('contractSummary', () => {
it('shows coverage, franchise and ceiling inline', () => {
const s = contractSummary(mk({ coverage_percent: 90, franchise_rials: 500_000, annual_ceiling_rials: 20_000_000 }));
expect(s).toContain('پوشش');
expect(s).toContain('فرانشیز');
expect(s).toContain('سقف پوشش');
});
it('omits franchise when zero and marks unlimited ceiling', () => {
const s = contractSummary(mk({ franchise_rials: 0, annual_ceiling_rials: null }));
expect(s).not.toContain('فرانشیز');
expect(s).toContain('سقف پوشش نامحدود');
});
});
describe('TenantInsuranceContracts', () => {
beforeEach(() => {
get.mockReset();
patch.mockReset();
patch.mockResolvedValue({ success: true, data: { data: {} } });
get.mockImplementation((path: string) => {
if (path.includes('tenant-insurances')) {
return Promise.resolve({ success: true, data: { data: [
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic', is_active: true }),
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic', is_active: false }),
mk({ uuid: 'u3', insurance_id: 7, insurance_name: 'بیمه دانا', insurance_kind: 'supplementary', is_active: true }),
] } });
}
return Promise.resolve({ success: true, data: { insurances: [] } });
});
});
// Desktop table and mobile cards both render in jsdom (CSS `hidden`/`md:` is inert),
// so each row's text appears twice — assertions use *AllBy* accordingly.
it('lists only the active tab (basic) contracts', async () => {
renderWithProviders(<TenantInsuranceContracts />);
expect((await screen.findAllByText('بیمه ایران')).length).toBeGreaterThan(0);
expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0);
// Supplementary contract is hidden under the other tab.
expect(screen.queryByText('بیمه دانا')).not.toBeInTheDocument();
});
it('switching to the supplementary tab filters by kind', async () => {
renderWithProviders(<TenantInsuranceContracts />);
await screen.findAllByText('بیمه ایران');
fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ }));
expect((await screen.findAllByText('بیمه دانا')).length).toBeGreaterThan(0);
expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument();
});
it('classifies by catalog type, overriding a stale contract kind', async () => {
// آسیا is stored on the contract as basic (legacy manual pick) but the catalog
// marks it supplementary — catalog type wins, so it must leave the basic tab.
get.mockImplementation((path: string) => {
if (path.includes('tenant-insurances')) {
return Promise.resolve({ success: true, data: { data: [
mk({ uuid: 'u1', insurance_id: 3, insurance_name: 'بیمه ایران', insurance_kind: 'basic' }),
mk({ uuid: 'u2', insurance_id: 5, insurance_name: 'بیمه آسیا', insurance_kind: 'basic' }),
] } });
}
return Promise.resolve({ success: true, data: { insurances: [
{ insurance_id: 3, insurance_name: 'بیمه ایران', type: 'basic' },
{ insurance_id: 5, insurance_name: 'بیمه آسیا', type: 'supplementary' },
] } });
});
renderWithProviders(<TenantInsuranceContracts />);
await screen.findByText('بیمه ایران');
expect(screen.queryByText('بیمه آسیا')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /بیمه تکمیلی/ }));
expect(await screen.findByText('بیمه آسیا')).toBeInTheDocument();
});
it('search box filters rows by name', async () => {
renderWithProviders(<TenantInsuranceContracts />);
await screen.findAllByText('بیمه ایران');
fireEvent.change(screen.getByPlaceholderText('جستجو در بیمه ها...'), { target: { value: 'آسیا' } });
expect(screen.queryByText('بیمه ایران')).not.toBeInTheDocument();
expect(screen.getAllByText('بیمه آسیا').length).toBeGreaterThan(0);
});
it('clicking a row expands its detail, clicking again collapses', async () => {
renderWithProviders(<TenantInsuranceContracts />);
const names = await screen.findAllByText('بیمه ایران');
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
fireEvent.click(names[0]);
expect(screen.getAllByText('نسخه قرارداد').length).toBeGreaterThan(0);
fireEvent.click(names[0]);
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
});
it('clicking the status toggle does not expand the row (stopPropagation)', async () => {
renderWithProviders(<TenantInsuranceContracts />);
await screen.findAllByText('بیمه ایران');
const toggles = screen.getAllByLabelText('غیرفعال کردن');
fireEvent.click(toggles[0]);
expect(screen.queryByText('نسخه قرارداد')).not.toBeInTheDocument();
await waitFor(() =>
expect(patch).toHaveBeenCalledWith('/api/v1/billing/tenant-insurances/u1', { is_active: false }),
);
});
});
@@ -1,211 +1,284 @@
import { useState } from 'react';
import { useMemo, useState, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon, PlusIcon, PencilIcon } from '@heroicons/react/24/outline';
import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import SearchableSelect from './ui/SearchableSelect';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
interface Contract {
uuid: string;
insurance_id: number;
insurance_name: string | null;
insurance_kind: string | null;
version: number;
coverage_percent: number;
franchise_rials: number;
annual_ceiling_rials: number | null;
type Kind = 'basic' | 'supplementary';
const KINDS: { key: Kind; label: string; addLabel: string; emptyLabel: string }[] = [
{ key: 'basic', label: 'بیمه پایه', addLabel: 'افزودن بیمه پایه', emptyLabel: 'هنوز بیمه‌ی پایه‌ای اضافه نکرده‌اید.' },
{ key: 'supplementary', label: 'بیمه تکمیلی', addLabel: 'افزودن بیمه تکمیلی', emptyLabel: 'هنوز بیمه‌ی تکمیلی‌ای اضافه نکرده‌اید.' },
];
/** Contract's effective kind, falling back to 'basic' for legacy rows with no kind/type. */
const contractKind = (c: Contract): Kind =>
(c.insurance_kind === 'supplementary' ? 'supplementary' : 'basic');
/** Case-insensitive filter over insurance name (and code) — the "جستجو در بیمه ها..." box. */
export function filterInsurances(list: Contract[], query: string): Contract[] {
const q = query.trim().toLowerCase();
if (!q) return list;
return list.filter((c) =>
(c.insurance_name ?? '').toLowerCase().includes(q) ||
String(c.insurance_id).includes(q),
);
}
interface InsuranceOption {
insurance_id: number;
insurance_name: string;
type: string;
/** One-line readable summary shown on the collapsed row: پوشش ۹۰٪ · فرانشیز … · سقف پوشش … */
export function contractSummary(c: Contract): string {
const parts = [`پوشش ${formatNumber(c.coverage_percent)}٪`];
if (c.franchise_rials > 0) parts.push(`فرانشیز ${formatRial(c.franchise_rials)}`);
parts.push(c.annual_ceiling_rials != null ? `سقف پوشش ${formatRial(c.annual_ceiling_rials)}` : 'سقف پوشش نامحدود');
return parts.join(' · ');
}
const KIND_LABEL: Record<string, string> = {
basic: 'پایه',
supplementary: 'تکمیلی',
};
export default function TenantInsuranceContracts() {
const qc = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const [editUuid, setEditUuid] = useState<string | null>(null);
const [insuranceId, setInsuranceId] = useState('');
const [coverage, setCoverage] = useState('');
const [franchise, setFranchise] = useState('');
const [ceiling, setCeiling] = useState('');
const [tab, setTab] = useState<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string | null>(null);
const contractsQuery = useQuery<{ data: Contract[] }>({
const contractsQuery = useQuery({
queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
});
const pricingQuery = useQuery<{ data: { insurances: InsuranceOption[] } }>({
const pricingQuery = useQuery({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const contracts = (contractsQuery.data as any)?.data?.data ?? [];
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: Contract) => c.insurance_id));
const available = allInsurances.filter((i) => !activeIds.has(i.insurance_id));
const activeIds = new Set(contracts.map((c) => c.insurance_id));
// Add-mode options: only the active tab's kind, excluding already-contracted insurances.
const available = allInsurances.filter((i) => i.type === tab).filter((i) => !activeIds.has(i.insurance_id));
const resetForm = () => {
setInsuranceId('');
setCoverage('');
setFranchise('');
setCeiling('');
setAddOpen(false);
setEditUuid(null);
};
// The insurance's real type comes from the catalog, not the contract's stored kind
// (legacy contracts carry a stale manually-picked kind). Catalog type is authoritative.
const catalogTypeById = useMemo(() => {
const m: Record<number, Kind> = {};
for (const i of allInsurances) m[i.insurance_id] = i.type === 'supplementary' ? 'supplementary' : 'basic';
return m;
}, [allInsurances]);
const kindOf = (c: Contract): Kind =>
catalogTypeById[c.insurance_id] ?? contractKind(c);
const openEdit = (c: Contract) => {
setEditUuid(c.uuid);
setAddOpen(false);
setInsuranceId(String(c.insurance_id));
setCoverage(String(c.coverage_percent ?? ''));
setFranchise(c.franchise_rials != null ? String(rialToToman(c.franchise_rials)) : '');
setCeiling(c.annual_ceiling_rials != null ? String(rialToToman(c.annual_ceiling_rials)) : '');
};
const byKind = useMemo(() => contracts.filter((c) => kindOf(c) === tab), [contracts, tab, catalogTypeById]);
const rows = useMemo(() => filterInsurances(byKind, search), [byKind, search]);
const counts = useMemo(() => ({
basic: contracts.filter((c) => kindOf(c) === 'basic').length,
supplementary: contracts.filter((c) => kindOf(c) === 'supplementary').length,
}), [contracts, catalogTypeById]);
const editMut = useMutation({
mutationFn: () =>
api.patch(`/api/v1/billing/tenant-insurances/${editUuid}`, {
coverage_percent: Number(coverage) || 0,
franchise_rials: tomanToRial(Number(franchise) || 0),
annual_ceiling_rials: ceiling === '' ? null : tomanToRial(Number(ceiling)),
}),
const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
const saveMut = useMutation({
mutationFn: (payload: ReturnType<typeof buildInsurancePayload>) =>
editContract
? api.patch(`/api/v1/billing/tenant-insurances/${editContract.uuid}`, payload)
: api.post('/api/v1/billing/tenant-insurances', payload),
onSuccess: () => {
toast.success('قرارداد بیمه ویرایش شد');
resetForm();
qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
toast.success(editContract ? 'قرارداد بیمه ویرایش شد' : 'قرارداد بیمه فعال شد');
closeModal();
invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const addMut = useMutation({
mutationFn: () =>
api.post('/api/v1/billing/tenant-insurances', {
insurance_id: Number(insuranceId),
coverage_percent: Number(coverage) || 0,
franchise_rials: tomanToRial(Number(franchise) || 0),
annual_ceiling_rials: ceiling === '' ? null : tomanToRial(Number(ceiling)),
}),
onSuccess: () => {
toast.success('قرارداد بیمه فعال شد');
resetForm();
qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
},
const toggleMut = useMutation({
mutationFn: (c: Contract) =>
api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, { is_active: !c.is_active }),
onSuccess: () => invalidate(),
onError: (e: Error) => toast.error(e.message),
});
const delMut = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/billing/tenant-insurances/${uuid}`),
onSuccess: () => {
toast.success('قرارداد غیرفعال شد');
qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
},
onError: (e: Error) => toast.error(e.message),
});
const openAdd = () => { setEditContract(null); setModalOpen(true); };
const openEdit = (c: Contract) => { setEditContract(c); setModalOpen(true); };
const closeModal = () => { setModalOpen(false); setEditContract(null); };
const activeKind = KINDS.find((k) => k.key === tab)!;
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 14 }}>
<div>
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>قراردادهای بیمه</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4, lineHeight: 1.7 }}>
بیمههایی که با آنها قرارداد دارید. درصد پوشش، فرانشیز و سقف تعهد هر بیمه را تعیین کنید.
</p>
</div>
{!addOpen && available.length > 0 && (
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
<PlusIcon style={{ width: 14 }} /> افزودن بیمه
</button>
)}
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>مدیریت بیمه</h2>
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
</div>
{(addOpen || editUuid) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end', padding: 14, borderRadius: 10, border: '1px solid var(--border)', background: 'var(--surface)', marginBottom: 14 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>بیمه</label>
<div style={{ minWidth: 200 }}>
<SearchableSelect
options={(editUuid ? allInsurances : available).map((i) => ({ value: String(i.insurance_id), label: `${i.insurance_name} (${KIND_LABEL[i.type] ?? i.type})` }))}
value={insuranceId}
onChange={(v) => setInsuranceId(v ? String(v) : '')}
isDisabled={!!editUuid}
placeholder="انتخاب..."
/>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>درصد پوشش</label>
<input type="number" min={0} max={100} dir="ltr" className="input" style={{ width: 100 }} value={coverage} onChange={(e) => setCoverage(e.target.value)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>فرانشیز (تومان)</label>
<input type="number" min={0} dir="ltr" className="input" style={{ width: 130 }} value={franchise} onChange={(e) => setFranchise(e.target.value)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11.5, fontWeight: 600 }}>سقف تعهد (تومان)</label>
<input type="number" min={0} dir="ltr" className="input" style={{ width: 130 }} placeholder="بی‌نهایت" value={ceiling} onChange={(e) => setCeiling(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<div role="tablist" style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', marginBottom: 16 }}>
{KINDS.map((k) => {
const active = k.key === tab;
return (
<button
className="btn primary sm"
disabled={!insuranceId || addMut.isPending || editMut.isPending}
onClick={() => (editUuid ? editMut.mutate() : addMut.mutate())}
key={k.key}
role="tab"
aria-selected={active}
onClick={() => { setTab(k.key); setExpanded(null); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
color: active ? 'var(--primary)' : 'var(--text-3)',
borderBottom: `2px solid ${active ? 'var(--primary)' : 'transparent'}`,
marginBottom: -1, transition: 'color .2s, border-color .2s',
}}
>
{addMut.isPending || editMut.isPending ? '...' : 'ذخیره'}
{k.label}
<span style={{
fontSize: 11, fontWeight: 700, minWidth: 18, padding: '0 6px', borderRadius: 'var(--r-pill)',
background: active ? 'var(--primary-soft)' : 'var(--surface-2)', color: active ? 'var(--primary)' : 'var(--text-3)',
}}>
{formatNumber(counts[k.key])}
</span>
</button>
<button className="btn ghost sm" onClick={resetForm}>لغو</button>
</div>
</div>
)}
);
})}
</div>
<div style={{ position: 'relative', marginBottom: 16 }}>
<MagnifyingGlassIcon style={{ width: 16, position: 'absolute', insetInlineStart: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)' }} />
<input
className="input"
style={{ paddingInlineStart: 36 }}
placeholder="جستجو در بیمه ها..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{contractsQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : contracts.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '12px 0' }}>
هنوز با هیچ بیمهای قرارداد فعال ندارید.
) : rows.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', padding: '16px 0', textAlign: 'center' }}>
{search ? 'بیمه‌ای یافت نشد.' : activeKind.emptyLabel}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{(['basic', 'supplementary'] as const).map((kind) => {
const group = contracts.filter((c: Contract) => c.insurance_kind === kind);
if (group.length === 0) return null;
return (
<div key={kind}>
<div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-2)', marginBottom: 8 }}>
بیمه {KIND_LABEL[kind]}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{group.map((c: Contract) => (
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
پوشش {c.coverage_percent}٪
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
</div>
</div>
<button className="mini-btn" title="ویرایش" onClick={() => openEdit(c)}>
<PencilIcon style={{ width: 15 }} />
</button>
<button className="mini-btn danger" title="غیرفعال‌سازی" disabled={delMut.isPending} onClick={() => delMut.mutate(c.uuid)}>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
</div>
</div>
);
})}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map((c) => (
<ContractCard
key={c.uuid}
contract={c}
open={expanded === c.uuid}
onToggleRow={() => toggleRow(c.uuid)}
onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending}
/>
))}
</div>
)}
<InsuranceModal
open={modalOpen}
editContract={editContract}
options={editContract ? allInsurances : available}
kind={editContract ? kindOf(editContract) : tab}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}
/>
</div>
);
}
interface RowProps {
contract: Contract;
open: boolean;
onToggleRow: () => void;
onEdit: () => void;
onToggleStatus: () => void;
statusPending?: boolean;
}
function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending }: RowProps) {
const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); };
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ChevronDownIcon style={{ width: 15, color: 'var(--text-3)', transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none' }} />
<div style={{ fontWeight: 700, fontSize: 14 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
</div>
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
<PencilIcon style={{ width: 15 }} />
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
</div>
);
}
/** Expanded full detail of a contract (all fields), shown when its card is open. */
function ContractDetails({ contract: c }: { contract: Contract }) {
return (
<div style={{
background: 'var(--surface-2)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)',
padding: 14,
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14,
}}>
<DetailCell label="درصد پوشش" value={`${formatNumber(c.coverage_percent)}٪`} />
<DetailCell label="فرانشیز" value={c.franchise_rials > 0 ? formatRial(c.franchise_rials) : '—'} />
<DetailCell label="سقف تعهد سالانه" value={c.annual_ceiling_rials != null ? formatRial(c.annual_ceiling_rials) : 'نامحدود'} />
<DetailCell label="تاریخ شروع قرارداد" value={formatDate(c.effective_from)} />
<DetailCell label="تاریخ پایان قرارداد" value={c.effective_to != null ? formatDate(c.effective_to) : 'بدون تاریخ پایان'} />
<DetailCell label="نسخه قرارداد" value={<span dir="ltr">{formatNumber(c.version)}</span>} />
<DetailCell label="کد بیمه" value={<span dir="ltr">{c.insurance_id}</span>} />
<DetailCell
label="وضعیت"
value={
<span style={{ color: c.is_active ? 'var(--success)' : 'var(--text-3)', fontWeight: 700 }}>
{c.is_active ? 'فعال' : 'غیرفعال'}
</span>
}
/>
</div>
);
}
function DetailCell({ label, value }: { label: string; value: ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>{label}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{value}</span>
</div>
);
}
function StatusToggle({ contract, onToggle, disabled }: { contract: Contract; onToggle: (e: React.MouseEvent) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={contract.is_active}
aria-label={contract.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
onClick={onToggle}
disabled={disabled}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 'none', cursor: disabled ? 'default' : 'pointer', padding: 0 }}
>
<span style={{
width: 36, height: 20, borderRadius: 999, position: 'relative', transition: 'background .2s',
background: contract.is_active ? 'var(--primary)' : 'var(--border)',
}}>
<span style={{
position: 'absolute', top: 2, width: 16, height: 16, borderRadius: 999, background: '#fff', transition: 'inset-inline .2s',
insetInlineStart: contract.is_active ? 18 : 2,
}} />
</span>
<span style={{ fontSize: 12, color: contract.is_active ? 'var(--success)' : 'var(--text-3)' }}>
{contract.is_active ? 'فعال' : 'غیرفعال'}
</span>
</button>
);
}
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn().mockResolvedValue({ success: true, data: [] }), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import WalletTransactionModal from './WalletTransactionModal';
// موجودی نمونه: ۳۰۰٬۰۰۰ ریال = ۳۰٬۰۰۰ تومان
const BALANCE_RIALS = 300_000;
function open(onSubmit = vi.fn()) {
renderWithProviders(
<WalletTransactionModal open balanceRials={BALANCE_RIALS} onClose={vi.fn()} onSubmit={onSubmit} />,
);
return onSubmit;
}
describe('WalletTransactionModal (شارژ/برداشت کیف پول)', () => {
it('renders both mode tabs, the balance banner and the quick amounts', () => {
open();
expect(screen.getByRole('button', { name: 'شارژ کیف پول' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'برداشت از کیف پول' })).toBeInTheDocument();
expect(screen.getByText('موجودی کیف پول')).toBeInTheDocument();
// چهار چیپ مبلغ سریع، هر کدام «... تومان»
expect(screen.getAllByRole('button', { name: /تومان/ }).length).toBe(4);
});
it('submits a charge with the amount converted from toman to rials and the default cash method', () => {
const onSubmit = open();
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
expect(onSubmit).toHaveBeenCalledWith({ mode: 'charge', amount_rials: 1_000_000, payment_method: 'cash' });
});
it('submits a withdraw (debit) when the برداشت tab is active and amount is within balance', () => {
const onSubmit = open();
fireEvent.click(screen.getByRole('button', { name: 'برداشت از کیف پول' }));
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '20000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
expect(onSubmit).toHaveBeenCalledWith({ mode: 'withdraw', amount_rials: 200_000, payment_method: 'cash' });
});
it('blocks a withdraw above the balance and shows the insufficient-funds hint', () => {
const onSubmit = open();
fireEvent.click(screen.getByRole('button', { name: 'برداشت از کیف پول' }));
// ۴۰٬۰۰۰ تومان = ۴۰۰٬۰۰۰ ریال > موجودی ۳۰۰٬۰۰۰ ریال
fireEvent.change(screen.getByPlaceholderText('مبلغ دلخواه (تومان)'), { target: { value: '40000' } });
expect(screen.getByText('موجودی کیف پول کافی نیست')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ثبت تراکنش' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'ثبت تراکنش' }));
expect(onSubmit).not.toHaveBeenCalled();
});
it('keeps submit disabled when no amount is entered', () => {
open();
expect(screen.getByRole('button', { name: 'ثبت تراکنش' })).toBeDisabled();
});
});
@@ -0,0 +1,205 @@
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
import SearchableSelect from './ui/SearchableSelect';
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
export type WalletMode = 'charge' | 'withdraw';
export interface WalletModalSubmit {
mode: WalletMode;
amount_rials: number;
description?: string;
payment_method?: string;
reference?: string;
}
interface Props {
open: boolean;
balanceRials: number;
submitting?: boolean;
onClose: () => void;
onSubmit: (payload: WalletModalSubmit) => void;
}
// مبالغ پیشنهادی سریع، به تومان.
const QUICK_TOMANS = [100_000, 200_000, 300_000, 400_000];
interface MethodOption { value: string; label: string; method: string; reference: string | null }
/**
* مودال شارژ/برداشت کیف پول بیمار با طراحیِ پنل و روشهای پرداختِ واقعیِ کلینیک
* (حساب بانکی/کارتخوان از /my/payment-methods + نقدی). مبلغ به تومان وارد و هنگام
* ثبت به ریال (واحد API) تبدیل میشود.
*/
export default function WalletTransactionModal({ open, balanceRials, submitting, onClose, onSubmit }: Props) {
const [mode, setMode] = useState<WalletMode>('charge');
const [amountToman, setAmountToman] = useState(0);
const [methodValue, setMethodValue] = useState<string>('cash');
const [description, setDescription] = useState('');
const banksQ = useBankAccounts();
const posQ = usePosDevices();
const methodOptions = useMemo<MethodOption[]>(() => {
const opts: MethodOption[] = [{ value: 'cash', label: 'نقدی', method: 'cash', reference: null }];
for (const b of banksQ.data?.data ?? []) {
if (!b.is_active) continue;
opts.push({ value: `bank:${b.uuid}`, label: `کارت به کارت — ${b.bank_name}`, method: 'card', reference: `${b.bank_name}${b.card_number ? ` (${b.card_number})` : ''}` });
}
for (const p of posQ.data?.data ?? []) {
if (!p.is_active) continue;
opts.push({ value: `pos:${p.uuid}`, label: `کارت‌خوان — ${p.bank_name}`, method: 'pos', reference: `${p.bank_name} (${p.terminal_number})` });
}
return opts;
}, [banksQ.data, posQ.data]);
useEffect(() => {
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const amountRials = tomanToRial(amountToman);
const isWithdraw = mode === 'withdraw';
const overBalance = isWithdraw && amountRials > balanceRials;
const canSubmit = amountToman > 0 && !overBalance && !submitting;
const submit = () => {
if (!canSubmit) return;
const opt = methodOptions.find((o) => o.value === methodValue);
onSubmit({
mode,
amount_rials: amountRials,
...(description.trim() ? { description: description.trim() } : {}),
...(opt ? { payment_method: opt.method } : {}),
...(opt?.reference ? { reference: opt.reference } : {}),
});
};
const tab = (key: WalletMode, label: string) => {
const on = mode === key;
return (
<button
type="button"
onClick={() => setMode(key)}
style={{
flex: 1, borderRadius: 'var(--r-sm)', padding: '9px 0', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13.5, fontWeight: 600, zIndex: 2, background: 'transparent',
color: on ? 'var(--on-primary)' : 'var(--text-2)', transition: 'color .25s var(--ease)',
}}
>
{label}
</button>
);
};
return createPortal(
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
<button type="button" className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 18, height: 18 }} />
</button>
</div>
<div className="modal-body" dir="rtl">
{/* موجودی — کارت ساده مطابق پنل (بدون گرادیان) */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--primary-soft)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: '12px 16px', marginBottom: 18,
}}>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>موجودی کیف پول</span>
<span style={{ fontSize: 18, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</span>
</div>
{/* toggle شارژ/برداشت */}
<div style={{ position: 'relative', display: 'flex', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 'var(--r)', padding: 4, marginBottom: 18 }}>
<div style={{
position: 'absolute', top: 4, bottom: 4, width: 'calc(50% - 4px)',
right: isWithdraw ? 'calc(50%)' : 4, left: isWithdraw ? 4 : 'calc(50%)',
background: 'var(--primary)', borderRadius: 'var(--r-sm)', transition: 'all .25s var(--ease)', zIndex: 1,
}} />
{tab('charge', 'شارژ کیف پول')}
{tab('withdraw', 'برداشت از کیف پول')}
</div>
{/* مبلغ + مبالغ سریع */}
<label className="field-label">{isWithdraw ? 'مبلغ برداشت (تومان)' : 'مبلغ شارژ (تومان)'}</label>
<div style={{ display: 'flex', gap: 8, margin: '8px 0 10px', flexWrap: 'wrap' }}>
{QUICK_TOMANS.map((t) => {
const on = amountToman === t;
return (
<button key={t} type="button" onClick={() => setAmountToman(t)} style={{
flex: '1 1 0', minWidth: 92, padding: '8px 6px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: `1px solid ${on ? 'var(--primary)' : 'var(--border)'}`,
background: on ? 'var(--primary-soft)' : 'var(--surface)',
color: on ? 'var(--primary)' : 'var(--text-2)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
}}>{formatNumber(t)} تومان</button>
);
})}
</div>
<div className="field">
<input
inputMode="numeric"
value={amountToman > 0 ? formatNumber(amountToman) : ''}
onChange={(e) => {
const raw = digitsOnly(e.target.value);
setAmountToman(raw ? parseInt(raw, 10) : 0);
}}
placeholder="مبلغ دلخواه (تومان)"
style={{ textAlign: 'center', direction: 'ltr' }}
/>
</div>
{overBalance && <div style={{ color: 'var(--danger)', fontSize: 12, marginTop: 6 }}>موجودی کیف پول کافی نیست</div>}
{/* روش پرداخت واقعی */}
<div style={{ marginTop: 16 }}>
<label className="field-label">روش پرداخت</label>
<div style={{ marginTop: 8 }}>
<SearchableSelect
options={methodOptions.map((o) => ({ value: o.value, label: o.label }))}
value={methodValue}
onChange={(v) => setMethodValue(String(v ?? 'cash'))}
placeholder="روش پرداخت"
height={46}
/>
</div>
</div>
{/* توضیحات */}
<div style={{ marginTop: 16 }}>
<label className="field-label">توضیحات</label>
<div className="field" style={{ height: 'auto', marginTop: 8 }}>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="دلیل تراکنش (اختیاری)"
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }}
/>
</div>
</div>
</div>
<div className="modal-foot">
<button type="button" className="btn" onClick={onClose}>انصراف</button>
<button type="button" className="btn primary" disabled={!canSubmit} onClick={submit}>
{submitting ? 'در حال ثبت...' : 'ثبت تراکنش'}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
const appointment = {
uuid: 'a1',
version: 1,
patient_name: 'محمد رضایی',
visit_price_rials: 2_120_000,
service_items: [{ uuid: 's1', name: 'لیزر', price_rials: 880_000 }],
};
function render() {
return renderWithProviders(
<ConfirmAppointmentModal open appointmentUuid="a1" appointment={appointment} onClose={() => {}} />,
);
}
beforeEach(() => vi.clearAllMocks());
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
render();
expect(screen.getByText('محمد رضایی')).toBeInTheDocument();
expect(screen.getByText('ویزیت')).toBeInTheDocument();
expect(screen.getByText('لیزر')).toBeInTheDocument();
expect(screen.getByText('جمع کل')).toBeInTheDocument();
});
it('مبلغ پرداختی پیش‌فرض برابر باقی‌مانده (کل هزینه) است', () => {
render();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(screen.getByPlaceholderText('0')).toHaveValue('۳۰۰٬۰۰۰');
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ، باقی‌مانده دوباره محاسبه می‌شود', () => {
render();
fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '100000' } });
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
// ۳۰۰٬۰۰۰ − ۱۰۰٬۰۰۰ تومان باقی‌مانده ⇒ ۲٬۰۰۰٬۰۰۰ ریال
expect(screen.getByText(/باقی‌مانده پس از این پرداخت/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'بدون پرداخت' }));
expect(screen.getByText('بدون پرداخت', { selector: 'span' })).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مبلغ بیشتر از جمع کل غیرفعال می‌شود', () => {
render();
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
expect(submit).not.toBeDisabled();
// مبلغ بالاتر از جمع کل (۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان < کل؛ پس عدد بزرگ‌تر می‌دهیم)
fireEvent.change(screen.getByPlaceholderText('0'), { target: { value: '9000000' } });
expect(screen.getByText('مبلغ پرداخت از جمع کل بیشتر است.')).toBeInTheDocument();
expect(submit).toBeDisabled();
});
});
@@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
/** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */
const METHOD_OPTIONS = [
{ value: 'cash', label: 'پرداخت نقدی' },
{ value: 'pos', label: 'پرداخت از طریق کارت خوان' },
{ value: 'card', label: 'کارت به کارت' },
{ value: 'wallet', label: 'پرداخت از طریق کیف پول' },
];
interface ServiceItem {
uuid: string;
name: string;
price_rials?: number | null;
}
interface AppointmentLike {
uuid: string;
version?: number;
visit_price_rials?: number | null;
service_items?: ServiceItem[] | null;
patient_name?: string | null;
}
interface Props {
open: boolean;
appointmentUuid: string;
/** اگر صفحه از قبل نوبت را دارد، پاس بده تا درخواست اضافه نرود. */
appointment?: AppointmentLike | null;
onClose: () => void;
/** کلید کوئریِ لیستی که بعد از قطعی‌شدن باید invalidate شود. */
queryKey?: unknown[];
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
padding: '9px 0',
fontSize: 13.5,
color: 'var(--text-2)',
};
/** رنگ چیپِ «وضعیت پرداخت» بر اساس نسبت پرداخت به جمع کل. */
const STATE_TONE: Record<string, { fg: string; bg: string }> = {
'بدون پرداخت': { fg: 'var(--text-2)', bg: 'var(--surface-3)' },
'پرداخت جزئی': { fg: 'var(--warning)', bg: 'var(--warning-bg)' },
'تسویه کامل': { fg: 'var(--success)', bg: 'var(--success-bg)' },
};
/**
* «قطعی کردن نوبت» هزینههای نوبت را نشان میدهد، پرداخت کامل یا جزئی میگیرد و
* نوبت را از «ثبت شده» به «قطعی شده» میبرد.
*
* سرور همین یک درخواست را اتمیک انجام می‌دهد: وضعیت + پرونده/مراجعه + پرداختها.
*/
export default function ConfirmAppointmentModal({
open,
appointmentUuid,
appointment,
onClose,
queryKey,
}: Props) {
const qc = useQueryClient();
const [method, setMethod] = useState('cash');
const [amountToman, setAmountToman] = useState(0);
/** تا وقتی کاربر مبلغ را دست نزده، فیلد با باقی‌ماندهٔ نوبت پر می‌ماند. */
const [touched, setTouched] = useState(false);
// وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم:
// مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند.
const detailQuery = useQuery({
queryKey: ['appointment', appointmentUuid],
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
enabled: open && !appointment,
});
const appt: AppointmentLike | null = appointment
?? ((detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null);
const visitPrice = Number(appt?.visit_price_rials ?? 0);
const services = appt?.service_items ?? [];
const servicesTotal = useMemo(
() => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0),
[services],
);
const total = visitPrice + servicesTotal;
// جمع کل تا لحظه‌ای که کاربر مبلغ را دستی تغییر ندهد پیش‌فرضِ «پرداخت کامل» است؛
// نوبت هنوز session ندارد، پس باقی‌مانده‌اش برابر کل هزینه است.
useEffect(() => {
if (!open || touched || total <= 0) return;
setAmountToman(rialToToman(total));
}, [open, touched, total]);
const amountRials = tomanToRial(amountToman);
const remaining = Math.max(0, total - amountRials);
const overpaid = amountRials > total;
const paymentState = amountRials === 0
? 'بدون پرداخت'
: remaining === 0
? 'تسویه کامل'
: 'پرداخت جزئی';
const confirmMut = useMutation({
mutationFn: () =>
api.post<ApiResponse<unknown>>(`/api/v1/appointment/${appointmentUuid}/confirm`, {
version: appt?.version,
payments: amountRials > 0 ? [{ method, amount_rials: amountRials }] : [],
}),
onSuccess: () => {
toast.success('نوبت قطعی شد');
if (queryKey) qc.invalidateQueries({ queryKey });
qc.invalidateQueries({ queryKey: ['appointment', appointmentUuid] });
qc.invalidateQueries({ queryKey: ['appointment-events', appointmentUuid] });
reset();
onClose();
},
onError: (e: any) => toast.error(e?.message || 'قطعی کردن نوبت ناموفق بود'),
});
function reset() {
setAmountToman(0);
setMethod('cash');
setTouched(false);
}
/** تغییر دستی مبلغ: از این به بعد پیش‌فرضِ خودکار دیگر روی فیلد ننشیند. */
function changeAmount(next: number) {
setTouched(true);
setAmountToman(next);
}
function handleClose() {
reset();
onClose();
}
const loading = detailQuery.isLoading && !appointment;
return (
<Modal
open={open}
title="قطعی کردن نوبت"
size="md"
onClose={handleClose}
footer={
<>
<button type="button" className="btn ghost" onClick={handleClose}>
انصراف
</button>
<button
type="button"
className="btn primary"
disabled={loading || overpaid || confirmMut.isPending}
onClick={() => confirmMut.mutate()}
>
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
</button>
</>
}
>
{loading ? (
<p style={{ color: 'var(--text-2)' }}>در حال دریافت اطلاعات نوبت</p>
) : (
<>
{appt?.patient_name && (
<div
style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16,
padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-soft)',
}}
>
<UserCircleIcon style={{ width: 20, height: 20, color: 'var(--primary-700)', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>بیمار</span>
<strong style={{ fontSize: 14, color: 'var(--primary-700)' }}>{appt.patient_name}</strong>
</div>
)}
{/* هزینه‌ها */}
<div
style={{
border: '1px solid var(--border)', borderRadius: 'var(--r)',
padding: '4px 14px 10px', marginBottom: 20,
}}
>
<div style={rowStyle}>
<span>ویزیت</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(visitPrice)}</strong>
</div>
{services.map((s) => (
<div key={s.uuid} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>{s.name}</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
</div>
))}
<div
style={{
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
}}
>
<span>جمع کل</span>
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(total)}</strong>
</div>
</div>
{/* پرداخت */}
<div
style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: 14, marginBottom: 10,
}}
>
<div className="field-block">
<label>روش پرداخت</label>
<SearchableSelect
value={method}
onChange={(v) => setMethod(String(v ?? 'cash'))}
options={METHOD_OPTIONS}
placeholder="روش پرداخت"
height={40}
/>
</div>
<div className="field-block">
<label>مبلغ پرداختی (تومان)</label>
<div className="field" style={overpaid ? { borderColor: 'var(--danger)' } : undefined}>
<PriceInput value={amountToman} onChange={changeAmount} suffix="تومان" max={rialToToman(total)} />
</div>
<span className="field-hint">باقیمانده پس از این پرداخت: {formatRial(remaining)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<button
type="button"
className="btn soft sm"
onClick={() => changeAmount(rialToToman(total))}
>
پرداخت کامل
</button>
{amountToman > 0 && (
<button type="button" className="btn ghost sm" onClick={() => changeAmount(0)}>
بدون پرداخت
</button>
)}
</div>
{overpaid && (
<p className="field-err" style={{ marginBottom: 14 }}>
مبلغ پرداخت از جمع کل بیشتر است.
</p>
)}
{/* خلاصه */}
<div
style={{
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 'var(--r)', padding: '4px 14px 10px',
}}
>
<div style={rowStyle}>
<span>پرداختشده</span>
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(amountRials, total))}</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>باقیمانده</span>
<strong style={{ color: remaining > 0 ? 'var(--danger)' : 'var(--success)' }}>
{formatRial(remaining)}
</strong>
</div>
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
<span>وضعیت پرداخت</span>
<span
style={{
padding: '4px 12px', borderRadius: 'var(--r-pill)',
fontSize: 12.5, fontWeight: 700,
color: STATE_TONE[paymentState].fg,
background: STATE_TONE[paymentState].bg,
}}
>
{paymentState}
</span>
</div>
</div>
</>
)}
</Modal>
);
}
@@ -0,0 +1,52 @@
/**
* تب دکترها با نشانگر underline بازسازی تبهای بالای صفحهٔ نوبتهای طرح tauri
* (`index.jsx`): فعال #5559ce با خط زیرین، بقیه خاکستری.
*/
export interface DoctorTab {
uuid: string;
name: string;
}
export default function DoctorTabs({
doctors, selected, onSelect, showAll = true,
}: {
doctors: DoctorTab[];
/** '' یعنی «همه». */
selected: string;
onSelect: (uuid: string) => void;
showAll?: boolean;
}) {
const tabs: DoctorTab[] = showAll ? [{ uuid: '', name: 'همه' }, ...doctors] : doctors;
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 24, padding: '8px 16px 0',
borderBottom: '1px solid var(--border)', overflowX: 'auto',
}}>
{tabs.map((d) => {
const active = selected === d.uuid;
return (
<button
key={d.uuid || 'all'}
type="button"
onClick={() => onSelect(d.uuid)}
style={{
position: 'relative', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 15, fontWeight: 500, padding: '8px 2px 12px',
color: active ? 'var(--primary)' : 'var(--text-2)', whiteSpace: 'nowrap',
// برچسب‌های کوتاه («همه») بدون این، عرضشان زیر ۴۴px می‌ماند (WCAG 2.5.5)
minWidth: 44,
}}
>
{d.name}
{active && (
<span style={{
position: 'absolute', bottom: -1, left: 0, right: 0, height: 3,
background: 'var(--primary)', borderRadius: '3px 3px 0 0',
}} />
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../../lib/api';
import ServiceSlotPicker, { type ServicePick } from './ServiceSlotPicker';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
const get = api.get as ReturnType<typeof vi.fn>;
const services: BookingService[] = [
{ uuid: 'i1', name: 'بوتاکس', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
{ uuid: 'i2', name: 'فیلر لب', duration_minutes: 20, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
{ uuid: 'i3', name: 'لیزر موهای زائد', duration_minutes: 45, price_rials: 0, service_section: { uuid: 'sec2', name: 'لیزر' } },
];
const pickSection = async (optionLabel: string) => {
const input = document.getElementById('service-mode-section-select') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText(optionLabel));
};
beforeEach(() => {
get.mockReset();
get.mockResolvedValue({ success: true, data: { total_duration_minutes: 50, buffer_minutes: 0, start_times: [] } });
});
describe('ServiceSlotPicker — انتخاب سرویس بر اساس بخش + مدت قابل‌ویرایش', () => {
it('accumulates services across sections and reports section→service + durations', async () => {
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
renderWithProviders(
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
);
// بخش زیبایی → دو سرویس
await pickSection('زیبایی');
fireEvent.click(await screen.findByText('بوتاکس'));
fireEvent.click(await screen.findByText('فیلر لب'));
// بخش لیزر → یک سرویس؛ لیست انباشته حفظ می‌شود
await pickSection('لیزر');
fireEvent.click(await screen.findByText('لیزر موهای زائد'));
expect(screen.getByText('سرویس‌های انتخاب‌شده (3)')).toBeInTheDocument();
// chip نام بخش را کنار سرویس نشان می‌دهد (دو chip از بخش زیبایی)
expect(screen.getAllByText('زیبایی').length).toBeGreaterThanOrEqual(2);
await waitFor(() => expect(last.serviceUuids).toEqual(['i1', 'i2', 'i3']));
expect(last.durations).toEqual({ i1: 30, i2: 20, i3: 45 });
});
it('lets the secretary override a service duration for this appointment only', async () => {
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
renderWithProviders(
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
);
await pickSection('زیبایی');
fireEvent.click(await screen.findByText('بوتاکس'));
fireEvent.change(screen.getByLabelText('مدت بوتاکس'), { target: { value: '90' } });
await waitFor(() => expect(last.durations).toEqual({ i1: 90 }));
});
it('removes a selected service from the accumulated list', async () => {
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
renderWithProviders(
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
);
await pickSection('زیبایی');
fireEvent.click(await screen.findByText('بوتاکس'));
fireEvent.click(await screen.findByText('فیلر لب'));
expect(screen.getByText('سرویس‌های انتخاب‌شده (2)')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('حذف بوتاکس'));
expect(screen.getByText('سرویس‌های انتخاب‌شده (1)')).toBeInTheDocument();
await waitFor(() => expect(last.serviceUuids).toEqual(['i2']));
});
});
@@ -0,0 +1,226 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
import { useClinicContext } from '../../hooks/useClinicContext';
import SearchableSelect from '../ui/SearchableSelect';
import DigitInput from '../ui/DigitInput';
interface ServiceSlot { start: number; end: number; start_time: string }
export interface PickedService { uuid: string; name: string; section: string; duration: number }
export interface ServicePick { serviceUuids: string[]; durations: Record<string, number>; slot: ServiceSlot | null }
/**
* انتخاب سرویس بر اساس بخش (بخش سرویس، انباشته از چند بخش) + زمانهای خالیِ پیشنهادی
* برای نوبتدهی سرویسی. مدتِ هر سرویس در پنل قابل ویرایش است (فقط برای همین نوبت؛ پیشفرضِ
* سرویس تغییر نمیکند). مدت کل = مجموع مدتها؛ زمانها از `appointment-service-slots`
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
*/
export default function ServiceSlotPicker({
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
}: {
doctorUuid: string;
date: string;
services: BookingService[];
onSelect: (v: ServicePick) => void;
editableDuration?: boolean;
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخاب‌شده خارج از context */
clinicUuidOverride?: string | null;
}) {
const contextClinicUuid = useClinicContext();
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
const [sectionUuid, setSectionUuid] = useState('');
const [selected, setSelected] = useState<PickedService[]>([]);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
// بخش‌های یکتا از روی سرویس‌های bookable (بدون endpoint اضافه — همه یکجا آمده‌اند).
const sections = useMemo(() => {
const map = new Map<string, { uuid: string; name: string }>();
services.forEach(s => { if (s.service_section) map.set(s.service_section.uuid, s.service_section); });
return [...map.values()];
}, [services]);
const sectionServices = useMemo(
() => services.filter(s => s.service_section?.uuid === sectionUuid),
[services, sectionUuid],
);
// تعویض پزشک ⇒ لیست سرویس‌ها عوض می‌شود؛ انتخاب‌ها ریست شوند.
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
const durations = useMemo(
() => Object.fromEntries(selected.map(s => [s.uuid, s.duration])) as Record<string, number>,
[selected],
);
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
),
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
});
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
const toggle = (s: BookingService) =>
setSelected(prev => prev.some(p => p.uuid === s.uuid)
? prev.filter(p => p.uuid !== s.uuid)
: [...prev, { uuid: s.uuid, name: s.name, section: s.service_section.name, duration: s.duration_minutes ?? 0 }]);
const remove = (uuid: string) => setSelected(prev => prev.filter(p => p.uuid !== uuid));
const setDuration = (uuid: string, minutes: number) =>
setSelected(prev => prev.map(p => p.uuid === uuid ? { ...p, duration: minutes } : p));
if (services.length === 0) {
return (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
</div>
);
}
return (
<div>
{/* انتخاب بخش */}
<label style={label}>بخش</label>
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
<SearchableSelect
inputId="service-mode-section-select"
options={sections.map(s => ({ value: s.uuid, label: s.name }))}
value={sectionUuid || null}
onChange={v => setSectionUuid(v ? String(v) : '')}
placeholder="ابتدا بخش را انتخاب کنید"
isClearable
height={44}
/>
</div>
{/* سرویس‌های بخشِ انتخاب‌شده — چند انتخابی */}
{sectionUuid && (
<>
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
{sectionServices.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
{sectionServices.map(s => {
const active = selected.some(p => p.uuid === s.uuid);
return (
<button key={s.uuid} type="button" onClick={() => toggle(s)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
fontFamily: 'inherit', fontSize: 13,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span style={{
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
background: active ? 'var(--primary)' : 'transparent',
}}>
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
</span>
{s.name}
</span>
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
</button>
);
})}
</div>
)}
</>
)}
{/* لیستِ انباشتهٔ سرویس‌های انتخاب‌شده (از هر بخش) — «بخش → سرویس» + مدت قابل‌ویرایش + حذف */}
{selected.length > 0 && (
<div style={{ margin: '4px 0 12px' }}>
<label style={label}>سرویسهای انتخابشده ({selected.length})</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
{selected.map(s => (
<div key={s.uuid} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
}}>
<span style={{
flex: 1, minWidth: 0, color: 'var(--primary-700)', fontWeight: 600,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
{' ← '}{s.name}
</span>
{editableDuration ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
height: 32, padding: '0 8px', borderRadius: 'var(--r-sm)',
background: 'var(--surface)', border: '1px solid var(--border-2)',
}}>
<DigitInput
aria-label={`مدت ${s.name}`}
value={String(s.duration || '')}
onChange={v => setDuration(s.uuid, Number(v) || 0)}
maxDigits={3}
style={{ width: 34, border: 'none', outline: 'none', background: 'transparent', textAlign: 'center', fontFamily: 'inherit', fontSize: 13, color: 'var(--text)' }}
/>
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
</span>
) : (
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
)}
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
style={{
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, flexShrink: 0,
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
}}>×</button>
</div>
))}
</div>
</div>
)}
{/* زمان‌های خالی پیشنهادی */}
{selected.length > 0 && (
<>
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
{slotsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
) : startTimes.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 4px' }}>
{startTimes.map(s => {
const active = pickedSlot?.start === s.start;
return (
<button key={s.start} type="button" dir="ltr"
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
style={{
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)',
}}>
{s.start_time}
</button>
);
})}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import TurnsStatInfo from './TurnsStatInfo';
describe('TurnsStatInfo', () => {
it('renders the four stat labels with Persian-digit values', () => {
render(<TurnsStatInfo stats={{ total: 236, completed: 200, waiting: 12, cancelled: 5 }} />);
expect(screen.getByText('کل نوبت های امروز')).toBeInTheDocument();
expect(screen.getByText('نوبت های انجام شده')).toBeInTheDocument();
expect(screen.getByText('بیماران در انتظار')).toBeInTheDocument();
expect(screen.getByText('نوبت های لغو شده')).toBeInTheDocument();
expect(screen.getByText('۲۳۶')).toBeInTheDocument();
expect(screen.getByText('۲۰۰')).toBeInTheDocument();
});
it('renders zeros when there is no data (empty state)', () => {
render(<TurnsStatInfo stats={{ total: 0, completed: 0, waiting: 0, cancelled: 0 }} />);
// چهار مقدار صفر فارسی
expect(screen.getAllByText('۰')).toHaveLength(4);
});
});
@@ -0,0 +1,61 @@
import { UsersIcon, UserPlusIcon, ClockIcon, XCircleIcon } from '@heroicons/react/24/outline';
/**
* نوار آمار نوبتها بازسازیِ `TurnsStatInfo.jsx` طرح tauri (کارت سفید با
* جداکنندههای عمودی و چهار آمار). رنگها از `data/turnsHeaderStats.json` مبدأ.
*/
export interface TurnsStats {
total: number;
completed: number;
waiting: number;
cancelled: number;
}
interface StatDef {
title: string;
value: number;
Icon: React.ElementType;
iconColor: string;
iconBg: string;
}
function BadgeIcon({ Icon, color, bg }: { Icon: React.ElementType; color: string; bg: string }) {
return (
<div style={{
width: 44, height: 44, borderRadius: 10, background: bg,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Icon style={{ width: 22, height: 22, color }} />
</div>
);
}
export default function TurnsStatInfo({ stats }: { stats: TurnsStats }) {
const items: StatDef[] = [
{ title: 'کل نوبت های امروز', value: stats.total, Icon: UsersIcon, iconColor: '#494cb3', iconBg: '#e9ebfb' },
{ title: 'نوبت های انجام شده', value: stats.completed, Icon: UserPlusIcon, iconColor: '#0d9f43', iconBg: '#e7f6ed' },
{ title: 'بیماران در انتظار', value: stats.waiting, Icon: ClockIcon, iconColor: '#e0a838', iconBg: '#fff2dc' },
{ title: 'نوبت های لغو شده', value: stats.cancelled, Icon: XCircleIcon, iconColor: '#ee5c6d', iconBg: '#fad6da' },
];
return (
<div style={{
display: 'flex', alignItems: 'center', background: 'var(--surface)',
border: '1px solid var(--border)', borderRadius: 'var(--r)',
padding: '14px 20px', marginBottom: 16, gap: 4,
}}>
{items.map((it, i) => (
<div key={it.title} style={{ display: 'flex', alignItems: 'center', flex: 1, gap: 10 }}>
{i > 0 && <div style={{ width: 1, height: 48, background: 'var(--border)', marginInlineEnd: 8 }} />}
<BadgeIcon Icon={it.Icon} color={it.iconColor} bg={it.iconBg} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{it.title}</span>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)' }}>
{it.value.toLocaleString('fa-IR')}
</span>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,74 @@
import { UserCircleIcon, PhoneIcon } from '@heroicons/react/24/outline';
import type { Appointment } from '../../types';
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
import AppointmentActionsMenu from '../AppointmentActions';
/**
* نمای جدولی (نمایش جدولی) بازسازیِ جدول نوبتهای طرح tauri (`List.jsx`):
* ستونهای ردیف/نام بیمار/شماره تماس/[پزشک]/شروع/پایان/سرویس/پرسنل/وضعیت/عملیات.
*/
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap', fontSize: 12.5 };
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle', fontSize: 13 };
export default function TurnsTable({
items, loading, queryKey, showDoctor,
}: {
items: Appointment[];
loading: boolean;
queryKey: unknown[];
showDoctor: boolean;
}) {
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (!items.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>نوبتی برای این روز ثبت نشده است</div>;
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={th}>ردیف</th>
<th style={th}>نام بیمار</th>
<th style={th}>شماره تماس</th>
{showDoctor && <th style={th}>پزشک</th>}
<th style={th}>شروع</th>
<th style={th}>پایان</th>
<th style={th}>سرویس</th>
<th style={th}>پرسنل</th>
<th style={th}>وضعیت</th>
<th style={th}>عملیات</th>
</tr>
</thead>
<tbody>
{items.map((a, i) => (
<tr key={a.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={td}>{(i + 1).toLocaleString('fa-IR')}</td>
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UserCircleIcon style={{ width: 18, height: 18, color: 'var(--text-3)' }} />
{a.patient_name || '—'}
</div>
</td>
<td style={{ ...td, direction: 'ltr', textAlign: 'right' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)' }} />
{a.patient_mobile}
</div>
</td>
{showDoctor && <td style={td}>{a.doctor_name}</td>}
<td style={{ ...td, fontWeight: 600 }}>{a.appointment_time}</td>
<td style={td}>{a.end_time}</td>
<td style={td}>{a.service_item?.name || '—'}</td>
<td style={td}>{a.staff?.full_name || '—'}</td>
<td style={td}>
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
</td>
<td style={td}>
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils';
vi.mock('../../lib/api', () => ({
api: { get: vi.fn(() => Promise.resolve({ success: true, data: [] })), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import TurnsTimeline from './TurnsTimeline';
import type { TimelineSlot } from './types';
import type { Appointment } from '../../types';
const appt = (over: Partial<Appointment> = {}): Appointment => ({
uuid: 'ap1', patient_name: 'ساغر صابری', patient_mobile: '09356619438',
doctor_uuid: 'doc1', doctor_name: 'دکتر محمدی',
slot_start: 1000, slot_end: 2000, appointment_date: '2024-12-31',
appointment_time: '08:00', end_time: '08:35', status: 'completed',
version: 1, created_at: '', service_item: { uuid: 's1', name: 'ویزیت عمومی' },
} as unknown as Appointment);
const occupiedSlot: TimelineSlot = {
start: 1000, end: 2000, start_time: '08:00', end_time: '08:35',
is_available: false, appointment: appt(), cancelled_appointment: null,
};
const emptySlot: TimelineSlot = {
start: Math.floor(Date.now() / 1000) + 3600, end: Math.floor(Date.now() / 1000) + 5400,
start_time: '09:10', end_time: '10:30', is_available: true, appointment: null, cancelled_appointment: null,
};
describe('TurnsTimeline', () => {
beforeEach(() => vi.clearAllMocks());
it('renders an occupied slot card with patient name + service', () => {
renderWithProviders(<TurnsTimeline slots={[occupiedSlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('ساغر صابری')).toBeInTheDocument();
expect(screen.getByText(/ویزیت عمومی/)).toBeInTheDocument();
});
it('renders an empty slot as «افزودن نوبت» and fires onBook on click', () => {
const onBook = vi.fn();
renderWithProviders(<TurnsTimeline slots={[emptySlot]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={onBook} />);
const add = screen.getByText('افزودن نوبت سریع');
fireEvent.click(add);
expect(onBook).toHaveBeenCalledWith(emptySlot);
});
it('empty_reason=day_off → «این روز شیفت کاری ندارد»', () => {
renderWithProviders(<TurnsTimeline slots={[]} emptyReason="day_off" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('این روز شیفت کاری ندارد')).toBeInTheDocument();
});
it('empty_reason=holiday → «این روز تعطیل است»', () => {
renderWithProviders(<TurnsTimeline slots={[]} emptyReason="holiday" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('این روز تعطیل است')).toBeInTheDocument();
});
it('خطای API هرگز به «شیفت کاری ندارد» ترجمه نمی‌شود', () => {
renderWithProviders(<TurnsTimeline slots={[]} errorMessage="دکتر یافت نشد" loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('خطا در دریافت برنامهٔ این روز')).toBeInTheDocument();
expect(screen.getByText('دکتر یافت نشد')).toBeInTheDocument();
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
});
it('دلیل ناشناخته/غایب → پیام خنثی، نه day_off', () => {
renderWithProviders(<TurnsTimeline slots={[]} loading={false} queryKey={['x']} onView={vi.fn()} onBook={vi.fn()} />);
expect(screen.getByText('برنامهٔ این روز در دسترس نیست')).toBeInTheDocument();
expect(screen.queryByText('این روز شیفت کاری ندارد')).toBeNull();
});
});
@@ -0,0 +1,236 @@
import { useEffect, useRef, useState } from 'react';
import { UserIcon, PhoneIcon, DocumentTextIcon, PlusIcon } from '@heroicons/react/24/outline';
import type { Appointment } from '../../types';
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown';
import AppointmentActionsMenu from '../AppointmentActions';
import ConfirmAppointmentModal from './ConfirmAppointmentModal';
import { turnStatusConfig, EMPTY_SLOT_CONFIG } from './turnStatus';
import type { TimelineSlot } from './types';
/**
* نمای زمانبندی (زمانبندی) بازسازیِ `Timeline.jsx` طرح tauri:
* هر ردیف: «ریل مارکر» بیرونی (نقطهٔ رنگی + ساعت شروع + خطچین) سمت راست + «کارت
* نوبت» چپِ آن. داخل کارت: مارکر زمانِ داخلی (نقطهٔ حلقهای شروع/پایان)، اطلاعات
* بیمار (نام/تلفن/سرویس) و در سمت چپ وضعیت + عملیات. اسلات خالی «افزودن نوبت».
* ردیفها با max-width وسطچین میشوند. رنگها عیناً از طرح مبدأ.
*/
const ROW_MAX = 760;
// نقطهٔ حلقه‌ای (حلقهٔ بیرونی + نقطهٔ داخلی) — مثل TimelineElement طرح tauri.
function RingDot({ color }: { color: string }) {
return (
<span style={{ position: 'relative', width: 17, height: 17, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ position: 'absolute', width: 16, height: 16, borderRadius: '50%', border: `2px solid ${color}` }} />
<span style={{ width: 7, height: 7, borderRadius: '50%', background: color }} />
</span>
);
}
// مارکر زمانِ داخلِ کارت (ساعت شروع بالا، ساعت پایان پایین + خط‌چین بین آن‌ها).
function InnerTimes({ start, end, color }: { start: string; end: string; color: string }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 4, minWidth: 62, alignSelf: 'stretch' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RingDot color={color} />
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 38, textAlign: 'center' }}>{start}</span>
</div>
<div style={{ width: 2, height: 14, marginInlineEnd: 6, backgroundImage: `repeating-linear-gradient(to bottom, ${color} 0, ${color} 3px, transparent 3px, transparent 6px)` }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<RingDot color={color} />
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 38, textAlign: 'center' }}>{end}</span>
</div>
</div>
);
}
// ریل مارکر بیرونی (سمت راستِ ردیف): نقطهٔ رنگی + ساعت + خط‌چین عمودی.
function OuterMarker({ color, time, showLine }: { color: string; time: string; showLine: boolean }) {
return (
<div style={{ width: 55, position: 'relative', display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start' }}>
{showLine && (
<div style={{
position: 'absolute', right: 5, top: 18, height: 'calc(100% + 16px)', width: 2,
backgroundImage: 'repeating-linear-gradient(to bottom, var(--border-2) 0, var(--border-2) 4px, transparent 4px, transparent 8px)',
zIndex: 0,
}} />
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, position: 'relative', zIndex: 1 }}>
<span style={{ width: 12, height: 12, borderRadius: '50%', background: color, border: '2px solid var(--surface)', boxShadow: '0 0 0 1px var(--border)', flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-2)', minWidth: 42 }}>{time}</span>
</div>
</div>
);
}
function EmptyCard({ slot, onBook }: { slot: TimelineSlot; onBook: (s: TimelineSlot) => void }) {
const cfg = EMPTY_SLOT_CONFIG;
const isPast = slot.start < Math.floor(Date.now() / 1000);
return (
<div
onClick={() => !isPast && onBook(slot)}
style={{
background: isPast ? 'var(--surface-2)' : cfg.bgColor,
border: `1px dashed ${isPast ? 'var(--border-2)' : cfg.borderColor}`,
borderRadius: 8, padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14,
cursor: isPast ? 'not-allowed' : 'pointer', opacity: isPast ? 0.7 : 1, minHeight: 76,
}}
>
<InnerTimes start={slot.start_time} end={slot.end_time} color={isPast ? '#9E9E9E' : cfg.dotColor} />
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<span style={{ fontSize: 13.5, color: isPast ? 'var(--text-3)' : cfg.textColor, fontWeight: 500 }}>
{isPast ? 'گذشته' : 'افزودن نوبت سریع'}
</span>
{!isPast && <PlusIcon style={{ width: 18, height: 18, color: cfg.textColor }} />}
</div>
{/* برچسب «نوبت جدید» سمت چپ (مطابق طرح) */}
{!isPast && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px', borderRadius: 99,
fontSize: 12, fontWeight: 700, color: cfg.textColor, background: `${cfg.dotColor}15`,
border: `1.5px solid ${cfg.dotColor}30`, whiteSpace: 'nowrap', alignSelf: 'flex-start',
}}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: cfg.dotColor }} />
نوبت جدید
</span>
)}
</div>
);
}
function OccupiedCard({
appointment: a, queryKey, onView,
}: {
appointment: Appointment; queryKey: unknown[]; onView: (a: Appointment) => void;
}) {
const cfg = turnStatusConfig(a.status);
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<div
onClick={() => onView(a)}
style={{
background: cfg.bgColor, border: `1px solid ${cfg.borderColor}`, borderRadius: 8,
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 14, cursor: 'pointer', minHeight: 88,
}}
>
<InnerTimes start={a.appointment_time} end={a.end_time} color={cfg.dotColor} />
{/* اطلاعات بیمار */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<UserIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{a.patient_name || '—'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ fontSize: 12.5, color: 'var(--text-2)', direction: 'ltr' }}>{a.patient_mobile}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<DocumentTextIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ fontSize: 11.5, color: 'var(--text-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
سرویس: {a.service_item?.name || '—'}
</span>
</div>
</div>
{/* وضعیت + عملیات (کلیک روی این ناحیه نباید کارت را باز کند) */}
<div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', alignItems: 'center', gap: 8, alignSelf: 'flex-start', flexShrink: 0 }}>
{a.status === 'pending' && (
<>
<button type="button" className="btn primary sm" onClick={() => setConfirmOpen(true)}>
قطعی کردن نوبت
</button>
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={a.uuid}
onClose={() => setConfirmOpen(false)}
queryKey={queryKey}
/>
</>
)}
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
</div>
</div>
);
}
/**
* چرا این روز اسلاتی ندارد از `empty_reason` پاسخ appointment-slots.
* خالیبودن لزوماً تعطیلی نیست.
*/
const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
no_schedule: { title: 'برنامهٔ نوبت‌دهی ثبت نشده', hint: 'برای این محل هنوز برنامهٔ کاری تعریف نشده است' },
holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' },
day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' },
outside_window: { title: 'خارج از بازهٔ نوبت‌دهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' },
};
export default function TurnsTimeline({
slots, loading, queryKey, onView, onBook, emptyReason, errorMessage,
}: {
slots: TimelineSlot[];
emptyReason?: string | null;
errorMessage?: string | null;
loading: boolean;
queryKey: unknown[];
onView: (a: Appointment) => void;
onBook: (s: TimelineSlot) => void;
}) {
const activeRef = useRef<HTMLDivElement | null>(null);
const now = Math.floor(Date.now() / 1000);
const activeIndex = slots.findIndex(s => s.appointment && s.end >= now);
useEffect(() => {
if (!activeRef.current) return;
const el = activeRef.current;
const top = el.getBoundingClientRect().top + window.scrollY - window.innerHeight * 0.4;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}, [activeIndex]);
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (errorMessage) {
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--danger)' }}>خطا در دریافت برنامهٔ این روز</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{errorMessage}</div>
</div>
);
}
if (!slots.length) {
// «شیفت کاری ندارد» فقط وقتی که backend صریحاً day_off گفته باشد؛
// دلیل ناشناخته/غایب نباید به تعطیلی تفسیر شود.
const reason = EMPTY_REASON_TEXT[emptyReason ?? '']
?? { title: 'برنامهٔ این روز در دسترس نیست', hint: 'اطلاعات برنامهٔ کاری برای این روز دریافت نشد' };
return (
<div style={{ padding: 40, textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>{reason.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>{reason.hint}</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center', padding: '4px 0' }}>
{slots.map((slot, i) => {
const color = slot.appointment ? turnStatusConfig(slot.appointment.status).dotColor : EMPTY_SLOT_CONFIG.dotColor;
return (
<div
key={`${slot.start}-${i}`}
ref={i === activeIndex ? activeRef : null}
style={{ display: 'flex', flexDirection: 'row-reverse', gap: 16, position: 'relative', width: '100%', maxWidth: ROW_MAX }}
>
<OuterMarker color={color} time={slot.start_time} showLine={i < slots.length - 1} />
<div style={{ flex: 1, minWidth: 0 }}>
{slot.appointment
? <OccupiedCard appointment={slot.appointment} queryKey={queryKey} onView={onView} />
: <EmptyCard slot={slot} onBook={onBook} />}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,51 @@
/**
* سوییچ کشویی «نمایش جدولی / زمانبندی» بازسازی `TurnsViewModeToggle.jsx` طرح
* tauri (کادر ۱۹۳×۴۸، پسزمینهٔ لغزنده، متن فعال #5559ce).
*/
export type TurnsViewMode = 'table' | 'timeline';
export default function TurnsViewToggle({
viewMode, onChange,
}: {
viewMode: TurnsViewMode;
onChange: (m: TurnsViewMode) => void;
}) {
const activeText = 'var(--primary)';
const idleText = 'var(--text-3)';
return (
<div style={{
position: 'relative', width: 193, height: 44, display: 'flex', overflow: 'hidden',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)',
}}>
{/* پس‌زمینهٔ لغزنده */}
<div style={{
position: 'absolute', top: 0, right: 0, height: '100%', width: '50%',
background: 'var(--primary-soft)', transition: 'transform .3s var(--ease)',
transform: viewMode === 'table' ? 'translateX(100%)' : 'translateX(0%)',
}} />
<button
type="button"
onClick={() => onChange('table')}
style={{
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: viewMode === 'table' ? activeText : idleText,
}}
>
نمایش جدولی
</button>
<div style={{ position: 'relative', zIndex: 1, width: 1, height: '100%', background: 'var(--border)' }} />
<button
type="button"
onClick={() => onChange('timeline')}
style={{
position: 'relative', zIndex: 1, width: '50%', height: '100%', border: 'none',
background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 500, color: viewMode === 'timeline' ? activeText : idleText,
}}
>
زمانبندی
</button>
</div>
);
}
@@ -0,0 +1,98 @@
/**
* پیکربندی وضعیت نوبتها رنگها عیناً از طرح tauri (`clinic-pro-tauri`
* `src/components/turns/Timeline.jsx` `statusConfig`) برداشته شده و به وضعیتهای
* واقعی بکاند (Appointment::STATUS_*) نگاشت شدهاند. لیبلها فارسی مطابق همان طرح.
*/
export interface TurnStatusConfig {
label: string;
bgColor: string;
darkBgColor: string;
borderColor: string;
darkBorderColor: string;
dotColor: string;
textColor: string;
darkTextColor: string;
}
// وضعیت‌های بک‌اند → رنگ طرح tauri.
const STATUS: Record<string, TurnStatusConfig> = {
// pending = «ثبت شده» (tauri: registered)
pending: {
label: 'ثبت شده',
bgColor: '#E3F2FD', darkBgColor: 'rgba(85, 89, 206, 0.18)',
borderColor: '#2196F3', darkBorderColor: 'rgba(199, 206, 244, 0.42)',
dotColor: '#2196F3', textColor: '#2196F3', darkTextColor: '#C7CEF4',
},
// confirmed = «قطعی شده» (tauri: finalized)
confirmed: {
label: 'قطعی شده',
bgColor: '#E0F2F1', darkBgColor: 'rgba(25, 191, 211, 0.14)',
borderColor: '#009688', darkBorderColor: 'rgba(25, 191, 211, 0.44)',
dotColor: '#009688', textColor: '#009688', darkTextColor: '#19BFD3',
},
// following_up = «در حال پیگیری» (tauri: in_progress)
following_up: {
label: 'در حال پیگیری',
bgColor: '#FFF3E0', darkBgColor: 'rgba(241, 119, 50, 0.16)',
borderColor: '#FF9800', darkBorderColor: 'rgba(241, 119, 50, 0.46)',
dotColor: '#FF9800', textColor: '#d87f00', darkTextColor: '#F17732',
},
// salon = «سالن»
salon: {
label: 'سالن',
bgColor: '#F3E5F5', darkBgColor: 'rgba(199, 206, 244, 0.16)',
borderColor: '#9C27B0', darkBorderColor: 'rgba(199, 206, 244, 0.42)',
dotColor: '#9C27B0', textColor: '#9C27B0', darkTextColor: '#C7CEF4',
},
// completed = «ویزیت شده» (tauri: success)
completed: {
label: 'ویزیت شده',
bgColor: '#E8F5E9', darkBgColor: 'rgba(34, 197, 94, 0.14)',
borderColor: '#4CAF50', darkBorderColor: 'rgba(34, 197, 94, 0.42)',
dotColor: '#4CAF50', textColor: '#0d9f43', darkTextColor: '#4ADE80',
},
// cancel variants = «لغو شده» (tauri: failed)
cancelled_by_doctor: {
label: 'لغو شده',
bgColor: '#fde7e9', darkBgColor: 'rgba(255, 84, 80, 0.16)',
borderColor: '#ee5c6d', darkBorderColor: 'rgba(255, 84, 80, 0.44)',
dotColor: '#ee5c6d', textColor: '#ee5c6d', darkTextColor: '#FF7A76',
},
cancelled_by_user: {
label: 'لغو توسط بیمار',
bgColor: '#fde7e9', darkBgColor: 'rgba(255, 84, 80, 0.16)',
borderColor: '#ee5c6d', darkBorderColor: 'rgba(255, 84, 80, 0.44)',
dotColor: '#ee5c6d', textColor: '#ee5c6d', darkTextColor: '#FF7A76',
},
// no_show / expired = خنثی (tauri: pending amber-ish → اینجا خاکستری)
no_show: {
label: 'غیبت',
bgColor: '#f4f4f5', darkBgColor: 'rgba(160, 160, 160, 0.14)',
borderColor: '#9E9E9E', darkBorderColor: 'rgba(160, 160, 160, 0.4)',
dotColor: '#9E9E9E', textColor: '#757575', darkTextColor: '#A1A1A1',
},
expired: {
label: 'منقضی',
bgColor: '#f4f4f5', darkBgColor: 'rgba(160, 160, 160, 0.14)',
borderColor: '#9E9E9E', darkBorderColor: 'rgba(160, 160, 160, 0.4)',
dotColor: '#9E9E9E', textColor: '#757575', darkTextColor: '#A1A1A1',
},
};
// اسلات خالی (نوبت جدید) — tauri: empty.
export const EMPTY_SLOT_CONFIG: TurnStatusConfig = {
label: 'نوبت جدید',
bgColor: '#E3F2FD', darkBgColor: 'rgba(85, 89, 206, 0.16)',
borderColor: '#2196F3', darkBorderColor: 'rgba(199, 206, 244, 0.38)',
dotColor: '#2196F3', textColor: '#5559ce', darkTextColor: '#C7CEF4',
};
export function turnStatusConfig(status: string): TurnStatusConfig {
return STATUS[status] ?? EMPTY_SLOT_CONFIG;
}
/** وضعیت‌هایی که نوبت را «لغوشده» می‌کنند (برای منطق تایم‌لاین/آمار). */
export const CANCELLED_STATUSES = new Set([
'cancelled_by_doctor', 'cancelled_by_user', 'cancelled_by_admin',
'auto_cancel_unpaid', 'no_show', 'expired',
]);
@@ -0,0 +1,12 @@
import type { Appointment } from '../../types';
/** یک اسلات زمانی در نمای زمانبندی — همان مدلِ merge‌شدهٔ اسلات + نوبت. */
export interface TimelineSlot {
start: number;
end: number;
start_time: string;
end_time: string;
is_available: boolean;
appointment: Appointment | null;
cancelled_appointment: Appointment | null;
}
@@ -0,0 +1,171 @@
/**
* «لیست نوبتهای جدید» داشبورد پزشک، با فیلتر و صفحهبندی سمت سرور.
*
* چرا endpoint داشبورد استفاده نمیشود: `/api/v1/dashboard/doctor` فقط ۱۰ نوبتِ
* امروز را برمیگرداند و `version` ندارد، پس نه فیلتر معنا میدهد نه تغییر وضعیت.
* بهجای ساخت endpoint جدید، `/api/v1/appointments/doctor/{uuid}` توسعه داده شد.
*/
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
import Pagination from '../ui/Pagination';
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
const PER_PAGE = 20;
/** وضعیت‌هایی که «هنوز ویزیت نشده» محسوب می‌شوند — فیلتر پیش‌فرض. */
export const NOT_VISITED_STATUSES = ['pending', 'confirmed'];
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: 'ویزیت‌نشده‌ها: ثبت شده + قطعی شده (پیش‌فرض)' },
{ value: 'pending', label: 'ثبت شده' },
{ value: 'confirmed', label: 'قطعی شده' },
{ value: 'following_up', label: 'در حال پیگیری' },
{ value: 'salon', label: 'سالن' },
{ value: 'completed', label: 'ویزیت شده' },
{ value: 'cancelled_by_doctor', label: 'لغو شده' },
{ value: 'cancelled_by_user', label: 'لغو توسط بیمار' },
{ value: 'no_show', label: 'غیبت' },
{ value: 'expired', label: 'منقضی شده' },
];
interface ServiceOption { uuid: string; name?: string }
/** YYYY-MM-DD میلادی → تایم‌استمپ ثانیه‌ای در ابتدا/انتهای همان روز محلی. */
function dayBound(iso: string, edge: 'start' | 'end'): number | null {
if (!iso) return null;
const [y, m, d] = iso.split('-').map(Number);
if (!y || !m || !d) return null;
const dt = edge === 'start' ? new Date(y, m - 1, d, 0, 0, 0) : new Date(y, m - 1, d, 23, 59, 59);
return Math.floor(dt.getTime() / 1000);
}
interface ApiAppointment {
uuid: string;
status: string;
version: number;
slot_start: number;
slot_end?: number | null;
patient_name?: string | null;
patient_mobile?: string | null;
doctor?: { name?: string | null } | null;
user?: { mobile?: string | null } | null;
service_item?: { name?: string | null } | null;
}
export default function DoctorAppointmentsPanel({ doctorUuid, clinicUuid }: {
doctorUuid?: string | null;
clinicUuid?: string | null;
}) {
const [status, setStatus] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [q, setQ] = useState('');
const [service, setService] = useState('');
const [page, setPage] = useState(1);
const servicesQ = useQuery<{ data?: ServiceOption[] }>({
queryKey: ['service-sections'],
queryFn: () => api.get('/api/v1/service-sections'),
});
const params = useMemo(() => {
const p = new URLSearchParams();
// فیلتر پیش‌فرض: هر نوبتی که هنوز ویزیت یا لغو نشده.
// براکت لازم است تا Symfony پارامتر را آرایه ببیند، نه رشته.
(status ? [status] : NOT_VISITED_STATUSES).forEach(s => p.append('statuses[]', s));
const f = dayBound(from, 'start');
const t = dayBound(to, 'end');
if (f !== null) p.set('from', String(f));
if (t !== null) p.set('to', String(t));
if (q.trim()) p.set('q', q.trim());
if (service) p.set('service_uuid', service);
if (clinicUuid) p.set('clinic_uuid', clinicUuid);
p.set('page', String(page));
p.set('limit', String(PER_PAGE));
return p.toString();
}, [status, from, to, q, service, clinicUuid, page]);
const queryKey = ['doctor-appointments', doctorUuid, params];
const listQ = useQuery<{ data?: ApiAppointment[]; meta?: { totalRecords?: number } }>({
queryKey,
queryFn: () => api.get(`/api/v1/appointments/doctor/${doctorUuid}?${params}`),
enabled: !!doctorUuid,
staleTime: 30_000,
});
const rows: ApptRow[] = useMemo(
() => (listQ.data?.data ?? []).map(a => ({
uuid: a.uuid,
patient_name: a.patient_name ?? null,
patient_mobile: a.patient_mobile ?? a.user?.mobile ?? null,
doctor_name: a.doctor?.name ?? null,
service_name: a.service_item?.name ?? null,
slot_start: a.slot_start,
slot_end: a.slot_end ?? null,
status: a.status,
version: a.version,
})),
[listQ.data],
);
const total = listQ.data?.meta?.totalRecords ?? 0;
/** هر تغییر فیلتر صفحه را به اول برمی‌گرداند تا کاربر روی صفحهٔ خالی نماند. */
const onFilter = <T,>(setter: (v: T) => void) => (v: T) => { setter(v); setPage(1); };
return (
<>
<div className="flex flex-wrap gap-[10px] items-end mb-[14px]">
<div style={{ minWidth: 170 }}>
<SearchableSelect
value={status}
onChange={v => onFilter(setStatus)(v == null ? '' : String(v))}
options={STATUS_OPTIONS}
placeholder="وضعیت"
/>
</div>
<div style={{ minWidth: 150 }}>
<PersianDateInput value={from} onChange={onFilter(setFrom)} placeholder="از تاریخ" />
</div>
<div style={{ minWidth: 150 }}>
<PersianDateInput value={to} onChange={onFilter(setTo)} placeholder="تا تاریخ" />
</div>
<div style={{ minWidth: 170 }}>
<SearchableSelect
value={service}
onChange={v => onFilter(setService)(v == null ? '' : String(v))}
options={[
{ value: '', label: 'همهٔ سرویس‌ها' },
...(servicesQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.name ?? '—' })),
]}
placeholder="سرویس"
/>
</div>
<input
className="input"
style={{ minWidth: 190 }}
value={q}
onChange={e => onFilter(setQ)(e.target.value)}
placeholder="نام یا شماره تماس بیمار"
/>
</div>
<NewAppointmentsTable
rows={rows}
loading={listQ.isLoading}
queryKey={queryKey}
emptyText="نوبتی با این فیلترها یافت نشد"
/>
{total > PER_PAGE && (
<div className="mt-[14px]">
<Pagination page={page} total={total} limit={PER_PAGE} onPageChange={setPage} />
</div>
)}
</>
);
}
@@ -0,0 +1,136 @@
/**
* «لیست نوبتهای جدید» table ported from clinic-pro-tauri
* `src/components/dashboard/list/` (CustomTable + DetailT + Status).
*
* Source data was static mock; here it is fed by the real dashboard API
* (`/api/v1/dashboard/{clinic,doctor}` `today_appointments`) or, when a
* `queryKey` is supplied, by the filtered doctor list endpoint. Status is
* editable in place only when the row carries a `version` (optimistic lock)
* and the parent passes the query key to invalidate.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
export interface ApptRow {
uuid: string;
patient_name: string | null;
patient_mobile?: string | null;
doctor_name?: string | null;
service_name?: string | null;
slot_start: number;
slot_end?: number | null;
status: string;
/** لازم برای قفل خوش‌بینانهٔ PATCH وضعیت؛ نبودنش یعنی وضعیت فقط‌خواندنی است. */
version?: number;
}
/**
* روزِ محلیِ نوبت (YYYY-MM-DD) برای دیپلینک «مشاهده» به همان تاریخ در لیست نوبتها.
* همان قراردادی که AppointmentDetailPage/AppointmentsPage استفاده میکنند.
*/
const isoDay = (ts?: number | null): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
function formatTime(ts?: number | null): string {
if (!ts) return '—';
return new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000));
}
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
interface Props {
rows: ApptRow[];
loading?: boolean;
/** کلید کوئری برای invalidate پس از تغییر وضعیت. بدون آن وضعیت فقط نمایش داده می‌شود. */
queryKey?: unknown[];
emptyText?: string;
}
export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Props) {
if (loading) {
return <div className="skeleton h-[180px] rounded-[8px]" />;
}
if (!rows.length) {
return (
<p className="text-center py-[32px] text-[13.5px] text-[#7E7E7E] dark:text-[#A1A1A1]">
{emptyText ?? 'نوبتی برای امروز ثبت نشده'}
</p>
);
}
return (
<div className="w-full overflow-x-auto rounded-[8px] border border-solid border-[#E7E7E7] dark:border-[#35343D]">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#EFEFEF] dark:bg-[#35343D]">
{HEAD.map((h, i) => (
<th
key={h}
className={`text-[#616161] dark:text-[#D7D8ED] text-[14px] font-normal py-[10px] px-[18px] whitespace-nowrap ${i === HEAD.length - 1 ? 'text-center' : 'text-start'}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr
key={r.uuid}
className="border-b border-[#DBDBDB] dark:border-transparent hover:bg-[#f4f5fd] dark:hover:bg-[#2a2c3d] transition-colors"
>
<Cell>{new Intl.NumberFormat('fa-IR').format(idx + 1)}</Cell>
<Cell>{r.patient_name || '—'}</Cell>
<Cell>{r.patient_mobile ? <bdi dir="ltr">{r.patient_mobile}</bdi> : '—'}</Cell>
<Cell><bdi dir="ltr">{formatTime(r.slot_start)}</bdi></Cell>
<Cell><bdi dir="ltr">{formatTime(r.slot_end)}</bdi></Cell>
<Cell>{r.service_name || '—'}</Cell>
<Cell>{r.doctor_name || '—'}</Cell>
<Cell>
{queryKey && r.version != null
? <AppointmentStatusDropdown uuid={r.uuid} currentStatus={r.status} version={r.version} queryKey={queryKey} />
: <StatusPill status={r.status} />}
</Cell>
<td className="py-[10px] px-[18px] text-center">
<Link
to={isoDay(r.slot_start) ? `/admin/appointments?date=${isoDay(r.slot_start)}` : '/admin/appointments'}
className="text-[#5559ce] text-[12px] font-medium hover:underline whitespace-nowrap"
>
مشاهده
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/** نمایش فقط‌خواندنی وضعیت — وقتی version یا queryKey در دسترس نیست. */
function StatusPill({ status }: { status: string }) {
const meta = STATUS_META[status] ?? { label: status, color: '#9ca3af' };
return (
<span
className="inline-flex items-center gap-[5px] px-[10px] py-[3px] rounded-full text-[12px] font-bold whitespace-nowrap"
style={{ background: `${meta.color}15`, border: `1.5px solid ${meta.color}30`, color: meta.color }}
>
<span className="w-[7px] h-[7px] rounded-full shrink-0" style={{ background: meta.color }} />
{meta.label}
</span>
);
}
/** همه‌ی سلول‌ها text-start هستند تا دقیقاً زیر هدر هم‌نامشان بنشینند. */
function Cell({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<td
className={`text-[#616161] dark:text-[#A1A1A1] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap text-start ${className}`}
>
{children}
</td>
);
}
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
const zero: ChartPoint[] = Array.from({ length: 7 }, (_, i) => ({ label: `روز ${i}`, value: 0 }));
const real: ChartPoint[] = [
{ label: '۷ خرداد', value: 15 },
{ label: '۸ خرداد', value: 40 },
{ label: '۹ خرداد', value: 48 },
];
const EMPTY = 'داده‌ای برای نمایش نیست';
describe('TauriBarChart', () => {
it('shows the empty state when every value is zero', () => {
render(<TauriBarChart data={zero} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('shows the empty state when there is no data', () => {
render(<TauriBarChart data={[]} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('renders the chart (x labels, no empty state) with real data', () => {
render(<TauriBarChart data={real} />);
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
expect(screen.getByText('۸ خرداد')).toBeInTheDocument();
});
it('renders one bar per day of a full Jalali month', () => {
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
const { container } = render(<TauriBarChart data={month} />);
expect(container.querySelectorAll('.bg-\\[\\#5559CE\\]')).toHaveLength(31);
});
/**
* A fixed-width bar makes its column's intrinsic min-width 24px, so a 31-day
* month sums past the plot width and the row overflows out of the card
* (bars land on the neighbouring chart). Columns must be shrinkable and the
* bar fluid-but-capped.
*/
it('keeps a dense month inside the card: shrinkable columns, fluid bars', () => {
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
const { container } = render(<TauriBarChart data={month} />);
const bar = container.querySelector('.bg-\\[\\#5559CE\\]') as HTMLElement;
// کسری از ستون، نه تمام آن — وگرنه میله‌ها بدون فاصله به هم می‌چسبند
expect(bar.className).toMatch(/\bw-\[\d+%\]/);
expect(bar.className.split(/\s+/)).not.toContain('w-full');
expect(bar.className).toContain('max-w-[24px]');
// فقط سقف عرض، نه عرض ثابت (max-w-[24px] خودش شامل رشته‌ی w-[24px] است)
expect(bar.className.split(/\s+/)).not.toContain('w-[24px]');
const column = bar.parentElement as HTMLElement;
expect(column.className).toContain('min-w-0');
const plot = container.querySelector('.relative.flex-1') as HTMLElement;
expect(plot.className).toContain('overflow-hidden');
});
/**
* styles.css ships an unlayered legacy `.flex { align-items: center }` that
* beats Tailwind's `items-*` utilities and collapses the plot column to zero
* height. The frame must pin alignment inline, or the chart renders blank.
*/
it('pins flex alignment inline so the legacy global .flex cannot collapse it', () => {
const { container } = render(<TauriBarChart data={real} />);
const frame = container.querySelector('.h-\\[300px\\]') as HTMLElement;
expect(frame.style.alignItems).toBe('stretch');
expect((frame.firstElementChild as HTMLElement).style.alignItems).toBe('stretch');
});
});
describe('TauriLineChart', () => {
it('shows the empty state when every value is zero', () => {
render(<TauriLineChart data={zero} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('shows the empty state with fewer than two points', () => {
render(<TauriLineChart data={[{ label: 'x', value: 5 }]} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('renders the chart with real data', () => {
const { container } = render(<TauriLineChart data={real} />);
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
expect(container.querySelector('svg path')).toBeTruthy();
});
/** سبک «gradient line»: خط از گرادیان افقی رنگ می‌گیرد، نه یک رنگ ثابت. */
it('paints the line with the horizontal gradient and a glow', () => {
const { container } = render(<TauriLineChart data={real} />);
const line = container.querySelectorAll('svg path')[1] as SVGPathElement;
expect(line.getAttribute('stroke')).toBe('url(#tdIncomeStroke)');
expect(line.getAttribute('filter')).toBe('url(#tdIncomeGlow)');
expect(container.querySelector('#tdIncomeStroke')).toBeTruthy();
expect(container.querySelector('#tdIncomeGlow feDropShadow')).toBeTruthy();
});
/** نقطه و تولتیپ برای هر داده ساخته می‌شود و با هاور نمایان می‌شود. */
it('renders a hover marker and value tooltip per point', () => {
const { container } = render(<TauriLineChart data={real} />);
expect(container.querySelectorAll('.td-hit')).toHaveLength(real.length);
expect(container.querySelectorAll('.td-dot')).toHaveLength(real.length);
// مقدارها با ارقام فارسی در تولتیپ
expect(screen.getByText('۴۸')).toBeInTheDocument();
});
});
/**
* ماههای نیامده صفر برمیگردند؛ رسمکردنشان یک خط صاف تا انتهای سال میساخت.
* از آخرین دادهٔ واقعی به بعد باید روند نقطهچین ادامه پیدا کند.
*/
describe('TauriLineChart — ادامهٔ پیش‌بینی', () => {
/** ۴ ماه واقعیِ صعودی + ۸ ماه نیامده (صفر) */
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
label: `ماه ${i + 1}`,
value: i < 4 ? (i + 1) * 10 : 0,
}));
it('بدون actualCount خط پیش‌بینی ندارد (رفتار قبلی حفظ می‌شود)', () => {
const { container } = render(<TauriLineChart data={year} />);
expect(container.querySelector('.td-forecast')).toBeNull();
expect(screen.queryByText('نقطه‌چین: پیش‌بینی')).not.toBeInTheDocument();
});
it('با actualCount ماه‌های باقی‌مانده را نقطه‌چین ادامه می‌دهد', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const forecast = container.querySelector('.td-forecast');
expect(forecast).toBeTruthy();
expect(forecast!.getAttribute('stroke')).toBe('var(--accent)');
expect(screen.getByText('نقطه‌چین: پیش‌بینی')).toBeInTheDocument();
});
it('روند صعودی را ادامه می‌دهد و صفرها را رسم نمی‌کند', () => {
render(<TauriLineChart data={year} actualCount={4} />);
// شیب ۱۰ در ماه، آخرین واقعی ۴۰ ⇒ ماه پنجم ۵۰
expect(screen.getByText('پیش‌بینی: ۵۰')).toBeInTheDocument();
// آخرین ماه: ۴۰ + ۸×۱۰ = ۱۲۰
expect(screen.getByText('پیش‌بینی: ۱۲۰')).toBeInTheDocument();
});
it('پیش‌بینی هرگز منفی نمی‌شود', () => {
const falling: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
label: `ماه ${i + 1}`,
value: i < 3 ? 100 - i * 45 : 0,
}));
const { container } = render(<TauriLineChart data={falling} actualCount={3} />);
const tips = Array.from(container.querySelectorAll('.td-tip'))
.map((el) => el.textContent ?? '')
.filter((t) => t.startsWith('پیش‌بینی'));
expect(tips.length).toBeGreaterThan(0);
expect(tips.some((t) => t.includes('-') || t.includes(''))).toBe(false);
});
it('سال کامل (actualCount برابر طول داده) پیش‌بینی ندارد', () => {
const full: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({ label: `ماه ${i + 1}`, value: 10 + i }));
const { container } = render(<TauriLineChart data={full} actualCount={12} />);
expect(container.querySelector('.td-forecast')).toBeNull();
});
});
@@ -0,0 +1,309 @@
/**
* Dashboard charts bar (تعداد بیماران) + line/area (میزان درآمد).
*
* The source (clinic-pro-tauri) draws these with @mui/x-charts. MUI is not used
* in clinicpro, so they are reproduced with plain DOM + inline SVG, matching the
* source visuals: bars #5559CE, dashed horizontal grid, y-axis ticks #858D9D,
* x-axis labels #7E7E7E. The income line follows the ApexCharts «gradient line»
* look instead gradient stroke, glow, fading area and hover markers drawn
* from the theme tokens so it tracks light/dark mode.
*/
import React from 'react';
export interface ChartPoint {
label: string;
value: number;
}
/** ~5 rounded gridline ticks covering [0, max], top bottom. Deduped so tiny
* integer ranges (e.g. max=1) never render repeated labels. */
function niceTicks(max: number, count = 4): number[] {
const safeMax = max > 0 ? max : 1;
const rawStep = safeMax / count;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
const norm = rawStep / mag;
const niceNorm = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
const step = niceNorm * mag;
const ticks: number[] = [];
for (let i = count; i >= 0; i--) ticks.push(Math.round(step * i));
// collapse duplicate rounded labels (keeps gridlines proportional via ticks[0])
const deduped = ticks.filter((t, i) => i === 0 || t !== ticks[i - 1]);
return deduped.length >= 2 ? deduped : [ticks[0], 0];
}
const faNum = new Intl.NumberFormat('fa-IR');
/** منحنی نرم از میان نقاط (کنترل‌پوینت وسط هر بازه — همان شکل قبلی خط). */
function smoothPath(pts: [number, number][]): string {
return pts
.map((p, i) => {
if (i === 0) return `M${p[0]},${p[1]}`;
const prev = pts[i - 1];
const cx = (prev[0] + p[0]) / 2;
return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`;
})
.join(' ');
}
/**
* ادامهٔ فرضیِ سری برای ماههایی که هنوز نرسیده‌اند: برازش خطی روی حداکثر ۶ نقطهٔ
* آخر (روند اخیر، نه کل سال) و ادامه دادن همان شیب. خروجی هرگز منفی نمیشود.
*
* این یک عدد واقعی نیست نمودار آن را نقطهچین و با برچسب «پیشبینی» نشان میدهد.
*/
function projectTrend(actual: number[], count: number): number[] {
const window = actual.slice(-6);
const n = window.length;
const meanX = (n - 1) / 2;
const meanY = window.reduce((s, v) => s + v, 0) / n;
let num = 0;
let den = 0;
window.forEach((v, i) => {
num += (i - meanX) * (v - meanY);
den += (i - meanX) ** 2;
});
const slope = den === 0 ? 0 : num / den;
const last = actual[actual.length - 1];
return Array.from({ length: count }, (_, k) => Math.max(0, Math.round(last + slope * (k + 1))));
}
/**
* Shared plot frame: left y-axis tick column + dashed gridlines + bottom x-axis
* labels. `render(top, bottom)` receives the plot-area vertical bounds (px kept
* implicit via fl/percentages) and returns the plot content.
*/
function ChartFrame({
ticks,
labels,
yWidth,
children,
}: {
ticks: number[];
labels: string[];
yWidth: number;
children: React.ReactNode;
}) {
// NOTE: styles.css ships an unlayered legacy `.flex { align-items: center }`
// which beats Tailwind's layered `items-*` utilities, collapsing the plot
// column to zero height. Inline `alignItems` is the only reliable override.
return (
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]" style={{ alignItems: 'stretch' }}>
<div className="flex-1 flex min-h-0" style={{ alignItems: 'stretch' }}>
{/* y-axis ticks, aligned to gridlines */}
<div
className="flex flex-col justify-between text-[14px] font-normal text-[#858D9D] text-left pl-[4px] shrink-0"
style={{ width: yWidth, alignItems: 'flex-start' }}
>
{ticks.map((t, i) => (
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
{faNum.format(t)}
</span>
))}
</div>
{/* plot area */}
{/* overflow-hidden keeps a dense series (a 31-day month) inside the card */}
<div className="relative flex-1 min-w-0 overflow-hidden">
{ticks.map((_, i) => (
<div
key={i}
className="absolute left-0 right-0 border-t border-dashed border-[#E7E7E7] dark:border-[#35343D]"
style={{ top: `${(i / (ticks.length - 1)) * 100}%` }}
/>
))}
{children}
</div>
</div>
{/* x-axis labels */}
<div className="flex pt-[8px]" style={{ paddingRight: yWidth, alignItems: 'flex-start' }}>
{labels.map((l, i) => (
<span key={i} className="flex-1 min-w-0 overflow-hidden text-center text-[10px] font-normal text-[#7E7E7E] whitespace-nowrap">
{l}
</span>
))}
</div>
</div>
);
}
/** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */
export function TauriBarChart({ data }: { data: ChartPoint[] }) {
if (!data.length || !data.some((d) => d.value > 0)) {
return <EmptyChart />;
}
const max = Math.max(...data.map((d) => d.value), 1);
const ticks = niceTicks(max);
const top = ticks[0] || 1;
return (
<ChartFrame ticks={ticks} labels={data.map((d) => d.label)} yWidth={42}>
{/* `min-w-0` on every column is required: without it each column's
intrinsic min-width is the bar's own width, so a 31-day month sums to
more than the plot width and the whole row overflows out of the card.
The bar is therefore a fraction of its column (which leaves the gap
between bars) and only capped at the source's 24px. */}
<div className="absolute inset-0 flex items-stretch" style={{ alignItems: 'stretch' }}>
{data.map((d, i) => (
<div key={i} className="flex-1 min-w-0 flex items-end justify-center" style={{ alignItems: 'flex-end' }}>
<div
title={faNum.format(d.value)}
className="w-[60%] max-w-[24px] rounded-t-[3px] bg-[#5559CE]"
style={{
height: `${(d.value / top) * 100}%`,
minHeight: d.value > 0 ? 4 : 0,
animation: `tdgrowcol .9s ${i * 0.06}s cubic-bezier(.22,.61,.36,1) both`,
}}
/>
</div>
))}
</div>
<style>{`@keyframes tdgrowcol{from{height:0}}`}</style>
</ChartFrame>
);
}
/**
* Line + area chart in the ApexCharts «gradient line» style: a smooth curve whose
* stroke runs through a horizontal gradient (--primary --accent), a soft glow
* beneath it, a fading area fill, and markers + tooltip that appear on hover.
*
* Still hand-drawn SVG no charting dependency. The plot is scaled non-uniformly
* (`preserveAspectRatio="none"`), so anything that must stay round (markers) or
* readable (tooltip) lives in an HTML overlay positioned in percentages instead.
*/
export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actualCount?: number }) {
if (data.length < 2 || !data.some((d) => d.value > 0)) {
return <EmptyChart />;
}
// ماه‌های نیامده صفر برمی‌گردند؛ رسم‌کردنشان یک خط صاف زشت تا انتهای سال می‌سازد.
// به‌جای آن، از آخرین دادهٔ واقعی به بعد روند را ادامه می‌دهیم و نقطه‌چین می‌کشیم.
const nActual = Math.min(actualCount ?? data.length, data.length);
const hasForecast = nActual >= 2 && nActual < data.length;
const values = hasForecast
? [...data.slice(0, nActual).map((d) => d.value), ...projectTrend(data.slice(0, nActual).map((d) => d.value), data.length - nActual)]
: data.map((d) => d.value);
const max = Math.max(...values, 1);
const ticks = niceTicks(max);
const top = ticks[0] || 1;
// SVG plot: 0..100 in both axes, non-uniform scaling (path has no text).
const W = 100;
const H = 100;
const stepX = data.length > 1 ? W / (data.length - 1) : W;
const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
// خط پیش‌بینی از آخرین نقطهٔ واقعی شروع می‌شود تا وصله‌ی دو بخش دیده نشود.
const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts);
const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : '';
const areaPts = hasForecast ? pts.slice(0, nActual) : pts;
const area = `${smoothPath(areaPts)} L${areaPts[areaPts.length - 1][0]},${H} L${areaPts[0][0]},${H} Z`;
return (
<ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}>
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
>
<defs>
{/* رنگِ خط در طول محور افقی از برند به اکسنت می‌رود (سبک دموی apex) */}
<linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="var(--primary)" />
<stop offset="55%" stopColor="var(--primary-600)" />
<stop offset="100%" stopColor="var(--accent)" />
</linearGradient>
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.22} />
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0} />
</linearGradient>
<filter id="tdIncomeGlow" x="-20%" y="-40%" width="140%" height="200%">
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="var(--primary)" floodOpacity="0.28" />
</filter>
</defs>
<path d={area} fill="url(#tdIncomeGrad)" />
{hasForecast && (
<path
className="td-forecast"
d={dashed}
fill="none"
stroke="var(--accent)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
/>
)}
<path
d={solid}
fill="none"
stroke="url(#tdIncomeStroke)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
filter="url(#tdIncomeGlow)"
style={{ strokeDasharray: 1200, animation: 'tddraw 1.1s var(--ease) both' }}
/>
</svg>
{hasForecast && (
<span
className="absolute top-0 left-0 rounded-[var(--r-pill)] px-2 py-[3px] text-[10.5px] font-bold"
style={{ background: 'var(--accent-bg)', color: 'var(--accent-600)' }}
>
نقطه‌چین: پیش‌بینی
</span>
)}
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS) */}
<div className="absolute inset-0 flex" style={{ alignItems: 'stretch' }}>
{data.map((p, i) => {
const isForecast = hasForecast && i >= nActual;
return (
<div key={i} className="td-hit relative flex-1 min-w-0">
<span
className="td-dot absolute block rounded-full border-2 border-[var(--surface)]"
style={{
width: 10,
height: 10,
background: isForecast ? 'var(--accent)' : 'var(--primary)',
left: `${(i * stepX / W) * 100}%`,
top: `${(pts[i][1] / H) * 100}%`,
transform: 'translate(-50%, -50%)',
}}
/>
<span
className="td-tip absolute whitespace-nowrap rounded-[var(--r-xs)] px-2 py-1 text-[11px] font-bold"
style={{
left: `${(i * stepX / W) * 100}%`,
top: `${(pts[i][1] / H) * 100}%`,
transform: 'translate(-50%, calc(-100% - 12px))',
background: isForecast ? 'var(--accent)' : 'var(--text)',
color: '#fff',
boxShadow: 'var(--shadow)',
}}
>
{isForecast ? `پیش‌بینی: ${faNum.format(values[i])}` : faNum.format(p.value)}
</span>
</div>
);
})}
</div>
<style>{`
@keyframes tddraw { from { stroke-dashoffset: 1200 } to { stroke-dashoffset: 0 } }
/* نقطه‌چینِ بخش پیش‌بینی — با non-scaling-stroke در محور کشیده نمی‌شود */
.td-forecast { stroke-dasharray: 0.1 7; stroke-linecap: round; }
.td-dot, .td-tip { opacity: 0; transition: opacity .14s var(--ease); pointer-events: none; }
.td-hit:hover .td-dot, .td-hit:hover .td-tip { opacity: 1; }
`}</style>
</ChartFrame>
);
}
function EmptyChart() {
return (
<div className="h-[300px] w-full grid place-items-center text-[13px] text-[#7E7E7E]">
دادهای برای نمایش نیست
</div>
);
}
@@ -0,0 +1,164 @@
/**
* Ported clinic/doctor dashboard view pixel-for-pixel from clinic-pro-tauri
* `src/components/dashboard/index.jsx` (Cards Charts New-appointments list,
* stacked with 24px gaps). Purely presentational; both ClinicDashboard and
* DoctorDashboard feed it their (real-API) data.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { TauriStatCards, type DashboardStats } from './TauriStatCards';
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
import SearchableSelect from '../ui/SearchableSelect';
export interface SelectorOption {
value: number;
label: string;
}
/** dropdown دوره‌ی نمودار — مقدارش داده‌ی نمودار را عوض می‌کند */
function SmSelector({ options, value, onChange }: { options: SelectorOption[]; value: number; onChange: (v: number) => void }) {
return (
<div style={{ minWidth: 110 }}>
<SearchableSelect
options={options}
value={value}
onChange={(v) => onChange(Number(v))}
height={34}
/>
</div>
);
}
function ChartTitle({ text }: { text: string }) {
return <p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">{text}</p>;
}
function ChartCard({
title,
selectorOptions,
selectorValue,
onSelectorChange,
children,
}: {
title: string;
selectorOptions: SelectorOption[];
selectorValue: number;
onSelectorChange: (v: number) => void;
children: React.ReactNode;
}) {
return (
<div className="w-full rounded-[8px] border border-solid border-[#EFEFEF] dark:border-transparent bg-[#FFF] dark:bg-[#222433]">
<div className="flex px-[20px] py-[12px] md:py-[14px] lg:py-[16px] items-center justify-between gap-[12px]">
<ChartTitle text={title} />
<SmSelector options={selectorOptions} value={selectorValue} onChange={onSelectorChange} />
</div>
{children}
</div>
);
}
export const JALALI_MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
const MONTH_OPTIONS: SelectorOption[] = JALALI_MONTHS.map((label, i) => ({ value: i + 1, label }));
/** سال جاری و ۳ سال گذشته‌ی شمسی */
function yearOptions(currentYear: number): SelectorOption[] {
return [0, 1, 2, 3].map((back) => ({
value: currentYear - back,
label: `سال ${new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(currentYear - back)}`,
}));
}
export interface TauriDashboardViewProps {
stats: DashboardStats;
/** «نمودار تعداد بیماران» series (appointments_by_day) */
patientBars: ChartPoint[];
/** «میزان درآمد» series (revenue_by_day) */
incomeLine: ChartPoint[];
appointments: ApptRow[];
/** جایگزین جدول ساده — برای نمایش نسخهٔ فیلتردار/قابل‌ویرایش. */
appointmentsSlot?: React.ReactNode;
loading: boolean;
formatNumber: (n: number) => string;
formatRial: (rial: number) => string;
/** ماه شمسی نمودار بیماران (۱..۱۲) */
patientsMonth: number;
onPatientsMonthChange: (m: number) => void;
/** سال شمسی نمودار درآمد */
revenueYear: number;
onRevenueYearChange: (y: number) => void;
/** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */
currentJalaliYear: number;
/** ماه شمسی جاری (۱..۱۲) — مرز دادهٔ واقعی و پیش‌بینی در نمودار درآمد */
currentJalaliMonth: number;
}
export function TauriDashboardView({
stats,
patientBars,
incomeLine,
appointments,
appointmentsSlot,
loading,
formatNumber,
formatRial,
patientsMonth,
onPatientsMonthChange,
revenueYear,
onRevenueYearChange,
currentJalaliYear,
currentJalaliMonth,
}: TauriDashboardViewProps) {
const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]);
// در سالِ جاری فقط تا ماه جاری داده‌ی واقعی وجود دارد؛ بقیه پیش‌بینی می‌شود.
// سال‌های گذشته کامل‌اند و پیش‌بینی ندارند.
const incomeActualCount = revenueYear === currentJalaliYear ? currentJalaliMonth : undefined;
return (
<div className="flex flex-col items-stretch gap-y-[24px] w-full">
{/* Cards */}
<TauriStatCards stats={stats} formatNumber={formatNumber} formatRial={formatRial} />
{/* Charts grid-2 (1fr 1fr, collapses to 1fr on narrow) for reliable
full-width equal columns, matching the rest of the admin dashboard */}
<div className="grid-2 w-full">
<ChartCard
title="نمودار تعداد بیماران"
selectorOptions={MONTH_OPTIONS}
selectorValue={patientsMonth}
onSelectorChange={onPatientsMonthChange}
>
<TauriBarChart data={patientBars} />
</ChartCard>
<ChartCard
title="میزان درآمد"
selectorOptions={yearOpts}
selectorValue={revenueYear}
onSelectorChange={onRevenueYearChange}
>
<TauriLineChart data={incomeLine} actualCount={incomeActualCount} />
</ChartCard>
</div>
{/* New appointments list */}
<div className="w-full rounded-[8px] border border-solid border-transparent md:border-[#EFEFEF] dark:border-transparent bg-transparent md:bg-[#FFF] md:dark:bg-[#222433]">
<div className="md:pt-[18px] md:px-[24px] md:pb-[20px] flex items-center justify-between">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">لیست نوبتهای جدید</p>
<Link
to="/admin/appointments"
className="flex items-center gap-[4px] p-1 text-[12px] md:text-[14px] text-[#616161] dark:text-[#A1A1A1] font-normal hover:text-[#5559ce]"
>
نوبتها
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M15 6L9 12L15 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
</div>
<div className="px-0 md:px-[24px] pb-[16px]">
{/* والد می‌تواند نسخهٔ فیلتردار را جای جدول ساده بنشاند. */}
{appointmentsSlot ?? <NewAppointmentsTable rows={appointments} loading={loading} />}
</div>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
/**
* Stat cards row ported pixel-for-pixel from clinic-pro-tauri
* `src/components/dashboard/cards/` (index + Card). Exact Tailwind arbitrary
* classes / colors kept; icons are the ported SVGs in `dashboardIcons`.
*/
import React from 'react';
import { UserAddIcon, CardIcon, CardTickIcon, CalendarIcon } from './dashboardIcons';
export interface StatCardModel {
/** background tint of the whole card (source `card_color`) */
cardColor: string;
/** solid background of the round icon bubble (source `icon_color`) */
iconColor: string;
icon: React.ReactNode;
value: React.ReactNode;
label: string;
}
function TauriStatCard({ data }: { data: StatCardModel }) {
return (
<li
className={`px-[8px] md:px-[16px] lg:px-[24px] py-[18px] sm:py-[20px] md:py-[22px] lg:py-[24px] rounded-[8px] flex items-center justify-start gap-[4px] md:gap-[8px] lg:gap-[12px] ${data.cardColor} dark:bg-[#222433]`}
>
<div
className={`p-[8px] w-[calc(18px+8px)] sm:w-[calc(20px+8px)] md:w-[calc(22px+8px)] lg:w-[calc(24px+8px)] min-w-[calc(18px+8px)] sm:min-w-[calc(20px+8px)] md:min-w-[calc(22px+8px)] lg:min-w-[calc(24px+8px)] h-[calc(18px+8px)] sm:h-[calc(20px+8px)] md:h-[calc(22px+8px)] lg:h-[calc(24px+8px)] grid place-items-center rounded-full ${data.iconColor}`}
>
{data.icon}
</div>
<div className="flex flex-col w-full items-start justify-center gap-[8px]">
<p className="text-[#3B3B3B] text-wrap dark:text-[#D7D8ED] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
{data.value}
</p>
<p className="text-[#616161] dark:text-[#A1A1A1] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
{data.label}
</p>
</div>
</li>
);
}
export interface DashboardStats {
/** تعداد کل مراجعین */
totalPatients: number;
/** کل پرداختی‌ها (ریال) */
totalPaymentsRials: number;
/** پرداختی‌های امروز (ریال) */
todayPaymentsRials: number;
/** تعداد نوبت‌های امروز */
todayAppointments: number;
}
/**
* 4-card grid. `formatNumber`/`formatRial` are passed in so this stays a pure
* presentational component (SRP) no coupling to the app's utils.
*/
export function TauriStatCards({
stats,
formatNumber,
formatRial,
}: {
stats: DashboardStats;
formatNumber: (n: number) => string;
formatRial: (rial: number) => string;
}) {
const cards: StatCardModel[] = [
{
cardColor: 'bg-[rgba(241,119,50,0.10)]',
iconColor: 'bg-[#F17732]',
icon: <UserAddIcon />,
value: `${formatNumber(stats.totalPatients)}+`,
label: 'تعداد کل مراجعین',
},
{
cardColor: 'bg-[rgba(0,157,121,0.10)]',
iconColor: 'bg-[#009D79]',
icon: <CardIcon />,
value: formatRial(stats.totalPaymentsRials),
label: 'کل پرداختی‌ها',
},
{
cardColor: 'bg-[rgba(85,89,206,0.08)]',
iconColor: 'bg-[#5559CE]',
icon: <CardTickIcon />,
value: formatRial(stats.todayPaymentsRials),
label: 'پرداختی‌های امروز',
},
{
cardColor: 'bg-[rgba(255,192,81,0.11)]',
iconColor: 'bg-[#FFC051]',
icon: <CalendarIcon />,
value: `${formatNumber(stats.todayAppointments)}+`,
label: 'تعداد نوبت‌های امروز',
},
];
return (
<ul className="grid w-full grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-[16px] sm:gap-[23px] md:gap-[30px] lg:gap-[36px]">
{cards.map((c) => (
<TauriStatCard key={c.label} data={c} />
))}
</ul>
);
}
@@ -0,0 +1,77 @@
/**
* Dashboard card icons ported 1:1 (exact SVG paths) from clinic-pro-tauri
* (`src/assets/icon/{UserAddCD,CardCD,CardTickCD,CalendarCD,ArrowDownBlueP}.jsx`)
* to keep the ported dashboard pixel-identical. Plain SVG, no icon library.
*/
import React from 'react';
const S = {
className: 'w-full h-full',
xmlns: 'http://www.w3.org/2000/svg',
fill: 'none',
} as const;
/** مراجعین — total patients card */
export function UserAddIcon() {
return (
<svg {...S} width="24" height="24" viewBox="0 0 24 24">
<path d="M12 12C14.7614 12 17 9.76142 17 7C17 4.23858 14.7614 2 12 2C9.23858 2 7 4.23858 7 7C7 9.76142 9.23858 12 12 12Z" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3.40991 22C3.40991 18.13 7.25991 15 11.9999 15C12.9599 15 13.8899 15.13 14.7599 15.37" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 18C22 18.32 21.96 18.63 21.88 18.93C21.79 19.33 21.63 19.72 21.42 20.06C20.73 21.22 19.46 22 18 22C16.97 22 16.04 21.61 15.34 20.97C15.04 20.71 14.78 20.4 14.58 20.06C14.21 19.46 14 18.75 14 18C14 16.92 14.43 15.93 15.13 15.21C15.86 14.46 16.88 14 18 14C19.18 14 20.25 14.51 20.97 15.33C21.61 16.04 22 16.98 22 18Z" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19.4897 17.98H16.5098" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18 16.52V19.51" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** کل پرداختی‌ها — total payments card */
export function CardIcon() {
return (
<svg {...S} width="25" height="24" viewBox="0 0 25 24">
<path d="M2.5 8.50488H22.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6.5 16.5049H8.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11 16.5049H15" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6.94 3.50488H18.05C21.61 3.50488 22.5 4.38488 22.5 7.89488V16.1049C22.5 19.6149 21.61 20.4949 18.06 20.4949H6.94C3.39 20.5049 2.5 19.6249 2.5 16.1149V7.89488C2.5 4.38488 3.39 3.50488 6.94 3.50488Z" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** پرداختی‌های امروز — today payments card */
export function CardTickIcon() {
return (
<svg {...S} width="25" height="24" viewBox="0 0 25 24">
<path d="M2.5 8.5H14" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6.5 16.5H8.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11 16.5H15" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22.5 11.03V16.11C22.5 19.62 21.61 20.5 18.06 20.5H6.94C3.39 20.5 2.5 19.62 2.5 16.11V7.89C2.5 4.38 3.39 3.5 6.94 3.5H14" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17 6L18.5 7.5L22.5 3.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** تعداد نوبت‌های امروز — today appointments card */
export function CalendarIcon() {
return (
<svg {...S} width="24" height="24" viewBox="0 0 24 24">
<path d="M8 2V5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M16 2V5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3.5 9.08984H20.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18 23C20.2091 23 22 21.2091 22 19C22 16.7909 20.2091 15 18 15C15.7909 15 14 16.7909 14 19C14 21.2091 15.7909 23 18 23Z" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19.49 19.0498H16.51" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18 17.5898V20.5798" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 8.5V16.36C20.27 15.53 19.2 15 18 15C15.79 15 14 16.79 14 19C14 19.75 14.21 20.46 14.58 21.06C14.79 21.42 15.06 21.74 15.37 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.9955 13.7002H12.0045" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.29431 13.7002H8.30329" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.29431 16.7002H8.30329" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** فلش رو به پایین — status chip chevron (source: ArrowDownBlueP) */
export function ChevronDownIcon({ color = '#5559CE' }: { color?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M16.6004 7.4585L11.1671 12.8918C10.5254 13.5335 9.47539 13.5335 8.83372 12.8918L3.40039 7.4585" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

Some files were not shown because too many files have changed in this diff Show More