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:
hamed
2026-07-30 10:18:41 +03:30
parent 6ec011e3ad
commit 57aeb40934
28 changed files with 1960 additions and 29 deletions
+111
View File
@@ -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 />;