diff --git a/.claude/prompt/doctor-experience-activity-date.md b/.claude/prompt/doctor-experience-activity-date.md new file mode 100644 index 00000000..59839cfb --- /dev/null +++ b/.claude/prompt/doctor-experience-activity-date.md @@ -0,0 +1,209 @@ +# افزودن فیلد «تاریخ شروع فعالیت» (سال تجربه) در پنل ادمین با تقویم شمسی + +## پروژه + +`clinicpro` (Admin SPA + کمی backend/docs). سایت عمومی `nobat724_front` تغییری لازم ندارد (توضیح در «نکات مهم»). + +## زمینه + +هر پزشک باید «سال تجربه» داشته باشد. این عدد در backend از فیلد `activity_time` (تاریخ شروع کار، به‌صورت Unix timestamp) محاسبه می‌شود و همه‌چیزِ backend و سایت عمومی از قبل آماده است: + +- `Doctor::$activityTime` (ستون `activity_time`, `integer`, nullable) + `getExperience()` که سال تجربه را حساب می‌کند. +- `toDetailArray()` هم `experience` (عدد سال) و هم `activity_time` (رشتهٔ timestamp) را برمی‌گرداند. +- endpointهای `POST /api/v1/doctor` و `PATCH /api/v1/doctor/{uuid}` کلید `activity_time` را در بدنه می‌پذیرند (`hydrate...` → `setActivityTime((int) $data['activity_time'])`). +- سایت عمومی در `nobat724_front/components/doctor/detailDoctor/Title.js` از قبل `{doctor?.experience} سال تجربه` را نشان می‌دهد. + +مشکل این است که **پنل ادمین (Admin SPA) هیچ فیلدی برای ست‌کردن این تاریخ ندارد**، پس `activity_time` همیشه `null` می‌ماند، `getExperience()` صفر برمی‌گرداند و در صفحهٔ عمومی «۰ سال تجربه» نمایش داده می‌شود (مثال: `/doctor/fbc11068-dd01-4c0c-9dab-ab86c38061ca`). + +## مشکل / هدف + +در فرم‌های پزشک در Admin SPA یک فیلد «تاریخ شروع فعالیت» با **تقویم شمسی** اضافه شود که ترتیب انتخاب آن **سال → ماه → روز** باشد. این کامپوننت از قبل در پروژه وجود دارد: `PersianDatePicker` با prop `enableYearPicker`. مقدار انتخاب‌شده باید به Unix timestamp (ثانیه) تبدیل و در کلید `activity_time` به API ارسال شود؛ و هنگام ویرایش، `activity_time` موجود باید به تاریخ برای نمایش در picker تبدیل شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/assets/admin/pages/DoctorDetailPage.tsx` | صفحهٔ اصلی ویرایش پزشک (هم ادمین هم پروفایل خود پزشک via `DoctorProfilePage`) — فرم ویرایش اینجاست | +| `clinicpro/assets/admin/pages/DoctorFormPage.tsx` | فرم «افزودن پزشک جدید» (POST) | +| `clinicpro/assets/admin/components/ui/PersianDatePicker.tsx` | تقویم شمسی موجود؛ prop `enableYearPicker` = ترتیب سال→ماه→روز | +| `clinicpro/assets/admin/lib/utils.ts` | `toDate()`، `toGregorianDate()`، `formatDate()` برای تبدیل تاریخ | +| `clinicpro/src/Doctor/Controller/DoctorController.php` | annotationهای `OA\Property` بدنهٔ create/update (فاقد `activity_time`) | +| `clinicpro/docs/api/doctor.md` | مستندات request body (فاقد `activity_time`) | + +## وضعیت فعلی + +### backend (آماده — فقط annotation/doc ناقص) + +```php +// src/Doctor/Entity/Doctor.php +public function getExperience(): int +{ + if ($this->activityTime === null) { + return 0; + } + return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600))); +} + +// src/Doctor/Controller/DoctorController.php (hydrate مشترک create/update) +if (array_key_exists('activity_time', $data)) $doctor->setActivityTime((int) $data['activity_time']); +``` + +### کامپوننت تقویم (قرارداد ورودی/خروجی) + +```tsx +// PersianDatePicker.tsx +interface Props { + value: string; // YYYY-MM-DD میلادی + onChange: (v: string) => void; + placeholder?: string; + height?: number; + minWidth?: number; + enableYearPicker?: boolean; // سال→ماه→روز +} +``` + +مقدار picker یک رشتهٔ **میلادی `YYYY-MM-DD`** است (نمایش داخلی‌اش شمسی است). نمونهٔ استفادهٔ موجود در `RepresentationProfilePage.tsx`: + +```tsx + +``` + +### فرم ویرایش پزشک — `DoctorDetailPage.tsx` (وضعیت فعلی، بدون فیلد تاریخ) + +```tsx +// interface (خط ~44) +experience: number; activity_time: string | null; medical_system_code: string | null; + +// zod schema (خط ~2191) +const editSchema = z.object({ + name: z.string().min(2, 'نام حداقل ۲ کاراکتر'), + gender: z.enum(['man', 'woman']).optional().or(z.literal('')), + degree: z.string().optional().or(z.literal('')), + medical_system_code: z.string().max(30).optional().or(z.literal('')), + // ... activity_time نیست +}); + +// reset مقادیر هنگام لود (خط ~2383) +reset({ + name: doctor.name, + gender: (doctor.gender as any) ?? '', + degree: doctor.degree ?? '', + medical_system_code: doctor.medical_system_code ?? '', + // ... activity_time نیست +}); + +// ارسال PATCH (خط ~2415) +mutationFn: (body: EditForm) => api.patch>(`/api/v1/doctor/${uuid}`, { + title: body.name, + gender: editGender || undefined, + degree: body.degree || undefined, + medical_system_code: body.medical_system_code || undefined, + info: body.info || undefined, + // ... activity_time نیست +}), + +// JSX فرم — بعد از grid «درجه تحصیلی / کد نظام پزشکی» (خط ~2878) +``` + +### فرم ساخت پزشک — `DoctorFormPage.tsx` (خط ~282) + +```tsx +mutationFn: (values: FormValues) => + api.post>(createDoctorEndpoint, { + // ... + gender: gender || undefined, + degree: values.degree || undefined, + medical_system_code: values.medical_system_code || undefined, + info: values.info || undefined, + // ... activity_time نیست + }), +``` + +## وظایف + +### ۱. افزودن فیلد تاریخ به فرم ویرایش `DoctorDetailPage.tsx` (اصلی) + +state جدا برای تاریخ نگه‌دار (مثل الگوی `editGender`) تا نیازی به دست‌زدن به zod نباشد: + +```tsx +const [editActivityDate, setEditActivityDate] = useState(''); // YYYY-MM-DD میلادی یا '' +``` + +هنگام لود در `useEffect`/`reset` مقدار اولیه را از `activity_time` (ثانیه) بساز: + +```tsx +setEditActivityDate( + doctor.activity_time ? toGregorianDate(toDate(Number(doctor.activity_time))!) : '' +); +``` + +(از `../lib/utils` → `toDate`, `toGregorianDate` را import کن اگر نیستند.) + +در JSX، بعد از grid «درجه تحصیلی / کد نظام پزشکی» (خط ~۲۸۷۸) یک `EditField` اضافه کن: + +```tsx +
+ + + +
+``` + +در `updateMut` کلید `activity_time` را اضافه کن (تبدیل به ثانیه؛ خالی → `null` تا پاک شود): + +```tsx +activity_time: editActivityDate + ? Math.floor(new Date(`${editActivityDate}T12:00:00`).getTime() / 1000) + : null, +``` + +> نکته: backend فقط وقتی کلید `activity_time` در بدنه **باشد** آن را ست می‌کند؛ ارسال `null` باعث `setActivityTime((int) null) = 0` می‌شود. اگر می‌خواهی «پاک‌کردن» واقعی (بازگشت به null) پشتیبانی شود، ببین وظیفهٔ ۳ (backend باید `null` را جدا از عدد مدیریت کند). در غیر این صورت وقتی خالی است اصلاً کلید را نفرست: +> ```tsx +> ...(editActivityDate ? { activity_time: Math.floor(new Date(`${editActivityDate}T12:00:00`).getTime()/1000) } : {}), +> ``` +> رویکرد دوم (نفرستادن هنگام خالی) ساده‌تر و بدون تغییر backend است — همان را استفاده کن مگر پاک‌کردن لازم باشد. + +همچنین (اختیاری ولی مفید): در بخش نمایش، کارت `experience` از قبل هست (`InfoCard ... label="سابقه (سال)"`); می‌توانی زیرش تاریخ شروع را با `formatDate(doctor.activity_time)` نشان دهی. + +### ۲. افزودن همان فیلد به فرم ساخت `DoctorFormPage.tsx` + +- `const [activityDate, setActivityDate] = useState('')` +- در JSX کنار سایر فیلدهای «اطلاعات حرفه‌ای» یک `PersianDatePicker ... enableYearPicker` با همان الگو. +- در بدنهٔ `api.post(createDoctorEndpoint, {...})` هنگام مقدار داشتن، `activity_time` را به ثانیه اضافه کن (همان تبدیل وظیفهٔ ۱). + +### ۳. (فقط اگر «پاک‌کردن» لازم است) اصلاح hydrate در backend + +اگر تصمیم گرفتی ارسال `null` را پشتیبانی کنی، در `DoctorController` جایی که `activity_time` hydrate می‌شود مقدار `null` را جدا مدیریت کن تا به `0` تبدیل نشود: + +```php +if (array_key_exists('activity_time', $data)) { + $doctor->setActivityTime($data['activity_time'] !== null ? (int) $data['activity_time'] : null); +} +``` + +اگر رویکرد «نفرستادن هنگام خالی» را انتخاب کردی، این وظیفه لازم نیست. + +### ۴. به‌روزرسانی annotation و مستندات API + +- در `DoctorController` به بدنهٔ **هر دو** endpoint (create ~خط ۶۳ و update ~خط ۲۷۱) این property را اضافه کن: + ```php + new OA\Property(property: 'activity_time', type: 'integer', nullable: true, description: 'Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ سال تجربه'), + ``` +- در `clinicpro/docs/api/doctor.md`: به جدول request body هر دو endpoint (POST و PATCH) ردیف `activity_time` (integer, Unix seconds, nullable) را اضافه کن و توضیح بده که `experience` در پاسخ از همین فیلد محاسبه می‌شود. + +## نکات مهم + +- **تقویم موجود را استفاده کن، جدید نساز:** `PersianDatePicker` با `enableYearPicker` دقیقاً ترتیب سال→ماه→روز را می‌دهد (همان که کاربر گفت «قبلاً این تقویم را نوشتی»). از `PersianDateInput` (که native `type=date` میلادی است) استفاده **نکن**. +- **قرارداد مقدار picker میلادی است** (`YYYY-MM-DD`)؛ نمایش داخلی خودش شمسی است. تبدیل به/از Unix timestamp سمت فرم انجام می‌شود. +- **واحد timestamp ثانیه است** (نه میلی‌ثانیه) — `getExperience()` با `time()` (ثانیه) کار می‌کند. حتماً `/1000` بزن. +- برای ثبات در برابر timezone از `T12:00:00` (ظهر) هنگام ساخت `Date` استفاده کن تا با تبدیل شمسی یک‌روز-جابه‌جایی رخ ندهد (الگوی موجود در `utils.toDate`). +- `DoctorProfilePage` صرفاً `` است؛ با اصلاح `DoctorDetailPage` هر دو مسیر (ادمین و پروفایل خود پزشک) پوشش داده می‌شوند. +- **سایت عمومی تغییری لازم ندارد:** `Title.js` از قبل `doctor.experience` را رندر می‌کند؛ به‌محض ست‌شدن `activity_time`، مقدار درست نمایش داده می‌شود. (اختیاری: اگر خواستی وقتی `experience === 0` عبارت «۰ سال تجربه» نمایش داده نشود، این یک تغییر کوچک نمایشی در `nobat724_front` است، خارج از این پرامپت.) +- بعد از تغییر TSX: `ddev exec npx tsc --noEmit --project tsconfig.json` و `ddev exec yarn dev` برای build. +- بعد از تغییر annotation کنترلر: `ddev exec php bin/console cache:clear` و طبق قانون پروژه `docs/api/doctor.md` را در همین session به‌روز کن. diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index b9866f2e..41b82299 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -22,12 +22,13 @@ import 'leaflet/dist/leaflet.css'; import { api, ApiError } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import type { ApiResponse } from '../lib/api'; -import { formatNumber, iranMobileOptionalSchema } from '../lib/utils'; +import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate } from '../lib/utils'; import MobileInput from '../components/ui/MobileInput'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import GlobalSearchableSelect from '../components/ui/SearchableSelect'; +import PersianDatePicker from '../components/ui/PersianDatePicker'; // Fix leaflet default marker icons delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -2338,6 +2339,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil const [editingAddr, setEditingAddr] = useState(null); const [deletingAddrId, setDeletingAddrId] = useState(null); const [editGender, setEditGender] = useState<'man' | 'woman' | ''>(''); + const [editActivityDate, setEditActivityDate] = useState(''); // ── Queries ── @@ -2396,6 +2398,9 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil social_linkedin: doctor.social_media?.linkedin ?? '', }); setEditGender((doctor.gender as any) ?? ''); + setEditActivityDate( + doctor.activity_time ? toGregorianDate(toDate(Number(doctor.activity_time))!) : '' + ); } }, [doctor, reset]); @@ -2419,6 +2424,9 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil medical_system_code: body.medical_system_code || undefined, mobile_number: body.mobile_number || undefined, info: body.info || undefined, + ...(editActivityDate + ? { activity_time: Math.floor(new Date(`${editActivityDate}T12:00:00`).getTime() / 1000) } + : {}), specialties: body.specialties ?? [], doctor_services: body.services ?? [], social_media: { @@ -2877,6 +2885,18 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil +
+ + + +
+