diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx index 3a5a581a..8321df9a 100644 --- a/assets/admin/components/resources/ResourceExceptionsPanel.tsx +++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx @@ -1,106 +1,21 @@ 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 ResourceExceptionsCard from './ResourceExceptionsCard'; -import type { ResourceAvailability } from '../../types'; - -/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ -const REASON_LABELS: Record = { - 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 }: { resourceUuid?: string; canUpdate: boolean; }) { - const previewFrom = todayMidnight(); - const previewTo = previewFrom + 13 * 86400; - const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo); - return (
-
- - -
- - -
- ); -} - -/** - * پیش‌نمایش دو هفته. - * - * ۱۴ سطرِ یکنواختِ «شیفتی تعریف نشده» فقط نویز است و با یک حالت خالیِ راه‌حل‌دار - * جمع می‌شود. شرط عمداً تنگ است: اگر حتی یک روز به دلیل دیگری (تعطیل رسمی، - * مرخصی) بسته باشد سطرها می‌مانند، چون آن دلیل خودش اطلاعات است — نه نویز. - */ -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 ( -
-

پیش‌نمایش دو هفته

-

- ساعت خام — نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند. -

- - {days.length === 0 ? ( -
- ) : nothingConfigured ? ( -

- در دو هفتهٔ آینده هیچ ساعتی باز نیست. اول در «شیفت هفتگی» روزهای کاری را تعریف کنید. -

- ) : ( -
- {days.map((day) => ( -
- - {DAY_LABELS[day.day_of_week]} · {formatDate(day.date)} - - {day.intervals.length === 0 ? ( - - {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} - - ) : ( - {formatNumber(day.total_minutes)} دقیقه - )} -
- ))} -
- )} + +
); } diff --git a/assets/admin/hooks/useResourceCalendar.ts b/assets/admin/hooks/useResourceCalendar.ts index a4201c6b..f2d3a25f 100644 --- a/assets/admin/hooks/useResourceCalendar.ts +++ b/assets/admin/hooks/useResourceCalendar.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api, ApiError, type ApiResponse } from '../lib/api'; import type { - HolidayOverride, NationalHoliday, ResourceAvailability, + HolidayOverride, NationalHoliday, ResourceCalendarDays, ResourceException, WorkingHoursPayload, } from '../types'; @@ -27,7 +27,6 @@ export function useResourceCalendar(resourceUuid: string | undefined) { onSuccess: () => { toast.success('شیفت‌ها ذخیره شد'); qc.invalidateQueries({ queryKey: key }); - qc.invalidateQueries({ queryKey: ['resource-availability', resourceUuid] }); }, onError: (e) => fail(e, 'ذخیرهٔ شیفت‌ها ناموفق بود'), }); @@ -41,7 +40,6 @@ export function useResourceExceptions(resourceUuid: string | undefined) { const invalidate = () => { qc.invalidateQueries({ queryKey: key }); - qc.invalidateQueries({ queryKey: ['resource-availability', resourceUuid] }); }; const query = useQuery({ @@ -66,17 +64,6 @@ export function useResourceExceptions(resourceUuid: string | undefined) { 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>(`/api/v1/resource/${resourceUuid}/availability?from=${from}&to=${to}`), - enabled: !!resourceUuid, - }); - - return { availability: query.data?.data, loading: query.isLoading }; -} - export function useHolidays(year: number) { const qc = useQueryClient(); const key = ['national-holidays', year]; diff --git a/assets/admin/hooks/useResources.ts b/assets/admin/hooks/useResources.ts index 091b37d8..8f349842 100644 --- a/assets/admin/hooks/useResources.ts +++ b/assets/admin/hooks/useResources.ts @@ -10,6 +10,8 @@ import type { * آدرس محل نوبت‌دهی است — پس فیلترها با `address_uuid` کار می‌کنند نه `branch_id`. */ const RESOURCES_KEY = 'resources'; +/** کلیدِ کوئریِ صفحهٔ جزئیات — `useResourceDetail` هم از همین می‌سازد. */ +const RESOURCE_DETAIL_KEY = 'resource-detail'; const TYPES_KEY = ['resource-types']; const SKILLS_KEY = ['skills']; const POOLS_KEY = ['resource-pools']; @@ -36,7 +38,16 @@ function toQuery(filters: ResourceFilters): string { export function useResources(filters: ResourceFilters = {}) { 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({ queryKey: [RESOURCES_KEY, filters], @@ -77,11 +88,7 @@ export function useResources(filters: ResourceFilters = {}) { const setCategories = useMutation({ mutationFn: ({ uuid, categoryUuids }: { uuid: string; categoryUuids: string[] }) => api.put>(`/api/v1/resource/${uuid}/categories`, { category_uuids: categoryUuids }), - onSuccess: () => { - toast.success('دسته‌بندی‌های منبع ذخیره شد'); - invalidate(); - qc.invalidateQueries({ queryKey: ['resource-detail'] }); - }, + onSuccess: () => { toast.success('دسته‌بندی‌های منبع ذخیره شد'); invalidate(); }, onError: (e) => fail(e, 'ذخیرهٔ دسته‌بندی‌ها ناموفق بود'), }); @@ -234,7 +241,7 @@ export function useResourcePools() { */ export function useResourceDetail(uuid: string | undefined) { const query = useQuery({ - queryKey: ['resource-detail', uuid], + queryKey: [RESOURCE_DETAIL_KEY, uuid], queryFn: () => api.get>( `/api/v1/resource/${uuid}`, diff --git a/assets/admin/pages/ResourceDetailPage.test.tsx b/assets/admin/pages/ResourceDetailPage.test.tsx index 1c1c3e90..be75eec4 100644 --- a/assets/admin/pages/ResourceDetailPage.test.tsx +++ b/assets/admin/pages/ResourceDetailPage.test.tsx @@ -15,6 +15,7 @@ import { api } from '../lib/api'; import ResourceDetailPage from './ResourceDetailPage'; const get = api.get as ReturnType; +const patch = api.patch as ReturnType; const resource = { uuid: 'r1', @@ -41,7 +42,7 @@ const resource = { const emptyDays = (): Record => Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]])); -function mockApi(days: Record = emptyDays(), availabilityDays: unknown[] = []) { +function mockApi(days: Record = 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 = 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(); }); }); diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx index d6ad24d1..3f3f6d5a 100644 --- a/assets/admin/pages/ResourceDetailPage.tsx +++ b/assets/admin/pages/ResourceDetailPage.tsx @@ -1,5 +1,5 @@ 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 PageHeader from '../components/ui/PageHeader'; import ConfirmDialog from '../components/ui/ConfirmDialog'; @@ -44,7 +44,6 @@ const SUBJECT_LABEL: Record = { */ export default function ResourceDetailPage() { const { resourceUuid } = useParams<{ resourceUuid: string }>(); - const navigate = useNavigate(); const [urlState, setUrlState] = useUrlState({ tab: 'info' }); 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 { types } = useResourceTypes(); const { skills } = useSkills(); - const { update, remove, setSkills, setCategories } = useResources(); + const { update, setSkills, setCategories } = useResources(); const { offerings, save: saveServices } = useResourceServices(resourceUuid); const { items: serviceOptions } = useAllServiceItems(); const { can } = usePermissions(); const canUpdate = can('appointment_settings', 'update'); 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} canUpdate={canUpdate} 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' && } - {/* حذف موفق یعنی این صفحه دیگر منبعی ندارد؛ ماندن روی آن یک «منبع یافت نشد» است. */} - remove.mutate(resource.uuid, { - onSuccess: () => { - setConfirmDelete(false); - navigate('/admin/resources'); - }, - }) + update.mutate( + { uuid: resource.uuid, d: { active: false } }, + { onSuccess: () => setConfirmDeactivate(false) }, + ) } - onCancel={() => setConfirmDelete(false)} + onCancel={() => setConfirmDeactivate(false)} /> void; - onDelete: () => void; }) { const attributes = Object.entries(resource.attributes ?? {}); @@ -270,23 +268,13 @@ function InfoTab({ resource, canUpdate, toggling, onToggleActive, onDelete }: {
- - {/* پس‌زمینه عمداً خنثی می‌ماند: `.btn.danger` خودش `--danger-bg` است و روی - ردیفِ هم‌رنگ نامرئی می‌شد. مرزِ قرمز به‌تنهایی هشدار را می‌رساند. */} -
-
-
حذف منبع
-
برگشت‌پذیر نیست. اگر فقط موقتاً لازمش ندارید، غیرفعالش کنید.
-
- -
)} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 00455c38..a51450fd 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1056,25 +1056,8 @@ export interface ResourceException { 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 { uuid: string;