Files
clinicpro/assets/admin/components/layout/Sidebar.tsx
T
hamed 9603b702c1 feat: implement domain guard for commission calculation and enhance representation dashboard
- Added domain guard in CommissionService to ensure commission is calculated only when the appointment is booked under the same representation as the doctor.
- Updated RepresentationController to filter statistics by representation, ensuring accurate data is shown for each representative.
- Introduced new endpoints for the representation dashboard to provide summary statistics, doctor performance, and financial reports.
- Created new pages for RepresentationFinance and RepresentationSettlement to display financial data and allow for settlement requests.
- Added migration to include booking_representation_id in appointments for tracking the representative under which the appointment was booked.
2026-06-24 16:14:41 +03:30

572 lines
20 KiB
TypeScript

import {
ArrowLeftOnRectangleIcon,
ArrowsRightLeftIcon,
BanknotesIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleLeftEllipsisIcon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CreditCardIcon,
DevicePhoneMobileIcon,
DocumentTextIcon,
FolderOpenIcon,
HeartIcon,
IdentificationIcon,
KeyIcon,
LockClosedIcon,
ShieldCheckIcon,
StarIcon,
TagIcon,
UserCircleIcon,
UserGroupIcon,
UserPlusIcon,
UsersIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/outline";
import { NavLink, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore";
type SectionItem = {
to: string;
icon: React.ElementType;
label: string;
feature?: string;
};
type Section = { label: string; items: SectionItem[] };
function buildSections(
primaryRole: string | null,
dbUuid: string | null,
scope: string | null,
): Section[] {
// پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبت‌های خودش؛
// ابزارهای مدیریتی مطب/کلینیک نمایش داده نمی‌شوند.
if (primaryRole === "doctor" && scope === "clinic") {
return [
{
label: "عمومی",
items: [
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
{ to: "/admin/appointments", icon: CalendarDaysIcon, 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/clinics",
icon: BuildingOffice2Icon,
label: "کلینیک‌ها",
},
],
},
{
label: "مدیریت",
items: [
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
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/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/my-clinic";
return [
{
label: "عمومی",
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
{
to: clinicTo,
icon: BuildingOffice2Icon,
label: "کلینیک من",
},
],
},
{
label: "مدیریت",
items: [
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
feature: "insurance",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
feature: "insurance",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
icon: IdentificationIcon,
label: "منشی ها",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
feature: "services",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
feature: "sms_panel",
},
],
},
{
label: "اشتراک",
items: [
{
to: "/admin/subscription",
icon: CreditCardIcon,
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: "نوبت‌های من",
},
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
feature: "insurance",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
feature: "insurance",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
icon: IdentificationIcon,
label: "منشی ها",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
feature: "services",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
feature: "sms_panel",
},
],
},
{
label: "اشتراک",
items: [
{
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
],
},
];
}
if (primaryRole === "secretary") {
return [
{
label: "عمومی",
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{
label: "مدیریت",
items: [
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{
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: "کلینیک‌ها" },
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبت‌ها" },
],
},
{
label: "مالی",
items: [
{ to: "/admin/representation-finance", icon: CreditCardIcon, label: "گزارش مالی" },
{ to: "/admin/representation-settlement", icon: BanknotesIcon, 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;
}
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 sections = buildSections(primaryRole, dbUuid, context?.scope ?? null);
const initials = (userName ?? "U").charAt(0).toUpperCase();
return (
<aside className="sidebar">
{/* Brand */}
<div className="sidebar-brand">
<div className="brand-logo">
<HeartIcon style={{ width: 20, height: 20 }} />
</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(
({ to, icon: Icon, label, feature }) => {
const isLocked = feature
? !hasFeature(feature)
: false;
const dest = isLocked
? "/admin/subscription"
: to;
return (
<NavLink
key={to}
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>
);
},
)}
</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>
);
}