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
+10 -2
View File
@@ -26,6 +26,7 @@ import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
import DoctorTabs from '../components/appointments/DoctorTabs';
import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable';
import { usePermissions } from '../hooks/usePermissions';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
@@ -497,6 +498,11 @@ export default function AppointmentsPage() {
// منشیِ محیطِ کلینیک باید مثل کلینیک چندپزشکه رفتار کند: تب پزشکان + تایم‌لاین.
// dbUuid در این محیط uuid کلینیک است (نه پزشک) — همان مبنای clinic/doctor-list.
const isClinicScopedSecretary = primaryRole === 'secretary' && scope === 'clinic';
// مجوزهای منشی روی نوبت‌ها؛ برای owner/پزشک همیشه true.
const { can } = usePermissions();
const canCreateAppt = can('appointments', 'create');
const canManageAppt = can('appointments', 'update_status');
const canCancelAppt = can('appointments', 'cancel');
const [params] = useSearchParams();
const today = new Date().toISOString().slice(0, 10);
@@ -715,7 +721,7 @@ export default function AppointmentsPage() {
// ── Slot click → quick booking modal
function handleSlotClick(slot: TimelineSlot) {
if (isRepresentation) return;
if (isRepresentation || !canCreateAppt) return;
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
setBookingSlot({
start: slot.start,
@@ -786,7 +792,7 @@ export default function AppointmentsPage() {
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
</button>
{!isRepresentation && (
{!isRepresentation && canCreateAppt && (
<button
className="btn primary sm"
onClick={() => {
@@ -817,6 +823,8 @@ export default function AppointmentsPage() {
loading={apptQuery.isLoading}
queryKey={apptQueryKey}
showDoctor={showDoctorCol}
canManage={canManageAppt}
canCancel={canCancelAppt}
/>
{filteredAppointments.length > TABLE_PAGE_SIZE && (
<div style={{ marginTop: 14 }}>