Files
clinicpro/assets/admin/components/layout/Sidebar.tsx
T
hamedandClaude Opus 5 c7fdb92df4 feat(admin): practice domain settings, treatment case list and platform domain CRUD
Completes the panel side. A clinic picks its practice domain in settings, where
the copy says plainly that this is not the specialty label the public site shows;
picking nothing stays valid and changes nothing.

Cases and the unbooked queue share one page rather than two, because they answer
the same question — which patient is where in their course and what is still
owed. The queue explains why booking is not automatic instead of leaving the
reader to wonder.

Platform admins get domain CRUD with a column showing whether a domain has a
dedicated workflow or falls back to the default, so the gap is visible rather
than guessed at; the code field is locked after creation because workflows bind
to it.

Also puts the staff session screens in the sidebar — they were reachable only by
typing the URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:51:21 +03:30

914 lines
33 KiB
TypeScript

import {
ArchiveBoxIcon,
ArrowLeftOnRectangleIcon,
ArrowsRightLeftIcon,
BanknotesIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleLeftEllipsisIcon,
ChevronDownIcon,
ClipboardDocumentCheckIcon,
ClipboardDocumentListIcon,
SparklesIcon,
Cog6ToothIcon,
CreditCardIcon,
CubeIcon,
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("appointments", "view")) {
items.push({ to: "/admin/treatment-cases", icon: ClipboardDocumentListIcon, label: "درمان‌های چندجلسه‌ای" });
}
if (can("appointment_settings", "view")) {
items.push({ to: "/admin/resources", icon: CubeIcon, 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/practice-domains",
icon: SparklesIcon,
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: "سرویس ها",
},
{
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "درمان‌های چندجلسه‌ای",
},
{
to: "/admin/resources",
icon: CubeIcon,
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: "سرویس ها",
},
{
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "درمان‌های چندجلسه‌ای",
},
{
to: "/admin/resources",
icon: CubeIcon,
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("appointments", "view")) {
items.push({
to: "/admin/treatment-cases",
icon: ClipboardDocumentListIcon,
label: "درمان‌های چندجلسه‌ای",
});
}
if (can("appointment_settings", "view")) {
items.push({
to: "/admin/resources",
icon: CubeIcon,
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-sessions",
icon: ClipboardDocumentListIcon,
label: "جلسات امروز من",
},
{
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>
);
}