feat: refactor ResourceExceptionsPanel and related hooks; remove unused availability logic and simplify resource detail management

This commit is contained in:
hamed
2026-08-03 10:36:13 +03:30
parent 00c275d618
commit 8509a04ae2
6 changed files with 99 additions and 221 deletions
+55 -57
View File
@@ -15,6 +15,7 @@ import { api } from '../lib/api';
import ResourceDetailPage from './ResourceDetailPage';
const get = api.get as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
const resource = {
uuid: 'r1',
@@ -41,7 +42,7 @@ const resource = {
const emptyDays = (): Record<string, 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) => {
if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource });
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 },
});
}
if (path.includes('/availability')) {
return Promise.resolve({
success: true,
data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays },
});
}
if (path.includes('/service-categories/tree')) {
return Promise.resolve({
success: true,
@@ -107,40 +102,6 @@ describe('ResourceDetailPage', () => {
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 است. */
it('نوار ذخیره تا وقتی تغییری نباشد نمی‌آید', async () => {
const user = userEvent.setup();
@@ -227,14 +188,6 @@ describe('ResourceDetailPage', () => {
expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument();
});
/** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */
it('پیش‌نمایش را خام معرفی می‌کند', async () => {
mockApi();
renderPage('/admin/resources/r1?tab=exceptions');
await waitFor(() => expect(screen.getByText(/نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند/)).toBeInTheDocument());
});
it('تب دسته‌بندی فقط انتخاب می‌دهد، نه ساخت', async () => {
mockApi();
const user = userEvent.setup();
@@ -248,6 +201,16 @@ describe('ResourceDetailPage', () => {
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();
});
/** فعال/غیرفعال و حذف از ردیف جدول به تب اطلاعات آمدند. */
it('تب اطلاعات، کلید فعال/غیرفعال و حذف منبع را دارد', async () => {
/**
* حذفِ منبع عمداً در رابط کاربری نیست: منبع به نوبت‌های ثبت‌شده گره خورده و راهِ
* درستِ «دیگر لازمش ندارم» غیرفعال کردن است.
*/
it('تب اطلاعات کلید فعال/غیرفعال دارد و حذف منبع را نه', async () => {
mockApi();
renderPage();
@@ -276,19 +242,51 @@ describe('ResourceDetailPage', () => {
// منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد می‌شود.
expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'حذف' })).toBeInTheDocument();
expect(screen.getByText(/اگر فقط موقتاً لازمش ندارید/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'حذف' })).not.toBeInTheDocument();
expect(screen.queryByText(/حذف منبع/)).not.toBeInTheDocument();
});
/** حذف بدون تأیید انجام نمی‌شود — عملی است که برگشت ندارد. */
it('حذف اول تأیید می‌گیرد', async () => {
/** غیرفعال کردن منبع را از نوبت‌دهی بیرون می‌برد، پس بی‌تأیید انجام نمی‌شود. */
it('غیرفعال کردن اول تأیید می‌گیرد و بدون تأیید درخواستی نمی‌فرستد', async () => {
mockApi();
const user = userEvent.setup();
renderPage();
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();
});
});