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:
@@ -73,7 +73,6 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
{ to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' },
|
||||
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
|
||||
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>;
|
||||
|
||||
interface Props {
|
||||
clinicUuid: string;
|
||||
onClose: () => void;
|
||||
onInvited?: () => void;
|
||||
}
|
||||
|
||||
export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Props) {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
});
|
||||
|
||||
const inviteMut = useMutation({
|
||||
mutationFn: (d: InviteForm) =>
|
||||
api.post<ApiResponse<unknown>>(`/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { formatDate } from '../../lib/utils';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', min, max, style, className }: Props) {
|
||||
const hiddenRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const open = () => {
|
||||
const el = hiddenRef.current;
|
||||
if (!el) return;
|
||||
if (typeof el.showPicker === 'function') {
|
||||
try { el.showPicker(); } catch { el.focus(); }
|
||||
} else {
|
||||
el.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', ...style }} className={className}>
|
||||
{/* visible text layer */}
|
||||
<div
|
||||
onClick={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)',
|
||||
cursor: 'pointer', fontSize: 13, color: value ? 'var(--text)' : 'var(--text-3)',
|
||||
userSelect: 'none', minWidth: 148, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<CalendarDaysIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
|
||||
{value && (
|
||||
<span
|
||||
onClick={e => { e.stopPropagation(); onChange(''); }}
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }}
|
||||
>
|
||||
<XMarkIcon style={{ width: 13, height: 13 }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* hidden native input — opens picker on click */}
|
||||
<input
|
||||
ref={hiddenRef}
|
||||
type="date"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{
|
||||
position: 'absolute', opacity: 0, pointerEvents: 'none',
|
||||
width: 1, height: 1, top: 0, left: 0,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user