refactor(resource): drop the branch domain from resources

Resources never needed a branch: devices and rooms belong to the clinic
itself, and the picker always had exactly one option — a mandatory click
that decided nothing.

- `address_uuid` is now optional on resource and pool creation; when it is
  missing the environment's own address is used. Clients still sending it
  keep working.
- The panel no longer asks for or displays a branch anywhere: resource
  form, list column and filter, pool form and column, detail row, and the
  resource-first booking page.
- Availability no longer gates on `doctor_addresses.active`. That gate shut
  down every device of a clinic whose address row happened to be inactive,
  with a message no page in the panel could act on — no endpoint writes
  that column at all.

`address_id` stays on the resource: the timezone and the tenant pair are
derived from it. It is simply no longer the user's decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-03 14:57:47 +03:30
co-authored by Claude Opus 5
parent 05bb4a4ade
commit 581a553516
19 changed files with 246 additions and 117 deletions
@@ -193,7 +193,6 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' },
exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' },
resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبت‌دهی، منبع را از صفحهٔ «منابع» فعال کنید' },
address_inactive: { title: 'شعبهٔ این منبع غیرفعال است', hint: 'تا وقتی شعبه غیرفعال باشد، منابعش نوبت نمی‌گیرند' },
};
export default function TurnsTimeline({
@@ -3,7 +3,7 @@ import Modal from '../ui/Modal';
import { useQuery } from '@tanstack/react-query';
import SearchableSelect from '../ui/SearchableSelect';
import { api, type ApiResponse } from '../../lib/api';
import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types';
import type { ClinicResource, ResourcePayload, ResourceType } from '../../types';
import { useResourceDetail } from '../../hooks/useResources';
import { formatNumber } from '../../lib/utils';
@@ -15,7 +15,6 @@ type AttributeRow = { key: string; value: string };
interface Props {
open: boolean;
resource: ClinicResource | null;
addresses: Branch[];
types: ResourceType[];
saving: boolean;
onClose: () => void;
@@ -23,7 +22,7 @@ interface Props {
}
export default function ResourceFormModal({
open, resource, addresses, types, saving, onClose, onSave,
open, resource, types, saving, onClose, onSave,
}: Props) {
// شمار نوبت‌های آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است.
const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined);
@@ -39,7 +38,6 @@ export default function ResourceFormModal({
const doctorsLoading = doctorsQuery.isLoading;
const [name, setName] = useState('');
const [addressUuid, setAddressUuid] = useState<string | null>(null);
const [typeUuid, setTypeUuid] = useState<string | null>(null);
const [supervisorUuid, setSupervisorUuid] = useState<string | null>(null);
const [capacity, setCapacity] = useState('1');
@@ -51,7 +49,6 @@ export default function ResourceFormModal({
useEffect(() => {
if (!open) return;
setName(resource?.name ?? '');
setAddressUuid(resource?.address_uuid ?? null);
setTypeUuid(resource?.type_uuid ?? null);
setSupervisorUuid(resource?.supervisor?.uuid ?? null);
setCapacity(String(resource?.capacity ?? 1));
@@ -68,7 +65,7 @@ export default function ResourceFormModal({
const invalid =
name.trim() === '' ||
!supervisorUuid ||
(!isEdit && (!addressUuid || !typeUuid)) ||
(!isEdit && !typeUuid) ||
!Number.isFinite(parsedCapacity) ||
parsedCapacity < 1;
@@ -88,10 +85,10 @@ export default function ResourceFormModal({
supervisor_doctor_uuid: supervisorUuid!,
};
// شعبه و نوع فقط هنگام ساخت فرستاده می‌شوند؛ جفت محیطِ منبع از آدرس مشتق شده و
// جابه‌جا کردنش یعنی همان منبع در محیط دیگری ظاهر شود.
// نوع فقط هنگام ساخت فرستاده می‌شود؛ جفت محیطِ منبع از آدرسِ محیط مشتق می‌شود و
// جابه‌جا کردنش یعنی همان منبع در محیط دیگری ظاهر شود. آدرس پرسیده نمی‌شود:
// منابع دامنهٔ شعبه ندارند و سرور آدرسِ خودِ کلینیک را برمی‌دارد.
if (!isEdit) {
payload.address_uuid = addressUuid!;
payload.type_uuid = typeUuid!;
}
@@ -106,16 +103,6 @@ export default function ResourceFormModal({
</Field>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<Field label="شعبه">
<SearchableSelect
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={addressUuid}
onChange={(v) => setAddressUuid(v ? String(v) : null)}
placeholder="شعبه را انتخاب کنید"
isDisabled={isEdit}
height={38}
/>
</Field>
<Field label="نوع منبع">
<SearchableSelect
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
@@ -126,8 +113,8 @@ export default function ResourceFormModal({
height={38}
/>
</Field>
{/* ناظر برخلاف شعبه و نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک
دستگاه عوض می‌شود، ولی محیطِ منبع نه. */}
{/* ناظر برخلاف نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک دستگاه
عوض می‌شود، ولی محیطِ منبع نه. */}
<Field label="پزشک ناظر">
<SearchableSelect
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
@@ -142,7 +129,7 @@ export default function ResourceFormModal({
{isEdit && (
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
شعبه و نوع منبع پس از ساخت تغییر نمیکنند؛ برای جابهجایی، منبع تازه بسازید.
نوع منبع پس از ساخت تغییر نمیکند؛ برای تغییرش، منبع تازه بسازید.
</p>
)}
@@ -36,7 +36,7 @@ interface Props {
const SOURCE_LABELS: Record<string, string> = {
resource_option: 'همین منبع',
resource_service: 'منبع، روی سرویس والد',
branch: 'شعبه',
branch: 'تنظیم محل نوبت‌دهی',
service_default: 'پیش‌فرض سرویس',
};
+2 -2
View File
@@ -47,8 +47,8 @@ export interface HoldResult {
}
export const REASON_LABELS: Record<string, string> = {
no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگ‌تر کنید یا شعبهٔ دیگری را امتحان کنید.',
no_working_hours: 'شعبه در این بازه ساعت کاری ندارد.',
no_capacity_in_range: 'در این بازه هیچ ظرفیتی نیست — بازه را بزرگ‌تر کنید یا سرویس دیگری را امتحان کنید.',
no_working_hours: 'در این بازه ساعت کاری تعریف نشده است.',
no_eligible_resource: 'هیچ منبعی شرایط بخش‌های این خدمت را ندارد.',
};
+3 -2
View File
@@ -6,7 +6,7 @@ import type {
} from '../types';
/**
* منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال یک شعبه است، و شعبه همان
* منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال محیط جاری است؛ لنگرش همان
* آدرس محل نوبت‌دهی است — پس فیلترها با `address_uuid` کار می‌کنند نه `branch_id`.
*/
const RESOURCES_KEY = 'resources';
@@ -203,7 +203,8 @@ export function useResourcePools() {
});
const create = useMutation({
mutationFn: (d: { address_uuid: string; type_uuid: string; name: string }) =>
// آدرس فرستاده نمی‌شود: منابع دامنهٔ شعبه ندارند و سرور آدرسِ خودِ محیط را برمی‌دارد.
mutationFn: (d: { type_uuid: string; name: string }) =>
api.post<ApiResponse<ResourcePool>>('/api/v1/resource-pools', d),
onSuccess: () => { toast.success('استخر افزوده شد'); invalidate(); },
onError: (e) => fail(e, 'افزودن استخر ناموفق بود'),
+8 -15
View File
@@ -33,6 +33,13 @@ export default function ResourceBookingPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { addresses } = useAddresses();
/**
* محل نوبت‌دهی از خودِ محیط می‌آید، نه از یک انتخابگر.
*
* منابع دامنهٔ شعبه ندارند: دستگاه و اتاق مالِ همین کلینیک‌اند و آن select همیشه یک
* گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود.
*/
const branchUuid = addresses[0]?.uuid ?? '';
const { items: services } = useAllServiceItems();
const { create, release, confirm, rebook } = useHold();
@@ -46,7 +53,6 @@ export default function ResourceBookingPage() {
const rebookUuid = params.get('rebook');
const [serviceUuid, setServiceUuid] = useState('');
const [branchUuid, setBranchUuid] = useState('');
const [days, setDays] = useState('7');
const [searching, setSearching] = useState(false);
@@ -96,7 +102,7 @@ export default function ResourceBookingPage() {
/**
* گزینه‌های جایگزین یک نقش: منابعی که در **همین زمان** پیشنهاد شده‌اند.
*
* فهرست کاملِ منابع شعبه اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد
* فهرست کاملِ منابع محیط اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد
* نبوده، و نشان دادنش یعنی اپراتور چیزی انتخاب کند که ۴۰۹ می‌گیرد.
*/
const optionsFor = (role: string): { value: string; label: string }[] =>
@@ -154,19 +160,6 @@ export default function ResourceBookingPage() {
/>
</div>
<div className="field-block" style={{ minWidth: 200, margin: 0 }}>
<label>شعبه</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => {
setBranchUuid(String(v ?? ''));
setPickedSlot(null);
}}
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
<div className="field-block" style={{ minWidth: 160, margin: 0 }}>
<label>بازه</label>
<SearchableSelect
@@ -78,7 +78,7 @@ describe('ResourceDetailPage', () => {
mockApi();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument());
expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument();
expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument();
});
@@ -193,7 +193,7 @@ describe('ResourceDetailPage', () => {
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'دسته‌بندی‌ها' }));
await waitFor(() => expect(screen.getByText(/فقط انتخاب می‌شود/)).toBeInTheDocument());
@@ -221,7 +221,7 @@ describe('ResourceDetailPage', () => {
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'غیرفعال‌سازی موقت' }));
expect(await screen.findByText(/یک بازهٔ مشخص را می‌بندد/)).toBeInTheDocument();
@@ -238,7 +238,7 @@ describe('ResourceDetailPage', () => {
mockApi();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument());
// منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد می‌شود.
expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument();
@@ -252,7 +252,7 @@ describe('ResourceDetailPage', () => {
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'غیرفعال کردن' }));
expect(await screen.findByText(/در جستجوی وقت و رزرو نوبت ظاهر نمی‌شود/)).toBeInTheDocument();
+1 -5
View File
@@ -13,7 +13,6 @@ import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesP
import ResourceBlocksPanel from '../components/resources/ResourceBlocksPanel';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useAddresses } from '../hooks/useAddresses';
import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import type { ClinicResource } from '../types';
@@ -48,7 +47,6 @@ export default function ResourceDetailPage() {
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
const { resource, loading } = useResourceDetail(resourceUuid);
const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { skills } = useSkills();
const { update, setSkills, setCategories } = useResources();
@@ -74,7 +72,7 @@ export default function ResourceDetailPage() {
<div className="fade-in">
<PageHeader
title={resource.name}
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}${resource.address_name ? ` · ${resource.address_name}` : ''}`}
description={`${resource.type_name}${resource.subject_kind ? ` · ${SUBJECT_LABEL[resource.subject_kind]}` : ' · تجهیزات'}`}
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: resource.name }]}
action={
@@ -175,7 +173,6 @@ export default function ResourceDetailPage() {
<ResourceFormModal
open={editOpen}
resource={resource}
addresses={addresses}
types={types}
saving={update.isPending}
onClose={() => setEditOpen(false)}
@@ -216,7 +213,6 @@ function InfoTab({ resource, canUpdate, toggling, onToggleActive }: {
return (
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
<div className="card card-pad">
<Row label="شعبه">{resource.address_name || '—'}</Row>
<Row label="نوع منبع">{resource.type_name}</Row>
<Row label="ظرفیت هم‌زمان">{resource.capacity} نفر</Row>
<Row label="آماده‌سازی / تمیزکاری">
+10 -30
View File
@@ -8,7 +8,6 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useAddresses } from '../hooks/useAddresses';
import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources';
import type { ResourcePool } from '../types';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
@@ -16,12 +15,11 @@ import ResourcesSubNav from '../components/resources/ResourcesSubNav';
/**
* استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند.
*
* اعضا باید هم‌شعبه و هم‌نوعِ خودِ استخر باشند؛ سرور این را اجبار می‌کند و فرم هم
* اعضا باید هم‌نوعِ خودِ استخر باشند؛ سرور این را اجبار می‌کند و فرم هم
* فهرست انتخاب را به همان‌ها محدود می‌کند تا کاربر به خطای ۴۲۲ نخورد.
*/
export default function ResourcePoolsPage() {
const { pools, loading, create, update, remove, setMembers } = useResourcePools();
const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
@@ -47,7 +45,6 @@ export default function ResourcePoolsPage() {
</div>
),
},
{ key: 'address_name', header: 'شعبه', render: (p) => <span style={{ fontSize: 13 }}>{p.address_name || '—'}</span> },
{
key: 'members',
header: 'اعضا',
@@ -69,7 +66,7 @@ export default function ResourcePoolsPage() {
<div className="fade-in">
<PageHeader
title="استخر منابع"
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید در یک شعبه و از یک نوع باشند."
description="منابعی که جایگزین کامل یکدیگرند — مثل «لیزرهای آلکساندرایت». همهٔ اعضا باید از یک نوع باشند."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'استخر منابع' }]}
action={
@@ -116,7 +113,6 @@ export default function ResourcePoolsPage() {
<CreatePoolModal
open={creating}
addresses={addresses}
types={types}
saving={create.isPending}
onClose={() => setCreating(false)}
@@ -147,27 +143,24 @@ export default function ResourcePoolsPage() {
}
function CreatePoolModal({
open, addresses, types, saving, onClose, onSave,
open, types, saving, onClose, onSave,
}: {
open: boolean;
addresses: ReturnType<typeof useAddresses>['addresses'];
types: ReturnType<typeof useResourceTypes>['types'];
saving: boolean;
onClose: () => void;
onSave: (payload: { address_uuid: string; type_uuid: string; name: string }) => void;
onSave: (payload: { type_uuid: string; name: string }) => void;
}) {
const [name, setName] = useState('');
const [addressUuid, setAddressUuid] = useState<string | null>(null);
const [typeUuid, setTypeUuid] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName('');
setAddressUuid(null);
setTypeUuid(null);
}, [open]);
const invalid = name.trim() === '' || !addressUuid || !typeUuid;
const invalid = name.trim() === '' || !typeUuid;
return (
<Modal open={open} onClose={onClose} title="افزودن استخر منابع">
@@ -177,17 +170,6 @@ function CreatePoolModal({
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="لیزرهای آلکساندرایت" />
</div>
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>شعبه</label>
<SearchableSelect
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={addressUuid}
onChange={(v) => setAddressUuid(v ? String(v) : null)}
placeholder="شعبه را انتخاب کنید"
height={38}
/>
</div>
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نوع منبع</label>
<SearchableSelect
@@ -200,7 +182,7 @@ function CreatePoolModal({
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
شعبه و نوع پس از ساخت تغییر نمیکنند اعضا باید با هر دو بخوانند.
نوع پس از ساخت تغییر نمیکند اعضا باید با آن بخوانند.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
@@ -209,7 +191,7 @@ function CreatePoolModal({
type="button"
className="btn primary"
disabled={saving || invalid}
onClick={() => onSave({ address_uuid: addressUuid!, type_uuid: typeUuid!, name: name.trim() })}
onClick={() => onSave({ type_uuid: typeUuid!, name: name.trim() })}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
@@ -229,11 +211,9 @@ function PoolMembersModal({
}) {
const [chosen, setChosen] = useState<string[]>([]);
// فهرست انتخاب به همان شعبه و نوعِ استخر محدود است — همان قاعده‌ای که سرور با ۴۲۲
// اجبار می‌کند، پس کاربر اصلاً به آن خطا نمی‌خورد.
const { resources } = useResources(
pool ? { address_uuid: pool.address_uuid, type_uuid: pool.type_uuid } : {},
);
// فهرست انتخاب به نوعِ استخر محدود است — همان قاعده‌ای که سرور با ۴۲۲ اجبار می‌کند،
// پس کاربر اصلاً به آن خطا نمی‌خورد.
const { resources } = useResources(pool ? { type_uuid: pool.type_uuid } : {});
useEffect(() => {
if (!pool) return;
+5 -4
View File
@@ -81,7 +81,7 @@ describe('ResourcesPage', () => {
it('passes the URL filters straight to the server', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, {
route: '/admin/resources?address=b1&type=t1&skill=s1&status=1',
route: '/admin/resources?type=t1&skill=s1&status=1',
});
await waitFor(() => expect(get).toHaveBeenCalled());
@@ -89,13 +89,14 @@ describe('ResourcesPage', () => {
const called = get.mock.calls.map((c) => String(c[0]));
const listCall = called.find((p) => p.startsWith('/api/v1/resources'));
expect(listCall).toContain('address_uuid=b1');
// شعبه فیلتر نیست: منابع دامنهٔ شعبه ندارند.
expect(listCall).not.toContain('address_uuid');
expect(listCall).toContain('type_uuid=t1');
expect(listCall).toContain('skill_uuid=s1');
expect(listCall).toContain('active=1');
});
it('sends address and type only when creating, never when editing', async () => {
it('sends the type only when creating, never when editing', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
@@ -104,7 +105,7 @@ describe('ResourcesPage', () => {
fireEvent.click(screen.getByText('افزودن منبع'));
fireEvent.change(screen.getByPlaceholderText('لیزر آلکساندرایت ۱'), { target: { value: 'لیزر تازه' } });
// شعبه و نوع هنوز انتخاب نشده‌اند → ذخیره غیرفعال است.
// نوع هنوز انتخاب نشده است → ذخیره غیرفعال است.
expect(screen.getByText('ذخیره').closest('button')).toBeDisabled();
});
+1 -16
View File
@@ -7,7 +7,6 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useAddresses } from '../hooks/useAddresses';
import { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import type { ClinicResource } from '../types';
@@ -29,17 +28,15 @@ const SUBJECT_LABEL: Record<string, string> = {
*/
export default function ResourcesPage() {
const [urlState, setUrlState] = useUrlState({
search: '', address: '', type: '', skill: '', status: '',
search: '', type: '', skill: '', status: '',
});
const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { skills } = useSkills();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const { resources, loading, create } = useResources({
address_uuid: urlState.address || undefined,
type_uuid: urlState.type || undefined,
skill_uuid: urlState.skill || undefined,
active: urlState.status || undefined,
@@ -67,7 +64,6 @@ export default function ResourcesPage() {
</div>
),
},
{ key: 'address_name', header: 'شعبه', render: (r) => <span style={{ fontSize: 13 }}>{r.address_name || '—'}</span> },
{
key: 'capacity',
header: 'ظرفیت هم‌زمان',
@@ -127,16 +123,6 @@ export default function ResourcesPage() {
emptyMessage="هیچ منبعی با این فیلترها یافت نشد"
headerExtra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginRight: 'auto' }}>
<div style={{ minWidth: 180 }}>
<SearchableSelect
options={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={urlState.address || null}
onChange={(v) => setUrlState({ address: v ? String(v) : '' })}
placeholder="همهٔ شعبه‌ها"
isClearable
height={36}
/>
</div>
<div style={{ minWidth: 170 }}>
<SearchableSelect
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
@@ -185,7 +171,6 @@ export default function ResourcesPage() {
<ResourceFormModal
open={createOpen}
resource={null}
addresses={addresses}
types={types}
saving={create.isPending}
onClose={() => setCreateOpen(false)}
+1 -1
View File
@@ -156,7 +156,7 @@
| `national_holiday` | تعطیل رسمی کشور |
| `tenant_holiday` | این محیط آن روز را تعطیل اعلام کرده |
| `exception` | مرخصی/غیبت/سرویس بخشی یا تمام روز را بریده |
| `resource_inactive` / `address_inactive` | منبع یا محل نوبت‌دهی غیرفعال است |
| `resource_inactive` | خودِ منبع غیرفعال است (آدرس دیگر گیت نیست — منابع دامنهٔ شعبه ندارند) |
**۴۲۲:** نبودِ `from`/`to` · `to < from` · بازهٔ بیش از ۹۲ روز (خروجی واقعی):
+26 -3
View File
@@ -176,7 +176,7 @@
| فیلد | نوع | الزامی | قاعده |
|---|---|---|---|
| `address_uuid` | string | | محل نوبت‌دهی؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه |
| `address_uuid` | string | | **از ۲۰۲۶-۰۸ اختیاری.** نیامدنش یعنی آدرسِ خودِ محیط؛ جفت محیطِ منبع از همین مشتق می‌شود، نه از بدنه |
| `type_uuid` | string | ✅ | |
| `supervisor_doctor_uuid` | string | ✅ | پزشکِ ناظرِ منبع. باید پزشکِ همین محیط باشد وگرنه ۴۰۴ |
| `name` | string | ✅ | حداکثر ۱۵۰ نویسه |
@@ -186,6 +186,29 @@
| `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر |
| `active` | bool | — | پیش‌فرض `true` |
### شعبه از منابع حذف شد (2026-08)
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیک‌اند، و آن انتخابگر همیشه یک
گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود.
| قبل | حالا |
|---|---|
| `address_uuid` در ساخت منبع و استخر الزامی | اختیاری؛ نیامدنش = آدرسِ خودِ محیط (اولین آدرس) |
| پنل «شعبه» می‌پرسید و ستون/فیلترش را داشت | هیچ‌جای پنل شعبه پرسیده یا نشان داده نمی‌شود |
| `doctor_addresses.active = 0` روزِ منبع را خالی می‌کرد (`address_inactive`) | فعال‌بودنِ آدرس دیگر گیت نیست |
ستون `address_id` سرِ جایش می‌ماند: منطقهٔ زمانی و جفتِ محیطِ منبع از آن می‌آیند. فقط
دیگر تصمیمِ کاربر نیست.
حذف گیتِ `address_inactive` یک باگ واقعی را می‌بندد: یک ردیف آدرسِ قدیمی با `active = 0`
همهٔ دستگاه‌های آن کلینیک را با پیامی خاموش می‌کرد که **هیچ صفحه‌ای در پنل راهی برای
روشن‌کردنش نداشت** — هیچ اندپوینتی هم `active` آدرس را نمی‌نویسد.
محیطی که هیچ آدرسی ندارد، `422` می‌گیرد با پیام «برای این محیط آدرسی ثبت نشده است —
ابتدا آدرس کلینیک را کامل کنید»، نه یک خطای مبهم.
تست: `tests/Resource/ResourceWithoutBranchTest.php`.
### `service_section` روی فهرست سرویس‌های منبع (2026-08)
`GET /api/v1/resource/{uuid}/services` برای هر سرویس `service_section` را هم می‌دهد
@@ -471,7 +494,7 @@
```
`empty_reason` وقتی `windows` خالی است می‌گوید چرا: `no_shift`، `national_holiday`،
`tenant_holiday`، `exception`، `resource_inactive`، `address_inactive`. خالی‌بودن خطا
`tenant_holiday`، `exception`، `resource_inactive`. خالی‌بودن خطا
نیست و کلاینت نباید همه را «تعطیل» بنامد.
### `GET /api/v1/resource/{uuid}/service-slots` (2026-08)
@@ -633,7 +656,7 @@ idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ مت
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Resource # ۱۳۱ تست / ۳۴۲ assertion
ddev exec php bin/phpunit tests/Resource # ۱۳۶ تست / ۳۵۴ assertion
ddev exec php vendor/bin/phpstan analyse src/Resource
npx vitest run assets/admin/pages/ResourcesPage.test.tsx
npx vitest run assets/admin/components/appointments/ResourceDayPanel.test.tsx \
@@ -73,8 +73,8 @@ class ResourceController extends BaseController
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
if (!is_string($data['type_uuid'] ?? null)) {
@@ -87,7 +87,11 @@ class ResourceController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'انتخاب پزشک ناظر الزامی است', 422, 'supervisor_doctor_uuid');
}
$address = $this->context->address($user, $data['address_uuid']);
// شعبه از کاربر پرسیده نمی‌شود؛ آدرسِ خودِ محیط مبناست. `address_uuid` فقط برای
// سازگاری با کلاینت‌های قدیمی پذیرفته می‌شود.
$address = is_string($data['address_uuid'] ?? null) && $data['address_uuid'] !== ''
? $this->context->address($user, $data['address_uuid'])
: $this->context->defaultAddress($user);
$type = $this->context->type($user, $data['type_uuid']);
$supervisor = $this->context->supervisor($user, $data['supervisor_doctor_uuid']);
@@ -50,8 +50,8 @@ class ResourcePoolController extends BaseController
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
if (!is_string($data['type_uuid'] ?? null)) {
@@ -64,8 +64,11 @@ class ResourcePoolController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام استخر الزامی است', 422, 'name');
}
// مثل منبع: شعبه پرسیده نمی‌شود و آدرسِ خودِ محیط مبناست.
$pool = new ResourcePool(
$this->context->address($user, $data['address_uuid']),
is_string($data['address_uuid'] ?? null) && $data['address_uuid'] !== ''
? $this->context->address($user, $data['address_uuid'])
: $this->context->defaultAddress($user),
$this->context->type($user, $data['type_uuid']),
$name,
);
@@ -171,9 +171,13 @@ final class ResourceAvailabilityService
return new DayAvailability($midnight, $dayOfWeek, [], ['resource_inactive']);
}
if (!$resource->getAddress()->isActive()) {
return new DayAvailability($midnight, $dayOfWeek, [], ['address_inactive']);
}
/**
* فعال‌بودنِ آدرس اینجا سنجیده **نمی‌شود**.
*
* منابع دامنهٔ شعبه ندارند: آدرس فقط حاملِ منطقهٔ زمانی و جفتِ محیط است و هیچ
* جای پنل هم روشن/خاموشش نمی‌کند. وقتی می‌شد، یک ردیفِ قدیمیِ `active = 0` کلِ
* دستگاه‌های کلینیک را با پیامی خاموش می‌کرد که کاربر راهی برای رفعش نداشت.
*/
$override = $overrideMap[$midnight] ?? null;
$holiday = $holidayMap[$midnight] ?? null;
+25
View File
@@ -54,6 +54,31 @@ final class ResourceContext
return $this->branches->resolve($user, $addressUuid);
}
/**
* آدرسِ خودِ محیط — مطب یا کلینیک.
*
* منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ همین کلینیک‌اند و پرسیدنِ شعبه از
* کاربر یک انتخابِ همیشه‌یکسان بود. آدرس همچنان نگه داشته می‌شود چون منطقهٔ زمانی و
* جفتِ محیطِ منبع از آن مشتق می‌شوند، ولی کاربر دیگر انتخابش نمی‌کند.
*
* محیطی که چند آدرس دارد، اولینش را می‌گیرد؛ ترتیب از خودِ مخزن می‌آید و پایدار است.
*/
public function defaultAddress(User $user): DoctorAddress
{
$addresses = $this->branches->listForContext($user);
if ($addresses === []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
'برای این محیط آدرسی ثبت نشده است — ابتدا آدرس کلینیک را کامل کنید',
422,
'address_uuid',
);
}
return $addresses[array_key_first($addresses)];
}
public function type(User $user, string $uuid): ResourceType
{
return $this->owned($user, $this->types->findByUuid($uuid), 'نوع منبع یافت نشد');
+1 -1
View File
@@ -16,7 +16,7 @@ final readonly class DayAvailability
* @param list<TimeInterval> $intervals بازه‌های آزاد، به‌صورت timestamp مطلق
* @param list<string> $reasons `national_holiday`، `tenant_holiday`، `no_shift`،
* `branch_closed`، `outside_branch_hours`، `exception`،
* `resource_inactive`، `branch_inactive`
* `resource_inactive`
*/
public function __construct(
public int $date,
@@ -0,0 +1,128 @@
<?php
namespace App\Tests\Resource;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\DoctorAddress;
/**
* منابع دامنهٔ «شعبه» ندارند.
*
* آدرس هنوز زیرِ منبع هست — منطقهٔ زمانی و جفتِ محیط از آن می‌آیند — ولی از کاربر
* پرسیده نمی‌شود و روشن/خاموش بودنش هیچ چیزی را گیت نمی‌کند. پیش از این، یک ردیفِ
* `doctor_addresses.active = 0` همهٔ دستگاه‌های کلینیک را با پیامی خاموش می‌کرد که هیچ
* جای پنل راهی برای رفعش نداشت.
*/
class ResourceWithoutBranchTest extends ResourceTestCase
{
private function nextSaturday(): int
{
return (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran')))
->setTime(0, 0)
->getTimestamp();
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testAResourceIsCreatedWithoutAskingForABranch(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$body = $this->authJson('POST', '/api/v1/resource', $user, [
'type_uuid' => $type->getUuid(),
'name' => 'لیزر بدون شعبه',
'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
// آدرسِ خودِ محیط برداشته می‌شود، نه چیزی که کاربر انتخاب کند.
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
}
public function testAPoolIsCreatedWithoutABranchToo(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$body = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'type_uuid' => $type->getUuid(),
'name' => 'لیزرهای آلکساندرایت',
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
}
/** کلاینتی که هنوز `address_uuid` می‌فرستد نباید بشکند. */
public function testAnExplicitAddressIsStillAccepted(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$second = $this->extraAddress($clinic);
$type = $this->resourceType($address);
$body = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $second->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'لیزر آدرس دوم',
'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
]);
self::assertSame(201, $this->responseCode());
self::assertSame($second->getUuid(), $body['data']['address_uuid']);
}
// ── ⚠️ مرزی ─────────────────────────────────────────────────────────────
/** آدرسِ خاموش دیگر تقویم منبع را خالی نمی‌کند. */
public function testAnInactiveAddressNoLongerBlanksTheDay(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$created = $this->createResource($user, $address, $type, ['name' => 'اپراتور مریم']);
$uuid = $created['data']['uuid'];
$this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, [
'days' => [0 => [['start_minute' => 540, 'end_minute' => 1020]]],
]);
self::assertSame(200, $this->responseCode());
$address->setActive(false);
$this->em->flush();
$saturday = $this->nextSaturday();
$body = $this->authJson('GET', "/api/v1/resource/$uuid/availability?from=$saturday&to=$saturday", $user);
self::assertSame(200, $this->responseCode());
self::assertSame(480, $body['data']['days'][0]['total_minutes']);
self::assertNotContains('address_inactive', $body['data']['days'][0]['reasons']);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
/** محیطی که هیچ آدرسی ندارد، منبع نمی‌سازد — ولی پیامش می‌گوید چه کار کند. */
public function testAnEnvironmentWithNoAddressIsToldToCompleteItsAddressFirst(): void
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک بی‌آدرس');
$this->em->persist($clinic);
$this->em->flush();
// نوع منبع به آدرس نیاز ندارد؛ فقط جفتِ محیط را می‌خواهد.
$probe = DoctorAddress::forClinic($clinic->getId());
$type = $this->resourceType($probe);
$this->em->remove($probe);
$this->em->flush();
$body = $this->authJson('POST', '/api/v1/resource', $user, [
'type_uuid' => $type->getUuid(),
'name' => 'لیزر بی‌خانمان',
'supervisor_doctor_uuid' => $this->supervisorFor($probe)->getUuid(),
]);
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertStringContainsString('آدرسی ثبت نشده', $body['errors'][0]['message']);
}
}