feat(representation): add welcome SMS notification for newly added doctors and clinics by representatives

This commit is contained in:
hamed
2026-06-25 09:37:20 +03:30
parent 763ad82b69
commit e65e506dae
8 changed files with 191 additions and 102 deletions
+4 -4
View File
@@ -137,9 +137,9 @@ export default function App() {
{/* داشبورد — همه نقش‌ها */} {/* داشبورد — همه نقش‌ها */}
<Route path="dashboard" element={<DashboardPage />} /> <Route path="dashboard" element={<DashboardPage />} />
{/* نوبت‌ها — همه نقش‌ها */} {/* نوبت‌ها — همه نقش‌ها به‌جز نماینده */}
<Route path="appointments" element={<AppointmentsPage />} /> <Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
<Route path="appointments/:uuid" element={<AppointmentDetailPage />} /> <Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></RoleRoute>} />
{/* فقط ادمین */} {/* فقط ادمین */}
<Route path="users" element={<RoleRoute roles={['admin']}><UsersPage /></RoleRoute>} /> <Route path="users" element={<RoleRoute roles={['admin']}><UsersPage /></RoleRoute>} />
@@ -172,7 +172,7 @@ export default function App() {
<Route path="representation-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} /> <Route path="representation-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} />
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} /> <Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} /> <Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} /> <Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic', 'representation']}><DoctorDetailPage /></RoleRoute>} />
<Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} /> <Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} />
{/* دکتر / منشی / کلینیک */} {/* دکتر / منشی / کلینیک */}
@@ -380,7 +380,6 @@ function buildSections(
items: [ items: [
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" }, { to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
{ to: "/admin/clinics", icon: BuildingOffice2Icon, label: "کلینیک‌ها" }, { to: "/admin/clinics", icon: BuildingOffice2Icon, label: "کلینیک‌ها" },
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبت‌ها" },
], ],
}, },
{ {
+18 -14
View File
@@ -629,6 +629,8 @@ export default function AppointmentsPage() {
// ── Handle slot click → open booking modal // ── Handle slot click → open booking modal
function handleSlotClick(slot: SlotItem) { function handleSlotClick(slot: SlotItem) {
// نماینده اجازه‌ی ثبت نوبت ندارد؛ صفحه برای او فقط مشاهده است.
if (isRepresentation) return;
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
setBookingHint(false); setBookingHint(false);
setBookingSlot({ setBookingSlot({
@@ -677,19 +679,21 @@ export default function AppointmentsPage() {
padding: '12px 16px', borderBottom: '1px solid var(--border)', padding: '12px 16px', borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
}}> }}>
{/* New appointment button */} {/* New appointment button — نماینده اجازه‌ی ثبت ندارد */}
<button {!isRepresentation && (
className="btn primary sm" <button
onClick={() => { className="btn primary sm"
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; } onClick={() => {
setViewMode('schedule'); if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
setBookingHint(true); setViewMode('schedule');
}} setBookingHint(true);
style={{ display: 'flex', alignItems: 'center', gap: 5 }} }}
> style={{ display: 'flex', alignItems: 'center', gap: 5 }}
<PlusIcon style={{ width: 15, height: 15 }} /> >
نوبت جدید <PlusIcon style={{ width: 15, height: 15 }} />
</button> نوبت جدید
</button>
)}
{/* View toggle */} {/* View toggle */}
<div style={{ <div style={{
@@ -752,7 +756,7 @@ export default function AppointmentsPage() {
/> />
) : ( ) : (
<> <>
{bookingHint && ( {bookingHint && !isRepresentation && (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
padding: '10px 16px', borderRadius: 'var(--r-sm)', padding: '10px 16px', borderRadius: 'var(--r-sm)',
+144 -80
View File
@@ -975,8 +975,8 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
// ── Address Card ─────────────────────────────────────────────────────────── // ── Address Card ───────────────────────────────────────────────────────────
function AddressCard({ addr, onEdit, onDelete }: { function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
addr: AddressData; onEdit: () => void; onDelete: () => void; addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean;
}) { }) {
return ( return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 border border-slate-100 dark:border-gray-700 group"> <div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 border border-slate-100 dark:border-gray-700 group">
@@ -1004,16 +1004,18 @@ function AddressCard({ addr, onEdit, onDelete }: {
)} )}
</div> </div>
</div> </div>
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0"> {!readOnly && (
<button onClick={onEdit} <div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors"> <button onClick={onEdit}
<PencilIcon className="w-3.5 h-3.5" /> className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
</button> <PencilIcon className="w-3.5 h-3.5" />
<button onClick={onDelete} </button>
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"> <button onClick={onDelete}
<TrashIcon className="w-3.5 h-3.5" /> className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
</button> <TrashIcon className="w-3.5 h-3.5" />
</div> </button>
</div>
)}
</div> </div>
); );
} }
@@ -1213,7 +1215,7 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
// ── Weekly Schedule Tab ──────────────────────────────────────────────────── // ── 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 qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE); const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null); const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
@@ -1315,6 +1317,48 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr
</div> </div>
); );
// نمای فقط‌خواندنی برای نماینده: برنامه‌ی هفتگی به‌صورت متن، بدون فرم.
if (readOnly) {
if (scheduleQ.isLoading) {
return <div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>;
}
return (
<div className="space-y-2">
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
)}
{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 (
<div key={day.key} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800/40">
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
<div className="flex-1 flex gap-2 items-center flex-wrap min-w-0">
{activeSessions.length > 0 ? activeSessions.map((s, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-lg bg-slate-100 dark:bg-gray-700 text-slate-700 dark:text-slate-200" style={{ fontFamily: 'monospace', direction: 'ltr', whiteSpace: 'nowrap' }}>
{s.start_time} {s.end_time}
</span>
)) : (
<span className="muted text-xs">تعطیل</span>
)}
</div>
{daySlots > 0 && (
<span className="badge green" style={{ flexShrink: 0 }}>
<span className="bdot" />{daySlots} نوبت
</span>
)}
</div>
);
})}
</div>
);
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* ─ خلاصه کل هفته */} {/* ─ خلاصه کل هفته */}
@@ -1452,11 +1496,13 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'} {hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}
</p> </p>
)} )}
<button type="button" onClick={() => saveMut.mutate()} {!readOnly && (
disabled={saveMut.isPending || hasAnyOverlap || missingLocation} <button type="button" onClick={() => saveMut.mutate()}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}> disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'} className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
</button> {saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
</button>
)}
</div> </div>
</div> </div>
); );
@@ -1604,7 +1650,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre
// ── Date Overrides Tab ───────────────────────────────────────────────────── // ── 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 qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DateOverrideData | null>(null); const [editing, setEditing] = useState<DateOverrideData | null>(null);
@@ -1643,12 +1689,14 @@ function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addre
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-end"> {!readOnly && (
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }} <div className="flex justify-end">
className="flex items-center gap-1.5 text-sm cp-btn-primary"> <button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
<PlusIcon className="w-4 h-4" />تاریخ خاص جدید className="flex items-center gap-1.5 text-sm cp-btn-primary">
</button> <PlusIcon className="w-4 h-4" />تاریخ خاص جدید
</div> </button>
</div>
)}
{overrides.length === 0 ? ( {overrides.length === 0 ? (
<div className="text-center py-10 text-slate-400 dark:text-slate-500"> <div className="text-center py-10 text-slate-400 dark:text-slate-500">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" /> <CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
@@ -1673,16 +1721,18 @@ function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addre
</div> </div>
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>} {ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> {!readOnly && (
<button onClick={() => { setEditing(ov); setModalOpen(true); }} <div className="flex items-center gap-1 shrink-0">
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors"> <button onClick={() => { setEditing(ov); setModalOpen(true); }}
<PencilIcon className="w-3.5 h-3.5" /> className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
</button> <PencilIcon className="w-3.5 h-3.5" />
<button onClick={() => setDeletingUuid(ov.uuid)} </button>
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"> <button onClick={() => setDeletingUuid(ov.uuid)}
<TrashIcon className="w-3.5 h-3.5" /> className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
</button> <TrashIcon className="w-3.5 h-3.5" />
</div> </button>
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -1772,7 +1822,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
// ── Holidays Tab ─────────────────────────────────────────────────────────── // ── Holidays Tab ───────────────────────────────────────────────────────────
function HolidaysTab({ doctorUuid }: { doctorUuid: string }) { function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<HolidayData | null>(null); const [editing, setEditing] = useState<HolidayData | null>(null);
@@ -1805,12 +1855,14 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-end"> {!readOnly && (
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }} <div className="flex justify-end">
className="flex items-center gap-1.5 text-sm cp-btn-primary"> <button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
<PlusIcon className="w-4 h-4" />تعطیلات جدید className="flex items-center gap-1.5 text-sm cp-btn-primary">
</button> <PlusIcon className="w-4 h-4" />تعطیلات جدید
</div> </button>
</div>
)}
{holidays.length === 0 ? ( {holidays.length === 0 ? (
<div className="text-center py-10 text-slate-400 dark:text-slate-500"> <div className="text-center py-10 text-slate-400 dark:text-slate-500">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" /> <CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
@@ -1845,20 +1897,22 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
</div> </div>
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>} {h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> {!readOnly && (
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending} <div className="flex items-center gap-1 shrink-0">
className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:border-slate-300 hover:text-slate-700 dark:hover:text-slate-200' : 'border-emerald-200 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'}`}> <button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
{h.active ? 'غیرفعال' : 'فعال'} className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:border-slate-300 hover:text-slate-700 dark:hover:text-slate-200' : 'border-emerald-200 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'}`}>
</button> {h.active ? 'غیرفعال' : 'فعال'}
<button onClick={() => { setEditing(h); setModalOpen(true); }} </button>
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors"> <button onClick={() => { setEditing(h); setModalOpen(true); }}
<PencilIcon className="w-3.5 h-3.5" /> className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
</button> <PencilIcon className="w-3.5 h-3.5" />
<button onClick={() => setDeletingUuid(h.uuid)} </button>
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"> <button onClick={() => setDeletingUuid(h.uuid)}
<TrashIcon className="w-3.5 h-3.5" /> className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
</button> <TrashIcon className="w-3.5 h-3.5" />
</div> </button>
</div>
)}
</div> </div>
); );
})} })}
@@ -1882,7 +1936,7 @@ const SCHEDULE_TABS = [
{ id: 'holidays' as const, label: 'تعطیلات' }, { 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 [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
const locationsQ = useQuery({ const locationsQ = useQuery({
@@ -1908,9 +1962,9 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
</button> </button>
))} ))}
</div> </div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} />} {tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} />} {tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />} {tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} readOnly={readOnly} />}
</div> </div>
); );
} }
@@ -2272,6 +2326,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
const dbUuid = useAuthStore(s => s.dbUuid); const dbUuid = useAuthStore(s => s.dbUuid);
const doctorUuid = useAuthStore(s => s.doctorUuid); const doctorUuid = useAuthStore(s => s.doctorUuid);
const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid; const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid;
// نماینده فقط مشاهده می‌کند؛ هیچ بخشی قابل ویرایش نیست.
const isReadOnly = primaryRole === 'representation';
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1'); const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@@ -2503,8 +2559,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum} <DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
onUpload={primaryRole !== 'clinic' ? handleImageUpload : undefined} uploading={uploadingImg} /> onUpload={primaryRole !== 'clinic' && !isReadOnly ? handleImageUpload : undefined} uploading={uploadingImg} />
{primaryRole !== 'clinic' && ( {primaryRole !== 'clinic' && !isReadOnly && (
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span> <span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
)} )}
</div> </div>
@@ -2545,14 +2601,14 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div> </div>
<div className="flex items-center gap-2 sm:mb-1 flex-wrap"> <div className="flex items-center gap-2 sm:mb-1 flex-wrap">
{primaryRole !== 'clinic' && ( {primaryRole !== 'clinic' && !isReadOnly && (
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm"> <button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
<PencilIcon className="w-4 h-4" />ویرایش <PencilIcon className="w-4 h-4" />ویرایش
</button> </button>
)} )}
{/* Toggle active / Delete — فقط ادمین */} {/* Toggle active / Delete — فقط ادمین */}
{!isOwnProfile && primaryRole !== 'clinic' && ( {!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && (
<> <>
<button <button
onClick={() => setToggleConfirm(true)} onClick={() => setToggleConfirm(true)}
@@ -2620,15 +2676,17 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300"> <h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
آدرسهای مطب ({formatNumber(addresses.length)}) آدرسهای مطب ({formatNumber(addresses.length)})
</h2> </h2>
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }} {!isReadOnly && (
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"> <button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
<PlusIcon className="w-4 h-4" />افزودن آدرس className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
</button> <PlusIcon className="w-4 h-4" />افزودن آدرس
</button>
)}
</div> </div>
{addresses.length > 0 ? ( {addresses.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{addresses.map(addr => ( {addresses.map(addr => (
<AddressCard key={addr.id} addr={addr} <AddressCard key={addr.id} addr={addr} readOnly={isReadOnly}
onEdit={() => { setEditingAddr(addr); setAddrModalOpen(true); }} onEdit={() => { setEditingAddr(addr); setAddrModalOpen(true); }}
onDelete={() => setDeletingAddrId(addr.id)} onDelete={() => setDeletingAddrId(addr.id)}
/> />
@@ -2638,15 +2696,17 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="text-center py-8 text-slate-400 dark:text-slate-500"> <div className="text-center py-8 text-slate-400 dark:text-slate-500">
<MapPinIcon className="w-10 h-10 mx-auto mb-2 opacity-30" /> <MapPinIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هنوز آدرسی ثبت نشده است</p> <p className="text-sm">هنوز آدرسی ثبت نشده است</p>
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }} {!isReadOnly && (
className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline"> <button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
اولین آدرس را اضافه کنید className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline">
</button> اولین آدرس را اضافه کنید
</button>
)}
</div> </div>
)} )}
</div> </div>
{uuid && <ScheduleSection doctorUuid={uuid} />} {uuid && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
{isOwnProfile && <ClinicInvitationsSection />} {isOwnProfile && <ClinicInvitationsSection />}
@@ -2686,9 +2746,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div> </div>
: <p className="text-sm text-slate-400">تخصصی ثبت نشده</p> : <p className="text-sm text-slate-400">تخصصی ثبت نشده</p>
} }
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right"> {!isReadOnly && (
ویرایش تخصصها <button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
</button> ویرایش تخصصها
</button>
)}
</div> </div>
<div className="cp-card p-5"> <div className="cp-card p-5">
@@ -2703,9 +2765,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div> </div>
: <p className="text-sm text-slate-400">خدمتی ثبت نشده</p> : <p className="text-sm text-slate-400">خدمتی ثبت نشده</p>
} }
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right"> {!isReadOnly && (
ویرایش خدمات <button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
</button> ویرایش خدمات
</button>
)}
</div> </div>
<div className="cp-card p-5"> <div className="cp-card p-5">
+2 -2
View File
@@ -268,7 +268,7 @@ Get yearly earnings dashboard for a representation.
### POST `/api/v1/representation/doctor` ### POST `/api/v1/representation/doctor`
افزودن پزشک توسط نماینده. `representation_id` پزشک به‌صورت خودکار روی نماینده‌ی کاربر جاری ست می‌شود. افزودن پزشک توسط نماینده. `representation_id` پزشک به‌صورت خودکار روی نماینده‌ی کاربر جاری ست می‌شود. پس از ثبت موفق، یک پیامک خوش‌آمد (تگ `welcome`) به‌صورت async به موبایل پزشک ارسال می‌شود.
#### Request Body #### Request Body
```json ```json
@@ -296,7 +296,7 @@ Get yearly earnings dashboard for a representation.
### POST `/api/v1/representation/clinic` ### POST `/api/v1/representation/clinic`
افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نماینده‌ی کاربر جاری ست می‌شود (مثل createDoctor) تا در لیست‌های scoped دیده شود. افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نماینده‌ی کاربر جاری ست می‌شود (مثل createDoctor) تا در لیست‌های scoped دیده شود. پس از ثبت موفق، یک پیامک خوش‌آمد (تگ `welcome`) به‌صورت async به موبایل مالک کلینیک ارسال می‌شود.
#### Request Body #### Request Body
```json ```json
+3 -1
View File
@@ -474,7 +474,9 @@ Updated template with `status: "rejected"`.
متن پیامک‌های سیستمی (OTP، پرداخت، دعوت کلینیک، پیش‌ثبت‌نام، تأیید موبایل) از پنل قابل ویرایش است و بر اساس **تگ** کلیددار می‌شود. هر متن placeholderهای مجاز خود را دارد (مثل `{code}`، `{doctor}`، `{date}`). هنگام ارسال، `SmsTextResolver` متنِ ویرایش‌شده‌ی DB را می‌گیرد و placeholderها را جایگزین می‌کند؛ اگر رکوردی نبود به متن پیش‌فرض fallback می‌شود. متن پیامک‌های سیستمی (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` ### GET `/api/v1/admin/sms/messages`
@@ -33,6 +33,8 @@ class RepresentationActionController extends BaseController
private readonly RepresentationRepository $representationRepo, private readonly RepresentationRepository $representationRepo,
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo, private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
private readonly \App\Subscription\Service\SubscriptionService $subscriptionService, private readonly \App\Subscription\Service\SubscriptionService $subscriptionService,
private readonly \App\Sms\Service\SmsService $smsService,
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
) {} ) {}
#[OA\Get( #[OA\Get(
@@ -133,6 +135,14 @@ class RepresentationActionController extends BaseController
$this->em->persist($doctor); $this->em->persist($doctor);
$this->em->flush(); $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); return $this->success(['uuid' => $doctor->getUuid()], 201);
} }
@@ -200,6 +210,14 @@ class RepresentationActionController extends BaseController
$this->em->persist($clinic); $this->em->persist($clinic);
$this->em->flush(); $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([ return $this->success([
'uuid' => $clinic->getUuid(), 'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(), 'name' => $clinic->getName(),
+2
View File
@@ -17,6 +17,7 @@ class SmsLog
public const TAG_PRE_REGISTRATION = 'pre_registration'; public const TAG_PRE_REGISTRATION = 'pre_registration';
public const TAG_NOTIFICATION_MOBILE = 'notification_mobile'; public const TAG_NOTIFICATION_MOBILE = 'notification_mobile';
public const TAG_USER_TEMPLATE = 'user_template'; public const TAG_USER_TEMPLATE = 'user_template';
public const TAG_WELCOME = 'welcome';
public const TAGS = [ public const TAGS = [
self::TAG_GLOBAL, self::TAG_GLOBAL,
@@ -26,6 +27,7 @@ class SmsLog
self::TAG_PRE_REGISTRATION, self::TAG_PRE_REGISTRATION,
self::TAG_NOTIFICATION_MOBILE, self::TAG_NOTIFICATION_MOBILE,
self::TAG_USER_TEMPLATE, self::TAG_USER_TEMPLATE,
self::TAG_WELCOME,
]; ];
#[ORM\Id] #[ORM\Id]