fix(settings): every settings page renders the settings shell

Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.

Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.

.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 13:34:49 +03:30
co-authored by Claude Opus 5
parent f11514950e
commit 4380e64a5d
13 changed files with 1010 additions and 919 deletions
@@ -0,0 +1,33 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
const LINKS = [
{ to: '/admin/resources', label: 'منابع' },
{ to: '/admin/resources/types', label: 'نوع منابع' },
{ to: '/admin/resources/skills', label: 'مهارت‌ها' },
{ to: '/admin/resources/pools', label: 'استخر منابع' },
];
/**
* چهار صفحهٔ خانوادهٔ منابع، یک آیتم در منوی تنظیمات دارند؛ این نوار جابه‌جایی بینشان
* است. اگر هر چهار تا آیتم جدا در منو می‌گرفتند، منوی تنظیمات یک‌سوم بلندتر می‌شد بی‌آنکه
* چیزی روشن‌تر شود.
*/
export default function ResourcesSubNav() {
const { pathname } = useLocation();
return (
<div className="seg" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
{LINKS.map((l) => (
<Link
key={l.to}
to={l.to}
className={pathname === l.to ? 'active' : ''}
style={{ whiteSpace: 'nowrap' }}
>
{l.label}
</Link>
))}
</div>
);
}
+3
View File
@@ -10,6 +10,7 @@ import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useBranchRooms, useBranches } from '../hooks/useBranches'; import { useBranchRooms, useBranches } from '../hooks/useBranches';
import type { Room, RoomPayload } from '../types'; import type { Room, RoomPayload } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** اتاق‌های یک شعبه. ظرفیت = چند بیمار هم‌زمان، نه چند اتاق. */ /** اتاق‌های یک شعبه. ظرفیت = چند بیمار هم‌زمان، نه چند اتاق. */
export default function BranchRoomsPage() { export default function BranchRoomsPage() {
@@ -49,6 +50,7 @@ export default function BranchRoomsPage() {
}; };
return ( return (
<SettingsLayout active="branches">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title={`اتاق‌های ${branch?.name ?? 'شعبه'}`} title={`اتاق‌های ${branch?.name ?? 'شعبه'}`}
@@ -106,6 +108,7 @@ export default function BranchRoomsPage() {
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
@@ -6,6 +6,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useBranchWorkingHours, useBranches } from '../hooks/useBranches'; import { useBranchWorkingHours, useBranches } from '../hooks/useBranches';
import type { WorkingHourRange } from '../types'; import type { WorkingHourRange } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بک‌اند. */ /** ۰ = شنبه — همان قرارداد محاسبهٔ اسلات در بک‌اند. */
const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
@@ -116,6 +117,7 @@ export default function BranchWorkingHoursPage() {
const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0); const totalRanges = Object.values(draft).reduce((sum, ranges) => sum + ranges.length, 0);
return ( return (
<SettingsLayout active="branches">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title={`ساعت کاری ${branch?.name ?? 'شعبه'}`} title={`ساعت کاری ${branch?.name ?? 'شعبه'}`}
@@ -237,6 +239,7 @@ export default function BranchWorkingHoursPage() {
</div> </div>
)} )}
</div> </div>
</SettingsLayout>
); );
} }
+3
View File
@@ -8,6 +8,7 @@ import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches'; import { useBranches } from '../hooks/useBranches';
import type { Branch } from '../types'; import type { Branch } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** /**
* شعبه‌ها — همان محل‌های نوبت‌دهی محیط جاری. * شعبه‌ها — همان محل‌های نوبت‌دهی محیط جاری.
@@ -91,6 +92,7 @@ export default function BranchesPage() {
]; ];
return ( return (
<SettingsLayout active="branches">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="شعبه‌ها و اتاق‌ها" title="شعبه‌ها و اتاق‌ها"
@@ -150,6 +152,7 @@ export default function BranchesPage() {
)} )}
/> />
</div> </div>
</SettingsLayout>
); );
} }
@@ -10,6 +10,7 @@ import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories'; import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
import type { CatalogCategory } from '../types'; import type { CatalogCategory } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** یک ردیف از درخت، صاف‌شده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */ /** یک ردیف از درخت، صاف‌شده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
type Row = CatalogCategory & { depth: number }; type Row = CatalogCategory & { depth: number };
@@ -58,6 +59,7 @@ export default function CatalogCategoriesPage() {
]; ];
return ( return (
<SettingsLayout active="service-categories">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="دسته‌بندی‌ها" title="دسته‌بندی‌ها"
@@ -133,6 +135,7 @@ export default function CatalogCategoriesPage() {
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
@@ -7,6 +7,7 @@ import { usePermissions } from '../hooks/usePermissions';
import { useHolidays } from '../hooks/useResourceCalendar'; import { useHolidays } from '../hooks/useResourceCalendar';
import { formatDate } from '../lib/utils'; import { formatDate } from '../lib/utils';
import type { NationalHoliday } from '../types'; import type { NationalHoliday } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** سالِ شمسی جاری از همان فرمت‌کنندهٔ شمسیِ مرورگر گرفته می‌شود، نه با محاسبهٔ دستی. */ /** سالِ شمسی جاری از همان فرمت‌کنندهٔ شمسیِ مرورگر گرفته می‌شود، نه با محاسبهٔ دستی. */
function currentJalaliYear(): number { function currentJalaliYear(): number {
@@ -54,6 +55,7 @@ export default function HolidaysSettingsPage() {
const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i); const years = Array.from({ length: 5 }, (_, i) => thisYear - 1 + i);
return ( return (
<SettingsLayout active="holidays">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="تعطیلات رسمی" title="تعطیلات رسمی"
@@ -117,6 +119,7 @@ export default function HolidaysSettingsPage() {
onRemove={(uuid) => removeOverride.mutate(uuid)} onRemove={(uuid) => removeOverride.mutate(uuid)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
+3
View File
@@ -12,6 +12,7 @@ import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches'; import { useBranches } from '../hooks/useBranches';
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists'; import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
import { useAllServiceItems } from '../hooks/useServiceCatalog'; import { useAllServiceItems } from '../hooks/useServiceCatalog';
import SettingsLayout from '../components/layout/SettingsLayout';
interface Draft { interface Draft {
uuid?: string; uuid?: string;
@@ -120,6 +121,7 @@ export default function PriceListsPage() {
}; };
return ( return (
<SettingsLayout active="price-lists">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="لیست‌های قیمت" title="لیست‌های قیمت"
@@ -351,5 +353,6 @@ export default function PriceListsPage() {
)} )}
</Modal> </Modal>
</div> </div>
</SettingsLayout>
); );
} }
+14 -2
View File
@@ -9,6 +9,7 @@ import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsP
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel'; import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel'; import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel'; import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
import SettingsLayout from '../components/layout/SettingsLayout';
import { useUrlState } from '../hooks/useUrlState'; import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches'; import { useBranches } from '../hooks/useBranches';
@@ -56,10 +57,20 @@ export default function ResourceDetailPage() {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
if (loading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>; // حالت‌های بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات می‌مانند، وگرنه منوی کناری
if (!resource) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>منبع یافت نشد.</div>; // یک لحظه می‌پرد و دوباره برمی‌گردد.
if (loading || !resource) {
return (
<SettingsLayout active="resources">
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>
{loading ? 'در حال بارگذاری...' : 'منبع یافت نشد.'}
</div>
</SettingsLayout>
);
}
return ( return (
<SettingsLayout active="resources">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title={resource.name} title={resource.name}
@@ -142,6 +153,7 @@ export default function ResourceDetailPage() {
} }
/> />
</div> </div>
</SettingsLayout>
); );
} }
+6
View File
@@ -11,6 +11,8 @@ import { usePermissions } from '../hooks/usePermissions';
import { useBranches } from '../hooks/useBranches'; import { useBranches } from '../hooks/useBranches';
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources'; import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
import type { ResourcePool } from '../types'; import type { ResourcePool } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
/** /**
* استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند. * استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند.
@@ -65,6 +67,7 @@ export default function ResourcePoolsPage() {
]; ];
return ( return (
<SettingsLayout active="resources">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="استخر منابع" title="استخر منابع"
@@ -80,6 +83,8 @@ export default function ResourcePoolsPage() {
} }
/> />
<ResourcesSubNav />
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -140,6 +145,7 @@ export default function ResourcePoolsPage() {
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
+6
View File
@@ -9,6 +9,8 @@ import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useResourceTypes } from '../hooks/useResources'; import { useResourceTypes } from '../hooks/useResources';
import type { ResourceType } from '../types'; import type { ResourceType } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
/** نوع منبع — کلینیک خودش تعریفش می‌کند؛ سه نوع سیستمی را backfill می‌سازد. */ /** نوع منبع — کلینیک خودش تعریفش می‌کند؛ سه نوع سیستمی را backfill می‌سازد. */
export default function ResourceTypesPage() { export default function ResourceTypesPage() {
@@ -42,6 +44,7 @@ export default function ResourceTypesPage() {
]; ];
return ( return (
<SettingsLayout active="resources">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="نوع منابع" title="نوع منابع"
@@ -57,6 +60,8 @@ export default function ResourceTypesPage() {
} }
/> />
<ResourcesSubNav />
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -109,6 +114,7 @@ export default function ResourceTypesPage() {
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
+6
View File
@@ -16,6 +16,8 @@ import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
import ResourceServicesModal from '../components/resources/ResourceServicesModal'; import ResourceServicesModal from '../components/resources/ResourceServicesModal';
import { useAllServiceItems } from '../hooks/useServiceCatalog'; import { useAllServiceItems } from '../hooks/useServiceCatalog';
import type { ClinicResource } from '../types'; import type { ClinicResource } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */ /** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
const SUBJECT_LABEL: Record<string, string> = { const SUBJECT_LABEL: Record<string, string> = {
@@ -112,6 +114,7 @@ export default function ResourcesPage() {
]; ];
return ( return (
<SettingsLayout active="resources">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="منابع" title="منابع"
@@ -126,6 +129,8 @@ export default function ResourcesPage() {
} }
/> />
<ResourcesSubNav />
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -267,5 +272,6 @@ export default function ResourcesPage() {
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
+6
View File
@@ -9,6 +9,8 @@ import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useSkills } from '../hooks/useResources'; import { useSkills } from '../hooks/useResources';
import type { Skill } from '../types'; import type { Skill } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
/** /**
* مهارت‌ها — «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است، نه یک * مهارت‌ها — «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است، نه یک
@@ -39,6 +41,7 @@ export default function SkillsPage() {
]; ];
return ( return (
<SettingsLayout active="resources">
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="مهارت‌ها" title="مهارت‌ها"
@@ -54,6 +57,8 @@ export default function SkillsPage() {
} }
/> />
<ResourcesSubNav />
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -106,6 +111,7 @@ export default function SkillsPage() {
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
/> />
</div> </div>
</SettingsLayout>
); );
} }
+7 -3
View File
@@ -651,11 +651,15 @@ table.t tbody tr:last-child td { border-bottom: none; }
.field:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); } .field:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); }
.field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; color: var(--text); font-family: inherit; font-size: 14px; } .field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; color: var(--text); font-family: inherit; font-size: 14px; }
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; } .seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
.seg button { padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); transition: .14s; cursor: pointer; font-family: inherit; } /* `a` هم پذیرفته می‌شود: نوار تب‌هایی که بین صفحه‌ها جابه‌جا می‌کنند باید لینک واقعی باشند
.seg button.on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); } (باز کردن در تب جدید، کلیک وسط)، نه دکمه‌ای که navigate صدا می‌زند. */
.seg button, .seg a { padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--text-2); transition: .14s; cursor: pointer; font-family: inherit; text-decoration: none; display: inline-flex; align-items: center; }
/* `active` هم‌معنی `on` است — هر دو در کد استفاده شده‌اند و نبودِ این alias یعنی تب فعال
در چند صفحه هیچ نشانه‌ای نداشت. */
.seg button.on, .seg button.active, .seg a.on, .seg a.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
/* هم‌راستا با .mini-btn: ارتفاع لمسیِ حداقلی روی موبایل (WCAG 2.5.5) */ /* هم‌راستا با .mini-btn: ارتفاع لمسیِ حداقلی روی موبایل (WCAG 2.5.5) */
@media (max-width: 640px), (pointer: coarse) { @media (max-width: 640px), (pointer: coarse) {
.seg button { min-height: 44px; padding: 7px 16px; } .seg button, .seg a { min-height: 44px; padding: 7px 16px; }
} }
/* ── Switch toggle ───────────────────────────────────────────── */ /* ── Switch toggle ───────────────────────────────────────────── */