feat(resources): warn before switching a resource off, and pick dates in Jalali
The deactivation warning was blocked on task 07: there was no way to count "the
appointments on this resource" until occupancy rows linked the two. They do
now, so GET /resource/{uuid} returns upcoming_appointments. It stays off the
list endpoint, where it would be one count query per row.
It is a warning, not a block, and the wording says so: switching a resource off
does not cancel anything, it only removes the resource from future searches.
The panel shows it the moment the "active" box is unticked.
The calendar's exception range still used <input type="date">, which is
Gregorian. Operators say dates in Jalali, and the mental conversion is exactly
where an exception gets recorded a day off. PersianDateInput takes the same
YYYY-MM-DD string, so this is a drop-in swap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('../../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { api } from '../../lib/api';
|
||||
import ResourceFormModal from './ResourceFormModal';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const resource = {
|
||||
uuid: 'r-1',
|
||||
name: 'لیزر ۱',
|
||||
address_uuid: 'b-1',
|
||||
type_uuid: 't-1',
|
||||
capacity: 1,
|
||||
is_active: true,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
const props = {
|
||||
open: true,
|
||||
resource: resource as never,
|
||||
branches: [{ uuid: 'b-1', name: 'شعبه' }] as never,
|
||||
types: [{ uuid: 't-1', name: 'دستگاه', code: 'device' }] as never,
|
||||
saving: false,
|
||||
onClose: () => {},
|
||||
onSave: () => {},
|
||||
};
|
||||
|
||||
describe('ResourceFormModal — هشدار غیرفعالسازی', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/** ⭐ غیرفعالکردن نوبتها را لغو نمیکند؛ اپراتور باید بداند چند بیمار درگیرند. */
|
||||
it('warns with the upcoming count only once the resource is switched off', async () => {
|
||||
get.mockResolvedValue({ data: { ...resource, upcoming_appointments: 4 } });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<ResourceFormModal {...props} />);
|
||||
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/resource/r-1'));
|
||||
expect(screen.queryByText(/نوبت آیندهٔ فعال دارد/)).toBeNull();
|
||||
|
||||
await user.click(screen.getByLabelText('منبع فعال است'));
|
||||
|
||||
expect(await screen.findByText(/۴ نوبت آیندهٔ فعال دارد/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is booked on the resource', async () => {
|
||||
get.mockResolvedValue({ data: { ...resource, upcoming_appointments: 0 } });
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<ResourceFormModal {...props} />);
|
||||
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await user.click(screen.getByLabelText('منبع فعال است'));
|
||||
|
||||
expect(screen.queryByText(/نوبت آیندهٔ فعال دارد/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types';
|
||||
import { useResourceDetail } from '../../hooks/useResources';
|
||||
import { formatNumber } from '../../lib/utils';
|
||||
|
||||
/** کلیدهای شناختهشدهٔ ویژگی — قرارداد است نه اجبار؛ سرور هر کلید snake_case را میپذیرد. */
|
||||
const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand'];
|
||||
@@ -21,6 +23,8 @@ interface Props {
|
||||
export default function ResourceFormModal({
|
||||
open, resource, branches, types, saving, onClose, onSave,
|
||||
}: Props) {
|
||||
// شمار نوبتهای آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است.
|
||||
const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined);
|
||||
const [name, setName] = useState('');
|
||||
const [addressUuid, setAddressUuid] = useState<string | null>(null);
|
||||
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
||||
@@ -184,6 +188,25 @@ export default function ResourceFormModal({
|
||||
منبع فعال است
|
||||
</label>
|
||||
|
||||
{/* غیرفعالکردن نوبتهای ثبتشده را لغو نمیکند؛ فقط از جستجوی وقتِ بعدی حذف
|
||||
میشود. پس این هشدار است نه مانع — ولی اپراتور باید بداند چند بیمار روی
|
||||
منبعی نوبت دارند که دارد خاموش میشود. */}
|
||||
{!active && upcoming > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.8,
|
||||
color: 'var(--warning)',
|
||||
background: 'var(--warning-bg)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
این منبع {formatNumber(upcoming)} نوبت آیندهٔ فعال دارد. غیرفعالکردن آنها را
|
||||
لغو نمیکند؛ فقط این منبع دیگر در جستجوی وقت نمیآید.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||
<button type="button" className="btn primary" disabled={saving || invalid} onClick={submit}>
|
||||
|
||||
@@ -180,3 +180,26 @@ export function useResourcePools() {
|
||||
|
||||
return { pools: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setMembers };
|
||||
}
|
||||
|
||||
/**
|
||||
* جزئیات یک منبع — تنها جایی که شمار نوبتهای آیندهٔ آن میآید.
|
||||
*
|
||||
* در فهرست نمیآید چون آنجا یک کوئری per ردیف میشد؛ اینجا فقط وقتی لازم است که
|
||||
* کاربر دارد همان یک منبع را ویرایش میکند.
|
||||
*/
|
||||
export function useResourceDetail(uuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['resource-detail', uuid],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<ClinicResource & { upcoming_appointments: number }>>(
|
||||
`/api/v1/resource/${uuid}`,
|
||||
),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
return {
|
||||
resource: query.data?.data,
|
||||
upcomingAppointments: query.data?.data?.upcoming_appointments ?? 0,
|
||||
loading: query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import {
|
||||
@@ -346,9 +347,15 @@ function ExceptionsCard({
|
||||
placeholder="نوع استثنا"
|
||||
height={36}
|
||||
/>
|
||||
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
|
||||
ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input type="date" className="field" value={startDate} onChange={(e) => setStartDate(e.target.value)} style={{ flex: 1 }} />
|
||||
<input type="date" className="field" value={endDate} onChange={(e) => setEndDate(e.target.value)} style={{ flex: 1 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
|
||||
</div>
|
||||
</div>
|
||||
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user