feat: redesign appointments (نوبتها) admin UI to match tauri turns + expandable sidebar
Rebuild the /admin/appointments page visual layer to match the tauri
clinic-pro-tauri "turns" design while keeping all existing data wiring and
backend endpoints unchanged (add/edit/move/transfer-reserve/replace already
supported via PATCH /api/v1/appointment/{uuid} and POST /api/v1/my/appointment).
Frontend (assets/admin):
- Sidebar: نوبتها becomes an expandable parent with sub-items
«نوبت های تایید شده» (/admin/appointments) and «افزودن نوبت»
(/admin/appointments/new); auto-expands on active child. Applied to
admin/clinic/doctor/secretary roles. Adds nav-subitem styling.
- New presentational components under components/appointments/: tauri status
palette (turnStatus), TurnsStatInfo, TurnsViewToggle (sliding), DoctorTabs
(underline), TurnsTimeline (marker rail + status cards, empty slot → افزودن
نوبت), TurnsTable.
- AppointmentsPage recomposed with the new components (stats bar, doctor tabs,
view toggle, timeline/table), preserving queries, filters, pagination,
quick-book modal and the row actions menu.
- AppointmentCreatePage: full-page create form (CreateTurn layout) at
/admin/appointments/new, reusing POST /api/v1/my|admin/appointment.
Tests: TurnsStatInfo, TurnsTimeline, Sidebar (expandable), AppointmentsPage,
AppointmentCreatePage. Backend move/reserve/replace verified green via existing
tests/Appointment/AppointmentUpdateTest + AppointmentWorkflowFieldsTest.
No API endpoints changed → no docs/api change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('../../hooks/useSubscription', () => ({
|
||||
useSubscription: () => ({ hasFeature: () => true }),
|
||||
}));
|
||||
|
||||
import Sidebar from './Sidebar';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
describe('Sidebar — expandable نوبتها menu', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
primaryRole: 'admin', dbUuid: 'c1', userName: 'ادمین',
|
||||
availableContexts: [], context: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('renders نوبتها as a collapsible parent and reveals sub-items on click', () => {
|
||||
renderWithProviders(<Sidebar />, { route: '/admin/dashboard' });
|
||||
|
||||
// منوی والد به شکل دکمه
|
||||
const parent = screen.getByRole('button', { name: /نوبتها/ });
|
||||
expect(parent).toBeInTheDocument();
|
||||
|
||||
// در ابتدا (مسیر داشبورد) بسته است → زیرمنوها نیستند
|
||||
expect(screen.queryByText('نوبت های تایید شده')).toBeNull();
|
||||
|
||||
fireEvent.click(parent);
|
||||
|
||||
// پس از باز شدن، دو زیرمنو دیده میشوند
|
||||
expect(screen.getByText('نوبت های تایید شده')).toBeInTheDocument();
|
||||
expect(screen.getByText('افزودن نوبت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('auto-expands when a child route is active', () => {
|
||||
renderWithProviders(<Sidebar />, { route: '/admin/appointments/new' });
|
||||
// چون «افزودن نوبت» فعال است، منو باید خودکار باز باشد
|
||||
expect(screen.getByText('افزودن نوبت')).toBeInTheDocument();
|
||||
expect(screen.getByText('نوبت های تایید شده')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ChevronDownIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
Cog6ToothIcon,
|
||||
CreditCardIcon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
HeartIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
StarIcon,
|
||||
TagIcon,
|
||||
@@ -23,19 +25,29 @@ import {
|
||||
UserGroupIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useSubscription } from "../../hooks/useSubscription";
|
||||
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 },
|
||||
];
|
||||
|
||||
function buildSections(
|
||||
primaryRole: string | null,
|
||||
dbUuid: string | null,
|
||||
@@ -49,7 +61,7 @@ function buildSections(
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتهای من" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتهای من", children: APPOINTMENTS_CHILDREN },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -86,6 +98,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
@@ -200,6 +213,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
@@ -274,6 +288,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای من",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/patients",
|
||||
@@ -333,6 +348,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
@@ -433,6 +449,125 @@ interface Props {
|
||||
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 [open, setOpen] = useState(childActive);
|
||||
|
||||
// منوی ساده — رفتار قبلی بدون تغییر.
|
||||
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={() => 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 } =
|
||||
@@ -461,56 +596,18 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
{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>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{section.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.to + item.label}
|
||||
item={item}
|
||||
sidebarOpen={sidebarOpen}
|
||||
isLocked={
|
||||
item.feature
|
||||
? !hasFeature(item.feature)
|
||||
: false
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user