Files
clinicpro/assets/admin/components/layout/Sidebar.tsx
T
hamedandClaude Opus 5 bd4347f9c5 feat(admin): a page for the official holiday calendar
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>
2026-08-02 17:14:23 +03:30

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>
);
}