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
@@ -48,10 +48,22 @@ const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
* 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)})
</button>
</div>
{!readOnly && (
{!readOnly && canCreate && (
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button>
@@ -167,23 +179,23 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
>
<EyeIcon style={{ width: 14, height: 14 }} />
</button>
{!readOnly && (
<>
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</>
{!readOnly && canUpdate && (
<button
className="mini-btn"
title="مدیریت دسترسی‌ها"
onClick={() => setPermissionsFor(doc)}
>
<ShieldCheckIcon style={{ width: 14, height: 14 }} />
</button>
)}
{!readOnly && canDelete && (
<button
className="mini-btn danger"
title="جداسازی از کلینیک"
onClick={() => setDetachDoctorConfirm(doc)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
)}
</div>
</div>
@@ -229,9 +241,9 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<span className={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span>
{!readOnly && (
{!readOnly && (canUpdate || canDelete) && (
<div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && (
{canUpdate && inv.status === 'pending' && (
<button
className="mini-btn"
title="ارسال مجدد"
@@ -241,7 +253,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<ArrowPathIcon style={{ width: 13, height: 13 }} />
</button>
)}
{inv.status !== 'removed' && inv.status !== 'accepted' && (
{canUpdate && inv.status !== 'removed' && inv.status !== 'accepted' && (
<button
className="mini-btn"
title={inv.status === 'suspended' ? 'فعال‌سازی و ارسال مجدد پیامک' : 'تعلیق'}
@@ -251,14 +263,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<NoSymbolIcon style={{ width: 13, height: 13 }} />
</button>
)}
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
{canDelete && (
<button
className="mini-btn danger"
title="حذف"
disabled={deleteInvMut.isPending}
onClick={() => deleteInvMut.mutate(inv.uuid)}
>
<TrashIcon style={{ width: 13, height: 13 }} />
</button>
)}
</div>
)}
</div>
+17 -5
View File
@@ -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<DiscountRuleType, string> = {
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<DiscountRule | null>(null);
@@ -94,9 +100,11 @@ export default function DiscountTab() {
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>قوانین تخفیف عمومی بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه</span>
<button className="btn primary sm" onClick={() => setModal('create')}>
<PlusIcon style={{ width: 15 }} /> قانون جدید
</button>
{canCreate && (
<button className="btn primary sm" onClick={() => setModal('create')}>
<PlusIcon style={{ width: 15 }} /> قانون جدید
</button>
)}
</div>
{isLoading ? (
@@ -129,8 +137,12 @@ export default function DiscountTab() {
<span className={`badge ${r.active ? 'green' : ''}`}>{r.active ? 'فعال' : 'غیرفعال'}</span>
</td>
<td style={{ padding: 10, textAlign: 'left', whiteSpace: 'nowrap' }}>
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button>
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button>
{canUpdate && (
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button>
)}
{canDelete && (
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button>
)}
</td>
</tr>
))}
+8 -6
View File
@@ -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 })
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
<div style={{ display: 'flex', marginTop: 16 }}>
<button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
{!readOnly && (
<div style={{ display: 'flex', marginTop: 16 }}>
<button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}>
{saveMut.isPending ? '...' : 'ذخیره'}
</button>
</div>
)}
</div>
);
}
@@ -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<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
@@ -148,9 +153,11 @@ export default function TenantInsuranceContracts() {
<div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>مدیریت بیمه</h2>
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
{canCreate && (
<button className="btn primary sm" onClick={openAdd}>
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
)}
</div>
{showDoctorPicker && (
@@ -225,6 +232,7 @@ export default function TenantInsuranceContracts() {
onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending}
canUpdate={canUpdate}
/>
))}
</div>
@@ -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 (
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}>
@@ -262,14 +271,18 @@ function ContractCard({ contract: c, open, onToggleRow, onEdit, onToggleStatus,
<ChevronDownIcon style={{ width: 15, color: 'var(--text-3)', transition: 'transform .2s var(--ease)', transform: open ? 'rotate(180deg)' : 'none' }} />
<div style={{ fontWeight: 700, fontSize: 14 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
</div>
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
<PencilIcon style={{ width: 15 }} />
</button>
{canUpdate && (
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
<PencilIcon style={{ width: 15 }} />
</button>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
)}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
</div>
);
@@ -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 <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (!items.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>نوبتی برای این روز ثبت نشده است</div>;
@@ -60,10 +64,18 @@ export default function TurnsTable({
<td style={td}>{a.service_item?.name || '—'}</td>
<td style={td}>{a.staff?.full_name || '—'}</td>
<td style={td}>
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
{canManage ? (
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
) : (
<span className="badge" style={{ color: STATUS_META[a.status]?.color ?? 'var(--text-2)' }}>
{STATUS_META[a.status]?.label ?? a.status}
</span>
)}
</td>
<td style={td}>
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
{(canManage || canCancel)
? <AppointmentActionsMenu appointment={a} queryKey={queryKey} />
: <span style={{ color: 'var(--text-3)' }}></span>}
</td>
</tr>
))}
@@ -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<HTMLButtonElement>(null);
@@ -65,32 +73,36 @@ export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props)
borderRadius: 12, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
}}
>
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onEdit(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
}}
>
<PencilSquareIcon style={{ width: 20, height: 20 }} />
ویرایش
</button>
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onDelete(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'transparent', border: 'none',
cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
}}
>
<TrashIcon style={{ width: 20, height: 20 }} />
حذف
</button>
{canUpdate && (
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onEdit(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
}}
>
<PencilSquareIcon style={{ width: 20, height: 20 }} />
ویرایش
</button>
)}
{canDelete && (
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onDelete(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'transparent', border: 'none',
cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
}}
>
<TrashIcon style={{ width: 20, height: 20 }} />
حذف
</button>
)}
</div>
</Portal>
)}
@@ -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 (
<div style={{ width: '100%' }}>
{/* Desktop table */}
@@ -75,14 +79,20 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
)}
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
<PencilSquareIcon style={{ width: 16 }} />
</button>
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
<TrashIcon style={{ width: 16 }} />
</button>
</div>
{(canUpdate || canDelete) && (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
{canUpdate && (
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
<PencilSquareIcon style={{ width: 16 }} />
</button>
)}
{canDelete && (
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
<TrashIcon style={{ width: 16 }} />
</button>
)}
</div>
)}
</li>
))}
</ul>
@@ -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<Props, 'onEdit' | 'onDelete'>) {
const { can } = usePermissions();
const canUpdate = can('inventory', 'update');
const canDelete = can('inventory', 'delete');
const [expanded, setExpanded] = useState(false);
const divider = <div style={{ height: 1, background: '#d7d7d7', margin: '8px 0' }} className="inv-divider" />;
@@ -91,20 +95,24 @@ function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick
قیمت پکیج: {formatRial(pkg.total)}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn sm ghost"
onClick={() => onDelete(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
>
<TrashIcon style={{ width: 16 }} /> حذف
</button>
<button
className="btn sm ghost"
onClick={() => onEdit(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
>
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
</button>
{canDelete && (
<button
className="btn sm ghost"
onClick={() => onDelete(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
>
<TrashIcon style={{ width: 16 }} /> حذف
</button>
)}
{canUpdate && (
<button
className="btn sm ghost"
onClick={() => onEdit(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
>
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
</button>
)}
</div>
</div>
</div>
@@ -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() {
>
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice />
<FreeVisitPrice readOnly={apptReadOnly} />
{!uuid ? (
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
@@ -94,7 +98,7 @@ export default function AppointmentSettingsPage() {
/>
</div>
)}
<ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} />
<ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} readOnly={apptReadOnly} />
</>
)}
</div>
+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 }}>
@@ -5,6 +5,7 @@ import { UserGroupIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import FreeVisitPrice from '../components/FreeVisitPrice';
@@ -18,6 +19,9 @@ import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
*/
function ClinicAppointmentSettingsContent() {
const { dbUuid, context, availableContexts } = useAuthStore();
const { can } = usePermissions();
// منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبت‌دهی، فقط مشاهده می‌کند.
const apptReadOnly = !can('appointment_settings', 'update');
const [activeUuid, setActiveUuid] = useState<string | null>(null);
// کاربری که هم پزشک است هم مالک کلینیک، dbUuid‌اش ممکن است uuid پزشک باشد.
@@ -99,8 +103,8 @@ function ClinicAppointmentSettingsContent() {
<UserGroupIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} />
<span style={{ fontWeight: 600 }}>{selectedDoctor?.name}</span>
</div>
<FreeVisitPrice doctorUuid={selected} />
<ScheduleSection doctorUuid={selected} clinicUuid={clinicUuid} />
<FreeVisitPrice doctorUuid={selected} readOnly={apptReadOnly} />
<ScheduleSection doctorUuid={selected} clinicUuid={clinicUuid} readOnly={apptReadOnly} />
</div>
)}
</>
+12 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import SettingsLayout from '../components/layout/SettingsLayout';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
@@ -14,6 +15,11 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
*/
function ClinicDoctorsContent() {
const { dbUuid, context, availableContexts, fetchMe } = useAuthStore();
// مجوزهای منشی؛ برای owner/کلینیک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('clinic_doctors', 'create');
const canUpdate = can('clinic_doctors', 'update');
const canDelete = can('clinic_doctors', 'delete');
useEffect(() => {
if (!dbUuid) fetchMe();
@@ -48,7 +54,12 @@ function ClinicDoctorsContent() {
</Link>
</div>
<ClinicDoctorsManager clinicUuid={clinicUuid} />
<ClinicDoctorsManager
clinicUuid={clinicUuid}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</div>
);
}
+36 -18
View File
@@ -15,6 +15,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ServiceSection, ServiceItem } from '../types';
import { formatRial, formatNumber } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ServiceTariffModal from '../components/ServiceTariffModal';
@@ -43,6 +44,11 @@ function Avatar({ name }: { name: string }) {
function ClinicServicesPageInner() {
const qc = useQueryClient();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('services', 'create');
const canUpdate = can('services', 'update');
const canDelete = can('services', 'delete');
const navigate = useNavigate();
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null);
@@ -138,9 +144,11 @@ function ClinicServicesPageInner() {
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<b style={{ fontSize: 16 }}>بخشها</b>
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
<PlusIcon style={{ width: 16 }} /> بخش جدید
</button>
{canCreate && (
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
<PlusIcon style={{ width: 16 }} /> بخش جدید
</button>
)}
</div>
{sectionsLoading ? (
@@ -180,14 +188,20 @@ function ClinicServicesPageInner() {
</span>
</div>
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
<PencilIcon style={{ width: 15 }} />
</button>
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
{(canUpdate || canDelete) && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
{canUpdate && (
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
<PencilIcon style={{ width: 15 }} />
</button>
)}
{canDelete && (
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
)}
</div>
))}
</div>
@@ -215,9 +229,11 @@ function ClinicServicesPageInner() {
<button className="btn sm ghost" onClick={() => setShowInactive((v) => !v)} title={showInactive ? 'پنهان‌کردن غیرفعال‌ها' : 'نمایش غیرفعال‌ها'}>
{showInactive ? <EyeIcon style={{ width: 16 }} /> : <EyeSlashIcon style={{ width: 16 }} />}
</button>
<button className="cp-btn-primary" onClick={openCreateItem}>
<PlusIcon style={{ width: 16 }} /> سرویس جدید
</button>
{canCreate && (
<button className="cp-btn-primary" onClick={openCreateItem}>
<PlusIcon style={{ width: 16 }} /> سرویس جدید
</button>
)}
</div>
</div>
@@ -229,7 +245,7 @@ function ClinicServicesPageInner() {
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
</div>
{allItems.length === 0 && (
{allItems.length === 0 && canCreate && (
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={openCreateItem}>افزودن سرویس</button>
)}
</div>
@@ -262,9 +278,11 @@ function ClinicServicesPageInner() {
>
{/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<button className="btn sm ghost" style={{ padding: 4 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === item.uuid ? null : item.uuid); }} title="عملیات">
<EllipsisHorizontalIcon style={{ width: 20 }} />
</button>
{canUpdate ? (
<button className="btn sm ghost" style={{ padding: 4 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === item.uuid ? null : item.uuid); }} title="عملیات">
<EllipsisHorizontalIcon style={{ width: 20 }} />
</button>
) : <span />}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{item.active ? 'فعال' : 'غیرفعال'}
+15 -6
View File
@@ -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<Tab>('stock');
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
@@ -80,15 +85,19 @@ export default function InventoryPage() {
/>
</div>
</div>
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن کالا
</button>
{canCreate && (
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن کالا
</button>
)}
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn primary" onClick={() => setPkgModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن پکیج
</button>
{canCreate && (
<button className="btn primary" onClick={() => setPkgModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن پکیج
</button>
)}
</div>
)}
</div>
+64 -40
View File
@@ -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<PatientRecord | null>(
null,
);
@@ -586,13 +591,15 @@ function MyPatientsPageInner() {
title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما"
action={
<button
className="btn primary sm"
onClick={() => setCreateRecordOpen(true)}
>
<UserPlusIcon style={{ width: 16 }} />
پرونده جدید
</button>
canCreate ? (
<button
className="btn primary sm"
onClick={() => setCreateRecordOpen(true)}
>
<UserPlusIcon style={{ width: 16 }} />
پرونده جدید
</button>
) : undefined
}
/>
@@ -1010,12 +1017,14 @@ function MyPatientsPageInner() {
>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<button
className="btn primary sm"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
{canUpdate && (
<button
className="btn primary sm"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
)}
</div>
}
/>
@@ -1087,17 +1096,19 @@ function MyPatientsPageInner() {
</span>
</span>
</div>
<button
onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")}
style={{
background: "#F17732", color: "#fff", border: "none", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
}}
>
یادداشت
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
</button>
{canUpdate && (
<button
onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")}
style={{
background: "#F17732", color: "#fff", border: "none", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 6,
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
}}
>
یادداشت
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
</button>
)}
</div>
</div>
@@ -1137,9 +1148,11 @@ function MyPatientsPageInner() {
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>اطلاعات بیمار</div>
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
{canUpdate && (
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
{([
@@ -1204,12 +1217,14 @@ function MyPatientsPageInner() {
) : (
<>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button
className="cp-btn-primary"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button>
{canUpdate && (
<button
className="cp-btn-primary"
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button>
)}
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
<FunnelIcon style={{ width: 18 }} />
</button>
@@ -1285,9 +1300,11 @@ function MyPatientsPageInner() {
</button>
)
) : (
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
تکمیل پرداخت
</button>
canUpdate && (
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
تکمیل پرداخت
</button>
)
)}
</td>
</tr>
@@ -1355,6 +1372,7 @@ function MyPatientsPageInner() {
onViewInvoice={viewInvoice}
issuing={issueInvoiceMut.isPending}
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
canUpdate={canUpdate}
/>
<Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => 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={
<>
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
{canUpdate && (
<button className="cp-btn-ghost" onClick={() => onEdit(session)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
)}
{paid ? (
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button>
) : (
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
canUpdate && (
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
)
)}
</>
}
+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>
{/* فیلتر تراکنش‌ها */}
+23 -12
View File
@@ -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 (
<div
@@ -66,7 +67,9 @@ function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => vo
<div style={{ position: 'fixed', inset: 0, zIndex: 60 }} onClick={(e) => { e.stopPropagation(); setMenu(false); }} />
<div style={{ position: 'absolute', top: 28, insetInlineStart: 0, zIndex: 61, minWidth: 130, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
<button type="button" className="btn sm ghost" style={{ justifyContent: 'flex-start', color: 'var(--primary)' }} onClick={(e) => { e.stopPropagation(); setMenu(false); onView(); }}><EyeIcon style={{ width: 15 }} /> مشاهده</button>
<button type="button" className="btn sm ghost" style={{ justifyContent: 'flex-start', color: 'var(--accent)' }} onClick={(e) => { e.stopPropagation(); setMenu(false); onEdit(); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
{canUpdate && (
<button type="button" className="btn sm ghost" style={{ justifyContent: 'flex-start', color: 'var(--accent)' }} onClick={(e) => { e.stopPropagation(); setMenu(false); onEdit(); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
)}
</div>
</>
)}
@@ -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() {
<span style={{ position: 'absolute', top: -6, insetInlineEnd: -6, minWidth: 16, height: 16, padding: '0 4px', borderRadius: 999, background: '#5559ce', color: '#fff', fontSize: 10, display: 'grid', placeItems: 'center' }}>{formatNumber(activeFilters)}</span>
)}
</button>
<button
type="button" onClick={() => navigate('/admin/patients/new')}
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
style={{ height: 48, minWidth: 137, background: '#5559ce', border: 'none', padding: '0 16px' }}
>
<AddTurn color="#fff" />
<span style={{ color: '#fff', fontSize: 14 }}>تشکیل پرونده</span>
</button>
{canCreate && (
<button
type="button" onClick={() => navigate('/admin/patients/new')}
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer"
style={{ height: 48, minWidth: 137, background: '#5559ce', border: 'none', padding: '0 16px' }}
>
<AddTurn color="#fff" />
<span style={{ color: '#fff', fontSize: 14 }}>تشکیل پرونده</span>
</button>
)}
</div>
</div>
</div>
@@ -242,7 +251,9 @@ export default function PatientsListPage() {
<td style={{ padding: '12px 14px' }}>
<span style={{ display: 'inline-flex', gap: 8, justifyContent: 'center' }}>
<Link to={viewHref(r)} aria-label="مشاهده" style={{ color: 'var(--primary)' }}><EyeIcon style={{ width: 18 }} /></Link>
<Link to={editHref(r)} aria-label="ویرایش" style={{ color: 'var(--accent)' }}><PencilIcon style={{ width: 18 }} /></Link>
{canUpdate && (
<Link to={editHref(r)} aria-label="ویرایش" style={{ color: 'var(--accent)' }}><PencilIcon style={{ width: 18 }} /></Link>
)}
</span>
</td>
</tr>
@@ -253,7 +264,7 @@ export default function PatientsListPage() {
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[12px] mt-[16px]">
{records.map((r) => (
<PatientCard key={r.uuid} r={r} onView={() => navigate(viewHref(r))} onEdit={() => navigate(editHref(r))} />
<PatientCard key={r.uuid} r={r} onView={() => navigate(viewHref(r))} onEdit={() => navigate(editHref(r))} canUpdate={canUpdate} />
))}
</div>
)}
+23 -17
View File
@@ -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<ApiResponse<TariffList>>({
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
قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است.
</div>
</div>
<button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button>
{canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button>}
</div>
{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
درصد پوشش، فرانشیز و سقف هر بیمهگر برای این خدمت.
</div>
</div>
<button className="btn primary sm" onClick={onManage}>مدیریت پوشش</button>
{canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت پوشش</button>}
</div>
{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<ApiResponse<InventoryPackage[]>>({
queryKey: ['inventory-packages'],
queryFn: () => api.get('/api/v1/inventory-packages'),
@@ -305,7 +306,7 @@ function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) {
پکیج آماده و کالاهای تکیِ مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب میشوند.
</div>
</div>
<button className="btn primary sm" onClick={onEdit}>ویرایش کالاها</button>
{canUpdate && <button className="btn primary sm" onClick={onEdit}>ویرایش کالاها</button>}
</div>
{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<TabId>('info');
const [editOpen, setEditOpen] = useState(false);
@@ -497,14 +501,16 @@ function ServiceDetailPageInner() {
{ label: item.name },
]}
action={
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn sm" onClick={() => setToggleOpen(true)}>
{item.active ? 'غیرفعال‌کردن' : 'فعال‌کردن'}
</button>
<button className="btn primary sm" onClick={() => setEditOpen(true)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
</div>
canUpdate ? (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn sm" onClick={() => setToggleOpen(true)}>
{item.active ? 'غیرفعال‌کردن' : 'فعال‌کردن'}
</button>
<button className="btn primary sm" onClick={() => setEditOpen(true)}>
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
</div>
) : undefined
}
/>
@@ -528,9 +534,9 @@ function ServiceDetailPageInner() {
</div>
{tab === 'info' && <InfoTab item={item} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} />}
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
{tab === 'history' && <HistoryTab item={item} />}
<ServiceItemFormModal
+29 -20
View File
@@ -20,6 +20,7 @@ import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate';
import { usePermissions } from '../hooks/usePermissions';
import { numericField } from '../lib/forms';
const chargeSchema = z.object({
@@ -40,6 +41,10 @@ const POST_VISIT_VARS: { key: string; label: string }[] = [
function SmsWalletPageInner() {
const qc = useQueryClient();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('sms', 'create'); // شارژ کیف پول
const canUpdate = can('sms', 'update'); // ذخیره تنظیمات
const [chargeOpen, setChargeOpen] = useState(false);
const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat');
const [logPage, setLogPage] = useState(1);
@@ -146,17 +151,19 @@ function SmsWalletPageInner() {
transition: 'width 0.4s ease',
}} />
</div>
<button
style={{
background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
onClick={() => setChargeOpen(true)}
>
<DevicePhoneMobileIcon style={{ width: 16 }} />
شارژ کیف پول
</button>
{canCreate && (
<button
style={{
background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
onClick={() => setChargeOpen(true)}
>
<DevicePhoneMobileIcon style={{ width: 16 }} />
شارژ کیف پول
</button>
)}
</>
)}
</div>
@@ -393,15 +400,17 @@ function SmsWalletPageInner() {
</div>
{/* footer ذخیره */}
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<button
className="btn primary sm"
disabled={saveMutation.isPending}
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
>
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
</button>
</div>
{canUpdate && (
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<button
className="btn primary sm"
disabled={saveMutation.isPending}
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
>
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
</button>
</div>
)}
</div>
) : (
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
+32 -19
View File
@@ -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<ClinicStaff | null>(null);
const [toggleTarget, setToggleTarget] = useState<ClinicStaff | null>(null);
@@ -138,19 +143,23 @@ export default function StaffPage() {
header: 'عملیات',
render: (s) => (
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش">
<PencilIcon style={{ width: 15 }} />
</button>
<button
className="btn sm"
onClick={() => setToggleTarget(s)}
title={s.active ? 'غیرفعال‌سازی' : 'فعال‌سازی'}
>
{s.active
? <EyeSlashIcon style={{ width: 15 }} />
: <EyeIcon style={{ width: 15 }} />
}
</button>
{canUpdate && (
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش">
<PencilIcon style={{ width: 15 }} />
</button>
)}
{canUpdate && (
<button
className="btn sm"
onClick={() => setToggleTarget(s)}
title={s.active ? 'غیرفعال‌سازی' : 'فعال‌سازی'}
>
{s.active
? <EyeSlashIcon style={{ width: 15 }} />
: <EyeIcon style={{ width: 15 }} />
}
</button>
)}
</div>
),
},
@@ -162,9 +171,11 @@ export default function StaffPage() {
title="مدیریت پرسنل"
description="لیست پرسنل کلینیک / مطب"
action={
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
canCreate ? (
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
) : undefined
}
/>
@@ -192,9 +203,11 @@ export default function StaffPage() {
<UserGroupIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز پرسنلی ثبت نشده</div>
<div style={{ fontSize: 13, marginBottom: 20 }}>اولین عضو تیم خود را اضافه کنید</div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
{canCreate && (
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
)}
</div>
) : (
<DataTable columns={columns} data={staff} loading={isLoading} />
+15 -3
View File
@@ -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<TenantTag | null>(null);
@@ -66,7 +72,9 @@ export default function TagsSettingsPage() {
<div className="fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
<h1 className="section-title">برچسبها</h1>
<button className="btn primary" onClick={openCreate}><PlusIcon style={{ width: 16 }} /> برچسب جدید</button>
{canCreate && (
<button className="btn primary" onClick={openCreate}><PlusIcon style={{ width: 16 }} /> برچسب جدید</button>
)}
</div>
{isLoading ? (
@@ -86,8 +94,12 @@ export default function TagsSettingsPage() {
<span style={{ width: 14, height: 14, borderRadius: '50%', background: t.color, flexShrink: 0, border: '1px solid var(--border)' }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 14 }}>{t.name}</span>
<span className={`badge ${t.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}><span className="bdot" />{t.active ? 'فعال' : 'غیرفعال'}</span>
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => openEdit(t)} style={{ color: 'var(--text-2)' }}><PencilIcon style={{ width: 15 }} /></button>
<button className="btn sm ghost" aria-label="حذف" onClick={() => setDeleteTarget(t)} style={{ color: 'var(--danger)' }}><TrashIcon style={{ width: 15 }} /></button>
{canUpdate && (
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => openEdit(t)} style={{ color: 'var(--text-2)' }}><PencilIcon style={{ width: 15 }} /></button>
)}
{canDelete && (
<button className="btn sm ghost" aria-label="حذف" onClick={() => setDeleteTarget(t)} style={{ color: 'var(--danger)' }}><TrashIcon style={{ width: 15 }} /></button>
)}
</div>
))}
</div>