feat: refactor ResourceExceptionsPanel and related hooks; remove unused availability logic and simplify resource detail management
This commit is contained in:
@@ -1,106 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useResourceAvailability } from '../../hooks/useResourceCalendar';
|
|
||||||
import { formatDate, formatNumber } from '../../lib/utils';
|
|
||||||
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
|
||||||
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
||||||
import ResourceExceptionsCard from './ResourceExceptionsCard';
|
import ResourceExceptionsCard from './ResourceExceptionsCard';
|
||||||
import type { ResourceAvailability } from '../../types';
|
|
||||||
|
|
||||||
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
|
||||||
const REASON_LABELS: Record<string, string> = {
|
|
||||||
national_holiday: 'تعطیل رسمی',
|
|
||||||
tenant_holiday: 'تعطیلی این محیط',
|
|
||||||
no_shift: 'شیفتی تعریف نشده',
|
|
||||||
exception: 'مرخصی یا سرویس',
|
|
||||||
resource_inactive: 'منبع غیرفعال است',
|
|
||||||
address_inactive: 'محل نوبتدهی غیرفعال است',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** نیمهشبِ امروز بهصورت timestamp ثانیهای. */
|
|
||||||
function todayMidnight(): number {
|
|
||||||
const d = new Date();
|
|
||||||
d.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor(d.getTime() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تقویمِ استثناهای یک منبع: تعطیلات رسمی، مرخصی و سرویس، و پیشنمایش دو هفته.
|
* تقویمِ استثناهای یک منبع: تعطیلات رسمی، و مرخصی و سرویس.
|
||||||
*
|
*
|
||||||
* این ترکیب مخصوص صفحهٔ جزئیات منبع است، جایی که کل صفحه دربارهٔ همان یک منبع است
|
* دو ورودیِ همخانواده که کنار هم میمانند — هر دو روزی را از تقویم منبع کم میکنند،
|
||||||
* و پیشنمایش پاسخِ «بعد از این تنظیمات، چه ساعتی باز میماند؟» را میدهد. صفحهٔ
|
* یکی سراسری و یکی مخصوص همین منبع.
|
||||||
* تنظیمات نوبتدهی همان دو کارت را بدون پیشنمایش و بهصورت تب میچیند.
|
|
||||||
*/
|
*/
|
||||||
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||||
resourceUuid?: string;
|
resourceUuid?: string;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
}) {
|
}) {
|
||||||
const previewFrom = todayMidnight();
|
|
||||||
const previewTo = previewFrom + 13 * 86400;
|
|
||||||
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="wh-two-col">
|
<div className="wh-two-col">
|
||||||
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
<NationalHolidaysCard canUpdate={canUpdate} />
|
||||||
<NationalHolidaysCard canUpdate={canUpdate} />
|
<ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />
|
||||||
<ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PreviewCard availability={availability} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* پیشنمایش دو هفته.
|
|
||||||
*
|
|
||||||
* ۱۴ سطرِ یکنواختِ «شیفتی تعریف نشده» فقط نویز است و با یک حالت خالیِ راهحلدار
|
|
||||||
* جمع میشود. شرط عمداً تنگ است: اگر حتی یک روز به دلیل دیگری (تعطیل رسمی،
|
|
||||||
* مرخصی) بسته باشد سطرها میمانند، چون آن دلیل خودش اطلاعات است — نه نویز.
|
|
||||||
*/
|
|
||||||
function PreviewCard({ availability }: { availability?: ResourceAvailability }) {
|
|
||||||
const days = availability?.days ?? [];
|
|
||||||
const nothingConfigured = days.length > 0
|
|
||||||
&& days.every((d) => d.intervals.length === 0
|
|
||||||
&& d.reasons.length > 0
|
|
||||||
&& d.reasons.every((r) => r === 'no_shift'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card card-pad" style={{ position: 'sticky', top: 'var(--gap)' }}>
|
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>پیشنمایش دو هفته</h2>
|
|
||||||
<p className="field-hint" style={{ marginTop: 4, marginBottom: 14 }}>
|
|
||||||
ساعت <strong>خام</strong> — نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{days.length === 0 ? (
|
|
||||||
<div className="skeleton" style={{ height: 120, borderRadius: 'var(--r-sm)' }} />
|
|
||||||
) : nothingConfigured ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 1.9 }}>
|
|
||||||
در دو هفتهٔ آینده هیچ ساعتی باز نیست. اول در «شیفت هفتگی» روزهای کاری را تعریف کنید.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gap: 7 }}>
|
|
||||||
{days.map((day) => (
|
|
||||||
<div
|
|
||||||
key={day.date}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
gap: 8, fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: 'var(--text-2)' }}>
|
|
||||||
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date)}
|
|
||||||
</span>
|
|
||||||
{day.intervals.length === 0 ? (
|
|
||||||
<span className="badge gray" style={{ fontSize: 11 }}>
|
|
||||||
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ fontWeight: 700 }}>{formatNumber(day.total_minutes)} دقیقه</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||||
import type {
|
import type {
|
||||||
HolidayOverride, NationalHoliday, ResourceAvailability,
|
HolidayOverride, NationalHoliday,
|
||||||
ResourceCalendarDays, ResourceException, WorkingHoursPayload,
|
ResourceCalendarDays, ResourceException, WorkingHoursPayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ export function useResourceCalendar(resourceUuid: string | undefined) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('شیفتها ذخیره شد');
|
toast.success('شیفتها ذخیره شد');
|
||||||
qc.invalidateQueries({ queryKey: key });
|
qc.invalidateQueries({ queryKey: key });
|
||||||
qc.invalidateQueries({ queryKey: ['resource-availability', resourceUuid] });
|
|
||||||
},
|
},
|
||||||
onError: (e) => fail(e, 'ذخیرهٔ شیفتها ناموفق بود'),
|
onError: (e) => fail(e, 'ذخیرهٔ شیفتها ناموفق بود'),
|
||||||
});
|
});
|
||||||
@@ -41,7 +40,6 @@ export function useResourceExceptions(resourceUuid: string | undefined) {
|
|||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: key });
|
qc.invalidateQueries({ queryKey: key });
|
||||||
qc.invalidateQueries({ queryKey: ['resource-availability', resourceUuid] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -66,17 +64,6 @@ export function useResourceExceptions(resourceUuid: string | undefined) {
|
|||||||
return { exceptions: query.data?.data ?? [], loading: query.isLoading, create, remove };
|
return { exceptions: query.data?.data ?? [], loading: query.isLoading, create, remove };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResourceAvailability(resourceUuid: string | undefined, from: number, to: number) {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ['resource-availability', resourceUuid, from, to],
|
|
||||||
queryFn: () =>
|
|
||||||
api.get<ApiResponse<ResourceAvailability>>(`/api/v1/resource/${resourceUuid}/availability?from=${from}&to=${to}`),
|
|
||||||
enabled: !!resourceUuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { availability: query.data?.data, loading: query.isLoading };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useHolidays(year: number) {
|
export function useHolidays(year: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const key = ['national-holidays', year];
|
const key = ['national-holidays', year];
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
* آدرس محل نوبتدهی است — پس فیلترها با `address_uuid` کار میکنند نه `branch_id`.
|
* آدرس محل نوبتدهی است — پس فیلترها با `address_uuid` کار میکنند نه `branch_id`.
|
||||||
*/
|
*/
|
||||||
const RESOURCES_KEY = 'resources';
|
const RESOURCES_KEY = 'resources';
|
||||||
|
/** کلیدِ کوئریِ صفحهٔ جزئیات — `useResourceDetail` هم از همین میسازد. */
|
||||||
|
const RESOURCE_DETAIL_KEY = 'resource-detail';
|
||||||
const TYPES_KEY = ['resource-types'];
|
const TYPES_KEY = ['resource-types'];
|
||||||
const SKILLS_KEY = ['skills'];
|
const SKILLS_KEY = ['skills'];
|
||||||
const POOLS_KEY = ['resource-pools'];
|
const POOLS_KEY = ['resource-pools'];
|
||||||
@@ -36,7 +38,16 @@ function toQuery(filters: ResourceFilters): string {
|
|||||||
|
|
||||||
export function useResources(filters: ResourceFilters = {}) {
|
export function useResources(filters: ResourceFilters = {}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
|
|
||||||
|
/**
|
||||||
|
* هر جهش روی یک منبع، هم فهرست را کهنه میکند هم صفحهٔ جزئیاتِ همان منبع را —
|
||||||
|
* دو کوئری با دو کلید جدا (`resources` و `resource-detail`). فقط باطلکردن فهرست
|
||||||
|
* یعنی صفحهٔ جزئیات تا رفرشِ دستی مقدار قدیمی را نشان میدهد.
|
||||||
|
*/
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
|
||||||
|
qc.invalidateQueries({ queryKey: [RESOURCE_DETAIL_KEY] });
|
||||||
|
};
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: [RESOURCES_KEY, filters],
|
queryKey: [RESOURCES_KEY, filters],
|
||||||
@@ -77,11 +88,7 @@ export function useResources(filters: ResourceFilters = {}) {
|
|||||||
const setCategories = useMutation({
|
const setCategories = useMutation({
|
||||||
mutationFn: ({ uuid, categoryUuids }: { uuid: string; categoryUuids: string[] }) =>
|
mutationFn: ({ uuid, categoryUuids }: { uuid: string; categoryUuids: string[] }) =>
|
||||||
api.put<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}/categories`, { category_uuids: categoryUuids }),
|
api.put<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}/categories`, { category_uuids: categoryUuids }),
|
||||||
onSuccess: () => {
|
onSuccess: () => { toast.success('دستهبندیهای منبع ذخیره شد'); invalidate(); },
|
||||||
toast.success('دستهبندیهای منبع ذخیره شد');
|
|
||||||
invalidate();
|
|
||||||
qc.invalidateQueries({ queryKey: ['resource-detail'] });
|
|
||||||
},
|
|
||||||
onError: (e) => fail(e, 'ذخیرهٔ دستهبندیها ناموفق بود'),
|
onError: (e) => fail(e, 'ذخیرهٔ دستهبندیها ناموفق بود'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,7 +241,7 @@ export function useResourcePools() {
|
|||||||
*/
|
*/
|
||||||
export function useResourceDetail(uuid: string | undefined) {
|
export function useResourceDetail(uuid: string | undefined) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['resource-detail', uuid],
|
queryKey: [RESOURCE_DETAIL_KEY, uuid],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<ApiResponse<ClinicResource & { upcoming_appointments: number }>>(
|
api.get<ApiResponse<ClinicResource & { upcoming_appointments: number }>>(
|
||||||
`/api/v1/resource/${uuid}`,
|
`/api/v1/resource/${uuid}`,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { api } from '../lib/api';
|
|||||||
import ResourceDetailPage from './ResourceDetailPage';
|
import ResourceDetailPage from './ResourceDetailPage';
|
||||||
|
|
||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const resource = {
|
const resource = {
|
||||||
uuid: 'r1',
|
uuid: 'r1',
|
||||||
@@ -41,7 +42,7 @@ const resource = {
|
|||||||
const emptyDays = (): Record<string, unknown[]> =>
|
const emptyDays = (): Record<string, unknown[]> =>
|
||||||
Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]]));
|
Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]]));
|
||||||
|
|
||||||
function mockApi(days: Record<string, unknown[]> = emptyDays(), availabilityDays: unknown[] = []) {
|
function mockApi(days: Record<string, unknown[]> = emptyDays()) {
|
||||||
get.mockImplementation((path: string) => {
|
get.mockImplementation((path: string) => {
|
||||||
if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource });
|
if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource });
|
||||||
if (path.endsWith('/calendar')) {
|
if (path.endsWith('/calendar')) {
|
||||||
@@ -50,12 +51,6 @@ function mockApi(days: Record<string, unknown[]> = emptyDays(), availabilityDays
|
|||||||
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', defined: true, days },
|
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', defined: true, days },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (path.includes('/availability')) {
|
|
||||||
return Promise.resolve({
|
|
||||||
success: true,
|
|
||||||
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (path.includes('/service-categories/tree')) {
|
if (path.includes('/service-categories/tree')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -107,40 +102,6 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.getByDisplayValue('17:00')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('17:00')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* دلیلِ خالی بودن روز باید فارسی نشان داده شود؛ نشان دادن کلید خام سرور
|
|
||||||
* («outside_branch_hours») به کاربر یعنی پیام بیمعنا.
|
|
||||||
*/
|
|
||||||
it('دلیل خالی بودن روز را فارسی میکند', async () => {
|
|
||||||
mockApi(emptyDays(), [
|
|
||||||
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] },
|
|
||||||
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
|
|
||||||
{ date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] },
|
|
||||||
]);
|
|
||||||
renderPage('/admin/resources/r1?tab=exceptions');
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument());
|
|
||||||
expect(screen.getByText('شیفتی تعریف نشده')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('مرخصی یا سرویس')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('no_shift')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* وقتی هیچ روزی شیفت ندارد، ۱۴ سطرِ یکسان نویز است و با یک حالت خالی جمع میشود.
|
|
||||||
* شرط باید تنگ بماند: تست بالا ثابت میکند دلیلِ غیر از `no_shift` سطرها را نگه میدارد.
|
|
||||||
*/
|
|
||||||
it('پیشنمایشِ کاملاً تعریفنشده را در یک حالت خالی جمع میکند', async () => {
|
|
||||||
mockApi(emptyDays(), [
|
|
||||||
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
|
|
||||||
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
|
|
||||||
]);
|
|
||||||
renderPage('/admin/resources/r1?tab=exceptions');
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText(/در دو هفتهٔ آینده هیچ ساعتی باز نیست/)).toBeInTheDocument());
|
|
||||||
expect(screen.queryByText('شیفتی تعریف نشده')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** نوار ذخیره فقط وقتی ظاهر میشود که چیزی تغییر کرده باشد — خودش نشانهٔ dirty است. */
|
/** نوار ذخیره فقط وقتی ظاهر میشود که چیزی تغییر کرده باشد — خودش نشانهٔ dirty است. */
|
||||||
it('نوار ذخیره تا وقتی تغییری نباشد نمیآید', async () => {
|
it('نوار ذخیره تا وقتی تغییری نباشد نمیآید', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -227,14 +188,6 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
|
||||||
it('پیشنمایش را خام معرفی میکند', async () => {
|
|
||||||
mockApi();
|
|
||||||
renderPage('/admin/resources/r1?tab=exceptions');
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText(/نوبتهای ثبتشده هنوز از آن کسر نشدهاند/)).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('تب دستهبندی فقط انتخاب میدهد، نه ساخت', async () => {
|
it('تب دستهبندی فقط انتخاب میدهد، نه ساخت', async () => {
|
||||||
mockApi();
|
mockApi();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -248,6 +201,16 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.getByText('تنظیمات ← دستهبندیها')).toHaveAttribute('href', '/admin/service-categories');
|
expect(screen.getByText('تنظیمات ← دستهبندیها')).toHaveAttribute('href', '/admin/service-categories');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** تب استثناها فقط دو ورودی دارد: تعطیلات رسمی، و مرخصی و سرویس. */
|
||||||
|
it('تب تعطیلات پیشنمایش دو هفته را ندارد', async () => {
|
||||||
|
mockApi();
|
||||||
|
renderPage('/admin/resources/r1?tab=exceptions');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('مرخصی و سرویس')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('پیشنمایش دو هفته')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/نوبتهای ثبتشده هنوز از آن کسر نشدهاند/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* مسدودسازی موردی از مودالِ جدول به تبِ خودِ منبع آمد و «غیرفعالسازی موقت» نام گرفت.
|
* مسدودسازی موردی از مودالِ جدول به تبِ خودِ منبع آمد و «غیرفعالسازی موقت» نام گرفت.
|
||||||
* متن تب باید تفاوتش با استثنای تقویم را بگوید، وگرنه اپراتور تعطیلیِ یک بعدازظهر را
|
* متن تب باید تفاوتش با استثنای تقویم را بگوید، وگرنه اپراتور تعطیلیِ یک بعدازظهر را
|
||||||
@@ -267,8 +230,11 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.getByRole('heading', { name: 'غیرفعالسازی موقت' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'غیرفعالسازی موقت' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** فعال/غیرفعال و حذف از ردیف جدول به تب اطلاعات آمدند. */
|
/**
|
||||||
it('تب اطلاعات، کلید فعال/غیرفعال و حذف منبع را دارد', async () => {
|
* حذفِ منبع عمداً در رابط کاربری نیست: منبع به نوبتهای ثبتشده گره خورده و راهِ
|
||||||
|
* درستِ «دیگر لازمش ندارم» غیرفعال کردن است.
|
||||||
|
*/
|
||||||
|
it('تب اطلاعات کلید فعال/غیرفعال دارد و حذف منبع را نه', async () => {
|
||||||
mockApi();
|
mockApi();
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
@@ -276,19 +242,51 @@ describe('ResourceDetailPage', () => {
|
|||||||
|
|
||||||
// منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد میشود.
|
// منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد میشود.
|
||||||
expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'حذف' })).toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(/اگر فقط موقتاً لازمش ندارید/)).toBeInTheDocument();
|
expect(screen.queryByText(/حذف منبع/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** حذف بدون تأیید انجام نمیشود — عملی است که برگشت ندارد. */
|
/** غیرفعال کردن منبع را از نوبتدهی بیرون میبرد، پس بیتأیید انجام نمیشود. */
|
||||||
it('حذف اول تأیید میگیرد', async () => {
|
it('غیرفعال کردن اول تأیید میگیرد و بدون تأیید درخواستی نمیفرستد', async () => {
|
||||||
mockApi();
|
mockApi();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
|
||||||
await user.click(screen.getByRole('button', { name: 'حذف' }));
|
await user.click(screen.getByRole('button', { name: 'غیرفعال کردن' }));
|
||||||
|
|
||||||
expect(await screen.findByText(/مطمئن هستید؟ این کار برگشتپذیر نیست/)).toBeInTheDocument();
|
expect(await screen.findByText(/در جستجوی وقت و رزرو نوبت ظاهر نمیشود/)).toBeInTheDocument();
|
||||||
|
expect(patch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'غیرفعال کن' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(patch).toHaveBeenCalledWith('/api/v1/resource/r1', { active: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* رگرسیون: جهش فقط کلیدِ فهرست (`resources`) را باطل میکرد، نه
|
||||||
|
* `resource-detail` را — پس وضعیت روی همین صفحه تا رفرشِ دستی کهنه میماند.
|
||||||
|
*/
|
||||||
|
it('وضعیت بعد از غیرفعال کردن بدون رفرش بهروز میشود', async () => {
|
||||||
|
mockApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
patch.mockResolvedValue({ success: true, data: { ...resource, active: false } });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('فعال')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// پاسخِ بعدیِ سرور برای همین منبع، غیرفعال است.
|
||||||
|
get.mockImplementation((path: string) =>
|
||||||
|
path === '/api/v1/resource/r1'
|
||||||
|
? Promise.resolve({ success: true, data: { ...resource, active: false } })
|
||||||
|
: Promise.resolve({ success: true, data: [] }));
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'غیرفعال کردن' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'غیرفعال کن' }));
|
||||||
|
|
||||||
|
// بدون باطلکردنِ `resource-detail`، این همیشه «فعال» میماند.
|
||||||
|
expect(await screen.findByText('غیرفعال')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('button', { name: 'فعال کردن' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
@@ -44,7 +44,6 @@ const SUBJECT_LABEL: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
export default function ResourceDetailPage() {
|
export default function ResourceDetailPage() {
|
||||||
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
|
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
|
||||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
|
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
|
||||||
|
|
||||||
@@ -52,14 +51,14 @@ export default function ResourceDetailPage() {
|
|||||||
const { addresses } = useAddresses();
|
const { addresses } = useAddresses();
|
||||||
const { types } = useResourceTypes();
|
const { types } = useResourceTypes();
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const { update, remove, setSkills, setCategories } = useResources();
|
const { update, setSkills, setCategories } = useResources();
|
||||||
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
||||||
const { items: serviceOptions } = useAllServiceItems();
|
const { items: serviceOptions } = useAllServiceItems();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('appointment_settings', 'update');
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
||||||
|
|
||||||
// حالتهای بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات میمانند، وگرنه منوی کناری
|
// حالتهای بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات میمانند، وگرنه منوی کناری
|
||||||
// یک لحظه میپرد و دوباره برمیگردد.
|
// یک لحظه میپرد و دوباره برمیگردد.
|
||||||
@@ -110,8 +109,12 @@ export default function ResourceDetailPage() {
|
|||||||
resource={resource}
|
resource={resource}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
toggling={update.isPending}
|
toggling={update.isPending}
|
||||||
onToggleActive={() => update.mutate({ uuid: resource.uuid, d: { active: !resource.active } })}
|
// فعال کردن بیخطر است و مستقیم انجام میشود؛ غیرفعال کردن منبع را از
|
||||||
onDelete={() => setConfirmDelete(true)}
|
// نوبتدهی بیرون میبرد، پس تأیید میگیرد.
|
||||||
|
onToggleActive={() => {
|
||||||
|
if (resource.active) setConfirmDeactivate(true);
|
||||||
|
else update.mutate({ uuid: resource.uuid, d: { active: true } });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -153,23 +156,20 @@ export default function ResourceDetailPage() {
|
|||||||
|
|
||||||
{tab === 'blocks' && <ResourceBlocksPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
{tab === 'blocks' && <ResourceBlocksPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
|
||||||
|
|
||||||
{/* حذف موفق یعنی این صفحه دیگر منبعی ندارد؛ ماندن روی آن یک «منبع یافت نشد» است. */}
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmDelete}
|
open={confirmDeactivate}
|
||||||
title="حذف منبع"
|
title="غیرفعال کردن منبع"
|
||||||
message={`آیا از حذف «${resource.name}» مطمئن هستید؟ این کار برگشتپذیر نیست.`}
|
message={`«${resource.name}» پس از غیرفعال شدن در جستجوی وقت و رزرو نوبت ظاهر نمیشود. نوبتهای ثبتشده سرِ جایشان میمانند.`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="غیرفعال کن"
|
||||||
danger
|
danger
|
||||||
loading={remove.isPending}
|
loading={update.isPending}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
remove.mutate(resource.uuid, {
|
update.mutate(
|
||||||
onSuccess: () => {
|
{ uuid: resource.uuid, d: { active: false } },
|
||||||
setConfirmDelete(false);
|
{ onSuccess: () => setConfirmDeactivate(false) },
|
||||||
navigate('/admin/resources');
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
onCancel={() => setConfirmDelete(false)}
|
onCancel={() => setConfirmDeactivate(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceFormModal
|
<ResourceFormModal
|
||||||
@@ -200,18 +200,16 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* اطلاعات منبع + دو اقدامِ سطح-منبع.
|
* اطلاعات منبع + وضعیت فعال/غیرفعالش.
|
||||||
*
|
*
|
||||||
* فعال/غیرفعال و حذف پیشتر دکمههای ردیفِ جدول بودند. جدول حالا فقط فهرست است و
|
* حذفِ منبع عمداً در رابط کاربری نیست: منبع به نوبتهای ثبتشده گره خورده و راهِ
|
||||||
* هر اقدام کنارِ همان چیزی نشسته که تغییرش میدهد؛ حذف هم پایین و جدا از بقیه است
|
* درستِ «دیگر لازمش ندارم» غیرفعال کردن است، نه پاک کردن.
|
||||||
* تا با «ویرایش» و «غیرفعال» یکجا هموزن دیده نشود.
|
|
||||||
*/
|
*/
|
||||||
function InfoTab({ resource, canUpdate, toggling, onToggleActive, onDelete }: {
|
function InfoTab({ resource, canUpdate, toggling, onToggleActive }: {
|
||||||
resource: ClinicResource;
|
resource: ClinicResource;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
toggling: boolean;
|
toggling: boolean;
|
||||||
onToggleActive: () => void;
|
onToggleActive: () => void;
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const attributes = Object.entries(resource.attributes ?? {});
|
const attributes = Object.entries(resource.attributes ?? {});
|
||||||
|
|
||||||
@@ -270,23 +268,13 @@ function InfoTab({ resource, canUpdate, toggling, onToggleActive, onDelete }: {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={resource.active ? 'btn secondary' : 'btn primary'}
|
className={resource.active ? 'btn danger' : 'btn primary'}
|
||||||
disabled={toggling}
|
disabled={toggling}
|
||||||
onClick={onToggleActive}
|
onClick={onToggleActive}
|
||||||
>
|
>
|
||||||
{resource.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
{resource.active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* پسزمینه عمداً خنثی میماند: `.btn.danger` خودش `--danger-bg` است و روی
|
|
||||||
ردیفِ همرنگ نامرئی میشد. مرزِ قرمز بهتنهایی هشدار را میرساند. */}
|
|
||||||
<div className="toggle-row" style={{ marginTop: 10, borderColor: 'var(--danger)' }}>
|
|
||||||
<div>
|
|
||||||
<div className="tr-title">حذف منبع</div>
|
|
||||||
<div className="tr-desc">برگشتپذیر نیست. اگر فقط موقتاً لازمش ندارید، غیرفعالش کنید.</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn danger" onClick={onDelete}>حذف</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1056,25 +1056,8 @@ export interface ResourceException {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailabilityInterval {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvailabilityDay {
|
|
||||||
date: number;
|
|
||||||
day_of_week: number;
|
|
||||||
intervals: AvailabilityInterval[];
|
|
||||||
total_minutes: number;
|
|
||||||
/** چرا روز خالی است — بدون این، پاسخ خالی از باگ قابل تشخیص نیست */
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResourceAvailability {
|
|
||||||
resource_uuid: string;
|
|
||||||
timezone: string;
|
|
||||||
days: AvailabilityDay[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NationalHoliday {
|
export interface NationalHoliday {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user