Files
clinicpro/assets/admin/components/layout/Sidebar.tsx
T
hamedandClaude Opus 5 a182b05e1f feat(treatment): skip reasons and reopening for session areas
Skipping an area recorded only that it was skipped. Why it was skipped is
clinical history — the next session needs to read it — so `skip` now takes an
optional note, the same way completing an area already did, and the panel asks
for it inline instead of firing on the first click.

An operator finds out mid-laser that they closed the wrong area, and until now
had to carry that mistake to the end of the session. `reopen` puts a settled
area — completed or skipped — back to in_progress and clears finished_at,
keeping the recorded parameters and note so they can be seen and overwritten.
It stops at the same boundary everything else in this domain stops at: once the
session is finished the record is history, and reopening it is 409.

Also drops /admin/my-services. The staff role has one job — today's sessions —
and the dashboard already lists the services they may perform, so the page was
a second place to read the same list. Route, page, sidebar entry and the two
links to it are gone; the services stat card is no longer a link because it no
longer has a destination.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:28:30 +03:30

909 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: "جلسات امروز من",
},
],
},
];
}
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>
);
}