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 * pending invitations (list, invite, resend, suspend, delete invitation, detach
* doctor). Reused by both the admin ClinicDetailPage and the clinic-owner * doctor). Reused by both the admin ClinicDetailPage and the clinic-owner
* settings tab (ClinicDoctorsPage). `readOnly` hides every mutating control. * 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; clinicUuid: string;
readOnly?: boolean; readOnly?: boolean;
canCreate?: boolean;
canUpdate?: boolean;
canDelete?: boolean;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -126,7 +138,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
دعوتنامهها ({formatNumber(invitationList.length)}) دعوتنامهها ({formatNumber(invitationList.length)})
</button> </button>
</div> </div>
{!readOnly && ( {!readOnly && canCreate && (
<button className="btn primary sm" onClick={() => setInviteOpen(true)}> <button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک <EnvelopeIcon style={{ width: 14, height: 14 }} /> دعوت پزشک
</button> </button>
@@ -167,23 +179,23 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
> >
<EyeIcon style={{ width: 14, height: 14 }} /> <EyeIcon style={{ width: 14, height: 14 }} />
</button> </button>
{!readOnly && ( {!readOnly && canUpdate && (
<> <button
<button className="mini-btn"
className="mini-btn" title="مدیریت دسترسی‌ها"
title="مدیریت دسترسی‌ها" onClick={() => setPermissionsFor(doc)}
onClick={() => setPermissionsFor(doc)} >
> <ShieldCheckIcon style={{ width: 14, height: 14 }} />
<ShieldCheckIcon style={{ width: 14, height: 14 }} /> </button>
</button> )}
<button {!readOnly && canDelete && (
className="mini-btn danger" <button
title="جداسازی از کلینیک" className="mini-btn danger"
onClick={() => setDetachDoctorConfirm(doc)} title="جداسازی از کلینیک"
> onClick={() => setDetachDoctorConfirm(doc)}
<TrashIcon style={{ width: 14, height: 14 }} /> >
</button> <TrashIcon style={{ width: 14, height: 14 }} />
</> </button>
)} )}
</div> </div>
</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={`badge ${isExpired ? 'gray' : statusInfo.cls}`} style={{ fontSize: 11 }}>
<span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label} <span className="bdot" />{isExpired ? 'منقضی' : statusInfo.label}
</span> </span>
{!readOnly && ( {!readOnly && (canUpdate || canDelete) && (
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{inv.status === 'pending' && ( {canUpdate && inv.status === 'pending' && (
<button <button
className="mini-btn" className="mini-btn"
title="ارسال مجدد" title="ارسال مجدد"
@@ -241,7 +253,7 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<ArrowPathIcon style={{ width: 13, height: 13 }} /> <ArrowPathIcon style={{ width: 13, height: 13 }} />
</button> </button>
)} )}
{inv.status !== 'removed' && inv.status !== 'accepted' && ( {canUpdate && inv.status !== 'removed' && inv.status !== 'accepted' && (
<button <button
className="mini-btn" className="mini-btn"
title={inv.status === 'suspended' ? 'فعال‌سازی و ارسال مجدد پیامک' : 'تعلیق'} title={inv.status === 'suspended' ? 'فعال‌سازی و ارسال مجدد پیامک' : 'تعلیق'}
@@ -251,14 +263,16 @@ export default function ClinicDoctorsManager({ clinicUuid, readOnly = false }: {
<NoSymbolIcon style={{ width: 13, height: 13 }} /> <NoSymbolIcon style={{ width: 13, height: 13 }} />
</button> </button>
)} )}
<button {canDelete && (
className="mini-btn danger" <button
title="حذف" className="mini-btn danger"
disabled={deleteInvMut.isPending} title="حذف"
onClick={() => deleteInvMut.mutate(inv.uuid)} disabled={deleteInvMut.isPending}
> onClick={() => deleteInvMut.mutate(inv.uuid)}
<TrashIcon style={{ width: 13, height: 13 }} /> >
</button> <TrashIcon style={{ width: 13, height: 13 }} />
</button>
)}
</div> </div>
)} )}
</div> </div>
+17 -5
View File
@@ -12,6 +12,7 @@ import SearchableSelect from './ui/SearchableSelect';
import PriceInput from './ui/PriceInput'; import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput'; import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils'; import { digitsOnly } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
const TYPE_LABELS: Record<DiscountRuleType, string> = { const TYPE_LABELS: Record<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار', patient_tag: 'تگ بیمار',
@@ -72,6 +73,11 @@ function labelStyle(): React.CSSProperties { return { fontSize: 12.5, color: 'va
export default function DiscountTab() { export default function DiscountTab() {
const qc = useQueryClient(); 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 [modal, setModal] = useState<'create' | DiscountRule | null>(null);
const [toDelete, setToDelete] = useState<DiscountRule | null>(null); const [toDelete, setToDelete] = useState<DiscountRule | null>(null);
@@ -94,9 +100,11 @@ export default function DiscountTab() {
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>قوانین تخفیف عمومی بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه</span> <span style={{ fontSize: 13, color: 'var(--text-3)' }}>قوانین تخفیف عمومی بر اساس تگ، مبلغ، بیمار، مناسبت، سرویس یا تعداد مراجعه</span>
<button className="btn primary sm" onClick={() => setModal('create')}> {canCreate && (
<PlusIcon style={{ width: 15 }} /> قانون جدید <button className="btn primary sm" onClick={() => setModal('create')}>
</button> <PlusIcon style={{ width: 15 }} /> قانون جدید
</button>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -129,8 +137,12 @@ export default function DiscountTab() {
<span className={`badge ${r.active ? 'green' : ''}`}>{r.active ? 'فعال' : 'غیرفعال'}</span> <span className={`badge ${r.active ? 'green' : ''}`}>{r.active ? 'فعال' : 'غیرفعال'}</span>
</td> </td>
<td style={{ padding: 10, textAlign: 'left', whiteSpace: 'nowrap' }}> <td style={{ padding: 10, textAlign: 'left', whiteSpace: 'nowrap' }}>
<button className="mini-btn" onClick={() => setModal(r)} aria-label="ویرایش"><PencilIcon style={{ width: 15 }} /></button> {canUpdate && (
<button className="mini-btn" onClick={() => setToDelete(r)} aria-label="حذف"><TrashIcon style={{ width: 15 }} /></button> <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> </td>
</tr> </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 } interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
/** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */ /** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */
export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) { export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { doctorUuid?: string; readOnly?: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [required, setRequired] = useState(false); const [required, setRequired] = useState(false);
@@ -97,11 +97,13 @@ export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string })
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد. با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p> </p>
<div style={{ display: 'flex', marginTop: 16 }}> {!readOnly && (
<button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}> <div style={{ display: 'flex', marginTop: 16 }}>
{saveMut.isPending ? '...' : 'ذخیره'} <button className="btn primary sm" style={{ marginInlineStart: 'auto' }} disabled={saveMut.isPending} onClick={save}>
</button> {saveMut.isPending ? '...' : 'ذخیره'}
</div> </button>
</div>
)}
</div> </div>
); );
} }
@@ -6,6 +6,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils'; import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import SearchableSelect from './ui/SearchableSelect'; import SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager'; import type { ClinicDoctorItem } from './ClinicDoctorsManager';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal'; import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
@@ -42,6 +43,10 @@ export function contractSummary(c: Contract): string {
export default function TenantInsuranceContracts() { export default function TenantInsuranceContracts() {
const qc = useQueryClient(); const qc = useQueryClient();
const { dbUuid, context, availableContexts } = useAuthStore(); 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 [tab, setTab] = useState<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null); const [editContract, setEditContract] = useState<Contract | null>(null);
@@ -148,9 +153,11 @@ export default function TenantInsuranceContracts() {
<div className="card" style={{ padding: 20 }}> <div className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>مدیریت بیمه</h2> <h2 style={{ fontSize: 15, fontWeight: 700, margin: 0 }}>مدیریت بیمه</h2>
<button className="btn primary sm" onClick={openAdd}> {canCreate && (
<PlusIcon style={{ width: 15 }} /> {activeKind.addLabel} <button className="btn primary sm" onClick={openAdd}>
</button> <PlusIcon style={{ width: 15 }} /> {activeKind.addLabel}
</button>
)}
</div> </div>
{showDoctorPicker && ( {showDoctorPicker && (
@@ -225,6 +232,7 @@ export default function TenantInsuranceContracts() {
onEdit={() => openEdit(c)} onEdit={() => openEdit(c)}
onToggleStatus={() => toggleMut.mutate(c)} onToggleStatus={() => toggleMut.mutate(c)}
statusPending={toggleMut.isPending} statusPending={toggleMut.isPending}
canUpdate={canUpdate}
/> />
))} ))}
</div> </div>
@@ -251,9 +259,10 @@ interface RowProps {
onEdit: () => void; onEdit: () => void;
onToggleStatus: () => void; onToggleStatus: () => void;
statusPending?: boolean; 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(); }; const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); fn(); };
return ( return (
<div style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, cursor: 'pointer' }} onClick={onToggleRow}> <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' }} /> <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 style={{ fontWeight: 700, fontSize: 14 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
</div> </div>
<button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}> {canUpdate && (
<PencilIcon style={{ width: 15 }} /> <button className="mini-btn" title="ویرایش" onClick={stop(onEdit)}>
</button> <PencilIcon style={{ width: 15 }} />
</button>
)}
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div> <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>{contractSummary(c)}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}> {canUpdate && (
<StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} /> <div style={{ display: 'flex', justifyContent: 'flex-end' }} onClick={stop(() => {})}>
</div> <StatusToggle contract={c} onToggle={stop(onToggleStatus)} disabled={statusPending} />
</div>
)}
{open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>} {open && <div style={{ marginTop: 10 }}><ContractDetails contract={c} /></div>}
</div> </div>
); );
@@ -1,6 +1,6 @@
import { UserCircleIcon, PhoneIcon } from '@heroicons/react/24/outline'; import { UserCircleIcon, PhoneIcon } from '@heroicons/react/24/outline';
import type { Appointment } from '../../types'; import type { Appointment } from '../../types';
import AppointmentStatusDropdown from '../ui/AppointmentStatusDropdown'; import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
import AppointmentActionsMenu from '../AppointmentActions'; 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 }; const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle', fontSize: 13 };
export default function TurnsTable({ export default function TurnsTable({
items, loading, queryKey, showDoctor, items, loading, queryKey, showDoctor, canManage = true, canCancel = true,
}: { }: {
items: Appointment[]; items: Appointment[];
loading: boolean; loading: boolean;
queryKey: unknown[]; queryKey: unknown[];
showDoctor: boolean; showDoctor: boolean;
/** مجوز تغییر وضعیت (منشی)؛ پیش‌فرض true برای owner/پزشک. */
canManage?: boolean;
/** مجوز لغو نوبت (منشی)؛ پیش‌فرض true. */
canCancel?: boolean;
}) { }) {
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>; 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>; 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.service_item?.name || '—'}</td>
<td style={td}>{a.staff?.full_name || '—'}</td> <td style={td}>{a.staff?.full_name || '—'}</td>
<td style={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>
<td style={td}> <td style={td}>
<AppointmentActionsMenu appointment={a} queryKey={queryKey} /> {(canManage || canCancel)
? <AppointmentActionsMenu appointment={a} queryKey={queryKey} />
: <span style={{ color: 'var(--text-3)' }}></span>}
</td> </td>
</tr> </tr>
))} ))}
@@ -2,6 +2,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react';
import { EllipsisHorizontalCircleIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; import { EllipsisHorizontalCircleIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import Portal from '../ui/Portal'; import Portal from '../ui/Portal';
import type { InventoryItem } from '../../hooks/useInventory'; import type { InventoryItem } from '../../hooks/useInventory';
import { usePermissions } from '../../hooks/usePermissions';
interface Props { interface Props {
item: InventoryItem; item: InventoryItem;
@@ -17,7 +18,14 @@ const MENU_W = 160;
* table container's `overflow: hidden`. * table container's `overflow: hidden`.
*/ */
export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props) { 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); const [open, setOpen] = useState(false);
// منشیِ بدون هیچ مجوزِ ویرایش/حذف، منوی «عملیات» را اصلاً نبیند.
if (!canUpdate && !canDelete) return null;
const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const [pos, setPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const triggerRef = useRef<HTMLButtonElement>(null); 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', borderRadius: 12, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
}} }}
> >
<button {canUpdate && (
type="button" <button
role="menuitem" type="button"
onClick={() => { setOpen(false); onEdit(item); }} role="menuitem"
style={{ onClick={() => { setOpen(false); onEdit(item); }}
display: 'flex', alignItems: 'center', gap: 8, width: '100%', style={{
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none', display: 'flex', alignItems: 'center', gap: 8, width: '100%',
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)', padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
}} cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
> }}
<PencilSquareIcon style={{ width: 20, height: 20 }} /> >
ویرایش <PencilSquareIcon style={{ width: 20, height: 20 }} />
</button> ویرایش
<button </button>
type="button" )}
role="menuitem" {canDelete && (
onClick={() => { setOpen(false); onDelete(item); }} <button
style={{ type="button"
display: 'flex', alignItems: 'center', gap: 8, width: '100%', role="menuitem"
padding: '12px 16px', background: 'transparent', border: 'none', onClick={() => { setOpen(false); onDelete(item); }}
cursor: 'pointer', fontSize: 14, color: 'var(--danger)', style={{
}} display: 'flex', alignItems: 'center', gap: 8, width: '100%',
> padding: '12px 16px', background: 'transparent', border: 'none',
<TrashIcon style={{ width: 20, height: 20 }} /> cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
حذف }}
</button> >
<TrashIcon style={{ width: 20, height: 20 }} />
حذف
</button>
)}
</div> </div>
</Portal> </Portal>
)} )}
@@ -4,6 +4,7 @@ import { formatRial, formatNumber } from '../../lib/utils';
import type { InventoryItem } from '../../hooks/useInventory'; import type { InventoryItem } from '../../hooks/useInventory';
import InventoryStatusBadge from './InventoryStatusBadge'; import InventoryStatusBadge from './InventoryStatusBadge';
import InventoryActionsMenu from './InventoryActionsMenu'; import InventoryActionsMenu from './InventoryActionsMenu';
import { usePermissions } from '../../hooks/usePermissions';
interface Props { interface Props {
items: InventoryItem[]; items: InventoryItem[];
@@ -15,6 +16,9 @@ const HEAD = ['نام کالا', 'دسته‌بندی', 'موجودی', 'واح
/** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */ /** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */
export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) { export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) {
const { can } = usePermissions();
const canUpdate = can('inventory', 'update');
const canDelete = can('inventory', 'delete');
return ( return (
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
{/* Desktop table */} {/* Desktop table */}
@@ -75,14 +79,20 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
)} )}
</div> </div>
))} ))}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}> {(canUpdate || canDelete) && (
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<PencilSquareIcon style={{ width: 16 }} /> {canUpdate && (
</button> <button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}> <PencilSquareIcon style={{ width: 16 }} />
<TrashIcon style={{ width: 16 }} /> </button>
</button> )}
</div> {canDelete && (
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
<TrashIcon style={{ width: 16 }} />
</button>
)}
</div>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ChevronDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import { formatRial } from '../../lib/utils'; import { formatRial } from '../../lib/utils';
import type { InventoryPackage } from '../../hooks/useInventory'; import type { InventoryPackage } from '../../hooks/useInventory';
import { usePermissions } from '../../hooks/usePermissions';
interface Props { interface Props {
packages: InventoryPackage[]; 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'>) { 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 [expanded, setExpanded] = useState(false);
const divider = <div style={{ height: 1, background: '#d7d7d7', margin: '8px 0' }} className="inv-divider" />; 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)} قیمت پکیج: {formatRial(pkg.total)}
</span> </span>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button {canDelete && (
className="btn sm ghost" <button
onClick={() => onDelete(pkg)} className="btn sm ghost"
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }} onClick={() => onDelete(pkg)}
> style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
<TrashIcon style={{ width: 16 }} /> حذف >
</button> <TrashIcon style={{ width: 16 }} /> حذف
<button </button>
className="btn sm ghost" )}
onClick={() => onEdit(pkg)} {canUpdate && (
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }} <button
> className="btn sm ghost"
<PencilSquareIcon style={{ width: 16 }} /> ویرایش onClick={() => onEdit(pkg)}
</button> style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
>
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -8,6 +8,7 @@ import FreeVisitPrice from '../components/FreeVisitPrice';
import { ScheduleSection } from '../components/schedule/ScheduleSection'; import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection'; import type { AddressData } from '../components/schedule/ScheduleSection';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { usePermissions } from '../hooks/usePermissions';
const PERSONAL = 'personal'; const PERSONAL = 'personal';
@@ -25,6 +26,9 @@ export default function AppointmentSettingsPage() {
const doctorUuid = useAuthStore((s) => s.doctorUuid); const doctorUuid = useAuthStore((s) => s.doctorUuid);
const dbUuid = useAuthStore((s) => s.dbUuid); const dbUuid = useAuthStore((s) => s.dbUuid);
const uuid = doctorUuid ?? dbUuid ?? undefined; const uuid = doctorUuid ?? dbUuid ?? undefined;
const { can } = usePermissions();
// منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبت‌دهی، فقط مشاهده می‌کند.
const apptReadOnly = !can('appointment_settings', 'update');
// کلینیک‌هایی که پزشک عضوشان است (منبع: پروفایل خود پزشک). // کلینیک‌هایی که پزشک عضوشان است (منبع: پروفایل خود پزشک).
const profileQ = useQuery({ const profileQ = useQuery({
@@ -73,7 +77,7 @@ export default function AppointmentSettingsPage() {
> >
<h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1> <h1 className="section-title" style={{ marginBottom: 16 }}>مدیریت نوبت دهی</h1>
<FreeVisitPrice /> <FreeVisitPrice readOnly={apptReadOnly} />
{!uuid ? ( {!uuid ? (
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}> <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
@@ -94,7 +98,7 @@ export default function AppointmentSettingsPage() {
/> />
</div> </div>
)} )}
<ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} /> <ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} readOnly={apptReadOnly} />
</> </>
)} )}
</div> </div>
+10 -2
View File
@@ -26,6 +26,7 @@ import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
import DoctorTabs from '../components/appointments/DoctorTabs'; import DoctorTabs from '../components/appointments/DoctorTabs';
import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable'; import TurnsTable from '../components/appointments/TurnsTable';
import { usePermissions } from '../hooks/usePermissions';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus'; import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
@@ -497,6 +498,11 @@ export default function AppointmentsPage() {
// منشیِ محیطِ کلینیک باید مثل کلینیک چندپزشکه رفتار کند: تب پزشکان + تایم‌لاین. // منشیِ محیطِ کلینیک باید مثل کلینیک چندپزشکه رفتار کند: تب پزشکان + تایم‌لاین.
// dbUuid در این محیط uuid کلینیک است (نه پزشک) — همان مبنای clinic/doctor-list. // dbUuid در این محیط uuid کلینیک است (نه پزشک) — همان مبنای clinic/doctor-list.
const isClinicScopedSecretary = primaryRole === 'secretary' && scope === 'clinic'; 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 [params] = useSearchParams();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@@ -715,7 +721,7 @@ export default function AppointmentsPage() {
// ── Slot click → quick booking modal // ── Slot click → quick booking modal
function handleSlotClick(slot: TimelineSlot) { function handleSlotClick(slot: TimelineSlot) {
if (isRepresentation) return; if (isRepresentation || !canCreateAppt) return;
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
setBookingSlot({ setBookingSlot({
start: slot.start, start: slot.start,
@@ -786,7 +792,7 @@ export default function AppointmentsPage() {
<AdjustmentsHorizontalIcon style={{ width: 16 }} /> <AdjustmentsHorizontalIcon style={{ width: 16 }} />
</button> </button>
{!isRepresentation && ( {!isRepresentation && canCreateAppt && (
<button <button
className="btn primary sm" className="btn primary sm"
onClick={() => { onClick={() => {
@@ -817,6 +823,8 @@ export default function AppointmentsPage() {
loading={apptQuery.isLoading} loading={apptQuery.isLoading}
queryKey={apptQueryKey} queryKey={apptQueryKey}
showDoctor={showDoctorCol} showDoctor={showDoctorCol}
canManage={canManageAppt}
canCancel={canCancelAppt}
/> />
{filteredAppointments.length > TABLE_PAGE_SIZE && ( {filteredAppointments.length > TABLE_PAGE_SIZE && (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
@@ -5,6 +5,7 @@ import { UserGroupIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import SettingsLayout from '../components/layout/SettingsLayout'; import SettingsLayout from '../components/layout/SettingsLayout';
import { ScheduleSection } from '../components/schedule/ScheduleSection'; import { ScheduleSection } from '../components/schedule/ScheduleSection';
import FreeVisitPrice from '../components/FreeVisitPrice'; import FreeVisitPrice from '../components/FreeVisitPrice';
@@ -18,6 +19,9 @@ import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
*/ */
function ClinicAppointmentSettingsContent() { function ClinicAppointmentSettingsContent() {
const { dbUuid, context, availableContexts } = useAuthStore(); const { dbUuid, context, availableContexts } = useAuthStore();
const { can } = usePermissions();
// منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبت‌دهی، فقط مشاهده می‌کند.
const apptReadOnly = !can('appointment_settings', 'update');
const [activeUuid, setActiveUuid] = useState<string | null>(null); const [activeUuid, setActiveUuid] = useState<string | null>(null);
// کاربری که هم پزشک است هم مالک کلینیک، dbUuid‌اش ممکن است uuid پزشک باشد. // کاربری که هم پزشک است هم مالک کلینیک، dbUuid‌اش ممکن است uuid پزشک باشد.
@@ -99,8 +103,8 @@ function ClinicAppointmentSettingsContent() {
<UserGroupIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} /> <UserGroupIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} />
<span style={{ fontWeight: 600 }}>{selectedDoctor?.name}</span> <span style={{ fontWeight: 600 }}>{selectedDoctor?.name}</span>
</div> </div>
<FreeVisitPrice doctorUuid={selected} /> <FreeVisitPrice doctorUuid={selected} readOnly={apptReadOnly} />
<ScheduleSection doctorUuid={selected} clinicUuid={clinicUuid} /> <ScheduleSection doctorUuid={selected} clinicUuid={clinicUuid} readOnly={apptReadOnly} />
</div> </div>
)} )}
</> </>
+12 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline'; import { PencilIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import SettingsLayout from '../components/layout/SettingsLayout'; import SettingsLayout from '../components/layout/SettingsLayout';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager'; import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
@@ -14,6 +15,11 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
*/ */
function ClinicDoctorsContent() { function ClinicDoctorsContent() {
const { dbUuid, context, availableContexts, fetchMe } = useAuthStore(); 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(() => { useEffect(() => {
if (!dbUuid) fetchMe(); if (!dbUuid) fetchMe();
@@ -48,7 +54,12 @@ function ClinicDoctorsContent() {
</Link> </Link>
</div> </div>
<ClinicDoctorsManager clinicUuid={clinicUuid} /> <ClinicDoctorsManager
clinicUuid={clinicUuid}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</div> </div>
); );
} }
+36 -18
View File
@@ -15,6 +15,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import type { ServiceSection, ServiceItem } from '../types'; import type { ServiceSection, ServiceItem } from '../types';
import { formatRial, formatNumber } from '../lib/utils'; import { formatRial, formatNumber } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import Modal from '../components/ui/Modal'; import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import ServiceTariffModal from '../components/ServiceTariffModal'; import ServiceTariffModal from '../components/ServiceTariffModal';
@@ -43,6 +44,11 @@ function Avatar({ name }: { name: string }) {
function ClinicServicesPageInner() { function ClinicServicesPageInner() {
const qc = useQueryClient(); 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 navigate = useNavigate();
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null); 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 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<b style={{ fontSize: 16 }}>بخشها</b> <b style={{ fontSize: 16 }}>بخشها</b>
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}> {canCreate && (
<PlusIcon style={{ width: 16 }} /> بخش جدید <button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
</button> <PlusIcon style={{ width: 16 }} /> بخش جدید
</button>
)}
</div> </div>
{sectionsLoading ? ( {sectionsLoading ? (
@@ -180,14 +188,20 @@ function ClinicServicesPageInner() {
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}> {(canUpdate || canDelete) && (
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}> <div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<PencilIcon style={{ width: 15 }} /> {canUpdate && (
</button> <button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}> <PencilIcon style={{ width: 15 }} />
<TrashIcon style={{ width: 15 }} /> </button>
</button> )}
</div> {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>
))} ))}
</div> </div>
@@ -215,9 +229,11 @@ function ClinicServicesPageInner() {
<button className="btn sm ghost" onClick={() => setShowInactive((v) => !v)} title={showInactive ? 'پنهان‌کردن غیرفعال‌ها' : 'نمایش غیرفعال‌ها'}> <button className="btn sm ghost" onClick={() => setShowInactive((v) => !v)} title={showInactive ? 'پنهان‌کردن غیرفعال‌ها' : 'نمایش غیرفعال‌ها'}>
{showInactive ? <EyeIcon style={{ width: 16 }} /> : <EyeSlashIcon style={{ width: 16 }} />} {showInactive ? <EyeIcon style={{ width: 16 }} /> : <EyeSlashIcon style={{ width: 16 }} />}
</button> </button>
<button className="cp-btn-primary" onClick={openCreateItem}> {canCreate && (
<PlusIcon style={{ width: 16 }} /> سرویس جدید <button className="cp-btn-primary" onClick={openCreateItem}>
</button> <PlusIcon style={{ width: 16 }} /> سرویس جدید
</button>
)}
</div> </div>
</div> </div>
@@ -229,7 +245,7 @@ function ClinicServicesPageInner() {
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}> <div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'} {allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
</div> </div>
{allItems.length === 0 && ( {allItems.length === 0 && canCreate && (
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={openCreateItem}>افزودن سرویس</button> <button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={openCreateItem}>افزودن سرویس</button>
)} )}
</div> </div>
@@ -262,9 +278,11 @@ function ClinicServicesPageInner() {
> >
{/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */} {/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <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="عملیات"> {canUpdate ? (
<EllipsisHorizontalIcon style={{ width: 20 }} /> <button className="btn sm ghost" style={{ padding: 4 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === item.uuid ? null : item.uuid); }} title="عملیات">
</button> <EllipsisHorizontalIcon style={{ width: 20 }} />
</button>
) : <span />}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}> <span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{item.active ? 'فعال' : 'غیرفعال'} <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 PackagesView from '../components/inventory/PackagesView';
import AddItemModal from '../components/inventory/AddItemModal'; import AddItemModal from '../components/inventory/AddItemModal';
import AddPackageModal from '../components/inventory/AddPackageModal'; import AddPackageModal from '../components/inventory/AddPackageModal';
import { usePermissions } from '../hooks/usePermissions';
type Tab = 'stock' | 'packages'; type Tab = 'stock' | 'packages';
@@ -19,6 +20,10 @@ export default function InventoryPage() {
createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage, createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage,
} = useInventory(); } = useInventory();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('inventory', 'create');
const [tab, setTab] = useState<Tab>('stock'); const [tab, setTab] = useState<Tab>('stock');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
@@ -80,15 +85,19 @@ export default function InventoryPage() {
/> />
</div> </div>
</div> </div>
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}> {canCreate && (
<PlusIcon style={{ width: 16 }} /> افزودن کالا <button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
</button> <PlusIcon style={{ width: 16 }} /> افزودن کالا
</button>
)}
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn primary" onClick={() => setPkgModal({ open: true, editing: null })}> {canCreate && (
<PlusIcon style={{ width: 16 }} /> افزودن پکیج <button className="btn primary" onClick={() => setPkgModal({ open: true, editing: null })}>
</button> <PlusIcon style={{ width: 16 }} /> افزودن پکیج
</button>
)}
</div> </div>
)} )}
</div> </div>
+64 -40
View File
@@ -41,6 +41,7 @@ import PageHeader from "../components/ui/PageHeader";
import Pagination from "../components/ui/Pagination"; import Pagination from "../components/ui/Pagination";
import SearchableSelect from "../components/ui/SearchableSelect"; import SearchableSelect from "../components/ui/SearchableSelect";
import PatientRecordInfoForm from "../components/PatientRecordInfoForm"; import PatientRecordInfoForm from "../components/PatientRecordInfoForm";
import { usePermissions } from "../hooks/usePermissions";
import { import {
GENDER_OPTS, GENDER_OPTS,
MARITAL_OPTS, MARITAL_OPTS,
@@ -193,6 +194,10 @@ function calcFinalPrice(
function MyPatientsPageInner() { function MyPatientsPageInner() {
const qc = useQueryClient(); const qc = useQueryClient();
const navigate = useNavigate(); 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>( const [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(
null, null,
); );
@@ -586,13 +591,15 @@ function MyPatientsPageInner() {
title="پرونده بیماران" title="پرونده بیماران"
description="مراجعه‌کنندگان ثبت‌شده شما" description="مراجعه‌کنندگان ثبت‌شده شما"
action={ action={
<button canCreate ? (
className="btn primary sm" <button
onClick={() => setCreateRecordOpen(true)} className="btn primary sm"
> onClick={() => setCreateRecordOpen(true)}
<UserPlusIcon style={{ width: 16 }} /> >
پرونده جدید <UserPlusIcon style={{ width: 16 }} />
</button> پرونده جدید
</button>
) : undefined
} }
/> />
@@ -1010,12 +1017,14 @@ function MyPatientsPageInner() {
> >
<ChevronRightIcon style={{ width: 15 }} /> بازگشت <ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button> </button>
<button {canUpdate && (
className="btn primary sm" <button
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)} className="btn primary sm"
> onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
<PlusIcon style={{ width: 15 }} /> مراجعه جدید >
</button> <PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
)}
</div> </div>
} }
/> />
@@ -1087,17 +1096,19 @@ function MyPatientsPageInner() {
</span> </span>
</span> </span>
</div> </div>
<button {canUpdate && (
onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")} <button
style={{ onClick={() => toast.info("امکان یادداشت به‌زودی اضافه می‌شود")}
background: "#F17732", color: "#fff", border: "none", cursor: "pointer", style={{
display: "inline-flex", alignItems: "center", gap: 6, background: "#F17732", color: "#fff", border: "none", cursor: "pointer",
padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 6,
}} padding: "8px 16px", borderRadius: 12, fontSize: 15, fontWeight: 500,
> }}
یادداشت >
<ChatBubbleLeftEllipsisIcon style={{ width: 20 }} /> یادداشت
</button> <ChatBubbleLeftEllipsisIcon style={{ width: 20 }} />
</button>
)}
</div> </div>
</div> </div>
@@ -1137,9 +1148,11 @@ function MyPatientsPageInner() {
<div className="card" style={{ padding: 16, marginBottom: 16 }}> <div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>اطلاعات بیمار</div> <div style={{ fontWeight: 600, fontSize: 14 }}>اطلاعات بیمار</div>
<button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}> {canUpdate && (
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش <button className="cp-btn-secondary" style={{ height: 34, padding: "0 12px", fontSize: 13 }} onClick={openEditInfo}>
</button> <PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
)}
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}> <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 }}> <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button {canUpdate && (
className="cp-btn-primary" <button
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)} className="cp-btn-primary"
> onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
<PlusIcon style={{ width: 16 }} /> مراجعه جدید >
</button> <PlusIcon style={{ width: 16 }} /> مراجعه جدید
</button>
)}
<button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر"> <button className="cp-btn-secondary" style={{ padding: "0 12px" }} title="فیلتر">
<FunnelIcon style={{ width: 18 }} /> <FunnelIcon style={{ width: 18 }} />
</button> </button>
@@ -1285,9 +1300,11 @@ function MyPatientsPageInner() {
</button> </button>
) )
) : ( ) : (
<button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}> canUpdate && (
تکمیل پرداخت <button className="cp-btn-primary" style={{ height: 32, padding: "0 12px" }} disabled={settleSessionMut.isPending} onClick={() => settleSessionMut.mutate(s.uuid)}>
</button> تکمیل پرداخت
</button>
)
)} )}
</td> </td>
</tr> </tr>
@@ -1355,6 +1372,7 @@ function MyPatientsPageInner() {
onViewInvoice={viewInvoice} onViewInvoice={viewInvoice}
issuing={issueInvoiceMut.isPending} issuing={issueInvoiceMut.isPending}
onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }} onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }}
canUpdate={canUpdate}
/> />
<Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}> <Modal open={!!invoiceUuid} title="فاکتور" size="md" onClose={() => setInvoiceUuid(null)}>
@@ -1884,6 +1902,7 @@ function VisitDetailModal({
onSettle, onSettle,
onViewInvoice, onViewInvoice,
onEdit, onEdit,
canUpdate,
}: { }: {
session: PatientSession | null; session: PatientSession | null;
onClose: () => void; onClose: () => void;
@@ -1893,6 +1912,7 @@ function VisitDetailModal({
/** کل session پاس می‌شود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */ /** کل session پاس می‌شود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */
onViewInvoice: (session: PatientSession) => void; onViewInvoice: (session: PatientSession) => void;
onEdit: (s: PatientSession) => void; onEdit: (s: PatientSession) => void;
canUpdate: boolean;
}) { }) {
if (!session) return null; if (!session) return null;
const services = session.services ?? []; const services = session.services ?? [];
@@ -1917,15 +1937,19 @@ function VisitDetailModal({
size="md" size="md"
footer={ footer={
<> <>
<button className="cp-btn-ghost" onClick={() => onEdit(session)}> {canUpdate && (
<PencilIcon style={{ width: 15 }} /> ویرایش <button className="cp-btn-ghost" onClick={() => onEdit(session)}>
</button> <PencilIcon style={{ width: 15 }} /> ویرایش
</button>
)}
{paid ? ( {paid ? (
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}> <button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'} {issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button> </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, profileToFormValues, formValuesToPayload,
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS, GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
} from '../lib/patientForm'; } from '../lib/patientForm';
import { usePermissions } from '../hooks/usePermissions';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records'; 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 "جزئیات پرونده"). */ /** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */
export default function PatientDetailPage() { export default function PatientDetailPage() {
const { uuid } = useParams<{ uuid: string }>(); 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 // ?tab=wallet etc. lets other pages (e.g. appointment forms) deep-link a tab
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const requested = searchParams.get('tab') as TabKey | null; 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 === 'all' ? 'on' : ''} onClick={() => setSessionFilter('all')}>همه</button>
<button className={sessionFilter === 'archived' ? 'on' : ''} onClick={() => setSessionFilter('archived')}>آرشیو</button> <button className={sessionFilter === 'archived' ? 'on' : ''} onClick={() => setSessionFilter('archived')}>آرشیو</button>
</div> </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 }}> {canUpdate && (
<AddTurn color="#fff" /> سرویس جدید <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 }}>
</Link> <AddTurn color="#fff" /> سرویس جدید
</Link>
)}
</div> </div>
{sessionsQ.isLoading ? ( {sessionsQ.isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div> <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. */ /** ضمیمه — patient attachments: upload (raw body), list, delete. */
function AttachmentsTab({ uuid }: { uuid: string }) { function AttachmentsTab({ uuid }: { uuid: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -325,12 +334,14 @@ function AttachmentsTab({ uuid }: { uuid: string }) {
return ( return (
<div> <div>
<div style={{ marginBottom: 14 }}> {canUpdate && (
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} /> <div style={{ marginBottom: 14 }}>
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}> <input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'} <button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
</button> <ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
</div> </button>
</div>
)}
{isLoading ? ( {isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div> <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 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} {a.size ? <div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatBytes(a.size)}</div> : null}
</div> </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>
))} ))}
</div> </div>
@@ -359,6 +372,9 @@ interface MedicalItem { uuid: string; title: string; body?: string | null; recor
/** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */ /** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */
function MedicalRecordsTab({ uuid }: { uuid: string }) { function MedicalRecordsTab({ uuid }: { uuid: string }) {
const qc = useQueryClient(); 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 [modal, setModal] = useState<'create' | MedicalItem | null>(null);
const [delTarget, setDelTarget] = useState<MedicalItem | null>(null); const [delTarget, setDelTarget] = useState<MedicalItem | null>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -396,9 +412,11 @@ function MedicalRecordsTab({ uuid }: { uuid: string }) {
return ( return (
<div> <div>
<div style={{ marginBottom: 14 }}> {canUpdate && (
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button> <div style={{ marginBottom: 14 }}>
</div> <button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</button>
</div>
)}
{isLoading ? ( {isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div> <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>} {m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <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> {canUpdate && (
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button> <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> </div>
</div> </div>
@@ -479,6 +501,9 @@ type NoteSort = 'newest' | 'oldest';
*/ */
function NotesTab({ uuid }: { uuid: string }) { function NotesTab({ uuid }: { uuid: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [sort, setSort] = useState<NoteSort>('newest'); const [sort, setSort] = useState<NoteSort>('newest');
const [editTarget, setEditTarget] = useState<Note | null>(null); 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)' }} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical', color: 'var(--text)' }}
/> />
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> {canUpdate && (
<button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<PlusIcon style={{ width: 16 }} /> ذخیره یادداشت <button className="btn primary" disabled={!body.trim() || create.isPending} onClick={() => create.mutate()}>
</button> <PlusIcon style={{ width: 16 }} /> ذخیره یادداشت
</div> </button>
</div>
)}
</div> </div>
{/* سرآیند + مرتب‌سازی */} {/* سرآیند + مرتب‌سازی */}
@@ -591,18 +618,24 @@ function NotesTab({ uuid }: { uuid: string }) {
{n.updated_at && <span style={{ fontSize: 11 }}>(ویرایششده)</span>} {n.updated_at && <span style={{ fontSize: 11 }}>(ویرایششده)</span>}
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button {canUpdate && (
className="btn sm ghost" <button
aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'} className="btn sm ghost"
title={n.pinned ? 'برداشتن پین' : 'پین کردن'} aria-label={n.pinned ? 'برداشتن پین' : 'پین کردن'}
style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }} title={n.pinned ? 'برداشتن پین' : 'پین کردن'}
disabled={update.isPending} style={{ color: n.pinned ? 'var(--accent)' : 'var(--text-3)' }}
onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })} disabled={update.isPending}
> onClick={() => update.mutate({ u: n.uuid, patch: { pinned: !n.pinned } })}
<PinIcon filled={n.pinned} color="currentColor" /> >
</button> <PinIcon filled={n.pinned} color="currentColor" />
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => openEdit(n)}><PencilIcon style={{ width: 15 }} /></button> </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="ویرایش" 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> </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). */ /** کال سنتر — patient call log: register a call + filterable history (all / success / missed). */
function CallCenterTab({ uuid }: { uuid: string }) { function CallCenterTab({ uuid }: { uuid: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
const canDelete = can('patients', 'delete');
const userName = useAuthStore((s) => s.userName); const userName = useAuthStore((s) => s.userName);
const [filter, setFilter] = useState<'all' | 'success' | 'missed'>('all'); const [filter, setFilter] = useState<'all' | 'success' | 'missed'>('all');
const [date, setDate] = useState(nowDate); 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('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> <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> </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> </div>
{/* history */} {/* history */}
@@ -754,7 +792,9 @@ function CallCenterTab({ uuid }: { uuid: string }) {
<div style={{ textAlign: 'end', minWidth: 120 }}> <div style={{ textAlign: 'end', minWidth: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDateTime(c.called_at)}</div> <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>} {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>
</div> </div>
); );
@@ -787,6 +827,8 @@ type WalletRow = WalletTxn & { row_no: number };
* روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل. * روش پرداخت، دلیل و وضعیت). طراحی مطابق پنل.
*/ */
function WalletTab({ uuid }: { uuid: string }) { function WalletTab({ uuid }: { uuid: string }) {
const { can } = usePermissions();
const canUpdate = can('patients', 'update');
const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid); const { balanceRials, transactions, isLoading, charge, withdraw } = usePatientWallet(uuid);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [filter, setFilter] = useState<WalletFilter>('all'); 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: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
<div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div> <div style={{ fontSize: 24, fontWeight: 800, color: 'var(--primary)', direction: 'ltr' }}>{formatRial(balanceRials)}</div>
</div> </div>
<button className="btn primary" onClick={() => setModalOpen(true)}> {canUpdate && (
<PlusIcon style={{ width: 16 }} /> شارژ کیف پول <button className="btn primary" onClick={() => setModalOpen(true)}>
</button> <PlusIcon style={{ width: 16 }} /> شارژ کیف پول
</button>
)}
</div> </div>
{/* فیلتر تراکنش‌ها */} {/* فیلتر تراکنش‌ها */}
+23 -12
View File
@@ -12,6 +12,7 @@ import { formatNumber, toDate } from '../lib/utils';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
import PatientTagsCell from '../components/PatientTagsCell'; import PatientTagsCell from '../components/PatientTagsCell';
import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal'; import PatientsFilterModal, { type PatientFilters } from '../components/PatientsFilterModal';
import { usePermissions } from '../hooks/usePermissions';
import { import {
SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn, SearchHeaderP, TurnsFilter, PatientsGridView, PatientsCategoryView, AddTurn,
} from '../components/icons/FilesToolbarIcons'; } 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 * A single patient card — mirrors tauri `files/list/CardView` pixel-for-pixel
* (avatar + name + ⋮ menu header, file-number/mobile rows, tags footer). * (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); const [menu, setMenu] = useState(false);
return ( return (
<div <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: '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 }}> <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(--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> </div>
</> </>
)} )}
@@ -98,6 +101,10 @@ function PatientCard({ r, onView, onEdit }: { r: PatientRecord; onView: () => vo
/** پرونده‌ها — patient records list. Ported from tauri /files (default card view). */ /** پرونده‌ها — patient records list. Ported from tauri /files (default card view). */
export default function PatientsListPage() { export default function PatientsListPage() {
const navigate = useNavigate(); 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 [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [view, setView] = useState<'table' | 'card'>('card'); 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> <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>
<button {canCreate && (
type="button" onClick={() => navigate('/admin/patients/new')} <button
className="flex items-center justify-center gap-2 rounded-[4px] cursor-pointer" type="button" onClick={() => navigate('/admin/patients/new')}
style={{ height: 48, minWidth: 137, background: '#5559ce', border: 'none', padding: '0 16px' }} 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> <AddTurn color="#fff" />
</button> <span style={{ color: '#fff', fontSize: 14 }}>تشکیل پرونده</span>
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -242,7 +251,9 @@ export default function PatientsListPage() {
<td style={{ padding: '12px 14px' }}> <td style={{ padding: '12px 14px' }}>
<span style={{ display: 'inline-flex', gap: 8, justifyContent: 'center' }}> <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={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> </span>
</td> </td>
</tr> </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]"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[12px] mt-[16px]">
{records.map((r) => ( {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> </div>
)} )}
+23 -17
View File
@@ -14,6 +14,7 @@ import { formatRial, formatNumber, formatYear, formatDateTime } from '../lib/uti
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate'; import FeatureGate from '../components/ui/FeatureGate';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import { usePermissions } from '../hooks/usePermissions';
import ServiceTariffModal from '../components/ServiceTariffModal'; import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal'; import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal'; 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>>({ const { data, isLoading } = useQuery<ApiResponse<TariffList>>({
queryKey: ['service-tariffs', item.uuid], queryKey: ['service-tariffs', item.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`), queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`),
@@ -178,7 +179,7 @@ function TariffsTab({ item, onManage }: { item: ServiceItem; onManage: () => voi
قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است. قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است.
</div> </div>
</div> </div>
<button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button> {canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button>}
</div> </div>
{isLoading ? ( {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[] } }>({ const { data: contractsData, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
queryKey: ['tenant-insurances'], queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'), queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
@@ -228,7 +229,7 @@ function InsuranceTab({ item, onManage }: { item: ServiceItem; onManage: () => v
درصد پوشش، فرانشیز و سقف هر بیمهگر برای این خدمت. درصد پوشش، فرانشیز و سقف هر بیمهگر برای این خدمت.
</div> </div>
</div> </div>
<button className="btn primary sm" onClick={onManage}>مدیریت پوشش</button> {canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت پوشش</button>}
</div> </div>
{isLoading ? ( {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[]>>({ const { data, isLoading } = useQuery<ApiResponse<InventoryPackage[]>>({
queryKey: ['inventory-packages'], queryKey: ['inventory-packages'],
queryFn: () => api.get('/api/v1/inventory-packages'), queryFn: () => api.get('/api/v1/inventory-packages'),
@@ -305,7 +306,7 @@ function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) {
پکیج آماده و کالاهای تکیِ مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب میشوند. پکیج آماده و کالاهای تکیِ مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب میشوند.
</div> </div>
</div> </div>
<button className="btn primary sm" onClick={onEdit}>ویرایش کالاها</button> {canUpdate && <button className="btn primary sm" onClick={onEdit}>ویرایش کالاها</button>}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -447,6 +448,9 @@ function ServiceDetailPageInner() {
const { uuid } = useParams<{ uuid: string }>(); const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
// مجوز منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canUpdate = can('services', 'update');
const [tab, setTab] = useState<TabId>('info'); const [tab, setTab] = useState<TabId>('info');
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -497,14 +501,16 @@ function ServiceDetailPageInner() {
{ label: item.name }, { label: item.name },
]} ]}
action={ action={
<div style={{ display: 'flex', gap: 8 }}> canUpdate ? (
<button className="btn sm" onClick={() => setToggleOpen(true)}> <div style={{ display: 'flex', gap: 8 }}>
{item.active ? 'غیرفعال‌کردن' : 'فعال‌کردن'} <button className="btn sm" onClick={() => setToggleOpen(true)}>
</button> {item.active ? 'غیرفعال‌کردن' : 'فعال‌کردن'}
<button className="btn primary sm" onClick={() => setEditOpen(true)}> </button>
<PencilIcon style={{ width: 15 }} /> ویرایش <button className="btn primary sm" onClick={() => setEditOpen(true)}>
</button> <PencilIcon style={{ width: 15 }} /> ویرایش
</div> </button>
</div>
) : undefined
} }
/> />
@@ -528,9 +534,9 @@ function ServiceDetailPageInner() {
</div> </div>
{tab === 'info' && <InfoTab item={item} />} {tab === 'info' && <InfoTab item={item} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} />} {tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} />} {tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} />} {tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
{tab === 'history' && <HistoryTab item={item} />} {tab === 'history' && <HistoryTab item={item} />}
<ServiceItemFormModal <ServiceItemFormModal
+29 -20
View File
@@ -20,6 +20,7 @@ import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import PageHeader from '../components/ui/PageHeader'; import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate'; import FeatureGate from '../components/ui/FeatureGate';
import { usePermissions } from '../hooks/usePermissions';
import { numericField } from '../lib/forms'; import { numericField } from '../lib/forms';
const chargeSchema = z.object({ const chargeSchema = z.object({
@@ -40,6 +41,10 @@ const POST_VISIT_VARS: { key: string; label: string }[] = [
function SmsWalletPageInner() { function SmsWalletPageInner() {
const qc = useQueryClient(); 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 [chargeOpen, setChargeOpen] = useState(false);
const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat'); const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat');
const [logPage, setLogPage] = useState(1); const [logPage, setLogPage] = useState(1);
@@ -146,17 +151,19 @@ function SmsWalletPageInner() {
transition: 'width 0.4s ease', transition: 'width 0.4s ease',
}} /> }} />
</div> </div>
<button {canCreate && (
style={{ <button
background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8, style={{
padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer', background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
}} display: 'inline-flex', alignItems: 'center', gap: 6,
onClick={() => setChargeOpen(true)} }}
> onClick={() => setChargeOpen(true)}
<DevicePhoneMobileIcon style={{ width: 16 }} /> >
شارژ کیف پول <DevicePhoneMobileIcon style={{ width: 16 }} />
</button> شارژ کیف پول
</button>
)}
</> </>
)} )}
</div> </div>
@@ -393,15 +400,17 @@ function SmsWalletPageInner() {
</div> </div>
{/* footer ذخیره */} {/* footer ذخیره */}
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}> {canUpdate && (
<button <div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
className="btn primary sm" <button
disabled={saveMutation.isPending} className="btn primary sm"
onClick={() => currentSettings && saveMutation.mutate(currentSettings)} disabled={saveMutation.isPending}
> onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'} >
</button> {saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
</div> </button>
</div>
)}
</div> </div>
) : ( ) : (
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</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 SettingsLayout from '../components/layout/SettingsLayout';
import { ActiveBadge } from '../components/ui/StatusBadge'; import { ActiveBadge } from '../components/ui/StatusBadge';
import { numericField } from '../lib/forms'; import { numericField } from '../lib/forms';
import { usePermissions } from '../hooks/usePermissions';
const schema = z.object({ const schema = z.object({
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'), full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
@@ -30,6 +31,10 @@ const EMPTY: ClinicStaff[] = [];
export default function StaffPage() { export default function StaffPage() {
const qc = useQueryClient(); 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 [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ClinicStaff | null>(null); const [editTarget, setEditTarget] = useState<ClinicStaff | null>(null);
const [toggleTarget, setToggleTarget] = useState<ClinicStaff | null>(null); const [toggleTarget, setToggleTarget] = useState<ClinicStaff | null>(null);
@@ -138,19 +143,23 @@ export default function StaffPage() {
header: 'عملیات', header: 'عملیات',
render: (s) => ( render: (s) => (
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش"> {canUpdate && (
<PencilIcon style={{ width: 15 }} /> <button className="btn sm" onClick={() => openEdit(s)} title="ویرایش">
</button> <PencilIcon style={{ width: 15 }} />
<button </button>
className="btn sm" )}
onClick={() => setToggleTarget(s)} {canUpdate && (
title={s.active ? 'غیرفعال‌سازی' : 'فعال‌سازی'} <button
> className="btn sm"
{s.active onClick={() => setToggleTarget(s)}
? <EyeSlashIcon style={{ width: 15 }} /> title={s.active ? 'غیرفعال‌سازی' : 'فعال‌سازی'}
: <EyeIcon style={{ width: 15 }} /> >
} {s.active
</button> ? <EyeSlashIcon style={{ width: 15 }} />
: <EyeIcon style={{ width: 15 }} />
}
</button>
)}
</div> </div>
), ),
}, },
@@ -162,9 +171,11 @@ export default function StaffPage() {
title="مدیریت پرسنل" title="مدیریت پرسنل"
description="لیست پرسنل کلینیک / مطب" description="لیست پرسنل کلینیک / مطب"
action={ action={
<button className="btn primary sm" onClick={() => setCreateOpen(true)}> canCreate ? (
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل <button className="btn primary sm" onClick={() => setCreateOpen(true)}>
</button> <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 }} /> <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={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز پرسنلی ثبت نشده</div>
<div style={{ fontSize: 13, marginBottom: 20 }}>اولین عضو تیم خود را اضافه کنید</div> <div style={{ fontSize: 13, marginBottom: 20 }}>اولین عضو تیم خود را اضافه کنید</div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}> {canCreate && (
<PlusIcon style={{ width: 16 }} /> افزودن پرسنل <button className="btn primary sm" onClick={() => setCreateOpen(true)}>
</button> <PlusIcon style={{ width: 16 }} /> افزودن پرسنل
</button>
)}
</div> </div>
) : ( ) : (
<DataTable columns={columns} data={staff} loading={isLoading} /> <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 Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog'; import ConfirmDialog from '../components/ui/ConfirmDialog';
import SettingsLayout from '../components/layout/SettingsLayout'; import SettingsLayout from '../components/layout/SettingsLayout';
import { usePermissions } from '../hooks/usePermissions';
interface TenantTag { uuid: string; name: string; color: string; active: boolean } 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. */ /** برچسب‌ها — per-tenant tag management inside the settings shell. */
export default function TagsSettingsPage() { export default function TagsSettingsPage() {
const qc = useQueryClient(); 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 [modal, setModal] = useState<'create' | TenantTag | null>(null);
const [deleteTarget, setDeleteTarget] = useState<TenantTag | null>(null); const [deleteTarget, setDeleteTarget] = useState<TenantTag | null>(null);
@@ -66,7 +72,9 @@ export default function TagsSettingsPage() {
<div className="fade-in"> <div className="fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
<h1 className="section-title">برچسبها</h1> <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> </div>
{isLoading ? ( {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={{ 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 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> <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> {canUpdate && (
<button className="btn sm ghost" aria-label="حذف" onClick={() => setDeleteTarget(t)} style={{ color: 'var(--danger)' }}><TrashIcon style={{ width: 15 }} /></button> <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>
))} ))}
</div> </div>