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:
hamed
2026-07-23 18:39:58 +03:30
co-authored by Claude Opus 4.8
parent 83a6dc6158
commit a3b29404f4
21 changed files with 538 additions and 282 deletions
+81 -37
View File
@@ -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>
{/* فیلتر تراکنش‌ها */}