diff --git a/.claude/prompt/fix-invitation-accept.md b/.claude/prompt/fix-invitation-accept.md new file mode 100644 index 00000000..4176ef05 --- /dev/null +++ b/.claude/prompt/fix-invitation-accept.md @@ -0,0 +1,106 @@ +# Fix: پذیرفتن دعوتنامه کلینیک، پزشک را به clinic_doctors اضافه نمیکند + +## مشکل + +وقتی پزشک دعوتنامه کلینیک را میپذیرد (از طریق پنل پزشک یا لینک SMS)، فقط وضعیت دعوتنامه به `accepted` تغییر میکند — اما پزشک به جدول `clinic_doctors` اضافه **نمیشود**. + +## ریشه باگ + +`src/ClinicInvitation/Service/ClinicInvitationService.php` — متد `accept()`: + +```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(); + $this->em->flush(); // ← فقط status عوض میشود، هیچ رابطهای ثبت نمیشود +} +``` + +باید بعد از `accept`، پزشک به `$clinic->getDoctors()` اضافه شود. رابطه در `Clinic::$doctors` (ManyToMany → جدول `clinic_doctors`) تعریف شده است. + +## فایلهای مرتبط + +| فایل | نقش | +|------|-----| +| `src/ClinicInvitation/Service/ClinicInvitationService.php` | متد `accept()` — جای اصلی باگ | +| `src/ClinicInvitation/Entity/ClinicDoctorInvitation.php` | entity دعوتنامه — دارای `getClinic()` و `getDoctor()` | +| `src/Clinic/Entity/Clinic.php` | دارای `getDoctors(): Collection` و رابطه ManyToMany | + +## وضعیت فعلی + +- `ClinicDoctorInvitation::$doctor` وقتی پزشک در سیستم وجود دارد، set میشود (در `invite()`) +- `ClinicDoctorInvitation::$doctor` ممکن است `null` باشد (پزشک هنوز ثبتنام نکرده) +- پذیرش از دو مسیر اتفاق میافتد: + 1. **لینک SMS** → `POST /api/v1/clinic-invitation/{token}/accept` (بدون احراز هویت) + 2. **پنل پزشک** → `POST /api/v1/doctor/invitation/{invUuid}/respond` با `action=accept` + +## وظایف + +### ۱. رفع باگ در `ClinicInvitationService::accept()` + +```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) { + $clinic = $inv->getClinic(); + if (!$clinic->getDoctors()->contains($doctor)) { + $clinic->getDoctors()->add($doctor); + } + } + + $this->em->flush(); +} +``` + +### ۲. بررسی حالت لبهای: پزشک null است + +اگر `$inv->getDoctor()` در زمان accept هنوز null باشد (پزشک بعداً ثبتنام کرده)، باید با موبایل در DoctorRepository جستجو کرد و doctor را set و اضافه کرد: + +```php +if ($doctor === null) { + $doctor = $this->doctorRepo->findOneByMobile($inv->getMobile()); + if ($doctor !== null) { + $inv->setDoctor($doctor); + } +} +``` + +### ۳. تست + +بعد از رفع: + +```bash +ddev exec php -l src/ClinicInvitation/Service/ClinicInvitationService.php +ddev exec php bin/console cache:clear +ddev exec yarn dev +``` + +تست دستی: +- یک دعوتنامه ارسال کن +- پزشک از پنل داشبورد دعوت را بپذیرد +- در صفحه کلینیک بررسی کن که پزشک در لیست پزشکان کلینیک ظاهر شده + +### ۴. مستندات + +فایل `docs/api/clinic-invitation.md` را بهروزرسانی کن: +- اضافه کن: هنگام accept، پزشک به clinic_doctors اضافه میشود +- edge case: اگر doctor هنگام دعوت ثبتنام نکرده بود، هنگام accept با موبایل match میشود + +## نکات مهم + +- **هر دو مسیر** (لینک SMS و پنل پزشک) از همان `$invitationService->accept()` استفاده میکنند — رفع باگ در یک جا کافی است +- `$clinic->getDoctors()->contains($doctor)` را چک کن تا duplicate نشود +- بعد از flush نیازی به migration نیست — جدول `clinic_doctors` از قبل وجود دارد diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 0e73cb92..3f4aada4 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -835,7 +835,13 @@ export default function ClinicDetailPage() { {inv.invited_specialty} )} {inv.doctor && ( - {inv.doctor.name} + )} diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 5fe19b0b..9ab1c5ac 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -1,11 +1,12 @@ import React, { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, - ClockIcon, StarIcon, UserIcon, + ClockIcon, StarIcon, UserIcon, CheckIcon, XMarkIcon, } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { formatNumber, formatRial, formatDateTime } from '../lib/utils'; @@ -626,6 +627,103 @@ function ClinicDashboard() { ); } +// ── Doctor Clinic Invitations Card ─────────────────────────────────────── + +interface ClinicInvitation { + uuid: string; + invited_specialty: string | null; + invited_at: number; + expires_at: number; + clinic: { uuid: string; name: string; logo: string | null }; +} + +function DoctorClinicInvitationsCard() { + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ['doctor-my-invitations'], + queryFn: () => api.get<{ data: { data: ClinicInvitation[] } }>('/api/v1/doctor/invitations'), + staleTime: 30_000, + }); + + const respondMut = useMutation({ + mutationFn: ({ uuid, action }: { uuid: string; action: 'accept' | 'reject' }) => + api.post(`/api/v1/doctor/invitation/${uuid}/respond`, { action }), + onSuccess: (_res, { action }) => { + toast.success(action === 'accept' ? 'دعوتنامه پذیرفته شد' : 'دعوتنامه رد شد'); + qc.invalidateQueries({ queryKey: ['doctor-my-invitations'] }); + qc.invalidateQueries({ queryKey: ['dashboard-doctor'] }); + }, + onError: (err: Error) => toast.error(err.message), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invitations: ClinicInvitation[] = (data?.data as any) ?? []; + + if (!isLoading && invitations.length === 0) return null; + + return ( +