در حال بارگذاری...
;
- if (!slots.length) return
@@ -340,18 +351,21 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
function NewAppointmentModal({
slot, onClose, onSuccess,
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
- const [mobile, setMobile] = useState('');
- const qc = useQueryClient();
+ const [mobile, setMobile] = useState('');
+ const [patientName, setPatientName] = useState('');
const role = useAuthStore(s => s.primaryRole);
- const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment';
+ const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
+
+ const isValid = mobile.length >= 10 && patientName.trim().length >= 2;
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
- doctor_uuid: slot.doctor_uuid,
- slot_start: slot.start,
- slot_end: slot.end,
+ doctor_uuid: slot.doctor_uuid,
+ slot_start: slot.start,
+ slot_end: slot.end,
patient_mobile: mobile,
+ patient_name: patientName.trim(),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
@@ -359,11 +373,20 @@ function NewAppointmentModal({
onClose();
},
onError: (e: any) => {
- const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت';
+ const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
toast.error(msg);
},
});
+ const inputSx: React.CSSProperties = {
+ width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
+ border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
+ boxSizing: 'border-box',
+ };
+ const labelSx: React.CSSProperties = {
+ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6,
+ };
+
return (
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
+
+
+ نام و نام خانوادگی بیمار *
+ setPatientName(e.target.value)}
+ placeholder="مثال: علی محمدی"
+ style={inputSx}
+ autoFocus
+ />
+
+
-
- موبایل بیمار
-
+ شماره موبایل بیمار *
setMobile(e.target.value)}
- placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
- style={{
- width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
- border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
- direction: 'ltr', boxSizing: 'border-box',
- }}
- autoFocus
+ placeholder="مثال: 09123456789"
+ style={{ ...inputSx, direction: 'ltr' }}
/>
+
انصراف
mutation.mutate()}
- disabled={mobile.length < 10 || mutation.isPending}
+ disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? '...' : 'ثبت نوبت'}
@@ -424,7 +454,8 @@ export default function AppointmentsPage() {
const [selectedDate, setSelectedDate] = useState(today);
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState
(isDoctor && dbUuid ? dbUuid : '');
- const [bookingSlot, setBookingSlot] = useState(null);
+ const [bookingSlot, setBookingSlot] = useState(null);
+ const [bookingHint, setBookingHint] = useState(false);
const qc = useQueryClient();
// ── Appointments query
@@ -464,17 +495,22 @@ export default function AppointmentsPage() {
if (viewMode !== 'schedule') return [];
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
const apptByStart = new Map();
- appointments.forEach(a => apptByStart.set(a.slot_start, a));
+ appointments.forEach(a => {
+ const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
+ apptByStart.set(key, a);
+ });
return rawSlots.map((s: any) => {
- const appt = apptByStart.get(s.start) ?? null;
+ const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
+ const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
+ const appt = apptByStart.get(slotStart) ?? null;
return {
- start: s.start,
- end: s.end,
- start_time: s.start_time ?? new Date(s.start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
- end_time: s.end_time ?? new Date(s.end * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
+ start: slotStart,
+ end: slotEnd,
+ start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
+ end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
is_available: !appt,
- appointment: appt,
+ appointment: appt,
};
});
}, [viewMode, slotsQuery.data, appointments]);
@@ -482,6 +518,7 @@ export default function AppointmentsPage() {
// ── Handle slot click → open booking modal
function handleSlotClick(slot: SlotItem) {
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
+ setBookingHint(false);
setBookingSlot({
start: slot.start,
end: slot.end,
@@ -533,8 +570,8 @@ export default function AppointmentsPage() {
className="btn primary sm"
onClick={() => {
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
- const d = doctors.find(x => x.uuid === selectedDoctorUuid);
- setBookingSlot({ start: 0, end: 0, start_time: '', end_time: '', doctor_uuid: selectedDoctorUuid, doctor_name: d?.name ?? '' });
+ setViewMode('schedule');
+ setBookingHint(true);
}}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
@@ -550,7 +587,7 @@ export default function AppointmentsPage() {
{(['table', 'schedule'] as const).map(mode => (
setViewMode(mode)}
+ onClick={() => { setViewMode(mode); if (mode === 'table') setBookingHint(false); }}
style={{
padding: '5px 12px', borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 600,
border: 'none', cursor: 'pointer',
@@ -606,12 +643,31 @@ export default function AppointmentsPage() {
showDoctor={showDoctorCol}
/>
) : (
-
+ <>
+ {bookingHint && (
+
+ 👆
+ روی یک زمان خالی کلیک کنید تا نوبت جدید ثبت شود
+ setBookingHint(false)}
+ style={{ marginRight: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#93c5fd', fontSize: 16 }}
+ >
+ ✕
+
+
+ )}
+
+ >
)}
diff --git a/docs/api/admin.md b/docs/api/admin.md
index ebe9e7a9..4f7e2ade 100644
--- a/docs/api/admin.md
+++ b/docs/api/admin.md
@@ -497,7 +497,7 @@ List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`.
### POST `/api/v1/admin/appointment`
-Create a new appointment for a patient identified by mobile number.
+Create a new appointment for a patient. If no user exists with the given mobile, a new user account is created automatically.
**Permission:** `ROLE_ADMIN`
@@ -508,10 +508,13 @@ Create a new appointment for a patient identified by mobile number.
"slot_start": 1718438400,
"slot_end": 1718439600,
"patient_mobile": "09123456789",
+ "patient_name": "علی محمدی",
"note": "optional note"
}
```
+> `patient_mobile` و `patient_name` هر دو اجباری هستند. اگر کاربری با این شماره موبایل نداشته باشیم، یک کاربر جدید با نقش `ROLE_USER` ساخته میشود.
+
### Response `201`
```json
{
@@ -528,9 +531,8 @@ Create a new appointment for a patient identified by mobile number.
### Error Responses
| Code | HTTP | Description |
|------|------|-------------|
-| `VALIDATION` | 422 | Missing required fields |
+| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name) |
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
-| `USER_NOT_FOUND` | 404 | No user with that mobile number |
| `SLOT_TAKEN` | 409 | Slot already booked |
---
diff --git a/docs/api/appointment.md b/docs/api/appointment.md
index 6f1a8191..99339ffb 100644
--- a/docs/api/appointment.md
+++ b/docs/api/appointment.md
@@ -278,6 +278,49 @@ Updated appointment object.
---
+## POST `/api/v1/my/appointment`
+
+Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
+
+**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
+
+### Request Body
+```json
+{
+ "doctor_uuid": "doctor-uuid",
+ "slot_start": 1718438400,
+ "slot_end": 1718439600,
+ "patient_mobile": "09123456789",
+ "patient_name": "علی محمدی",
+ "note": "optional note"
+}
+```
+
+> اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش `ROLE_USER` ساخته میشود.
+
+### Response `201`
+```json
+{
+ "success": true,
+ "data": {
+ "uuid": "appt-uuid",
+ "slot_start": 1718438400,
+ "slot_end": 1718439600,
+ "status": "pending"
+ }
+}
+```
+
+### Error Responses
+| Code | HTTP | Description |
+|------|------|-------------|
+| `FORBIDDEN` | 403 | Role not allowed |
+| `VALIDATION` | 422 | Missing required fields |
+| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
+| `SLOT_TAKEN` | 409 | Slot already booked |
+
+---
+
## GET /api/v1/my/appointments
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php
index 969e86cf..528d99b2 100644
--- a/src/Admin/Controller/AdminApiController.php
+++ b/src/Admin/Controller/AdminApiController.php
@@ -733,21 +733,27 @@ class AdminApiController extends BaseController
#[Route('/api/v1/admin/appointment', methods: ['POST'])]
public function createAppointment(Request $request): JsonResponse
{
- $data = json_decode($request->getContent(), true) ?? [];
- $doctorUuid = trim($data['doctor_uuid'] ?? '');
- $slotStart = (int) ($data['slot_start'] ?? 0);
- $slotEnd = (int) ($data['slot_end'] ?? 0);
- $mobile = trim($data['patient_mobile'] ?? '');
+ $data = json_decode($request->getContent(), true) ?? [];
+ $doctorUuid = trim($data['doctor_uuid'] ?? '');
+ $slotStart = (int) ($data['slot_start'] ?? 0);
+ $slotEnd = (int) ($data['slot_end'] ?? 0);
+ $mobile = trim($data['patient_mobile'] ?? '');
+ $patientName = trim($data['patient_name'] ?? '');
- if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile)) {
- return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end و patient_mobile الزامی است', 422);
+ if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
+ return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
}
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
- if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
+ if (!$patient) {
+ $patient = new User($mobile);
+ $patient->setRealName($patientName);
+ $patient->setRoles(['ROLE_USER']);
+ $this->em->persist($patient);
+ }
$conflict = $this->em->createQueryBuilder()
->select('COUNT(a.id)')
diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php
index 96bb5032..2315d0e0 100644
--- a/src/Appointment/Controller/MyAppointmentsController.php
+++ b/src/Appointment/Controller/MyAppointmentsController.php
@@ -24,6 +24,63 @@ class MyAppointmentsController extends BaseController
private readonly DoctorSecretaryRepository $secretaryRepo,
) {}
+ #[Route('/api/v1/my/appointment', methods: ['POST'])]
+ #[IsGranted('IS_AUTHENTICATED_FULLY')]
+ public function createAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
+ {
+ $roles = $user->getRoles();
+ $allowed = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
+ if (!array_intersect($allowed, $roles)) {
+ return $this->error('FORBIDDEN', 'دسترسی ندارید', 403);
+ }
+
+ $data = json_decode($request->getContent(), true) ?? [];
+ $doctorUuid = trim($data['doctor_uuid'] ?? '');
+ $slotStart = (int) ($data['slot_start'] ?? 0);
+ $slotEnd = (int) ($data['slot_end'] ?? 0);
+ $mobile = trim($data['patient_mobile'] ?? '');
+ $patientName = trim($data['patient_name'] ?? '');
+
+ if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
+ return $this->error('VALIDATION', 'همه فیلدها الزامی است', 422);
+ }
+
+ $doctor = $this->doctorRepo->findByUuid($doctorUuid);
+ if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
+
+ $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);
+ }
+
+ $conflict = $this->em->createQueryBuilder()
+ ->select('COUNT(a.id)')->from(Appointment::class, 'a')
+ ->where('a.doctor = :doctor')
+ ->andWhere('a.slotStart < :end AND a.slotEnd > :start')
+ ->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
+ ->setParameter('doctor', $doctor)
+ ->setParameter('start', $slotStart)
+ ->setParameter('end', $slotEnd)
+ ->getQuery()->getSingleScalarResult();
+
+ if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
+
+ $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
+ if (!empty($data['note'])) $appointment->setNote($data['note']);
+ $this->em->persist($appointment);
+ $this->em->flush();
+
+ return $this->success([
+ 'uuid' => $appointment->getUuid(),
+ 'slot_start' => $slotStart,
+ 'slot_end' => $slotEnd,
+ 'status' => $appointment->getStatus(),
+ ], 201);
+ }
+
#[Route('/api/v1/my/appointments', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse