734 lines
25 KiB
TypeScript
734 lines
25 KiB
TypeScript
import {
|
|
ArchiveBoxIcon,
|
|
ArrowLeftOnRectangleIcon,
|
|
ArrowsRightLeftIcon,
|
|
BanknotesIcon,
|
|
BuildingOffice2Icon,
|
|
CalendarDaysIcon,
|
|
ChartBarIcon,
|
|
ChatBubbleLeftEllipsisIcon,
|
|
ChevronDownIcon,
|
|
ClipboardDocumentCheckIcon,
|
|
Cog6ToothIcon,
|
|
CreditCardIcon,
|
|
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 { 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,
|
|
): Section[] {
|
|
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
|
|
// برایش تعیین کرده ساخته میشود، نه بهصورت hardcode.
|
|
if (primaryRole === "doctor" && scope === "clinic") {
|
|
const items: SectionItem[] = [
|
|
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
|
];
|
|
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",
|
|
});
|
|
}
|
|
|
|
return [{ label: "عمومی", items }];
|
|
}
|
|
|
|
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/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/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") {
|
|
return [
|
|
{
|
|
label: "عمومی",
|
|
items: [
|
|
{
|
|
to: "/admin/dashboard",
|
|
icon: ChartBarIcon,
|
|
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/insurance-pricing",
|
|
icon: ShieldCheckIcon,
|
|
label: "قیمتگذاری بیمه",
|
|
feature: "insurance",
|
|
},
|
|
{
|
|
to: "/admin/claims",
|
|
icon: DocumentTextIcon,
|
|
label: "مطالبات بیمه",
|
|
feature: "insurance",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
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-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: "منشی",
|
|
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 sections = buildSections(primaryRole, dbUuid, context?.scope ?? null, can);
|
|
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>
|
|
);
|
|
}
|