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