fix(dashboard): make the staff dashboard show the operator's actual work
Every number on it was zero. "نوبتهای امروز من" counted appointments where appointments.staff_id matches — a column no booking path fills by default, and which is NULL on every row in the database. "سرویسهای من" read only direct service assignment, so an operator whose whole job comes from a treatment protocol was told they had no services. The landing page of the only role that has one data page said, in effect, that they had nothing to do — while they had two sessions booked that day. Today's work now comes from TreatmentSessionRepository::findTodayForStaff, the same queue rule the sessions page uses, so there is one definition of "my work today" rather than two that disagree. Services are the union of direct assignment and protocol authorisation. The two stat cards are links to the pages they name; a number with no destination made the user hunt the sidebar for a page the card had just mentioned. Each row of the work list opens that session. The avatar moves from its own full-width card into the header — two lines of text were costing a card and pushing the day's work below the fold on mobile. The assigned-appointments table renders only when it has rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -188,3 +188,60 @@ describe('DashboardPage (ported clinic dashboard)', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('داشبورد پرسنل', () => {
|
||||
const staffPayload = {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'clinic',
|
||||
staff: { uuid: 'st-1', full_name: 'پرسنل۱', job_title: 'پرسنل لیزر' },
|
||||
owner: { name: 'مدیسا' },
|
||||
stats: { today_sessions: 2, today_appointments: 0, services: 1 },
|
||||
services: [{ uuid: 'svc-1', name: 'لیزر توتال', section_name: 'لیزر', price_rials: 10_000_000, duration_minutes: 40 }],
|
||||
today_sessions: [
|
||||
{ uuid: 'ses-1', session_number: 1, total_sessions: 3, status: 'in_progress', service_name: 'لیزر توتال', patient_name: 'محمد رسولی', slot_start: 1_786_084_200 },
|
||||
{ uuid: 'ses-2', session_number: 1, total_sessions: 3, status: 'done', service_name: 'لیزر توتال', patient_name: 'محمد رستمی', slot_start: 1_786_080_600 },
|
||||
],
|
||||
today_appointments: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'staff' } as never);
|
||||
get.mockResolvedValue(staffPayload);
|
||||
});
|
||||
|
||||
/** کارِ اپراتور جلسهٔ درمان است؛ `appointments.staff_id` را هیچ مسیری پر نمیکند. */
|
||||
it('کار امروز را از جلسات درمان میسازد، نه نوبتهای اختصاصیافته', async () => {
|
||||
renderWithProviders(<DashboardPage />);
|
||||
|
||||
expect(await screen.findByText('محمد رسولی')).toBeInTheDocument();
|
||||
expect(screen.getByText('محمد رستمی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('کارتهای آمار به صفحهٔ کار خودشان لینکاند', async () => {
|
||||
renderWithProviders(<DashboardPage />);
|
||||
|
||||
const sessions = (await screen.findByText('جلسات امروز من')).closest('a');
|
||||
expect(sessions).toHaveAttribute('href', '/admin/my-sessions');
|
||||
|
||||
const services = screen.getByText('سرویسهای من').closest('a');
|
||||
expect(services).toHaveAttribute('href', '/admin/my-services');
|
||||
});
|
||||
|
||||
it('هر ردیف کار به همان جلسه میرود', async () => {
|
||||
renderWithProviders(<DashboardPage />);
|
||||
|
||||
const row = (await screen.findByText('محمد رسولی')).closest('a');
|
||||
expect(row).toHaveAttribute('href', '/admin/my-sessions/ses-1');
|
||||
});
|
||||
|
||||
/** جدولِ همیشه-خالی زیر کارِ واقعی فقط صفحه را بلند میکرد. */
|
||||
it('نوبتهای اختصاصیافته وقتی خالی است اصلاً نمیآید', async () => {
|
||||
renderWithProviders(<DashboardPage />);
|
||||
await screen.findByText('محمد رسولی');
|
||||
|
||||
expect(screen.queryByText('نوبتهای اختصاصیافته به من')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTab
|
||||
import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { STATUS_META } from '../components/ui/AppointmentStatusDropdown';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
|
||||
// ── Chart period (Jalali) ─────────────────────────────────────────────────
|
||||
|
||||
@@ -959,8 +960,18 @@ interface StaffDashboardData {
|
||||
scope: 'doctor' | 'clinic';
|
||||
staff: { uuid: string; full_name: string; job_title: string | null };
|
||||
owner: { name: string };
|
||||
stats: { today_appointments: number; services: number };
|
||||
stats: { today_sessions: number; today_appointments: number; services: number };
|
||||
services: { uuid: string; name: string; section_name: string; price_rials: number; duration_minutes: number | null }[];
|
||||
/** کارِ امروزِ اپراتور. نوبتِ اختصاصیافته زیرمجموعهٔ همین صف است. */
|
||||
today_sessions: {
|
||||
uuid: string;
|
||||
session_number: number;
|
||||
total_sessions: number;
|
||||
status: string;
|
||||
service_name: string;
|
||||
patient_name: string | null;
|
||||
slot_start: number | null;
|
||||
}[];
|
||||
today_appointments: ApptRow[];
|
||||
}
|
||||
|
||||
@@ -991,17 +1002,41 @@ function StaffDashboard() {
|
||||
}
|
||||
|
||||
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
|
||||
const sessions = d.today_sessions ?? [];
|
||||
const settled = sessions.filter(s => ['done', 'cancelled', 'no_show'].includes(s.status)).length;
|
||||
|
||||
// هر کارت به صفحهٔ کارِ خودش میرود. عددِ بیمقصد یعنی کاربر باید در سایدبار
|
||||
// دنبال صفحهای بگردد که کارت همین حالا اسمش را برده است.
|
||||
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)' },
|
||||
{
|
||||
to: '/admin/my-sessions',
|
||||
label: 'جلسات امروز من',
|
||||
value: formatNumber(d.stats?.today_sessions ?? 0),
|
||||
hint: sessions.length > 0 ? `${formatNumber(settled)} انجام شده` : 'کاری برای امروز نیست',
|
||||
icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)',
|
||||
},
|
||||
{
|
||||
to: '/admin/my-services',
|
||||
label: 'سرویسهای من',
|
||||
value: formatNumber(d.stats?.services ?? 0),
|
||||
hint: 'سرویسهایی که مجاز به انجامشان هستید',
|
||||
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 style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||||
<AvatarEl initials={(d.staff?.full_name || 'P').slice(0, 1)} hue={162} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 className="section-title" style={{ margin: 0 }}>{d.staff?.full_name ?? 'داشبورد پرسنل'}</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
|
||||
{d.staff?.job_title ? `${d.staff.job_title} · ` : ''}{scopeLabel} {d.owner?.name ?? ''} · {today}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
@@ -1009,26 +1044,61 @@ function StaffDashboard() {
|
||||
</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">
|
||||
<Link key={c.label} to={c.to} className="stat" style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||
<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 className="hint">{c.hint}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>کار امروز من</h3>
|
||||
<Link to="/admin/my-sessions" className="link">همهٔ جلسات</Link>
|
||||
</div>
|
||||
{sessions.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 }}>
|
||||
{sessions.map(s => (
|
||||
<Link
|
||||
key={s.uuid}
|
||||
to={`/admin/my-sessions/${s.uuid}`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px',
|
||||
borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
||||
color: 'inherit', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
flexShrink: 0, minWidth: 52, fontWeight: 700, fontSize: 14,
|
||||
color: 'var(--primary-700)', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{s.slot_start === null
|
||||
? '—'
|
||||
: new Date(s.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.patient_name || 'بیمار نامشخص'}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
{s.service_name} · جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge type="treatment-session" value={s.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>سرویسهای تخصیصیافته</h3>
|
||||
@@ -1053,12 +1123,16 @@ function StaffDashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نوبتهای امروز من</h3>
|
||||
{/* نوبتِ مستقیمِ اختصاصیافته فقط وقتی نشان داده میشود که وجود داشته باشد؛
|
||||
یک جدول همیشه-خالی زیر کارِ واقعی، فقط صفحه را بلند میکرد. */}
|
||||
{(d.today_appointments?.length ?? 0) > 0 && (
|
||||
<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={false} />
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user