diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 218ef479..60bbb6c6 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -137,9 +137,9 @@ export default function App() { {/* داشبورد — همه نقش‌ها */} } /> - {/* نوبت‌ها — همه نقش‌ها */} - } /> - } /> + {/* نوبت‌ها — همه نقش‌ها به‌جز نماینده */} + } /> + } /> {/* فقط ادمین */} } /> @@ -172,7 +172,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> {/* دکتر / منشی / کلینیک */} diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index d29b858c..e4860e19 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -380,7 +380,6 @@ function buildSections( items: [ { to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" }, { to: "/admin/clinics", icon: BuildingOffice2Icon, label: "کلینیک‌ها" }, - { to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبت‌ها" }, ], }, { diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index b7041336..5b0aa02c 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -629,6 +629,8 @@ export default function AppointmentsPage() { // ── Handle slot click → open booking modal function handleSlotClick(slot: SlotItem) { + // نماینده اجازه‌ی ثبت نوبت ندارد؛ صفحه برای او فقط مشاهده است. + if (isRepresentation) return; const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; setBookingHint(false); setBookingSlot({ @@ -677,19 +679,21 @@ export default function AppointmentsPage() { padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', }}> - {/* New appointment button */} - + {/* New appointment button — نماینده اجازه‌ی ثبت ندارد */} + {!isRepresentation && ( + + )} {/* View toggle */}
) : ( <> - {bookingHint && ( + {bookingHint && !isRepresentation && (
void; onDelete: () => void; +function AddressCard({ addr, onEdit, onDelete, readOnly = false }: { + addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean; }) { return (
@@ -1004,16 +1004,18 @@ function AddressCard({ addr, onEdit, onDelete }: { )}
-
- - -
+ {!readOnly && ( +
+ + +
+ )}
); } @@ -1213,7 +1215,7 @@ function SessionEditor({ session, onChange, onRemove, addresses }: { // ── Weekly Schedule Tab ──────────────────────────────────────────────────── -function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) { +function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) { const qc = useQueryClient(); const [scheduleMap, setScheduleMap] = useState(EMPTY_NEW_SCHEDULE); const [scheduleUuid, setScheduleUuid] = useState(null); @@ -1315,6 +1317,48 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr ); + // نمای فقط‌خواندنی برای نماینده: برنامه‌ی هفتگی به‌صورت متن، بدون فرم. + if (readOnly) { + if (scheduleQ.isLoading) { + return
{Array.from({ length: 4 }).map((_, i) =>
)}
; + } + return ( +
+ {totalSlots > 0 && ( +
+ + + مجموع {totalSlots} نوبت در هفته + +
+ )} + {SCHEDULE_DAYS.map(day => { + const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active); + const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0); + return ( +
+ {day.label} +
+ {activeSessions.length > 0 ? activeSessions.map((s, i) => ( + + {s.start_time} – {s.end_time} + + )) : ( + تعطیل + )} +
+ {daySlots > 0 && ( + + {daySlots} نوبت + + )} +
+ ); + })} +
+ ); + } + return (
{/* ─ خلاصه کل هفته */} @@ -1452,11 +1496,13 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr {hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}

)} - + {!readOnly && ( + + )}
); @@ -1604,7 +1650,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre // ── Date Overrides Tab ───────────────────────────────────────────────────── -function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) { +function DateOverridesTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; addresses: AddressData[]; readOnly?: boolean }) { const qc = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -1643,12 +1689,14 @@ function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addre return (
-
- -
+ {!readOnly && ( +
+ +
+ )} {overrides.length === 0 ? (
@@ -1673,16 +1721,18 @@ function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addre
{ov.reason &&

{ov.reason}

}
-
- - -
+ {!readOnly && ( +
+ + +
+ )} ))} @@ -1772,7 +1822,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: { // ── Holidays Tab ─────────────────────────────────────────────────────────── -function HolidaysTab({ doctorUuid }: { doctorUuid: string }) { +function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) { const qc = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -1805,12 +1855,14 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) { return (
-
- -
+ {!readOnly && ( +
+ +
+ )} {holidays.length === 0 ? (
@@ -1845,20 +1897,22 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
{h.reason &&

{h.reason}

}
-
- - - -
+ {!readOnly && ( +
+ + + +
+ )} ); })} @@ -1882,7 +1936,7 @@ const SCHEDULE_TABS = [ { id: 'holidays' as const, label: 'تعطیلات' }, ]; -function ScheduleSection({ doctorUuid }: { doctorUuid: string }) { +function ScheduleSection({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) { const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); const locationsQ = useQuery({ @@ -1908,9 +1962,9 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) { ))} - {tab === 'weekly' && } - {tab === 'overrides' && } - {tab === 'holidays' && } + {tab === 'weekly' && } + {tab === 'overrides' && } + {tab === 'holidays' && } ); } @@ -2272,6 +2326,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil const dbUuid = useAuthStore(s => s.dbUuid); const doctorUuid = useAuthStore(s => s.doctorUuid); const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid; + // نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست. + const isReadOnly = primaryRole === 'representation'; const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1'); const [deleteOpen, setDeleteOpen] = useState(false); @@ -2503,8 +2559,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
- {primaryRole !== 'clinic' && ( + onUpload={primaryRole !== 'clinic' && !isReadOnly ? handleImageUpload : undefined} uploading={uploadingImg} /> + {primaryRole !== 'clinic' && !isReadOnly && ( کلیک برای تغییر عکس )}
@@ -2545,14 +2601,14 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
- {primaryRole !== 'clinic' && ( + {primaryRole !== 'clinic' && !isReadOnly && ( )} {/* Toggle active / Delete — فقط ادمین */} - {!isOwnProfile && primaryRole !== 'clinic' && ( + {!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && ( <> + {!isReadOnly && ( + + )}
{addresses.length > 0 ? (
{addresses.map(addr => ( - { setEditingAddr(addr); setAddrModalOpen(true); }} onDelete={() => setDeletingAddrId(addr.id)} /> @@ -2638,15 +2696,17 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil

هنوز آدرسی ثبت نشده است

- + {!isReadOnly && ( + + )}
)}
- {uuid && } + {uuid && } {isOwnProfile && } @@ -2686,9 +2746,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil :

تخصصی ثبت نشده

} - + {!isReadOnly && ( + + )}
@@ -2703,9 +2765,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
:

خدمتی ثبت نشده

} - + {!isReadOnly && ( + + )}
diff --git a/docs/api/representation.md b/docs/api/representation.md index 705fb61d..431e0f1d 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -268,7 +268,7 @@ Get yearly earnings dashboard for a representation. ### POST `/api/v1/representation/doctor` -افزودن پزشک توسط نماینده. `representation_id` پزشک به‌صورت خودکار روی نماینده‌ی کاربر جاری ست می‌شود. +افزودن پزشک توسط نماینده. `representation_id` پزشک به‌صورت خودکار روی نماینده‌ی کاربر جاری ست می‌شود. پس از ثبت موفق، یک پیامک خوش‌آمد (تگ `welcome`) به‌صورت async به موبایل پزشک ارسال می‌شود. #### Request Body ```json @@ -296,7 +296,7 @@ Get yearly earnings dashboard for a representation. ### POST `/api/v1/representation/clinic` -افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نماینده‌ی کاربر جاری ست می‌شود (مثل createDoctor) تا در لیست‌های scoped دیده شود. +افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نماینده‌ی کاربر جاری ست می‌شود (مثل createDoctor) تا در لیست‌های scoped دیده شود. پس از ثبت موفق، یک پیامک خوش‌آمد (تگ `welcome`) به‌صورت async به موبایل مالک کلینیک ارسال می‌شود. #### Request Body ```json diff --git a/docs/api/sms.md b/docs/api/sms.md index 61b38ca8..4ad58e69 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -474,7 +474,9 @@ Updated template with `status: "rejected"`. متن پیامک‌های سیستمی (OTP، پرداخت، دعوت کلینیک، پیش‌ثبت‌نام، تأیید موبایل) از پنل قابل ویرایش است و بر اساس **تگ** کلیددار می‌شود. هر متن placeholderهای مجاز خود را دارد (مثل `{code}`، `{doctor}`، `{date}`). هنگام ارسال، `SmsTextResolver` متنِ ویرایش‌شده‌ی DB را می‌گیرد و placeholderها را جایگزین می‌کند؛ اگر رکوردی نبود به متن پیش‌فرض fallback می‌شود. -> تگ‌ها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت می‌شود.) +> تگ‌ها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`، `welcome`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت می‌شود.) +> +> تگ `welcome`: پیامک خوش‌آمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) به‌صورت async به موبایل پزشک/مالک ارسال می‌شود. متن فعلاً ثابت است (نام + `site_name`)، نه از قالب DB. ### GET `/api/v1/admin/sms/messages` diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index bf7cb391..341246ce 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -33,6 +33,8 @@ class RepresentationActionController extends BaseController private readonly RepresentationRepository $representationRepo, private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo, private readonly \App\Subscription\Service\SubscriptionService $subscriptionService, + private readonly \App\Sms\Service\SmsService $smsService, + private readonly \App\Config\Repository\SiteConfigRepository $configRepo, ) {} #[OA\Get( @@ -133,6 +135,14 @@ class RepresentationActionController extends BaseController $this->em->persist($doctor); $this->em->flush(); + // پیام خوش‌آمد به پزشک + $siteName = $this->configRepo->get('site_name') ?: 'کلینیک‌پرو'; + $this->smsService->dispatchAsync( + $mobile, + sprintf('دکتر %s عزیز، به %s خوش آمدید. پروفایل شما توسط نماینده ثبت شد.', $name, $siteName), + tag: \App\Sms\Entity\SmsLog::TAG_WELCOME, + ); + return $this->success(['uuid' => $doctor->getUuid()], 201); } @@ -200,6 +210,14 @@ class RepresentationActionController extends BaseController $this->em->persist($clinic); $this->em->flush(); + // پیام خوش‌آمد به مالک کلینیک + $siteName = $this->configRepo->get('site_name') ?: 'کلینیک‌پرو'; + $this->smsService->dispatchAsync( + $mobile, + sprintf('کلینیک %s به %s خوش آمد. پروفایل کلینیک شما توسط نماینده ثبت شد.', $name, $siteName), + tag: \App\Sms\Entity\SmsLog::TAG_WELCOME, + ); + return $this->success([ 'uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), diff --git a/src/Sms/Entity/SmsLog.php b/src/Sms/Entity/SmsLog.php index a92735ca..487174c9 100644 --- a/src/Sms/Entity/SmsLog.php +++ b/src/Sms/Entity/SmsLog.php @@ -17,6 +17,7 @@ class SmsLog public const TAG_PRE_REGISTRATION = 'pre_registration'; public const TAG_NOTIFICATION_MOBILE = 'notification_mobile'; public const TAG_USER_TEMPLATE = 'user_template'; + public const TAG_WELCOME = 'welcome'; public const TAGS = [ self::TAG_GLOBAL, @@ -26,6 +27,7 @@ class SmsLog self::TAG_PRE_REGISTRATION, self::TAG_NOTIFICATION_MOBILE, self::TAG_USER_TEMPLATE, + self::TAG_WELCOME, ]; #[ORM\Id]