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:
@@ -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: 'پیشفرض سرویس',
|
||||
};
|
||||
|
||||
|
||||
@@ -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: 'هیچ منبعی شرایط بخشهای این خدمت را ندارد.',
|
||||
};
|
||||
|
||||
|
||||
@@ -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, 'افزودن استخر ناموفق بود'),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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="آمادهسازی / تمیزکاری">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user