From a3b29404f4b6732ec607a84185a2d56ca31bb685 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 18:39:58 +0330 Subject: [PATCH] fix(secretary): gate CRUD action buttons across all panel pages by permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/components/ClinicDoctorsManager.tsx | 74 ++++++----- assets/admin/components/DiscountTab.tsx | 22 +++- assets/admin/components/FreeVisitPrice.tsx | 14 ++- .../components/TenantInsuranceContracts.tsx | 33 +++-- .../components/appointments/TurnsTable.tsx | 20 ++- .../inventory/InventoryActionsMenu.tsx | 64 ++++++---- .../inventory/InventoryItemsTable.tsx | 26 ++-- .../components/inventory/PackagesView.tsx | 36 +++--- .../admin/pages/AppointmentSettingsPage.tsx | 8 +- assets/admin/pages/AppointmentsPage.tsx | 12 +- .../pages/ClinicAppointmentSettingsPage.tsx | 8 +- assets/admin/pages/ClinicDoctorsPage.tsx | 13 +- assets/admin/pages/ClinicServicesPage.tsx | 54 +++++--- assets/admin/pages/InventoryPage.tsx | 21 +++- assets/admin/pages/MyPatientsPage.tsx | 104 +++++++++------ assets/admin/pages/PatientDetailPage.tsx | 118 ++++++++++++------ assets/admin/pages/PatientsListPage.tsx | 35 ++++-- assets/admin/pages/ServiceDetailPage.tsx | 40 +++--- assets/admin/pages/SmsWalletPage.tsx | 49 +++++--- assets/admin/pages/StaffPage.tsx | 51 +++++--- assets/admin/pages/TagsSettingsPage.tsx | 18 ++- 21 files changed, 538 insertions(+), 282 deletions(-) diff --git a/assets/admin/components/ClinicDoctorsManager.tsx b/assets/admin/components/ClinicDoctorsManager.tsx index 869ffde8..a9f0099c 100644 --- a/assets/admin/components/ClinicDoctorsManager.tsx +++ b/assets/admin/components/ClinicDoctorsManager.tsx @@ -48,10 +48,22 @@ const INV_STATUS_MAP: Record = { * pending invitations (list, invite, resend, suspend, delete invitation, detach * doctor). Reused by both the admin ClinicDetailPage and the clinic-owner * settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control. + * `canCreate`/`canUpdate`/`canDelete` allow a caller (e.g. a secretary-scoped + * page) to gate each action group; they default to `true` so unrestricted + * callers (owner/admin) are unaffected. */ -export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { +export default function ClinicDoctorsManager({ + clinicUuid, + readOnly = false, + canCreate = true, + canUpdate = true, + canDelete = true, +}: { clinicUuid: string; readOnly?: boolean; + canCreate?: boolean; + canUpdate?: boolean; + canDelete?: boolean; }) { const navigate = useNavigate(); const qc = useQueryClient(); @@ -126,7 +138,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { دعوتنامه‌ها ({formatNumber(invitationList.length)}) - {!readOnly && ( + {!readOnly && canCreate && ( @@ -167,23 +179,23 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { > - {!readOnly && ( - <> - - - + {!readOnly && canUpdate && ( + + )} + {!readOnly && canDelete && ( + )} @@ -229,9 +241,9 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: { {isExpired ? 'منقضی' : statusInfo.label} - {!readOnly && ( + {!readOnly && (canUpdate || canDelete) && (
- {inv.status === 'pending' && ( + {canUpdate && inv.status === 'pending' && ( )} - {inv.status !== 'removed' && inv.status !== 'accepted' && ( + {canUpdate && inv.status !== 'removed' && inv.status !== 'accepted' && ( )} - + {canDelete && ( + + )}
)} diff --git a/assets/admin/components/DiscountTab.tsx b/assets/admin/components/DiscountTab.tsx index 797edfa4..d5f7ad18 100644 --- a/assets/admin/components/DiscountTab.tsx +++ b/assets/admin/components/DiscountTab.tsx @@ -12,6 +12,7 @@ import SearchableSelect from './ui/SearchableSelect'; import PriceInput from './ui/PriceInput'; import PersianDateInput from './ui/PersianDateInput'; import { digitsOnly } from '../lib/utils'; +import { usePermissions } from '../hooks/usePermissions'; const TYPE_LABELS: Record = { patient_tag: 'تگ بیمار', @@ -72,6 +73,11 @@ function labelStyle(): React.CSSProperties { return { fontSize: 12.5, color: 'va export default function DiscountTab() { const qc = useQueryClient(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can('discounts', 'create'); + const canUpdate = can('discounts', 'update'); + const canDelete = can('discounts', 'delete'); const [modal, setModal] = useState<'create' | DiscountRule | null>(null); const [toDelete, setToDelete] = useState(null); @@ -94,9 +100,11 @@ export default function DiscountTab() {
قوانین تخفیف عمومی — بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه - + {canCreate && ( + + )}
{isLoading ? ( @@ -129,8 +137,12 @@ export default function DiscountTab() { {r.active ? 'فعال' : 'غیرفعال'} - - + {canUpdate && ( + + )} + {canDelete && ( + + )} ))} diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx index 351c4a07..5c532182 100644 --- a/assets/admin/components/FreeVisitPrice.tsx +++ b/assets/admin/components/FreeVisitPrice.tsx @@ -8,7 +8,7 @@ import { digitsOnly } from '../lib/utils'; interface Pricing { free_visit_price_rials: number; require_visit_price: boolean } /** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */ -export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) { +export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { doctorUuid?: string; readOnly?: boolean }) { const qc = useQueryClient(); const [value, setValue] = useState(''); const [required, setRequired] = useState(false); @@ -97,11 +97,13 @@ export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی می‌شود و بدون آن امکان ذخیره وجود ندارد.

-
- -
+ {!readOnly && ( +
+ +
+ )}
); } diff --git a/assets/admin/components/TenantInsuranceContracts.tsx b/assets/admin/components/TenantInsuranceContracts.tsx index 5bcfb5ef..34cbb3bb 100644 --- a/assets/admin/components/TenantInsuranceContracts.tsx +++ b/assets/admin/components/TenantInsuranceContracts.tsx @@ -6,6 +6,7 @@ import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { formatRial, formatNumber, formatDate } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; +import { usePermissions } from '../hooks/usePermissions'; import SearchableSelect from './ui/SearchableSelect'; import type { ClinicDoctorItem } from './ClinicDoctorsManager'; import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal'; @@ -42,6 +43,10 @@ export function contractSummary(c: Contract): string { export default function TenantInsuranceContracts() { const qc = useQueryClient(); const { dbUuid, context, availableContexts } = useAuthStore(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can('insurances', 'create'); + const canUpdate = can('insurances', 'update'); const [tab, setTab] = useState('basic'); const [modalOpen, setModalOpen] = useState(false); const [editContract, setEditContract] = useState(null); @@ -148,9 +153,11 @@ export default function TenantInsuranceContracts() {

مدیریت بیمه

- + {canCreate && ( + + )}
{showDoctorPicker && ( @@ -225,6 +232,7 @@ export default function TenantInsuranceContracts() { onEdit={() => openEdit(c)} onToggleStatus={() => toggleMut.mutate(c)} statusPending={toggleMut.isPending} + canUpdate={canUpdate} /> ))}
@@ -251,9 +259,10 @@ interface RowProps { onEdit: () => void; onToggleStatus: () => void; statusPending?: boolean; + canUpdate?: boolean; } -function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending }: RowProps) { +function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus, statusPending, canUpdate }: RowProps) { const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); }; return (
@@ -262,14 +271,18 @@ function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus,
{c.insurance_name ?? `#${c.insurance_id}`}
- + {canUpdate && ( + + )}
{contractSummary(c)}
-
{})}> - -
+ {canUpdate && ( +
{})}> + +
+ )} {open &&
} ); diff --git a/assets/admin/components/appointments/TurnsTable.tsx b/assets/admin/components/appointments/TurnsTable.tsx index 414029a3..75aa5cbf 100644 --- a/assets/admin/components/appointments/TurnsTable.tsx +++ b/assets/admin/components/appointments/TurnsTable.tsx @@ -1,6 +1,6 @@ import { UserCircleIcon, PhoneIcon } from '@heroicons/react/24/outline'; import type { Appointment } from '../../types'; -import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown'; +import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown'; import AppointmentActionsMenu from '../AppointmentActions'; /** @@ -11,12 +11,16 @@ const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', font const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle', fontSize: 13 }; export default function TurnsTable({ - items, loading, queryKey, showDoctor, + items, loading, queryKey, showDoctor, canManage = true, canCancel = true, }: { items: Appointment[]; loading: boolean; queryKey: unknown[]; showDoctor: boolean; + /** مجوز تغییر وضعیت (منشی)؛ پیش‌فرض true برای owner/پزشک. */ + canManage?: boolean; + /** مجوز لغو نوبت (منشی)؛ پیش‌فرض true. */ + canCancel?: boolean; }) { if (loading) return
در حال بارگذاری...
; if (!items.length) return
نوبتی برای این روز ثبت نشده است
; @@ -60,10 +64,18 @@ export default function TurnsTable({ {a.service_item?.name || '—'} {a.staff?.full_name || '—'} - + {canManage ? ( + + ) : ( + + {STATUS_META[a.status]?.label ?? a.status} + + )} - + {(canManage || canCancel) + ? + : } ))} diff --git a/assets/admin/components/inventory/InventoryActionsMenu.tsx b/assets/admin/components/inventory/InventoryActionsMenu.tsx index 29b7fee6..ac7fd85e 100644 --- a/assets/admin/components/inventory/InventoryActionsMenu.tsx +++ b/assets/admin/components/inventory/InventoryActionsMenu.tsx @@ -2,6 +2,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react'; import { EllipsisHorizontalCircleIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; import Portal from '../ui/Portal'; import type { InventoryItem } from '../../hooks/useInventory'; +import { usePermissions } from '../../hooks/usePermissions'; interface Props { item: InventoryItem; @@ -17,7 +18,14 @@ const MENU_W = 160; * table container's `overflow: hidden`. */ export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props) { + const { can } = usePermissions(); + const canUpdate = can('inventory', 'update'); + const canDelete = can('inventory', 'delete'); const [open, setOpen] = useState(false); + + // منشیِ بدون هیچ مجوزِ ویرایش/حذف، منوی «عملیات» را اصلاً نبیند. + if (!canUpdate && !canDelete) return null; + const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const triggerRef = useRef(null); @@ -65,32 +73,36 @@ export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props) borderRadius: 12, boxShadow: 'var(--shadow-lg)', overflow: 'hidden', }} > - - + {canUpdate && ( + + )} + {canDelete && ( + + )} )} diff --git a/assets/admin/components/inventory/InventoryItemsTable.tsx b/assets/admin/components/inventory/InventoryItemsTable.tsx index b676119c..41a6c8c8 100644 --- a/assets/admin/components/inventory/InventoryItemsTable.tsx +++ b/assets/admin/components/inventory/InventoryItemsTable.tsx @@ -4,6 +4,7 @@ import { formatRial, formatNumber } from '../../lib/utils'; import type { InventoryItem } from '../../hooks/useInventory'; import InventoryStatusBadge from './InventoryStatusBadge'; import InventoryActionsMenu from './InventoryActionsMenu'; +import { usePermissions } from '../../hooks/usePermissions'; interface Props { items: InventoryItem[]; @@ -15,6 +16,9 @@ const HEAD = ['نام کالا', 'دسته‌بندی', 'موجودی', 'واح /** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) { + const { can } = usePermissions(); + const canUpdate = can('inventory', 'update'); + const canDelete = can('inventory', 'delete'); return (
{/* Desktop table */} @@ -75,14 +79,20 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) )}
))} -
- - -
+ {(canUpdate || canDelete) && ( +
+ {canUpdate && ( + + )} + {canDelete && ( + + )} +
+ )} ))} diff --git a/assets/admin/components/inventory/PackagesView.tsx b/assets/admin/components/inventory/PackagesView.tsx index c671c50f..25b9c017 100644 --- a/assets/admin/components/inventory/PackagesView.tsx +++ b/assets/admin/components/inventory/PackagesView.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { ChevronDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; import { formatRial } from '../../lib/utils'; import type { InventoryPackage } from '../../hooks/useInventory'; +import { usePermissions } from '../../hooks/usePermissions'; interface Props { packages: InventoryPackage[]; @@ -21,6 +22,9 @@ export default function PackagesView({ packages, onEdit, onDelete }: Props) { } function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick) { + const { can } = usePermissions(); + const canUpdate = can('inventory', 'update'); + const canDelete = can('inventory', 'delete'); const [expanded, setExpanded] = useState(false); const divider =
; @@ -91,20 +95,24 @@ function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick قیمت پکیج: {formatRial(pkg.total)}
- - + {canDelete && ( + + )} + {canUpdate && ( + + )}
diff --git a/assets/admin/pages/AppointmentSettingsPage.tsx b/assets/admin/pages/AppointmentSettingsPage.tsx index c1fd3be1..7ba6ff27 100644 --- a/assets/admin/pages/AppointmentSettingsPage.tsx +++ b/assets/admin/pages/AppointmentSettingsPage.tsx @@ -8,6 +8,7 @@ import FreeVisitPrice from '../components/FreeVisitPrice'; import { ScheduleSection } from '../components/schedule/ScheduleSection'; import type { AddressData } from '../components/schedule/ScheduleSection'; import SearchableSelect from '../components/ui/SearchableSelect'; +import { usePermissions } from '../hooks/usePermissions'; const PERSONAL = 'personal'; @@ -25,6 +26,9 @@ export default function AppointmentSettingsPage() { const doctorUuid = useAuthStore((s) => s.doctorUuid); const dbUuid = useAuthStore((s) => s.dbUuid); const uuid = doctorUuid ?? dbUuid ?? undefined; + const { can } = usePermissions(); + // منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبت‌دهی، فقط مشاهده می‌کند. + const apptReadOnly = !can('appointment_settings', 'update'); // کلینیک‌هایی که پزشک عضوشان است (منبع: پروفایل خود پزشک). const profileQ = useQuery({ @@ -73,7 +77,7 @@ export default function AppointmentSettingsPage() { >

مدیریت نوبت دهی

- + {!uuid ? (
@@ -94,7 +98,7 @@ export default function AppointmentSettingsPage() { />
)} - + )} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 6a67d574..02849383 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -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() { - {!isRepresentation && ( + {!isRepresentation && canCreateAppt && ( + {canCreate && ( + + )} {sectionsLoading ? ( @@ -180,14 +188,20 @@ function ClinicServicesPageInner() {
-
e.stopPropagation()}> - - -
+ {(canUpdate || canDelete) && ( +
e.stopPropagation()}> + {canUpdate && ( + + )} + {canDelete && ( + + )} +
+ )} ))} @@ -215,9 +229,11 @@ function ClinicServicesPageInner() { - + {canCreate && ( + + )} @@ -229,7 +245,7 @@ function ClinicServicesPageInner() {
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
- {allItems.length === 0 && ( + {allItems.length === 0 && canCreate && ( )} @@ -262,9 +278,11 @@ function ClinicServicesPageInner() { > {/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */}
- + {canUpdate ? ( + + ) : }
{item.active ? 'فعال' : 'غیرفعال'} diff --git a/assets/admin/pages/InventoryPage.tsx b/assets/admin/pages/InventoryPage.tsx index 19c0114b..f003a5d4 100644 --- a/assets/admin/pages/InventoryPage.tsx +++ b/assets/admin/pages/InventoryPage.tsx @@ -9,6 +9,7 @@ import InventoryItemsTable from '../components/inventory/InventoryItemsTable'; import PackagesView from '../components/inventory/PackagesView'; import AddItemModal from '../components/inventory/AddItemModal'; import AddPackageModal from '../components/inventory/AddPackageModal'; +import { usePermissions } from '../hooks/usePermissions'; type Tab = 'stock' | 'packages'; @@ -19,6 +20,10 @@ export default function InventoryPage() { createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage, } = useInventory(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can('inventory', 'create'); + const [tab, setTab] = useState('stock'); const [search, setSearch] = useState(''); const [category, setCategory] = useState(''); @@ -80,15 +85,19 @@ export default function InventoryPage() { />
- + {canCreate && ( + + )} ) : (
- + {canCreate && ( + + )}
)} diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 30f0195d..ffed162f 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -41,6 +41,7 @@ import PageHeader from "../components/ui/PageHeader"; import Pagination from "../components/ui/Pagination"; import SearchableSelect from "../components/ui/SearchableSelect"; import PatientRecordInfoForm from "../components/PatientRecordInfoForm"; +import { usePermissions } from "../hooks/usePermissions"; import { GENDER_OPTS, MARITAL_OPTS, @@ -193,6 +194,10 @@ function calcFinalPrice( function MyPatientsPageInner() { const qc = useQueryClient(); const navigate = useNavigate(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can("patients", "create"); + const canUpdate = can("patients", "update"); const [selectedRecord, setSelectedRecord] = useState( null, ); @@ -586,13 +591,15 @@ function MyPatientsPageInner() { title="پرونده بیماران" description="مراجعه‌کنندگان ثبت‌شده شما" action={ - + canCreate ? ( + + ) : undefined } /> @@ -1010,12 +1017,14 @@ function MyPatientsPageInner() { > بازگشت - + {canUpdate && ( + + )} } /> @@ -1087,17 +1096,19 @@ function MyPatientsPageInner() { - + {canUpdate && ( + + )} @@ -1137,9 +1148,11 @@ function MyPatientsPageInner() {
اطلاعات بیمار
- + {canUpdate && ( + + )}
{([ @@ -1204,12 +1217,14 @@ function MyPatientsPageInner() { ) : ( <>
- + {canUpdate && ( + + )} @@ -1285,9 +1300,11 @@ function MyPatientsPageInner() { ) ) : ( - + canUpdate && ( + + ) )} @@ -1355,6 +1372,7 @@ function MyPatientsPageInner() { onViewInvoice={viewInvoice} issuing={issueInvoiceMut.isPending} onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }} + canUpdate={canUpdate} /> setInvoiceUuid(null)}> @@ -1884,6 +1902,7 @@ function VisitDetailModal({ onSettle, onViewInvoice, onEdit, + canUpdate, }: { session: PatientSession | null; onClose: () => void; @@ -1893,6 +1912,7 @@ function VisitDetailModal({ /** کل session پاس می‌شود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */ onViewInvoice: (session: PatientSession) => void; onEdit: (s: PatientSession) => void; + canUpdate: boolean; }) { if (!session) return null; const services = session.services ?? []; @@ -1917,15 +1937,19 @@ function VisitDetailModal({ size="md" footer={ <> - + {canUpdate && ( + + )} {paid ? ( ) : ( - + canUpdate && ( + + ) )} } diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index f96c43d1..2b4057e5 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -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() {
- - سرویس جدید - + {canUpdate && ( + + سرویس جدید + + )}
{sessionsQ.isLoading ? (
در حال بارگذاری...
@@ -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(null); const [uploading, setUploading] = useState(false); @@ -325,12 +334,14 @@ function AttachmentsTab({ uuid }: { uuid: string }) { return (
-
- e.target.files?.[0] && onFile(e.target.files[0])} /> - -
+ {canUpdate && ( +
+ e.target.files?.[0] && onFile(e.target.files[0])} /> + +
+ )} {isLoading ? (
در حال بارگذاری...
@@ -345,7 +356,9 @@ function AttachmentsTab({ uuid }: { uuid: string }) { {a.name} {a.size ?
{formatBytes(a.size)}
: null}
- + {canDelete && ( + + )}
))} @@ -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(null); const [title, setTitle] = useState(''); @@ -396,9 +412,11 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) { return (
-
- -
+ {canUpdate && ( +
+ +
+ )} {isLoading ? (
در حال بارگذاری...
@@ -415,8 +433,12 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) { {m.body &&
{m.body}
}
- - + {canUpdate && ( + + )} + {canDelete && ( + + )}
@@ -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('newest'); const [editTarget, setEditTarget] = useState(null); @@ -534,11 +559,13 @@ function NotesTab({ uuid }: { uuid: string }) { style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }} /> -
- -
+ {canUpdate && ( +
+ +
+ )} {/* سرآیند + مرتب‌سازی */} @@ -591,18 +618,24 @@ function NotesTab({ uuid }: { uuid: string }) { {n.updated_at && (ویرایش‌شده)}
- - - + {canUpdate && ( + + )} + {canUpdate && ( + + )} + {canDelete && ( + + )}
@@ -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 }) { - + {canUpdate && ( + + )} {/* history */} @@ -754,7 +792,9 @@ function CallCenterTab({ uuid }: { uuid: string }) {
{formatDateTime(c.called_at)}
{c.personnel &&
{c.personnel}
} - + {canDelete && ( + + )}
); @@ -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('all'); @@ -837,9 +879,11 @@ function WalletTab({ uuid }: { uuid: string }) {
موجودی کیف پول
{formatRial(balanceRials)}
- + {canUpdate && ( + + )} {/* فیلتر تراکنش‌ها */} diff --git a/assets/admin/pages/PatientsListPage.tsx b/assets/admin/pages/PatientsListPage.tsx index 0c9d0570..4ca6e71f 100644 --- a/assets/admin/pages/PatientsListPage.tsx +++ b/assets/admin/pages/PatientsListPage.tsx @@ -12,6 +12,7 @@ import { formatNumber, toDate } from '../lib/utils'; import Pagination from '../components/ui/Pagination'; import PatientTagsCell from '../components/PatientTagsCell'; import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal'; +import { usePermissions } from '../hooks/usePermissions'; import { SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn, } from '../components/icons/FilesToolbarIcons'; @@ -40,7 +41,7 @@ function countFilters(f: PatientFilters): number { * A single patient card — mirrors tauri `files/list/CardView` pixel-for-pixel * (avatar + name + ⋮ menu header, file-number/mobile rows, tags footer). */ -function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => void; onEdit: () => void }) { +function PatientCard({ r, onView, onEdit, canUpdate }: { r: PatientRecord; onView: () => void; onEdit: () => void; canUpdate: boolean }) { const [menu, setMenu] = useState(false); return (
vo
{ e.stopPropagation(); setMenu(false); }} />
- + {canUpdate && ( + + )}
)} @@ -98,6 +101,10 @@ function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => vo /** پرونده‌ها — patient records list. Ported from tauri /files (default card view). */ export default function PatientsListPage() { const navigate = useNavigate(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can('patients', 'create'); + const canUpdate = can('patients', 'update'); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [view, setView] = useState<'table' | 'card'>('card'); @@ -183,14 +190,16 @@ export default function PatientsListPage() { {formatNumber(activeFilters)} )} - + {canCreate && ( + + )}
@@ -242,7 +251,9 @@ export default function PatientsListPage() { - + {canUpdate && ( + + )} @@ -253,7 +264,7 @@ export default function PatientsListPage() { ) : (
{records.map((r) => ( - navigate(viewHref(r))} onEdit={() => navigate(editHref(r))} /> + navigate(viewHref(r))} onEdit={() => navigate(editHref(r))} canUpdate={canUpdate} /> ))}
)} diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index 9485acfc..2dcc28dd 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -14,6 +14,7 @@ import { formatRial, formatNumber, formatYear, formatDateTime } from '../lib/uti import PageHeader from '../components/ui/PageHeader'; import FeatureGate from '../components/ui/FeatureGate'; import ConfirmDialog from '../components/ui/ConfirmDialog'; +import { usePermissions } from '../hooks/usePermissions'; import ServiceTariffModal from '../components/ServiceTariffModal'; import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; import ServiceItemFormModal from '../components/ServiceItemFormModal'; @@ -160,7 +161,7 @@ function InfoTab({ item }: { item: ServiceItem }) { ); } -function TariffsTab({ item, onManage }: { item: ServiceItem; onManage: () => void }) { +function TariffsTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) { const { data, isLoading } = useQuery>({ queryKey: ['service-tariffs', item.uuid], queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`), @@ -178,7 +179,7 @@ function TariffsTab({ item, onManage }: { item: ServiceItem; onManage: () => voi قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است. - + {canUpdate && } {isLoading ? ( @@ -211,7 +212,7 @@ function TariffsTab({ item, onManage }: { item: ServiceItem; onManage: () => voi ); } -function InsuranceTab({ item, onManage }: { item: ServiceItem; onManage: () => void }) { +function InsuranceTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) { const { data: contractsData, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({ queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'), @@ -228,7 +229,7 @@ function InsuranceTab({ item, onManage }: { item: ServiceItem; onManage: () => v درصد پوشش، فرانشیز و سقف هر بیمه‌گر برای این خدمت. - + {canUpdate && } {isLoading ? ( @@ -286,7 +287,7 @@ function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance ); } -function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) { +function GoodsTab({ item, onEdit, canUpdate }: { item: ServiceItem; onEdit: () => void; canUpdate: boolean }) { const { data, isLoading } = useQuery>({ queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'), @@ -305,7 +306,7 @@ function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) { پکیج آماده و کالاهای تکیِ مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب می‌شوند. - + {canUpdate && } {isLoading ? ( @@ -447,6 +448,9 @@ function ServiceDetailPageInner() { const { uuid } = useParams<{ uuid: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); + // مجوز منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canUpdate = can('services', 'update'); const [tab, setTab] = useState('info'); const [editOpen, setEditOpen] = useState(false); @@ -497,14 +501,16 @@ function ServiceDetailPageInner() { { label: item.name }, ]} action={ -
- - -
+ canUpdate ? ( +
+ + +
+ ) : undefined } /> @@ -528,9 +534,9 @@ function ServiceDetailPageInner() { {tab === 'info' && } - {tab === 'tariffs' && setTariffOpen(true)} />} - {tab === 'insurance' && setInsuranceOpen(true)} />} - {tab === 'goods' && setEditOpen(true)} />} + {tab === 'tariffs' && setTariffOpen(true)} canUpdate={canUpdate} />} + {tab === 'insurance' && setInsuranceOpen(true)} canUpdate={canUpdate} />} + {tab === 'goods' && setEditOpen(true)} canUpdate={canUpdate} />} {tab === 'history' && } ('mellat'); const [logPage, setLogPage] = useState(1); @@ -146,17 +151,19 @@ function SmsWalletPageInner() { transition: 'width 0.4s ease', }} /> - + {canCreate && ( + + )} )} @@ -393,15 +400,17 @@ function SmsWalletPageInner() { {/* footer ذخیره */} -
- -
+ {canUpdate && ( +
+ +
+ )} ) : (
در حال بارگذاری...
diff --git a/assets/admin/pages/StaffPage.tsx b/assets/admin/pages/StaffPage.tsx index ed8b9027..ccbdb43d 100644 --- a/assets/admin/pages/StaffPage.tsx +++ b/assets/admin/pages/StaffPage.tsx @@ -16,6 +16,7 @@ import PageHeader from '../components/ui/PageHeader'; import SettingsLayout from '../components/layout/SettingsLayout'; import { ActiveBadge } from '../components/ui/StatusBadge'; import { numericField } from '../lib/forms'; +import { usePermissions } from '../hooks/usePermissions'; const schema = z.object({ full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'), @@ -30,6 +31,10 @@ const EMPTY: ClinicStaff[] = []; export default function StaffPage() { const qc = useQueryClient(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can('staff', 'create'); + const canUpdate = can('staff', 'update'); const [createOpen, setCreateOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [toggleTarget, setToggleTarget] = useState(null); @@ -138,19 +143,23 @@ export default function StaffPage() { header: 'عملیات', render: (s) => (
- - + {canUpdate && ( + + )} + {canUpdate && ( + + )}
), }, @@ -162,9 +171,11 @@ export default function StaffPage() { title="مدیریت پرسنل" description="لیست پرسنل کلینیک / مطب" action={ - + canCreate ? ( + + ) : undefined } /> @@ -192,9 +203,11 @@ export default function StaffPage() {
هنوز پرسنلی ثبت نشده
اولین عضو تیم خود را اضافه کنید
- + {canCreate && ( + + )} ) : ( diff --git a/assets/admin/pages/TagsSettingsPage.tsx b/assets/admin/pages/TagsSettingsPage.tsx index 99edaf89..36eaf746 100644 --- a/assets/admin/pages/TagsSettingsPage.tsx +++ b/assets/admin/pages/TagsSettingsPage.tsx @@ -10,6 +10,7 @@ import type { ApiResponse } from '../lib/api'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import SettingsLayout from '../components/layout/SettingsLayout'; +import { usePermissions } from '../hooks/usePermissions'; interface TenantTag { uuid: string; name: string; color: string; active: boolean } @@ -29,6 +30,11 @@ const EMPTY: TenantTag[] = []; /** برچسب‌ها — per-tenant tag management inside the settings shell. */ export default function TagsSettingsPage() { const qc = useQueryClient(); + // مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است). + const { can } = usePermissions(); + const canCreate = can('tags', 'create'); + const canUpdate = can('tags', 'update'); + const canDelete = can('tags', 'delete'); const [modal, setModal] = useState<'create' | TenantTag | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -66,7 +72,9 @@ export default function TagsSettingsPage() {

برچسب‌ها

- + {canCreate && ( + + )}
{isLoading ? ( @@ -86,8 +94,12 @@ export default function TagsSettingsPage() { {t.name} {t.active ? 'فعال' : 'غیرفعال'} - - + {canUpdate && ( + + )} + {canDelete && ( + + )}
))}