feat(doctors): add activity start date field with Persian calendar in admin panel

This commit is contained in:
hamed
2026-07-02 11:35:59 +03:30
parent 4565f521c4
commit 0a1e03d0ce
9 changed files with 257 additions and 1 deletions
@@ -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
<PersianDatePicker value={birthDate} onChange={setBirthDate} placeholder="تاریخ تولد" enableYearPicker />
```
### فرم ویرایش پزشک — `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<ApiResponse<any>>(`/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<ApiResponse<{ uuid: string }>>(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
<div style={{ marginTop: 14 }}>
<EditField label="تاریخ شروع فعالیت">
<PersianDatePicker
value={editActivityDate}
onChange={setEditActivityDate}
placeholder="انتخاب تاریخ"
enableYearPicker
minWidth={200}
/>
</EditField>
</div>
```
در `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 isOwnProfile />` است؛ با اصلاح `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 به‌روز کن.
+21 -1
View File
@@ -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<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(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
</EditField>
</div>
<div style={{ marginTop: 14 }}>
<EditField label="تاریخ شروع فعالیت" hint="مبنای محاسبهٔ سال تجربه">
<PersianDatePicker
value={editActivityDate}
onChange={setEditActivityDate}
placeholder="انتخاب تاریخ"
enableYearPicker
minWidth={200}
/>
</EditField>
</div>
<div style={{ marginTop: 14 }}>
<EditField label="بیوگرافی">
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
+17
View File
@@ -14,6 +14,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import SearchableSelect from '../components/ui/SearchableSelect';
import MobileInput from '../components/ui/MobileInput';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import { iranMobileSchema } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
@@ -261,6 +262,7 @@ export default function DoctorFormPage() {
primaryRole === 'representation' ? '/api/v1/representation/doctor' : '/api/v1/admin/doctors';
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
const [gender, setGender] = useState<'man' | 'woman' | ''>('');
const [activityDate, setActivityDate] = useState('');
const specialtiesQ = useQuery({
queryKey: ['specialties-list'],
@@ -286,6 +288,9 @@ export default function DoctorFormPage() {
degree: values.degree || undefined,
medical_system_code: values.medical_system_code || undefined,
info: values.info || undefined,
...(activityDate
? { activity_time: Math.floor(new Date(`${activityDate}T12:00:00`).getTime() / 1000) }
: {}),
specialties: selectedSpecialties,
}),
onSuccess: (res) => {
@@ -369,6 +374,18 @@ export default function DoctorFormPage() {
</Field>
</div>
<div style={{ marginTop: 14 }}>
<Field label="تاریخ شروع فعالیت">
<PersianDatePicker
value={activityDate}
onChange={setActivityDate}
placeholder="انتخاب تاریخ"
enableYearPicker
minWidth={200}
/>
</Field>
</div>
<div style={{ marginTop: 14 }}>
<Field label="بیوگرافی">
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
+3
View File
@@ -295,6 +295,9 @@ Delete a user.
|-------|------|----------|-------------|
| `mobile` | string | ✅ | موبایل ورود؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) — ارقام فارسی/عربی به انگلیسی نرمال می‌شوند |
| `name` | string | ✅ | نام پزشک |
| `gender` / `degree` / `medical_system_code` / `info` | string | ❌ | اطلاعات حرفه‌ای |
| `activity_time` | integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ سال تجربه |
| `specialties` | integer[] | ❌ | آرایهٔ IDهای تخصص |
#### Errors
| Code | HTTP | Description |
+1
View File
@@ -34,6 +34,7 @@ Create a doctor profile for the authenticated user.
| `info` | string | ❌ | Bio/description |
| `specialties` | integer[] | ❌ | Array of specialty IDs |
| `doctor_services` | integer[] | ❌ | Array of doctor service IDs |
| `activity_time` | integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ `experience` (سال تجربه) در پاسخ |
### Response `201`
```json
+1
View File
@@ -349,6 +349,7 @@ Get yearly earnings dashboard for a representation.
| `mobile` | string | ✅ |
| `name` | string | ✅ |
| `gender` / `degree` / `medical_system_code` / `info` | string | ❌ |
| `activity_time` | integer (Unix ts، تاریخ شروع فعالیت) | ❌ |
| `specialties` | integer[] | ❌ |
#### Response `201`
@@ -418,6 +418,7 @@ class AdminApiController extends BaseController
if (!empty($data['medical_system_code'])) $doctor->setMedicalSystemCode($data['medical_system_code']);
if (!empty($data['info'])) $doctor->setInfo($data['info']);
if (!empty($data['mobile_number'])) $doctor->setMobileNumber($data['mobile_number']);
if (!empty($data['activity_time'])) $doctor->setActivityTime((int) $data['activity_time']);
if (!empty($data['specialties']) && is_array($data['specialties'])) {
foreach ($data['specialties'] as $id) {
@@ -61,6 +61,7 @@ class DoctorController extends BaseController
new OA\Property(property: 'medical_system_code', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'activity_time', type: 'integer', nullable: true, description: 'Unix timestamp (seconds) of career start date; basis for years-of-experience'),
new OA\Property(
property: 'specialties',
type: 'array',
@@ -269,6 +270,7 @@ class DoctorController extends BaseController
new OA\Property(property: 'medical_system_code', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'activity_time', type: 'integer', nullable: true, description: 'Unix timestamp (seconds) of career start date; basis for years-of-experience'),
new OA\Property(
property: 'specialties',
type: 'array',
@@ -242,6 +242,7 @@ class RepresentationActionController extends BaseController
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'medical_system_code', type: 'string', nullable: true),
new OA\Property(property: 'activity_time', type: 'integer', nullable: true, description: 'Unix timestamp (seconds) of career start date; basis for years-of-experience'),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
@@ -284,6 +285,7 @@ class RepresentationActionController extends BaseController
if (!empty($data['medical_system_code'])) $doctor->setMedicalSystemCode($data['medical_system_code']);
if (!empty($data['info'])) $doctor->setInfo($data['info']);
if (!empty($data['mobile_number'])) $doctor->setMobileNumber($data['mobile_number']);
if (!empty($data['activity_time'])) $doctor->setActivityTime((int) $data['activity_time']);
if (!empty($data['specialties']) && is_array($data['specialties'])) {
foreach ($data['specialties'] as $id) {