feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services. - Created StaffMyServicesPage to display assigned services for staff users. - Added migration to link clinic staff rows to user accounts for ROLE_STAFF access. - Defined StaffPermissions class for static permissions related to staff role. - Introduced StaffRouteGuardSubscriber to restrict API access for staff users. - Developed StaffAccountService for managing staff user accounts and linking them to clinic staff. - Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment. - Implemented tests for staff dashboard access to ensure proper permissions and access control. - Created tests for staff login context to verify correct environment visibility based on user roles.
This commit is contained in:
@@ -59,6 +59,7 @@ import MyFinancialPage from './pages/MyFinancialPage';
|
||||
import ClinicFormPage from './pages/ClinicFormPage';
|
||||
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import StaffMyServicesPage from './pages/StaffMyServicesPage';
|
||||
import SubscriptionPage from './pages/SubscriptionPage';
|
||||
import DiscountsPage from './pages/DiscountsPage';
|
||||
import ClinicServicesPage from './pages/ClinicServicesPage';
|
||||
@@ -80,7 +81,7 @@ import PwaInstallBanner from './components/ui/PwaInstallBanner';
|
||||
|
||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'staff', 'representation'] as const;
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe, logout } = useAuthStore();
|
||||
@@ -262,6 +263,8 @@ export default function App() {
|
||||
|
||||
{/* فاز ۲ — دکتر / کلینیک */}
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
|
||||
{/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */}
|
||||
<Route path="my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
|
||||
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
|
||||
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
|
||||
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
|
||||
|
||||
@@ -490,6 +490,27 @@ function buildSections(
|
||||
];
|
||||
}
|
||||
|
||||
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 [
|
||||
{
|
||||
@@ -570,6 +591,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
clinic: "مالک کلینیک",
|
||||
doctor: "پزشک",
|
||||
secretary: "منشی",
|
||||
staff: "پرسنل",
|
||||
representation: "نماینده",
|
||||
user: "کاربر",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../test/utils";
|
||||
|
||||
vi.mock("../../hooks/useSubscription", () => ({
|
||||
useSubscription: () => ({ hasFeature: () => true }),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
/**
|
||||
* پرسنل فقط داشبورد و سرویسهای خودش را دارد؛ هیچ آیتم مدیریتی نباید در منویش
|
||||
* ظاهر شود — قرینهٔ enforcement سمت API (StaffRouteGuardSubscriber).
|
||||
*/
|
||||
describe("Sidebar — نقش پرسنل", () => {
|
||||
const asStaff = () =>
|
||||
useAuthStore.setState({
|
||||
primaryRole: "staff",
|
||||
dbUuid: "d1",
|
||||
userName: "زهرا احمدی",
|
||||
availableContexts: [],
|
||||
context: {
|
||||
type: "doctor",
|
||||
db_uuid: "d1",
|
||||
name: "مطب تست",
|
||||
role: "staff",
|
||||
scope: "doctor",
|
||||
permissions: { version: 1, resources: { services: { view: true }, appointments: { view: true } } },
|
||||
},
|
||||
} as any);
|
||||
|
||||
it("shows only داشبورد and سرویسهای من", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
|
||||
expect(screen.getByText("داشبورد").closest("a")).toHaveAttribute("href", "/admin/dashboard");
|
||||
expect(screen.getByText("سرویسهای من").closest("a")).toHaveAttribute("href", "/admin/my-services");
|
||||
expect(screen.getByText("پرسنل")).toBeInTheDocument(); // برچسب نقش در فوتر
|
||||
});
|
||||
|
||||
it("hides management entries", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
|
||||
for (const label of ["پرونده بیماران", "تنظیمات", "نوبتها", "پرداختها"]) {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -953,6 +953,116 @@ function SecretaryDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Staff Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
interface StaffDashboardData {
|
||||
scope: 'doctor' | 'clinic';
|
||||
staff: { uuid: string; full_name: string; job_title: string | null };
|
||||
owner: { name: string };
|
||||
stats: { today_appointments: number; services: number };
|
||||
services: { uuid: string; name: string; section_name: string; price_rials: number; duration_minutes: number | null }[];
|
||||
today_appointments: ApptRow[];
|
||||
}
|
||||
|
||||
function StaffDashboard() {
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-staff'],
|
||||
queryFn: () => api.get<ApiResponse<StaffDashboardData>>('/api/v1/dashboard/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<StaffDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
if (q.isError || !d) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
|
||||
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
|
||||
<p className="muted" style={{ fontSize: 13.5 }}>دسترسی شما به این محیط فعال نیست. با مدیر مطب/کلینیک تماس بگیرید.</p>
|
||||
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
|
||||
const kpiCards = [
|
||||
{ label: 'نوبتهای امروز من', value: formatNumber(d.stats?.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'سرویسهای من', value: formatNumber(d.stats?.services ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد پرسنل</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {scopeLabel} {d.owner?.name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<AvatarEl initials={(d.staff?.full_name || 'P').slice(0, 1)} hue={162} size="lg" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{d.staff?.full_name ?? '—'}</div>
|
||||
{d.staff?.job_title && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.staff.job_title}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
{kpiCards.map(c => (
|
||||
<div key={c.label} className="stat">
|
||||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||
<c.icon style={{ width: 21, height: 21 }} />
|
||||
</div>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val">{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>سرویسهای تخصیصیافته</h3>
|
||||
<Link to="/admin/my-services" className="link">همه سرویسها</Link>
|
||||
</div>
|
||||
{d.services.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: 13.5, padding: '1.5rem 0', textAlign: 'center' }}>
|
||||
هنوز سرویسی به شما تخصیص نیافته است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||
{d.services.slice(0, 5).map(s => (
|
||||
<div key={s.uuid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{s.section_name}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>{formatRial(s.price_rials)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نوبتهای امروز من</h3>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepSummary {
|
||||
appointments: { today: number; week: number; month: number; total: number };
|
||||
income: {
|
||||
@@ -1173,6 +1283,7 @@ export default function DashboardPage() {
|
||||
if (primaryRole === 'doctor' && scope === 'clinic') return <InvitedDoctorDashboard />;
|
||||
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
||||
if (primaryRole === 'secretary') return <SecretaryDashboard />;
|
||||
if (primaryRole === 'staff') return <StaffDashboard />;
|
||||
if (primaryRole === 'representation') return <RepresentationDashboard />;
|
||||
|
||||
return <AdminDashboard />;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { StaffAssignedService } from '../types';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
|
||||
interface StaffDashboardData {
|
||||
services: StaffAssignedService[];
|
||||
}
|
||||
|
||||
const EMPTY: StaffAssignedService[] = [];
|
||||
|
||||
/**
|
||||
* سرویسهای تخصیصیافته به پرسنل — فقط خواندنی.
|
||||
* داده از همان اندپوینت داشبورد پرسنل میآید؛ نقش staff اندپوینت دیگری ندارد.
|
||||
*/
|
||||
export default function StaffMyServicesPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['dashboard-staff'],
|
||||
queryFn: () => api.get<ApiResponse<StaffDashboardData>>('/api/v1/dashboard/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const services = data?.data?.services ?? EMPTY;
|
||||
|
||||
const columns: Column<StaffAssignedService>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'سرویس',
|
||||
render: (s) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{s.section_name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price_rials',
|
||||
header: 'تعرفه',
|
||||
render: (s) => <span style={{ fontSize: 13 }}>{formatRial(s.price_rials)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'duration_minutes',
|
||||
header: 'مدت',
|
||||
render: (s) => (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
{s.duration_minutes ? `${s.duration_minutes} دقیقه` : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="سرویسهای من"
|
||||
description="سرویسهایی که به شما تخصیص داده شده است"
|
||||
backTo="/admin/dashboard"
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
{services.length === 0 && !isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
|
||||
<WrenchScrewdriverIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>
|
||||
هنوز سرویسی به شما تخصیص نیافته
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>پس از تخصیص سرویس توسط مطب/کلینیک، اینجا نمایش داده میشود.</div>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={columns} data={services} loading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,22 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { numericField } from '../lib/forms';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
// هر پرسنل حساب کاربری ورود دارد، پس موبایل همان نامکاربری است و اجباری.
|
||||
const schema = z.object({
|
||||
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
|
||||
phone: z.string().optional(),
|
||||
phone: z.string().regex(/^09\d{9}$/, 'شماره موبایل معتبر (۱۱ رقمی) وارد کنید'),
|
||||
job_title: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
national_code: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.password && data.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['password'],
|
||||
message: 'رمز عبور حداقل ۸ کاراکتر باشد',
|
||||
});
|
||||
}
|
||||
});
|
||||
type StaffFormData = z.infer<typeof schema>;
|
||||
|
||||
@@ -91,6 +101,7 @@ export default function StaffPage() {
|
||||
job_title: s.job_title ?? '',
|
||||
address: s.address ?? '',
|
||||
national_code: s.national_code ?? '',
|
||||
password: '',
|
||||
});
|
||||
setEditTarget(s);
|
||||
};
|
||||
@@ -131,6 +142,15 @@ export default function StaffPage() {
|
||||
header: 'وضعیت',
|
||||
render: (s) => <ActiveBadge active={s.active} />,
|
||||
},
|
||||
{
|
||||
key: 'has_account',
|
||||
header: 'حساب کاربری',
|
||||
render: (s) => (
|
||||
<span className={`badge ${s.has_account ? 'green' : ''}`} style={{ fontSize: 12 }}>
|
||||
{s.has_account ? 'دارد' : 'ندارد'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'تاریخ ثبت',
|
||||
@@ -275,8 +295,9 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<label>موبایل (نام کاربری ورود) *</label>
|
||||
<input {...numericField(register('phone'), 11)} placeholder="09121234567" />
|
||||
{errors.phone && <span className="field-error">{errors.phone.message}</span>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>کد ملی</label>
|
||||
@@ -287,6 +308,16 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
|
||||
<label>آدرس</label>
|
||||
<input {...register('address')} placeholder="آدرس محل سکونت" />
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<label>رمز عبور ورود به پنل</label>
|
||||
<input type="password" autoComplete="new-password" {...register('password')} placeholder="حداقل ۸ کاراکتر — خالی یعنی بدون تغییر" />
|
||||
{errors.password && <span className="field-error">{errors.password.message}</span>}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||
برای هر پرسنل حساب کاربری ساخته میشود: با همین شماره موبایل وارد پنل میشود و فقط
|
||||
داشبورد و سرویسهای خودش را میبیند.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface ContextItem {
|
||||
type: 'doctor' | 'clinic';
|
||||
db_uuid: string;
|
||||
name: string;
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user';
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user';
|
||||
scope?: string | null;
|
||||
doctor_uuid?: string;
|
||||
permissions?: Record<string, any>;
|
||||
@@ -17,7 +17,7 @@ interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
userUuid: string | null;
|
||||
userName: string | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user' | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user' | null;
|
||||
dbUuid: string | null;
|
||||
dbKey: string | null;
|
||||
doctorUuid: string | null;
|
||||
|
||||
@@ -607,9 +607,21 @@ export interface ClinicStaff {
|
||||
address: string | null;
|
||||
national_code: string | null;
|
||||
active: boolean;
|
||||
/** حساب ورود به پنل با نقش ROLE_STAFF */
|
||||
has_account: boolean;
|
||||
user_uuid: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** یک قلم از سرویسهای تخصیصیافته به پرسنل — خروجی GET /api/v1/dashboard/staff */
|
||||
export interface StaffAssignedService {
|
||||
uuid: string;
|
||||
name: string;
|
||||
section_name: string;
|
||||
price_rials: number;
|
||||
duration_minutes: number | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
uuid: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user