The three admin endpoints shipped without anywhere to call them from, so the person who is supposed to maintain the national calendar could only do it with curl or a console command. That is not "the director can register the year's holidays". /admin/national-holidays is ROLE_ADMIN only and sits under the System group in the admin nav. The year lives in the query string, so back and refresh return to the year being edited. Editing takes only the title: `date` is the unique key, so moving a holiday is really deleting one and creating another, and the form says so rather than silently creating a duplicate. The date field is the shared Jalali picker, which speaks Gregorian, so the page converts before POSTing the jalali_date the API expects — and shows the converted value under the field so the user can see what will be stored. Deleting warns that the day leaves every clinic's calendar, because it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
861 lines
31 KiB
TypeScript
861 lines
31 KiB
TypeScript
import {
|
|
ArchiveBoxIcon,
|
|
ArrowLeftOnRectangleIcon,
|
|
ArrowsRightLeftIcon,
|
|
BanknotesIcon,
|
|
BuildingOffice2Icon,
|
|
CalendarDaysIcon,
|
|
ChartBarIcon,
|
|
ChatBubbleLeftEllipsisIcon,
|
|
ChevronDownIcon,
|
|
ClipboardDocumentCheckIcon,
|
|
Cog6ToothIcon,
|
|
CreditCardIcon,
|
|
CurrencyDollarIcon,
|
|
DevicePhoneMobileIcon,
|
|
DocumentTextIcon,
|
|
FolderOpenIcon,
|
|
HeartIcon,
|
|
KeyIcon,
|
|
LockClosedIcon,
|
|
PlusIcon,
|
|
ShieldCheckIcon,
|
|
StarIcon,
|
|
TagIcon,
|
|
UserCircleIcon,
|
|
UserGroupIcon,
|
|
UsersIcon,
|
|
WrenchScrewdriverIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import { useState } from "react";
|
|
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
|
import { useSubscription } from "../../hooks/useSubscription";
|
|
import { usePermissions } from "../../hooks/usePermissions";
|
|
import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings";
|
|
import { useAuthStore } from "../../stores/authStore";
|
|
import { useUiStore } from "../../stores/uiStore";
|
|
|
|
type SubItem = { to: string; label: string; icon?: React.ElementType };
|
|
type SectionItem = {
|
|
to: string;
|
|
icon: React.ElementType;
|
|
label: string;
|
|
feature?: string;
|
|
/** وقتی مقدار داشته باشد، آیتم به یک منوی بازشونده تبدیل میشود. */
|
|
children?: SubItem[];
|
|
};
|
|
type Section = { label: string; items: SectionItem[] };
|
|
|
|
/** زیرمنوهای مشترکِ «نوبتها» (نوبتهای تأییدشده + افزودن نوبت). */
|
|
const APPOINTMENTS_CHILDREN: SubItem[] = [
|
|
{ to: "/admin/appointments", label: "نوبت ها", icon: CalendarDaysIcon },
|
|
{ to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon },
|
|
];
|
|
|
|
/**
|
|
* همان زیرمنوها + «رزرو نوبت». برای نقشهایی که مالک برنامهٔ نوبتدهیاند
|
|
* (ادمین/کلینیک/پزشک) تا همهٔ عملیات نوبت زیر یک بخش جمع شود. پزشکِ مهمانِ کلینیک
|
|
* از نسخهٔ بدون رزرو (`APPOINTMENTS_CHILDREN`) استفاده میکند.
|
|
*/
|
|
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
|
|
...APPOINTMENTS_CHILDREN,
|
|
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon },
|
|
];
|
|
|
|
function buildSections(
|
|
primaryRole: string | null,
|
|
dbUuid: string | null,
|
|
scope: string | null,
|
|
can: (resource: string, action: string) => boolean,
|
|
/** سهم درآمد نوبت آنلاین برای این منشی فعال است؟ آیتمهای مالی به آن گِیت میشوند. */
|
|
secretaryShareEnabled = false,
|
|
): Section[] {
|
|
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
|
|
// برایش تعیین کرده ساخته میشود، نه بهصورت hardcode.
|
|
if (primaryRole === "doctor" && scope === "clinic") {
|
|
const items: SectionItem[] = [];
|
|
if (can("appointments", "view")) {
|
|
items.push({
|
|
to: "/admin/appointments",
|
|
icon: CalendarDaysIcon,
|
|
label: "نوبتهای من",
|
|
children: APPOINTMENTS_CHILDREN,
|
|
});
|
|
}
|
|
if (can("patients", "view")) {
|
|
items.push({
|
|
to: "/admin/patients",
|
|
icon: FolderOpenIcon,
|
|
label: "پرونده بیماران",
|
|
feature: "patient_records",
|
|
});
|
|
}
|
|
if (can("payments", "view")) {
|
|
items.push({ to: "/admin/my-payments", icon: CreditCardIcon, label: "پرداختها" });
|
|
}
|
|
if (can("insurances", "view")) {
|
|
items.push(
|
|
{ to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمتگذاری بیمه", feature: "insurance" },
|
|
{ to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", feature: "insurance" },
|
|
);
|
|
}
|
|
if (can("services", "view")) {
|
|
items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" });
|
|
}
|
|
if (can("inventory", "view")) {
|
|
items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" });
|
|
}
|
|
|
|
// زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings)
|
|
// مثل پزشکِ مستقل داخل صفحهٔ تنظیماتاند، نه سایدبار اصلی؛ منویِ کناریِ آن
|
|
// صفحه بر اساس مجوز فیلتر میشود.
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
|
},
|
|
{ label: "مدیریت", items },
|
|
{
|
|
label: "تنظیمات",
|
|
items: [{ to: "/admin/account-settings", icon: Cog6ToothIcon, label: "تنظیمات" }],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (primaryRole === "admin") {
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [
|
|
{
|
|
to: "/admin/dashboard",
|
|
icon: ChartBarIcon,
|
|
label: "داشبورد",
|
|
},
|
|
{
|
|
to: "/admin/users",
|
|
icon: UserGroupIcon,
|
|
label: "کاربران",
|
|
},
|
|
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
|
{
|
|
to: "/admin/doctor-claims",
|
|
icon: HeartIcon,
|
|
label: "تصاحب پروفایل",
|
|
},
|
|
{
|
|
to: "/admin/clinics",
|
|
icon: BuildingOffice2Icon,
|
|
label: "کلینیکها",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "مدیریت",
|
|
items: [
|
|
{
|
|
to: "/admin/appointments",
|
|
icon: CalendarDaysIcon,
|
|
label: "نوبتها",
|
|
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
|
},
|
|
{
|
|
to: "/admin/payments",
|
|
icon: CreditCardIcon,
|
|
label: "پرداختها",
|
|
},
|
|
{
|
|
to: "/admin/settlements",
|
|
icon: BanknotesIcon,
|
|
label: "تسویهحساب",
|
|
},
|
|
{
|
|
to: "/admin/financial-report",
|
|
icon: BanknotesIcon,
|
|
label: "گزارش مالی",
|
|
},
|
|
{
|
|
to: "/admin/pre-registrations",
|
|
icon: ClipboardDocumentCheckIcon,
|
|
label: "درخواستهای ثبتنام",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "محتوا",
|
|
items: [
|
|
{
|
|
to: "/admin/comments",
|
|
icon: ChatBubbleLeftEllipsisIcon,
|
|
label: "نظرات",
|
|
},
|
|
{ to: "/admin/ratings", icon: StarIcon, label: "امتیازها" },
|
|
{
|
|
to: "/admin/blogs",
|
|
icon: DocumentTextIcon,
|
|
label: "بلاگ",
|
|
},
|
|
{
|
|
to: "/admin/blog-review",
|
|
icon: DocumentTextIcon,
|
|
label: "بازبینی بلاگ",
|
|
},
|
|
{
|
|
to: "/admin/sms",
|
|
icon: DevicePhoneMobileIcon,
|
|
label: "پیامک",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "سیستم",
|
|
items: [
|
|
{
|
|
to: "/admin/logs",
|
|
icon: ClipboardDocumentCheckIcon,
|
|
label: "لاگها",
|
|
},
|
|
{
|
|
to: "/admin/categories",
|
|
icon: TagIcon,
|
|
label: "دستهبندیها",
|
|
},
|
|
{
|
|
to: "/admin/representations",
|
|
icon: UsersIcon,
|
|
label: "نمایندگان",
|
|
},
|
|
{
|
|
to: "/admin/secretaries",
|
|
icon: KeyIcon,
|
|
label: "منشیها",
|
|
},
|
|
{
|
|
to: "/admin/admin-subscription",
|
|
icon: CreditCardIcon,
|
|
label: "اشتراکها",
|
|
},
|
|
{
|
|
to: "/admin/national-holidays",
|
|
icon: CalendarDaysIcon,
|
|
label: "تعطیلات رسمی",
|
|
},
|
|
{
|
|
to: "/admin/settings",
|
|
icon: Cog6ToothIcon,
|
|
label: "تنظیمات",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (primaryRole === "clinic") {
|
|
const clinicTo = dbUuid
|
|
? `/admin/clinics/${dbUuid}`
|
|
: "/admin/settings/clinic-doctors";
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [
|
|
{
|
|
to: "/admin/dashboard",
|
|
icon: ChartBarIcon,
|
|
label: "داشبورد",
|
|
},
|
|
{
|
|
to: clinicTo,
|
|
icon: BuildingOffice2Icon,
|
|
label: "کلینیک من",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "مدیریت",
|
|
items: [
|
|
{
|
|
to: "/admin/appointments",
|
|
icon: CalendarDaysIcon,
|
|
label: "نوبتها",
|
|
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
|
},
|
|
{
|
|
to: "/admin/patients",
|
|
icon: FolderOpenIcon,
|
|
label: "پرونده بیماران",
|
|
feature: "patient_records",
|
|
},
|
|
{
|
|
to: "/admin/my-payments",
|
|
icon: CreditCardIcon,
|
|
label: "پرداختها",
|
|
},
|
|
{
|
|
to: "/admin/claims",
|
|
icon: DocumentTextIcon,
|
|
label: "مطالبات بیمه",
|
|
feature: "insurance",
|
|
},
|
|
{
|
|
to: "/admin/inventory",
|
|
icon: ArchiveBoxIcon,
|
|
label: "انبارداری",
|
|
},
|
|
{
|
|
to: "/admin/clinic-services",
|
|
icon: WrenchScrewdriverIcon,
|
|
label: "سرویس ها",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
// مدیریت بیمه / کیف پول پیامک زیر همین «تنظیمات» است
|
|
// (منوی داخل صفحهٔ اشتراک)، نه در سایدبار اصلی.
|
|
label: "تنظیمات",
|
|
items: [
|
|
{
|
|
to: "/admin/subscription",
|
|
icon: Cog6ToothIcon,
|
|
label: "تنظیمات",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (primaryRole === "doctor") {
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [
|
|
{
|
|
to: "/admin/dashboard",
|
|
icon: ChartBarIcon,
|
|
label: "داشبورد",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "پروفایل",
|
|
items: [
|
|
{
|
|
to: "/admin/profile",
|
|
icon: UserCircleIcon,
|
|
label: "پروفایل من",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "مدیریت",
|
|
items: [
|
|
{
|
|
to: "/admin/appointments",
|
|
icon: CalendarDaysIcon,
|
|
label: "نوبتهای من",
|
|
children: APPOINTMENTS_CHILDREN,
|
|
},
|
|
{
|
|
to: "/admin/patients",
|
|
icon: FolderOpenIcon,
|
|
label: "پرونده بیماران",
|
|
feature: "patient_records",
|
|
},
|
|
{
|
|
to: "/admin/my-payments",
|
|
icon: CreditCardIcon,
|
|
label: "پرداختها",
|
|
},
|
|
{
|
|
to: "/admin/claims",
|
|
icon: DocumentTextIcon,
|
|
label: "مطالبات بیمه",
|
|
feature: "insurance",
|
|
},
|
|
{
|
|
to: "/admin/inventory",
|
|
icon: ArchiveBoxIcon,
|
|
label: "انبارداری",
|
|
},
|
|
{
|
|
to: "/admin/clinic-services",
|
|
icon: WrenchScrewdriverIcon,
|
|
label: "سرویس ها",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
// مدیریت بیمه / کیف پول پیامک زیر همین «تنظیمات» است
|
|
// (منوی داخل صفحهٔ اشتراک)، نه در سایدبار اصلی.
|
|
label: "تنظیمات",
|
|
items: [
|
|
{
|
|
to: "/admin/subscription",
|
|
icon: Cog6ToothIcon,
|
|
label: "تنظیمات",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (primaryRole === "secretary") {
|
|
// منو از روی مجوزهای همان منشی ساخته میشود: آیتمی که مجوز مشاهدهاش را
|
|
// ندارد اصلاً نمایش داده نمیشود (همراستا با enforcement سمت API).
|
|
const items: SectionItem[] = [];
|
|
if (can("appointments", "view")) {
|
|
items.push({
|
|
to: "/admin/appointments",
|
|
icon: CalendarDaysIcon,
|
|
label: "نوبتها",
|
|
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
|
});
|
|
}
|
|
if (can("patients", "view")) {
|
|
items.push({
|
|
to: "/admin/patients",
|
|
icon: FolderOpenIcon,
|
|
label: "پرونده بیماران",
|
|
feature: "patient_records",
|
|
});
|
|
}
|
|
if (can("payments", "view")) {
|
|
items.push({
|
|
to: "/admin/my-payments",
|
|
icon: CreditCardIcon,
|
|
label: "پرداختها",
|
|
});
|
|
}
|
|
if (can("insurances", "view")) {
|
|
items.push(
|
|
{
|
|
to: "/admin/insurance-pricing",
|
|
icon: ShieldCheckIcon,
|
|
label: "قیمتگذاری بیمه",
|
|
feature: "insurance",
|
|
},
|
|
{
|
|
to: "/admin/claims",
|
|
icon: DocumentTextIcon,
|
|
label: "مطالبات بیمه",
|
|
feature: "insurance",
|
|
},
|
|
);
|
|
}
|
|
if (can("services", "view")) {
|
|
items.push({
|
|
to: "/admin/clinic-services",
|
|
icon: WrenchScrewdriverIcon,
|
|
label: "سرویس ها",
|
|
});
|
|
}
|
|
if (can("inventory", "view")) {
|
|
items.push({
|
|
to: "/admin/inventory",
|
|
icon: ArchiveBoxIcon,
|
|
label: "انبارداری",
|
|
});
|
|
}
|
|
// درآمد و تسویه فقط وقتی ادمین سهم نوبت آنلاین را برای این منشی فعال کرده باشد.
|
|
if (secretaryShareEnabled) {
|
|
items.push(
|
|
{
|
|
to: "/admin/secretary-earnings",
|
|
icon: CurrencyDollarIcon,
|
|
label: "درآمد من",
|
|
},
|
|
{
|
|
to: "/admin/secretary-settlement",
|
|
icon: BanknotesIcon,
|
|
label: "تسویه حساب",
|
|
},
|
|
);
|
|
}
|
|
|
|
// منابعِ زیرمجموعهٔ «تنظیمات» (staff/discounts/sms/tags/appointment_settings/
|
|
// clinic_doctors) در سایدبار اصلی نمیآیند — دقیقاً مثل پزشک/کلینیک، فقط داخل
|
|
// صفحهٔ «تنظیمات» با منویِ permission-aware نمایش داده میشوند.
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
|
},
|
|
{ label: "مدیریت", items },
|
|
{
|
|
label: "تنظیمات",
|
|
items: [
|
|
{
|
|
// لندینگِ تنظیمات؛ حساب کاربری همیشه باز است و منویِ کناری
|
|
// بقیهٔ بخشهای مجاز را بر اساس permission نشان میدهد.
|
|
to: "/admin/account-settings",
|
|
icon: Cog6ToothIcon,
|
|
label: "تنظیمات",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (primaryRole === "staff") {
|
|
// پرسنل فقط داشبورد خودش و سرویسهای تخصیصیافته را دارد؛ بقیهٔ مسیرها
|
|
// سمت API هم برایش بسته است (StaffRouteGuardSubscriber).
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
|
},
|
|
{
|
|
label: "مدیریت",
|
|
items: [
|
|
{
|
|
to: "/admin/my-services",
|
|
icon: WrenchScrewdriverIcon,
|
|
label: "سرویسهای من",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (primaryRole === "representation") {
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [
|
|
{
|
|
to: "/admin/dashboard",
|
|
icon: ChartBarIcon,
|
|
label: "داشبورد",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "مدیریت",
|
|
items: [
|
|
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
|
{
|
|
to: "/admin/clinics",
|
|
icon: BuildingOffice2Icon,
|
|
label: "کلینیکها",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "محتوا",
|
|
items: [
|
|
{
|
|
to: "/admin/representation-blogs",
|
|
icon: DocumentTextIcon,
|
|
label: "وبلاگ من",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "مالی",
|
|
items: [
|
|
{
|
|
to: "/admin/representation-finance",
|
|
icon: CreditCardIcon,
|
|
label: "گزارش مالی",
|
|
},
|
|
{
|
|
to: "/admin/representation-settlement",
|
|
icon: BanknotesIcon,
|
|
label: "تسویه حساب",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "حساب",
|
|
items: [
|
|
{
|
|
to: "/admin/representation-profile",
|
|
icon: UserCircleIcon,
|
|
label: "پروفایل",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [
|
|
{
|
|
to: "/admin/dashboard",
|
|
icon: ChartBarIcon,
|
|
label: "داشبورد",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
admin: "مدیر کل",
|
|
clinic: "مالک کلینیک",
|
|
doctor: "پزشک",
|
|
secretary: "منشی",
|
|
staff: "پرسنل",
|
|
representation: "نماینده",
|
|
user: "کاربر",
|
|
};
|
|
|
|
const HUES = [256, 205, 162, 295, 272];
|
|
|
|
function avatarBg(name: string): string {
|
|
const hue = HUES[(name.charCodeAt(0) ?? 0) % HUES.length];
|
|
return `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`;
|
|
}
|
|
|
|
interface Props {
|
|
mobileOpen?: boolean;
|
|
onMobileClose?: () => void;
|
|
}
|
|
|
|
/**
|
|
* یک آیتم سایدبار: منوی ساده (NavLink) یا منوی بازشونده با زیرمنو.
|
|
* منوی بازشونده وقتی یکی از زیرمنوهایش فعال است بهصورت خودکار باز میشود.
|
|
*/
|
|
function NavItem({
|
|
item,
|
|
sidebarOpen,
|
|
isLocked,
|
|
}: {
|
|
item: SectionItem;
|
|
sidebarOpen: boolean;
|
|
isLocked: boolean;
|
|
}) {
|
|
const { icon: Icon, label, to, children } = item;
|
|
const location = useLocation();
|
|
const childActive = !!children?.some((c) => location.pathname === c.to);
|
|
// منوی «نوبتها» همیشه باز میماند و جمع نمیشود.
|
|
const alwaysOpen = !!children?.some((c) =>
|
|
c.to.startsWith("/admin/appointments"),
|
|
);
|
|
const [openState, setOpen] = useState(childActive);
|
|
const open = alwaysOpen || openState;
|
|
|
|
// منوی ساده — رفتار قبلی بدون تغییر.
|
|
if (!children || children.length === 0) {
|
|
const dest = isLocked ? "/admin/subscription" : to;
|
|
return (
|
|
<NavLink
|
|
to={dest}
|
|
title={
|
|
isLocked
|
|
? "نیاز به ارتقاء پنل"
|
|
: !sidebarOpen
|
|
? label
|
|
: undefined
|
|
}
|
|
className={({ isActive }) =>
|
|
`nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`
|
|
}
|
|
style={isLocked ? { opacity: 0.55 } : undefined}
|
|
>
|
|
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
|
<span>{label}</span>
|
|
{isLocked && (
|
|
<LockClosedIcon
|
|
style={{
|
|
width: 12,
|
|
height: 12,
|
|
marginRight: "auto",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
}
|
|
|
|
// منوی بازشونده.
|
|
return (
|
|
<div className="nav-parent">
|
|
<button
|
|
type="button"
|
|
className={`nav-item${childActive ? " active" : ""}`}
|
|
aria-expanded={open}
|
|
title={!sidebarOpen ? label : undefined}
|
|
onClick={() => !alwaysOpen && setOpen((o) => !o)}
|
|
style={{
|
|
width: "100%",
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
}}
|
|
>
|
|
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
|
<span>{label}</span>
|
|
<ChevronDownIcon
|
|
style={{
|
|
width: 15,
|
|
height: 15,
|
|
marginRight: "auto",
|
|
flexShrink: 0,
|
|
transition: "transform .2s var(--ease)",
|
|
transform: open ? "rotate(180deg)" : "none",
|
|
}}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div style={{ paddingInlineStart: 14 }}>
|
|
{children.map((c) => {
|
|
const CIcon = c.icon;
|
|
return (
|
|
<NavLink
|
|
key={c.to + c.label}
|
|
to={c.to}
|
|
end
|
|
title={!sidebarOpen ? c.label : undefined}
|
|
className={({ isActive }) =>
|
|
`nav-item nav-subitem${isActive ? " active" : ""}`
|
|
}
|
|
>
|
|
{CIcon ? (
|
|
<CIcon
|
|
style={{
|
|
width: 16,
|
|
height: 16,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
) : (
|
|
<span
|
|
style={{ width: 16, flexShrink: 0 }}
|
|
/>
|
|
)}
|
|
<span>{c.label}</span>
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
|
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
|
const {
|
|
logout,
|
|
primaryRole,
|
|
userName,
|
|
availableContexts,
|
|
dbUuid,
|
|
context,
|
|
} = useAuthStore();
|
|
const { hasFeature } = useSubscription();
|
|
const navigate = useNavigate();
|
|
|
|
const { can } = usePermissions();
|
|
const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary");
|
|
const sections = buildSections(
|
|
primaryRole,
|
|
dbUuid,
|
|
context?.scope ?? null,
|
|
can,
|
|
secretaryEarnings?.enabled ?? false,
|
|
);
|
|
const initials = (userName ?? "U").charAt(0).toUpperCase();
|
|
|
|
return (
|
|
<aside className="sidebar">
|
|
{/* Brand */}
|
|
<div className="sidebar-brand">
|
|
<div className="brand-logo">
|
|
<img
|
|
src="/logo.svg"
|
|
alt="Clinic Pro"
|
|
className="brand-img"
|
|
/>
|
|
</div>
|
|
<div className="brand-text">
|
|
<b>ClinicPro</b>
|
|
<span>پنل مدیریت</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="nav">
|
|
{sections.map((section) => (
|
|
<div className="nav-group" key={section.label}>
|
|
<span className="nav-label">{section.label}</span>
|
|
{section.items.map((item) => (
|
|
<NavItem
|
|
key={item.to + item.label}
|
|
item={item}
|
|
sidebarOpen={sidebarOpen}
|
|
isLocked={
|
|
item.feature
|
|
? !hasFeature(item.feature)
|
|
: false
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
{/* تغییر محیط کاری — فقط اگر چند context دارد */}
|
|
{availableContexts.length > 1 && (
|
|
<div className="nav-group">
|
|
<span className="nav-label">محیط کاری</span>
|
|
<NavLink
|
|
to="/admin/select-context"
|
|
title={!sidebarOpen ? "تغییر محیط" : undefined}
|
|
className={({ isActive }) =>
|
|
`nav-item${isActive ? " active" : ""}`
|
|
}
|
|
>
|
|
<ArrowsRightLeftIcon
|
|
style={{ width: 19, height: 19, flexShrink: 0 }}
|
|
/>
|
|
<span>تغییر محیط</span>
|
|
</NavLink>
|
|
</div>
|
|
)}
|
|
</nav>
|
|
|
|
{/* User footer */}
|
|
<div className="sidebar-foot">
|
|
<div
|
|
className="user-chip"
|
|
onClick={() => {
|
|
logout();
|
|
navigate("/admin/login");
|
|
}}
|
|
title="خروج از سیستم"
|
|
>
|
|
<div
|
|
className="avatar sm"
|
|
style={{
|
|
background: avatarBg(userName ?? "U"),
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{initials}
|
|
</div>
|
|
<div className="user-meta">
|
|
<b>{userName ?? "کاربر"}</b>
|
|
<span>
|
|
{ROLE_LABELS[primaryRole ?? ""] ??
|
|
primaryRole ??
|
|
""}
|
|
</span>
|
|
</div>
|
|
<ArrowLeftOnRectangleIcon
|
|
style={{
|
|
width: 16,
|
|
height: 16,
|
|
flexShrink: 0,
|
|
color: "var(--text-3)",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|