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="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: "نوبتها" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user