fix(secretary): gate CRUD action buttons across all panel pages by permission
Backend already returned 403 for ungranted secretary actions, but the UI still showed the add/edit/delete buttons (e.g. clinic-services showed «بخش جدید» to a secretary without services.create). Sweep every secretary-reachable page so each create/edit/delete/manage control renders only when the matching usePermissions().can(resource, action) is true. Owner/doctor/clinic are unaffected — can() returns true when there is no permission context — so this restricts only secretaries and mirrors the server checks. Pages/components gated (resource): - services: ClinicServicesPage, ServiceDetailPage (+ its tabs) - inventory: InventoryPage, InventoryItemsTable, InventoryActionsMenu, PackagesView - tags: TagsSettingsPage · staff: StaffPage · discounts: DiscountTab - sms: SmsWalletPage · insurances: TenantInsuranceContracts - clinic_doctors: ClinicDoctorsPage + ClinicDoctorsManager (props, default true) - patients: PatientsListPage, MyPatientsPage, PatientDetailPage (records/notes/ sessions/attachments/calls/wallet — create/update/delete split) - appointments: AppointmentsPage (add + empty-slot booking gated by create), TurnsTable (status dropdown → read-only badge without update_status; actions menu hidden without manage/cancel) - appointment_settings: AppointmentSettingsPage + ClinicAppointmentSettingsPage pass readOnly to ScheduleSection + FreeVisitPrice (new readOnly prop) Not gated: view/read, search, filter, tabs, navigation, export, and modal submit buttons reachable only via an already-gated trigger. tsc clean; full frontend suite 501/501 passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
profileToFormValues, formValuesToPayload,
|
||||
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
|
||||
} from '../lib/patientForm';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
|
||||
|
||||
@@ -65,6 +66,9 @@ function Placeholder({ label }: { label: string }) {
|
||||
/** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */
|
||||
export default function PatientDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('patients', 'update');
|
||||
// ?tab=wallet etc. lets other pages (e.g. appointment forms) deep-link a tab
|
||||
const [searchParams] = useSearchParams();
|
||||
const requested = searchParams.get('tab') as TabKey | null;
|
||||
@@ -232,9 +236,11 @@ export default function PatientDetailPage() {
|
||||
<button className={sessionFilter === 'all' ? 'on' : ''} onClick={() => setSessionFilter('all')}>همه</button>
|
||||
<button className={sessionFilter === 'archived' ? 'on' : ''} onClick={() => setSessionFilter('archived')}>آرشیو</button>
|
||||
</div>
|
||||
<Link to={`/admin/patients/${uuid}/session/new`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="#fff" /> سرویس جدید
|
||||
</Link>
|
||||
{canUpdate && (
|
||||
<Link to={`/admin/patients/${uuid}/session/new`} className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="#fff" /> سرویس جدید
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{sessionsQ.isLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
@@ -284,6 +290,9 @@ const formatBytes = (n?: number | null) => {
|
||||
/** ضمیمه — patient attachments: upload (raw body), list, delete. */
|
||||
function AttachmentsTab({ uuid }: { uuid: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('patients', 'update');
|
||||
const canDelete = can('patients', 'delete');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
@@ -325,12 +334,14 @@ function AttachmentsTab({ uuid }: { uuid: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||||
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
|
||||
</button>
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
|
||||
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
@@ -345,7 +356,9 @@ function AttachmentsTab({ uuid }: { uuid: string }) {
|
||||
<a href={a.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, fontSize: 14, color: 'var(--text)', textDecoration: 'none', wordBreak: 'break-all' }}>{a.name}</a>
|
||||
{a.size ? <div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatBytes(a.size)}</div> : null}
|
||||
</div>
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(a.uuid)}><TrashIcon style={{ width: 16 }} /></button>
|
||||
{canDelete && (
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(a.uuid)}><TrashIcon style={{ width: 16 }} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -359,6 +372,9 @@ interface MedicalItem { uuid: string; title: string; body?: string | null; recor
|
||||
/** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */
|
||||
function MedicalRecordsTab({ uuid }: { uuid: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('patients', 'update');
|
||||
const canDelete = can('patients', 'delete');
|
||||
const [modal, setModal] = useState<'create' | MedicalItem | null>(null);
|
||||
const [delTarget, setDelTarget] = useState<MedicalItem | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
@@ -396,9 +412,11 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
@@ -415,8 +433,12 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) {
|
||||
{m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
|
||||
{canUpdate && (
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,6 +501,9 @@ type NoteSort = 'newest' | 'oldest';
|
||||
*/
|
||||
function NotesTab({ uuid }: { uuid: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('patients', 'update');
|
||||
const canDelete = can('patients', 'delete');
|
||||
const [body, setBody] = useState('');
|
||||
const [sort, setSort] = useState<NoteSort>('newest');
|
||||
const [editTarget, setEditTarget] = useState<Note | null>(null);
|
||||
@@ -534,11 +559,13 @@ function NotesTab({ uuid }: { uuid: string }) {
|
||||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}>
|
||||
<PlusIcon style={{ width: 16 }} /> ذخیره یادداشت
|
||||
</button>
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}>
|
||||
<PlusIcon style={{ width: 16 }} /> ذخیره یادداشت
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سرآیند + مرتبسازی */}
|
||||
@@ -591,18 +618,24 @@ function NotesTab({ uuid }: { uuid: string }) {
|
||||
{n.updated_at && <span style={{ fontSize: 11 }}>(ویرایششده)</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'}
|
||||
title={n.pinned ? 'برداشتن پین' : 'پین کردن'}
|
||||
style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }}
|
||||
disabled={update.isPending}
|
||||
onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })}
|
||||
>
|
||||
<PinIcon filled={n.pinned} color="currentColor" />
|
||||
</button>
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => openEdit(n)}><PencilIcon style={{ width: 15 }} /></button>
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(n)}><TrashIcon style={{ width: 15 }} /></button>
|
||||
{canUpdate && (
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'}
|
||||
title={n.pinned ? 'برداشتن پین' : 'پین کردن'}
|
||||
style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }}
|
||||
disabled={update.isPending}
|
||||
onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })}
|
||||
>
|
||||
<PinIcon filled={n.pinned} color="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => openEdit(n)}><PencilIcon style={{ width: 15 }} /></button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(n)}><TrashIcon style={{ width: 15 }} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -647,6 +680,9 @@ const nowTime = () => { const d = new Date(); return `${pad2(d.getHours())}:${pa
|
||||
/** کال سنتر — patient call log: register a call + filterable history (all / success / missed). */
|
||||
function CallCenterTab({ uuid }: { uuid: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('patients', 'update');
|
||||
const canDelete = can('patients', 'delete');
|
||||
const userName = useAuthStore((s) => s.userName);
|
||||
const [filter, setFilter] = useState<'all' | 'success' | 'missed'>('all');
|
||||
const [date, setDate] = useState(nowDate);
|
||||
@@ -720,7 +756,9 @@ function CallCenterTab({ uuid }: { uuid: string }) {
|
||||
<button onClick={() => setOutcome('success')} style={{ flex: 1, padding: '8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, background: outcome === 'success' ? 'var(--success-bg)' : 'var(--surface)', color: outcome === 'success' ? 'var(--success)' : 'var(--text-2)', fontWeight: outcome === 'success' ? 700 : 500 }}>موفق</button>
|
||||
<button onClick={() => setOutcome('missed')} style={{ flex: 1, padding: '8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'inherit', fontSize: 13, background: outcome === 'missed' ? 'var(--danger-bg)' : 'var(--surface)', color: outcome === 'missed' ? 'var(--danger)' : 'var(--text-2)', fontWeight: outcome === 'missed' ? 700 : 500 }}>بیپاسخ</button>
|
||||
</div>
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={!subject.trim() || create.isPending} onClick={() => create.mutate()}><PlusIcon style={{ width: 16 }} /> ثبت تماس</button>
|
||||
{canUpdate && (
|
||||
<button className="btn primary" style={{ width: '100%' }} disabled={!subject.trim() || create.isPending} onClick={() => create.mutate()}><PlusIcon style={{ width: 16 }} /> ثبت تماس</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* history */}
|
||||
@@ -754,7 +792,9 @@ function CallCenterTab({ uuid }: { uuid: string }) {
|
||||
<div style={{ textAlign: 'end', minWidth: 120 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDateTime(c.called_at)}</div>
|
||||
{c.personnel && <div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 2 }}>{c.personnel}</div>}
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)', marginTop: 4 }} onClick={() => del.mutate(c.uuid)}><TrashIcon style={{ width: 14 }} /></button>
|
||||
{canDelete && (
|
||||
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)', marginTop: 4 }} onClick={() => del.mutate(c.uuid)}><TrashIcon style={{ width: 14 }} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -787,6 +827,8 @@ type WalletRow = WalletTxn & { row_no: number };
|
||||
* روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل.
|
||||
*/
|
||||
function WalletTab({ uuid }: { uuid: string }) {
|
||||
const { can } = usePermissions();
|
||||
const canUpdate = can('patients', 'update');
|
||||
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<WalletFilter>('all');
|
||||
@@ -837,9 +879,11 @@ function WalletTab({ uuid }: { uuid: string }) {
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setModalOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول
|
||||
</button>
|
||||
{canUpdate && (
|
||||
<button className="btn primary" onClick={() => setModalOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فیلتر تراکنشها */}
|
||||
|
||||
Reference in New Issue
Block a user