feat(representation): add welcome SMS notification for newly added doctors and clinics by representatives
This commit is contained in:
@@ -137,9 +137,9 @@ export default function App() {
|
||||
{/* داشبورد — همه نقشها */}
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
{/* نوبتها — همه نقشها */}
|
||||
<Route path="appointments" element={<AppointmentsPage />} />
|
||||
<Route path="appointments/:uuid" element={<AppointmentDetailPage />} />
|
||||
{/* نوبتها — همه نقشها بهجز نماینده */}
|
||||
<Route path="appointments" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentsPage /></RoleRoute>} />
|
||||
<Route path="appointments/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor', 'secretary']}><AppointmentDetailPage /></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="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></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>} />
|
||||
|
||||
{/* دکتر / منشی / کلینیک */}
|
||||
|
||||
@@ -380,7 +380,6 @@ function buildSections(
|
||||
items: [
|
||||
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
||||
{ to: "/admin/clinics", icon: BuildingOffice2Icon, label: "کلینیکها" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتها" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -629,6 +629,8 @@ export default function AppointmentsPage() {
|
||||
|
||||
// ── Handle slot click → open booking modal
|
||||
function handleSlotClick(slot: SlotItem) {
|
||||
// نماینده اجازهی ثبت نوبت ندارد؛ صفحه برای او فقط مشاهده است.
|
||||
if (isRepresentation) return;
|
||||
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
||||
setBookingHint(false);
|
||||
setBookingSlot({
|
||||
@@ -677,19 +679,21 @@ export default function AppointmentsPage() {
|
||||
padding: '12px 16px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* New appointment button */}
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => {
|
||||
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
||||
setViewMode('schedule');
|
||||
setBookingHint(true);
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||
>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
نوبت جدید
|
||||
</button>
|
||||
{/* New appointment button — نماینده اجازهی ثبت ندارد */}
|
||||
{!isRepresentation && (
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => {
|
||||
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
||||
setViewMode('schedule');
|
||||
setBookingHint(true);
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||
>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
نوبت جدید
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* View toggle */}
|
||||
<div style={{
|
||||
@@ -752,7 +756,7 @@ export default function AppointmentsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{bookingHint && (
|
||||
{bookingHint && !isRepresentation && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
||||
padding: '10px 16px', borderRadius: 'var(--r-sm)',
|
||||
|
||||
@@ -975,8 +975,8 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
||||
|
||||
// ── Address Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function AddressCard({ addr, onEdit, onDelete }: {
|
||||
addr: AddressData; onEdit: () => void; onDelete: () => void;
|
||||
function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
|
||||
addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean;
|
||||
}) {
|
||||
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">
|
||||
@@ -1004,16 +1004,18 @@ function AddressCard({ addr, onEdit, onDelete }: {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
|
||||
<button onClick={onEdit}
|
||||
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">
|
||||
<PencilIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={onDelete}
|
||||
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">
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
|
||||
<button onClick={onEdit}
|
||||
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">
|
||||
<PencilIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={onDelete}
|
||||
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">
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1213,7 +1215,7 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
|
||||
|
||||
// ── 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 [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
|
||||
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
|
||||
@@ -1315,6 +1317,48 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr
|
||||
</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 (
|
||||
<div className="space-y-2">
|
||||
{/* ─ خلاصه کل هفته */}
|
||||
@@ -1452,11 +1496,13 @@ function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addr
|
||||
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازهها الزامی است'}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
|
||||
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
|
||||
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
|
||||
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
|
||||
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1604,7 +1650,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved, addre
|
||||
|
||||
// ── 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 [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DateOverrideData | null>(null);
|
||||
@@ -1643,12 +1689,14 @@ function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addre
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
|
||||
className="flex items-center gap-1.5 text-sm cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" />تاریخ خاص جدید
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
|
||||
className="flex items-center gap-1.5 text-sm cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" />تاریخ خاص جدید
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{overrides.length === 0 ? (
|
||||
<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" />
|
||||
@@ -1673,16 +1721,18 @@ function DateOverridesTab({ doctorUuid, addresses }: { doctorUuid: string; addre
|
||||
</div>
|
||||
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => { setEditing(ov); setModalOpen(true); }}
|
||||
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">
|
||||
<PencilIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setDeletingUuid(ov.uuid)}
|
||||
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">
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => { setEditing(ov); setModalOpen(true); }}
|
||||
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">
|
||||
<PencilIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setDeletingUuid(ov.uuid)}
|
||||
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">
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1772,7 +1822,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
||||
|
||||
// ── Holidays Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
|
||||
function HolidaysTab({ doctorUuid, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<HolidayData | null>(null);
|
||||
@@ -1805,12 +1855,14 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
|
||||
className="flex items-center gap-1.5 text-sm cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" />تعطیلات جدید
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
|
||||
className="flex items-center gap-1.5 text-sm cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" />تعطیلات جدید
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{holidays.length === 0 ? (
|
||||
<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" />
|
||||
@@ -1845,20 +1897,22 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
|
||||
</div>
|
||||
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
|
||||
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'}`}>
|
||||
{h.active ? 'غیرفعال' : 'فعال'}
|
||||
</button>
|
||||
<button onClick={() => { setEditing(h); setModalOpen(true); }}
|
||||
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">
|
||||
<PencilIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setDeletingUuid(h.uuid)}
|
||||
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">
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
|
||||
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'}`}>
|
||||
{h.active ? 'غیرفعال' : 'فعال'}
|
||||
</button>
|
||||
<button onClick={() => { setEditing(h); setModalOpen(true); }}
|
||||
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">
|
||||
<PencilIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setDeletingUuid(h.uuid)}
|
||||
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">
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1882,7 +1936,7 @@ const SCHEDULE_TABS = [
|
||||
{ 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 locationsQ = useQuery({
|
||||
@@ -1908,9 +1962,9 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} />}
|
||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} />}
|
||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
|
||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
|
||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} addresses={availableLocations} readOnly={readOnly} />}
|
||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} readOnly={readOnly} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2272,6 +2326,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const doctorUuid = useAuthStore(s => s.doctorUuid);
|
||||
const uuid = isOwnProfile ? (doctorUuid ?? dbUuid ?? undefined) : paramUuid;
|
||||
// نماینده فقط مشاهده میکند؛ هیچ بخشی قابل ویرایش نیست.
|
||||
const isReadOnly = primaryRole === 'representation';
|
||||
|
||||
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
|
||||
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">
|
||||
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
|
||||
onUpload={primaryRole !== 'clinic' ? handleImageUpload : undefined} uploading={uploadingImg} />
|
||||
{primaryRole !== 'clinic' && (
|
||||
onUpload={primaryRole !== 'clinic' && !isReadOnly ? handleImageUpload : undefined} uploading={uploadingImg} />
|
||||
{primaryRole !== 'clinic' && !isReadOnly && (
|
||||
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2545,14 +2601,14 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<PencilIcon className="w-4 h-4" />ویرایش
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Toggle active / Delete — فقط ادمین */}
|
||||
{!isOwnProfile && primaryRole !== 'clinic' && (
|
||||
{!isOwnProfile && primaryRole !== 'clinic' && !isReadOnly && (
|
||||
<>
|
||||
<button
|
||||
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">
|
||||
آدرسهای مطب ({formatNumber(addresses.length)})
|
||||
</h2>
|
||||
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
|
||||
<PlusIcon className="w-4 h-4" />افزودن آدرس
|
||||
</button>
|
||||
{!isReadOnly && (
|
||||
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
|
||||
<PlusIcon className="w-4 h-4" />افزودن آدرس
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{addresses.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{addresses.map(addr => (
|
||||
<AddressCard key={addr.id} addr={addr}
|
||||
<AddressCard key={addr.id} addr={addr} readOnly={isReadOnly}
|
||||
onEdit={() => { setEditingAddr(addr); setAddrModalOpen(true); }}
|
||||
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">
|
||||
<MapPinIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">هنوز آدرسی ثبت نشده است</p>
|
||||
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
|
||||
className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline">
|
||||
اولین آدرس را اضافه کنید
|
||||
</button>
|
||||
{!isReadOnly && (
|
||||
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
|
||||
className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline">
|
||||
اولین آدرس را اضافه کنید
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uuid && <ScheduleSection doctorUuid={uuid} />}
|
||||
{uuid && <ScheduleSection doctorUuid={uuid} readOnly={isReadOnly} />}
|
||||
|
||||
{isOwnProfile && <ClinicInvitationsSection />}
|
||||
|
||||
@@ -2686,9 +2746,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
</div>
|
||||
: <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">
|
||||
ویرایش تخصصها ←
|
||||
</button>
|
||||
{!isReadOnly && (
|
||||
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
|
||||
ویرایش تخصصها ←
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cp-card p-5">
|
||||
@@ -2703,9 +2765,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
|
||||
</div>
|
||||
: <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">
|
||||
ویرایش خدمات ←
|
||||
</button>
|
||||
{!isReadOnly && (
|
||||
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
|
||||
ویرایش خدمات ←
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cp-card p-5">
|
||||
|
||||
@@ -268,7 +268,7 @@ Get yearly earnings dashboard for a representation.
|
||||
|
||||
### POST `/api/v1/representation/doctor`
|
||||
|
||||
افزودن پزشک توسط نماینده. `representation_id` پزشک بهصورت خودکار روی نمایندهی کاربر جاری ست میشود.
|
||||
افزودن پزشک توسط نماینده. `representation_id` پزشک بهصورت خودکار روی نمایندهی کاربر جاری ست میشود. پس از ثبت موفق، یک پیامک خوشآمد (تگ `welcome`) بهصورت async به موبایل پزشک ارسال میشود.
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
@@ -296,7 +296,7 @@ Get yearly earnings dashboard for a representation.
|
||||
|
||||
### POST `/api/v1/representation/clinic`
|
||||
|
||||
افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نمایندهی کاربر جاری ست میشود (مثل createDoctor) تا در لیستهای scoped دیده شود.
|
||||
افزودن کلینیک توسط نماینده. `representation_id` کلینیک خودکار روی نمایندهی کاربر جاری ست میشود (مثل createDoctor) تا در لیستهای scoped دیده شود. پس از ثبت موفق، یک پیامک خوشآمد (تگ `welcome`) بهصورت async به موبایل مالک کلینیک ارسال میشود.
|
||||
|
||||
#### Request Body
|
||||
```json
|
||||
|
||||
+3
-1
@@ -474,7 +474,9 @@ Updated template with `status: "rejected"`.
|
||||
|
||||
متن پیامکهای سیستمی (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`
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ class RepresentationActionController extends BaseController
|
||||
private readonly RepresentationRepository $representationRepo,
|
||||
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
||||
private readonly \App\Subscription\Service\SubscriptionService $subscriptionService,
|
||||
private readonly \App\Sms\Service\SmsService $smsService,
|
||||
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -133,6 +135,14 @@ class RepresentationActionController extends BaseController
|
||||
$this->em->persist($doctor);
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -200,6 +210,14 @@ class RepresentationActionController extends BaseController
|
||||
$this->em->persist($clinic);
|
||||
$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' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
|
||||
@@ -17,6 +17,7 @@ class SmsLog
|
||||
public const TAG_PRE_REGISTRATION = 'pre_registration';
|
||||
public const TAG_NOTIFICATION_MOBILE = 'notification_mobile';
|
||||
public const TAG_USER_TEMPLATE = 'user_template';
|
||||
public const TAG_WELCOME = 'welcome';
|
||||
|
||||
public const TAGS = [
|
||||
self::TAG_GLOBAL,
|
||||
@@ -26,6 +27,7 @@ class SmsLog
|
||||
self::TAG_PRE_REGISTRATION,
|
||||
self::TAG_NOTIFICATION_MOBILE,
|
||||
self::TAG_USER_TEMPLATE,
|
||||
self::TAG_WELCOME,
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
|
||||
Reference in New Issue
Block a user