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:
hamed
2026-08-01 13:45:25 +03:30
co-authored by Claude Opus 5
parent ba7cf7edc9
commit 000cf70761
9 changed files with 164 additions and 5 deletions
@@ -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 Modal from '../ui/Modal';
import SearchableSelect from '../ui/SearchableSelect'; import SearchableSelect from '../ui/SearchableSelect';
import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types'; import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types';
import { useResourceDetail } from '../../hooks/useResources';
import { formatNumber } from '../../lib/utils';
/** کلیدهای شناخته‌شدهٔ ویژگی — قرارداد است نه اجبار؛ سرور هر کلید snake_case را می‌پذیرد. */ /** کلیدهای شناخته‌شدهٔ ویژگی — قرارداد است نه اجبار؛ سرور هر کلید snake_case را می‌پذیرد. */
const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand']; const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand'];
@@ -21,6 +23,8 @@ interface Props {
export default function ResourceFormModal({ export default function ResourceFormModal({
open, resource, branches, types, saving, onClose, onSave, open, resource, branches, types, saving, onClose, onSave,
}: Props) { }: Props) {
// شمار نوبت‌های آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است.
const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [addressUuid, setAddressUuid] = useState<string | null>(null); const [addressUuid, setAddressUuid] = useState<string | null>(null);
const [typeUuid, setTypeUuid] = useState<string | null>(null); const [typeUuid, setTypeUuid] = useState<string | null>(null);
@@ -184,6 +188,25 @@ export default function ResourceFormModal({
منبع فعال است منبع فعال است
</label> </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 }}> <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 secondary" onClick={onClose}>انصراف</button>
<button type="button" className="btn primary" disabled={saving || invalid} onClick={submit}> <button type="button" className="btn primary" disabled={saving || invalid} onClick={submit}>
+23
View File
@@ -180,3 +180,26 @@ export function useResourcePools() {
return { pools: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setMembers }; 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,
};
}
+9 -2
View File
@@ -4,6 +4,7 @@ import { PlusIcon, TrashIcon } 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';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDateInput from '../components/ui/PersianDateInput';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useResources } from '../hooks/useResources'; import { useResources } from '../hooks/useResources';
import { import {
@@ -346,9 +347,15 @@ function ExceptionsCard({
placeholder="نوع استثنا" placeholder="نوع استثنا"
height={36} height={36}
/> />
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و
ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input type="date" className="field" value={startDate} onChange={(e) => setStartDate(e.target.value)} style={{ flex: 1 }} /> <div style={{ flex: 1 }}>
<input type="date" className="field" value={endDate} onChange={(e) => setEndDate(e.target.value)} style={{ flex: 1 }} /> <PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
</div>
<div style={{ flex: 1 }}>
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
</div>
</div> </div>
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" /> <input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
<button <button
+9
View File
@@ -403,6 +403,15 @@ npx vitest run assets/admin/pages/ResourcesPage.test.tsx
مسدودسازی روی بازه‌ای که نوبت دارد **ظرفیت را پس نمی‌گیرد**: نوبت سرجایش می‌ماند و مسدودسازی روی بازه‌ای که نوبت دارد **ظرفیت را پس نمی‌گیرد**: نوبت سرجایش می‌ماند و
کاربر باید اول تکلیفش را روشن کند. کاربر باید اول تکلیفش را روشن کند.
### شمار نوبت‌های آینده
`GET /api/v1/resource/{uuid}` علاوه بر خودِ منبع، `upcoming_appointments` می‌دهد: تعداد
نوبت‌های **آینده** که روی این منبع نشسته‌اند.
در فهرست منابع نمی‌آید — آنجا یک کوئری per ردیف می‌شد. غیرفعال‌کردن منبع نوبت‌های
ثبت‌شده را **لغو نمی‌کند** و فقط از جستجوی وقتِ بعدی حذفش می‌کند، پس این عدد هشدار است نه
مانع؛ پنل هنگام برداشتن تیک «منبع فعال است» نشانش می‌دهد.
### DELETE `/api/v1/resource-block/{uuid}` ### DELETE `/api/v1/resource-block/{uuid}`
اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف **نمی‌شود** (`422`) — وگرنه اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف **نمی‌شود** (`422`) — وگرنه
@@ -60,7 +60,7 @@
| ۳.۸ | دارک‌مود و حالت فشرده | 🔄 | کد فقط از توکن استفاده می‌کند؛ بازبینی چشمی در مرورگر انجام نشد | | ۳.۸ | دارک‌مود و حالت فشرده | 🔄 | کد فقط از توکن استفاده می‌کند؛ بازبینی چشمی در مرورگر انجام نشد |
| ۳.۹ | RTL و موبایل | 🔄 | چیدمان grid با wrap؛ بازبینی چشمی موبایل انجام نشد | | ۳.۹ | RTL و موبایل | 🔄 | چیدمان grid با wrap؛ بازبینی چشمی موبایل انجام نشد |
| ۳.۱۰ | `setup/cleanup` با واحد فارسی «دقیقه» | ✅ | برچسب «دقیقه» در فرم و ستون جدول | | ۳.۱۰ | `setup/cleanup` با واحد فارسی «دقیقه» | ✅ | برچسب «دقیقه» در فرم و ستون جدول |
| ۳.۱۱ | هشدار «این منبع N نوبت آیندهٔ فعال دارد» هنگام غیرفعال‌سازی | | نوبت هنوز به منبع وصل نیست — شمارش «نوبت آیندهٔ این منبع» تا تسک ۰۷ ممکن نیست | | ۳.۱۱ | هشدار «این منبع N نوبت آیندهٔ فعال دارد» هنگام غیرفعال‌سازی | | `upcoming_appointments` روی `GET /resource/{uuid}`؛ هشدار است نه مانع — غیرفعال‌سازی نوبت‌ها را لغو نمی‌کند |
## ۴. تست ## ۴. تست
@@ -68,7 +68,7 @@
|---|---|---|---| |---|---|---|---|
| ۵.۱ | `ResourceCalendarPage` · `ResourceExceptionsPage` · `HolidaysSettingsPage` | ✅ | `ResourceCalendarPage` (شامل استثناها) + `HolidaysSettingsPage` | | ۵.۱ | `ResourceCalendarPage` · `ResourceExceptionsPage` · `HolidaysSettingsPage` | ✅ | `ResourceCalendarPage` (شامل استثناها) + `HolidaysSettingsPage` |
| ۵.۲ | گرید هفت‌روزه با فرم ساده (بدون drag) | ✅ | گرید هفت‌روزه با فرم ساده | | ۵.۲ | گرید هفت‌روزه با فرم ساده (بدون drag) | ✅ | گرید هفت‌روزه با فرم ساده |
| ۵.۳ | بازهٔ تاریخ با `PersianDatePicker` | | ورودی تاریخ فعلاً `<input type="date">` میلادی است، نه `PersianDatePicker`. مقصد: پاس UI فارسی‌سازی | | ۵.۳ | بازهٔ تاریخ با `PersianDatePicker` | | `PersianDateInput` (همان قرارداد رشتهٔ `YYYY-MM-DD`) |
| ۵.۴ | `backTo`/`BackButton` روی هر سه | ✅ | `backTo` + breadcrumb | | ۵.۴ | `backTo`/`BackButton` روی هر سه | ✅ | `backTo` + breadcrumb |
| ۵.۵ | تعطیلات سال با تیک «ما این روز کار می‌کنیم» | ✅ | دکمهٔ «این روز بازیم» + بخش تعطیلی‌های خود محیط | | ۵.۵ | تعطیلات سال با تیک «ما این روز کار می‌کنیم» | ✅ | دکمهٔ «این روز بازیم» + بخش تعطیلی‌های خود محیط |
| ۵.۶ | هیچ رنگ/شعاع hard-code | ✅ | همه از `var(--…)` | | ۵.۶ | هیچ رنگ/شعاع hard-code | ✅ | همه از `var(--…)` |
@@ -80,6 +80,28 @@ class ResourceOccupancyRepository extends ServiceEntityRepository
->getSingleScalarResult() > 0; ->getSingleScalarResult() > 0;
} }
/**
* چند نوبتِ **آینده** روی این منبع نشسته است؟
*
* غیرفعال‌کردن منبع نوبت‌های ثبت‌شده را لغو نمی‌کند — فقط از جستجوی وقتِ بعدی
* حذفش می‌کند. پس این عدد هشدار است نه مانع: اپراتور باید بداند چند بیمار روی
* منبعی نوبت دارند که دارد خاموش می‌شود.
*/
public function countUpcomingAppointments(int $resourceId, ?int $now = null): int
{
return (int) $this->createQueryBuilder('o')
->select('COUNT(DISTINCT o.appointmentId)')
->where('IDENTITY(o.resource) = :resource')
->andWhere('o.appointmentId IS NOT NULL')
->andWhere('o.status IN (:blocking)')
->andWhere('o.endsAt > :now')
->setParameter('resource', $resourceId)
->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
->setParameter('now', $now ?? time())
->getQuery()
->getSingleScalarResult();
}
/** /**
* مسدودسازی‌های دستیِ یک منبع در یک بازه. * مسدودسازی‌های دستیِ یک منبع در یک بازه.
* *
@@ -2,6 +2,7 @@
namespace App\Resource\Controller; namespace App\Resource\Controller;
use App\Appointment\Availability\Repository\ResourceOccupancyRepository;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Resource\Entity\ClinicResource; use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ClinicResourceRepository; use App\Resource\Repository\ClinicResourceRepository;
@@ -28,6 +29,7 @@ class ResourceController extends BaseController
private readonly ClinicResourceRepository $resources, private readonly ClinicResourceRepository $resources,
private readonly ResourceService $service, private readonly ResourceService $service,
private readonly SkillAssignmentService $skills, private readonly SkillAssignmentService $skills,
private readonly ResourceOccupancyRepository $occupancy,
) {} ) {}
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])] #[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
@@ -85,7 +87,13 @@ class ResourceController extends BaseController
{ {
$this->denyUnlessGranted($user, 'view'); $this->denyUnlessGranted($user, 'view');
return $this->success($this->context->resource($user, $uuid)->toArray()); $resource = $this->context->resource($user, $uuid);
// شمار نوبت‌های آینده کنار خودِ منبع می‌آید تا پنل بتواند پیش از خاموش‌کردنش
// هشدار بدهد. در فهرست نمی‌آید — آنجا یک کوئری per ردیف می‌شد.
return $this->success($resource->toArray() + [
'upcoming_appointments' => $this->occupancy->countUpcomingAppointments((int) $resource->getId()),
]);
} }
#[Route('/api/v1/resource/{uuid}', name: 'resource_update', methods: ['PATCH'])] #[Route('/api/v1/resource/{uuid}', name: 'resource_update', methods: ['PATCH'])]