feat(admin): default the booking staff from the service's protocol, and call it پرسنل
The staff field started empty even when the service's treatment protocol already named who may perform it — a decision made once in the service settings and then asked again on every booking. The modal now reads that protocol and preselects its first staff member, but only until the user touches the field; otherwise a manual choice would be wiped on the next service change. A protocol with no staff leaves it empty and does not block submission. Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record in /admin/staff that they all refer to. ResourcesPage keeps the word: there it names a kind of bookable resource (doctor, operator, room, device), not a ClinicStaff row. Drops a test whose premise the default invalidated; the two new ones cover both sides — protocol with staff sends staff_uuid, protocol without staff sends none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -209,7 +209,7 @@ function EditForm({ detail, doctors, doctorsLoading, staff, staffLoading, onSave
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field-block">
|
<div className="field-block">
|
||||||
<label>اپراتور</label>
|
<label>پرسنل</label>
|
||||||
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
|
{/* «در حال خواندن» با «تعریف نشده» یکی نیست؛ بدون این تفکیک، فهرستِ هنوز
|
||||||
نیامده بهصورت «پرسنلی ندارید» خوانده میشد. */}
|
نیامده بهصورت «پرسنلی ندارید» خوانده میشد. */}
|
||||||
{staffLoading ? (
|
{staffLoading ? (
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export default function NewAppointmentModal({
|
|||||||
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز میگذارد،
|
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز میگذارد،
|
||||||
// پر کردنش آن را از قبل به یک نفر میدهد.
|
// پر کردنش آن را از قبل به یک نفر میدهد.
|
||||||
const [staffUuid, setStaffUuid] = useState('');
|
const [staffUuid, setStaffUuid] = useState('');
|
||||||
|
// پیشفرضِ پروتکل فقط تا وقتی اعمال میشود که کاربر دست نزده باشد؛ وگرنه انتخاب
|
||||||
|
// دستیِ منشی با هر تغییرِ سرویس پاک میشد.
|
||||||
|
const [staffTouched, setStaffTouched] = useState(false);
|
||||||
|
|
||||||
const role = useAuthStore(s => s.primaryRole);
|
const role = useAuthStore(s => s.primaryRole);
|
||||||
// نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمیشناسد.
|
// نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمیشناسد.
|
||||||
@@ -99,6 +102,28 @@ export default function NewAppointmentModal({
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پرسنلِ مجازِ «طول درمان» همان سرویس.
|
||||||
|
*
|
||||||
|
* منشی نباید چیزی را دوباره انتخاب کند که در تنظیمات سرویس یک بار تعریف شده؛
|
||||||
|
* پیشفرض همان است و تغییرش آزاد.
|
||||||
|
*/
|
||||||
|
const primaryService = pick.serviceUuids[0] ?? '';
|
||||||
|
const protocolQ = useQuery<ApiResponse<{ staff: { uuid: string; name: string }[] } | null>>({
|
||||||
|
queryKey: ['service-protocol', primaryService],
|
||||||
|
queryFn: () => api.get(`/api/v1/service-item/${primaryService}/treatment-protocol`),
|
||||||
|
enabled: primaryService !== '',
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const protocolStaff = protocolQ.data?.data?.staff ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (staffTouched || protocolStaff.length === 0) return;
|
||||||
|
|
||||||
|
setStaffUuid(protocolStaff[0].uuid);
|
||||||
|
}, [protocolStaff, staffTouched]);
|
||||||
|
|
||||||
const selfPricingQ = useQuery<Pricing>({
|
const selfPricingQ = useQuery<Pricing>({
|
||||||
queryKey: ['insurance-pricing'],
|
queryKey: ['insurance-pricing'],
|
||||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||||
@@ -288,13 +313,13 @@ export default function NewAppointmentModal({
|
|||||||
{/* اختیاری بهعمد: پیشفرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت
|
{/* اختیاری بهعمد: پیشفرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت
|
||||||
میکند که بیمار اپراتور مشخصی خواسته باشد. */}
|
میکند که بیمار اپراتور مشخصی خواسته باشد. */}
|
||||||
<div className="field-block" style={{ marginTop: 12 }}>
|
<div className="field-block" style={{ marginTop: 12 }}>
|
||||||
<label htmlFor="appt-operator">اپراتور <span className="opt">(اختیاری)</span></label>
|
<label htmlFor="appt-operator">پرسنل <span className="opt">(اختیاری)</span></label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
inputId="appt-operator"
|
inputId="appt-operator"
|
||||||
options={(staffQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.full_name }))}
|
options={(staffQ.data?.data ?? []).map(s => ({ value: s.uuid, label: s.full_name }))}
|
||||||
value={staffUuid || null}
|
value={staffUuid || null}
|
||||||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
onChange={v => { setStaffUuid(v ? String(v) : ''); setStaffTouched(true); }}
|
||||||
placeholder="در صف مشترک پرسنل بماند"
|
placeholder={protocolQ.isLoading ? 'در حال خواندن پرسنل سرویس…' : 'در صف مشترک پرسنل بماند'}
|
||||||
isLoading={staffQ.isLoading}
|
isLoading={staffQ.isLoading}
|
||||||
isClearable
|
isClearable
|
||||||
height={40}
|
height={40}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function mockApi() {
|
|||||||
if (url === '/api/v1/staff') {
|
if (url === '/api/v1/staff') {
|
||||||
return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] });
|
return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] });
|
||||||
}
|
}
|
||||||
|
if (url.includes('/treatment-protocol')) {
|
||||||
|
return Promise.resolve({ success: true, data: { staff: [{ uuid: 'st-1', name: 'پرسنل یک' }] } });
|
||||||
|
}
|
||||||
if (url.includes('/service-slots')) {
|
if (url.includes('/service-slots')) {
|
||||||
return Promise.resolve({ success: true, data: {
|
return Promise.resolve({ success: true, data: {
|
||||||
total_duration_minutes: 25,
|
total_duration_minutes: 25,
|
||||||
@@ -164,7 +167,41 @@ describe('مودال ثبت نوبتِ منبع', () => {
|
|||||||
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('اپراتور اختیاری است و فقط وقتی انتخاب شود staff_uuid میرود', async () => {
|
/** آنچه در «طول درمان» سرویس تعریف شده نباید دوباره از منشی پرسیده شود. */
|
||||||
|
it('پرسنلِ پروتکل سرویس را پیشفرض میفرستد', async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await pickServiceAndTime();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||||
|
await screen.findByText('بیمار یافت شد');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||||
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
|
expect(post.mock.calls[0][1]).toMatchObject({ staff_uuid: 'st-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** پروتکل بدون پرسنل نباید فرم را قفل کند. */
|
||||||
|
it('پروتکل بدون پرسنل، فیلد را خالی میگذارد و ثبت همچنان ممکن است', async () => {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/treatment-protocol')) return Promise.resolve({ success: true, data: null });
|
||||||
|
if (url.includes('patient-lookup')) {
|
||||||
|
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
|
||||||
|
}
|
||||||
|
if (url.includes('/service-slots')) {
|
||||||
|
return Promise.resolve({ success: true, data: {
|
||||||
|
total_duration_minutes: 25,
|
||||||
|
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||||
);
|
);
|
||||||
@@ -175,7 +212,6 @@ describe('مودال ثبت نوبتِ منبع', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||||
await screen.findByText('بیمار یافت شد');
|
await screen.findByText('بیمار یافت شد');
|
||||||
|
|
||||||
// بدون انتخاب اپراتور، جلسه باید در صفِ مشترک بماند — پس کلید اصلاً نرود.
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
|
expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid');
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function StaffSessionDetailPage() {
|
|||||||
|
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5, color: 'var(--text-2)' }}>
|
||||||
{session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>}
|
{session.appointment && <span>تاریخ: {formatDate(session.appointment.slot_start)}</span>}
|
||||||
{session.performed_by && <span>اپراتور: {session.performed_by.name}</span>}
|
{session.performed_by && <span>پرسنل: {session.performed_by.name}</span>}
|
||||||
<span>{formatNumber(settled)} از {formatNumber(areas.length)} ناحیه انجام شده</span>
|
<span>{formatNumber(settled)} از {formatNumber(areas.length)} ناحیه انجام شده</span>
|
||||||
{elapsed && (
|
{elapsed && (
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
|||||||
@@ -142,14 +142,14 @@ describe('صفحهٔ پروندههای درمان', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** تا وقتی جلسهای انجام نشده، برنامه را نشان میدهیم نه هیچ. */
|
/** تا وقتی جلسهای انجام نشده، برنامه را نشان میدهیم نه هیچ. */
|
||||||
it('اگر هنوز انجام نشده، اپراتورِ اختصاصیافته را نشان میدهد', async () => {
|
it('اگر هنوز انجام نشده، پرسنلِ اختصاصیافته را نشان میدهد', async () => {
|
||||||
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
|
mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]);
|
||||||
|
|
||||||
renderWithProviders(<TreatmentCasesPage />);
|
renderWithProviders(<TreatmentCasesPage />);
|
||||||
await screen.findByText('محمد رسولی');
|
await screen.findByText('محمد رسولی');
|
||||||
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
|
fireEvent.click(screen.getByLabelText('نمایش کارتی'));
|
||||||
|
|
||||||
expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument();
|
expect(await screen.findByText(/پرسنل: پرسنل۲/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** نمای پیشفرض جدول است — همان الگوی صفحهٔ پروندهها، با سوییچ به کارتی. */
|
/** نمای پیشفرض جدول است — همان الگوی صفحهٔ پروندهها، با سوییچ به کارتی. */
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: ()
|
|||||||
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
{c.supervisor && <span>پزشک ناظر: {c.supervisor.name}</span>}
|
||||||
{operator !== null && (
|
{operator !== null && (
|
||||||
operator.planned
|
operator.planned
|
||||||
? <span style={{ color: 'var(--text-3)' }}>اپراتور: {operator.text} (هنوز انجام نشده)</span>
|
? <span style={{ color: 'var(--text-3)' }}>پرسنل: {operator.text} (هنوز انجام نشده)</span>
|
||||||
: <span>انجامدهنده: {operator.text}</span>
|
: <span>انجامدهنده: {operator.text}</span>
|
||||||
)}
|
)}
|
||||||
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
{c.areas.length > 0 && <span>نواحی: {c.areas.map((a) => a.name).join('، ')}</span>}
|
||||||
|
|||||||
Reference in New Issue
Block a user