feat: add doctor invitation modal and appointment creation API

- Implemented InviteDoctorModal component for inviting doctors to clinics.
- Updated ClinicDashboard to include a button for inviting doctors and handle modal state.
- Added createAppointment API endpoint in AdminApiController for scheduling appointments.
- Enhanced ClinicInvitationController to check user access when inviting doctors.
- Updated MyAppointmentsController to ensure unique appointment records.
- Added seed_test_data.php for populating test data including doctors, clinics, and appointments.
- Refactored styles to include new appointment status badges and updated font imports.
This commit is contained in:
hamed
2026-06-11 13:36:54 +03:30
parent 04b51273e3
commit 82e1c264a1
18 changed files with 1095 additions and 274 deletions
+13 -66
View File
@@ -21,6 +21,7 @@ import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import { useAuthStore } from '../stores/authStore';
// Fix leaflet icons
@@ -505,64 +506,6 @@ const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
removed: { label: 'حذف‌شده', cls: 'gray' },
};
// ── Invite modal ───────────────────────────────────────────────────────────
const inviteSchema = z.object({
mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع می‌شود'),
name: z.string().optional(),
specialty: z.string().optional(),
});
type InviteForm = z.infer<typeof inviteSchema>;
function InviteModal({ clinicUuid, onClose, onInvited }: {
clinicUuid: string; onClose: () => void; onInvited: () => void;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
resolver: zodResolver(inviteSchema),
});
const inviteMut = useMutation({
mutationFn: (d: InviteForm) =>
api.post<ApiResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>دعوت پزشک به کلینیک</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
</div>
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
</div>
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال میشود</p>
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
{inviteMut.isPending ? 'در حال ارسال...' : 'ارسال دعوتنامه'}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────
@@ -748,13 +691,17 @@ export default function ClinicDetailPage() {
<button className="btn ghost sm" onClick={() => openEdit('basic')}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
{primaryRole === 'admin' && (
<>
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
</>
)}
</div>
</div>
@@ -1054,7 +1001,7 @@ export default function ClinicDetailPage() {
{/* Invite doctor modal */}
{inviteOpen && uuid && createPortal(
<InviteModal
<InviteDoctorModal
clinicUuid={uuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}