Manage a resource's services from the panel
A "سرویسها" action on each resource row opens a modal listing what that resource performs, with its own duration and price. It follows the skills modal exactly — same PUT-replaces-everything contract, same components, no new page and no new route. Leaving a cell empty means inherit, so the effective value is shown as the placeholder along with where it came from: "40 — service default", "1,800,000 toman — branch". Without that the user cannot tell an unset field from a zero, which is the one thing this screen has to communicate. Two backend adjustments came out of wiring it up: - the offering filter in findEligible is now scoped to the requirement's resource type. Registering lasers for a service was making the room in the same plan ineligible and breaking the whole booking — "who performs this" is about the performing role, not about rooms and support resources. The seeder caught this immediately. - the scenario seeder now creates offerings and resource categories, so the demo data exercises this model instead of leaving every resource empty. Three vitest tests: inherited value with its source, saving an override, and clearing back to inheritance. Verified in the browser at 1440 dark, 1440 compact and 390 mobile — the last with no horizontal scroll. Panel suite 648 green across 98 files, tsc clean, encore build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import ResourceServicesModal from './ResourceServicesModal';
|
||||
|
||||
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
|
||||
|
||||
const services = [
|
||||
{ uuid: 's-1', name: 'لیزر پا', duration_minutes: 30, price_rials: 8_000_000 },
|
||||
{ uuid: 's-2', name: 'لیزر دست', duration_minutes: 20, price_rials: 5_000_000 },
|
||||
];
|
||||
|
||||
/** ردیفی که مدتش را خودِ منبع گفته ولی قیمتش را از سرویس ارث برده. */
|
||||
const offerings = [
|
||||
{
|
||||
service_uuid: 's-1',
|
||||
service_name: 'لیزر پا',
|
||||
duration_minutes: 15,
|
||||
price_rials: null,
|
||||
active: true,
|
||||
effective_duration_minutes: 15,
|
||||
effective_price_rials: 8_000_000,
|
||||
duration_source: 'resource_option',
|
||||
price_source: 'service_default',
|
||||
},
|
||||
];
|
||||
|
||||
function props(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
resource,
|
||||
offerings: offerings as never,
|
||||
services: services as never,
|
||||
saving: false,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ResourceServicesModal', () => {
|
||||
it('نشان میدهد مقدار ارثبریشده از کجا آمده', () => {
|
||||
renderWithProviders(<ResourceServicesModal {...props()} />);
|
||||
|
||||
// مدت را خودِ منبع گفته، پس در خانه است.
|
||||
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
|
||||
|
||||
// قیمت تنظیم نشده: خانه خالی است و مقدار مؤثر با برچسبِ منبعش در placeholder.
|
||||
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
|
||||
expect(price).toHaveValue('');
|
||||
});
|
||||
|
||||
it('override را ذخیره میکند', async () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
|
||||
|
||||
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
|
||||
await userEvent.type(price, '9500000');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith([
|
||||
{ service_uuid: 's-1', duration_minutes: '15', price_rials: '9500000', active: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('پاککردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
|
||||
|
||||
await userEvent.clear(screen.getByDisplayValue('15'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
|
||||
|
||||
// رشتهٔ خالی به سرور میرود و سرور آن را `null` میفهمد — نه `0`.
|
||||
expect(onSave).toHaveBeenCalledWith([
|
||||
{ service_uuid: 's-1', duration_minutes: '', price_rials: '', active: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { formatRial } from '../../lib/utils';
|
||||
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
|
||||
|
||||
type Line = {
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
duration_minutes: string;
|
||||
price_rials: string;
|
||||
active: boolean;
|
||||
effective_duration_minutes: number | null;
|
||||
effective_price_rials: number;
|
||||
duration_source: string | null;
|
||||
price_source: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
resource: ClinicResource | null;
|
||||
offerings: ResourceServiceOffering[];
|
||||
services: ServiceItemOption[];
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (lines: Array<{ service_uuid: string; duration_minutes: string; price_rials: string; active: boolean }>) => void;
|
||||
}
|
||||
|
||||
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
resource_option: 'همین منبع',
|
||||
resource_service: 'منبع، روی سرویس والد',
|
||||
branch: 'شعبه',
|
||||
service_default: 'پیشفرض سرویس',
|
||||
};
|
||||
|
||||
/**
|
||||
* سرویسهایی که یک منبع ارائه میدهد، با مدت و قیمت اختصاصی.
|
||||
*
|
||||
* خالیگذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
|
||||
* بهصورت placeholder با برچسب منبعش نشان داده میشود، وگرنه کاربر نمیفهمد خانهٔ خالی
|
||||
* یعنی «تنظیم نشده» یا «رایگان».
|
||||
*
|
||||
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد مودال مهارتها.
|
||||
*/
|
||||
export default function ResourceServicesModal({ resource, offerings, services, saving, onClose, onSave }: Props) {
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resource) return;
|
||||
|
||||
setLines(
|
||||
offerings.map((o) => ({
|
||||
service_uuid: o.service_uuid,
|
||||
service_name: o.service_name,
|
||||
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
|
||||
price_rials: o.price_rials === null ? '' : String(o.price_rials),
|
||||
active: o.active,
|
||||
effective_duration_minutes: o.effective_duration_minutes,
|
||||
effective_price_rials: o.effective_price_rials,
|
||||
duration_source: o.duration_source,
|
||||
price_source: o.price_source,
|
||||
})),
|
||||
);
|
||||
}, [resource, offerings]);
|
||||
|
||||
const chosen = new Set(lines.map((l) => l.service_uuid));
|
||||
const available = services.filter((s) => !chosen.has(s.uuid));
|
||||
|
||||
const patch = (index: number, changes: Partial<Line>) =>
|
||||
setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x)));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={resource !== null}
|
||||
onClose={onClose}
|
||||
title={`سرویسهای ${resource?.name ?? 'منبع'}`}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
{services.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
هنوز هیچ سرویسی تعریف نشده است. اول از صفحهٔ «سرویسها» یکی بسازید.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lines.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
این منبع هیچ سرویسی ارائه نمیدهد.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={line.service_uuid}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface-2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={line.active}
|
||||
onChange={(e) => patch(index, { active: e.target.checked })}
|
||||
/>
|
||||
فعال
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
|
||||
aria-label={`حذف ${line.service_name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div className="field-block" style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>مدت (دقیقه)</label>
|
||||
<input
|
||||
className="field"
|
||||
inputMode="numeric"
|
||||
value={line.duration_minutes}
|
||||
onChange={(e) => patch(index, { duration_minutes: e.target.value })}
|
||||
placeholder={
|
||||
line.effective_duration_minutes === null
|
||||
? 'تعیین نشده'
|
||||
: `${line.effective_duration_minutes} — ${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-block" style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
|
||||
<input
|
||||
className="field"
|
||||
inputMode="numeric"
|
||||
value={line.price_rials}
|
||||
onChange={(e) => patch(index, { price_rials: e.target.value })}
|
||||
placeholder={`${formatRial(line.effective_price_rials)} — ${SOURCE_LABELS[line.price_source] ?? '—'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن سرویس</label>
|
||||
<SearchableSelect
|
||||
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
const picked = services.find((s) => s.uuid === String(v));
|
||||
if (!picked) return;
|
||||
|
||||
setLines((l) => [
|
||||
...l,
|
||||
{
|
||||
service_uuid: picked.uuid,
|
||||
service_name: picked.name,
|
||||
duration_minutes: '',
|
||||
price_rials: '',
|
||||
active: true,
|
||||
effective_duration_minutes: picked.duration_minutes ?? null,
|
||||
effective_price_rials: picked.price_rials ?? 0,
|
||||
duration_source: 'service_default',
|
||||
price_source: 'service_default',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
placeholder="یک سرویس انتخاب کنید"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
||||
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
|
||||
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
onSave(
|
||||
lines.map((l) => ({
|
||||
service_uuid: l.service_uuid,
|
||||
duration_minutes: l.duration_minutes.trim(),
|
||||
price_rials: l.price_rials.trim(),
|
||||
active: l.active,
|
||||
})),
|
||||
)
|
||||
}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
ClinicResource, ResourcePool, ResourcePayload, ResourceType, Skill,
|
||||
ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
@@ -77,6 +77,36 @@ export function useResources(filters: ResourceFilters = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهایی که یک منبع ارائه میدهد.
|
||||
*
|
||||
* مقدار مؤثر و منبعِ هر عدد از سرور میآید، نه از محاسبهٔ فرانت: زنجیرهٔ حل چهار سطح
|
||||
* دارد و بازسازیاش اینجا یعنی دو پیادهسازی که با هم واگرا میشوند.
|
||||
*/
|
||||
export function useResourceServices(resourceUuid?: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['resource-services', resourceUuid];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${resourceUuid}/services`),
|
||||
enabled: !!resourceUuid,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: ({ uuid, services }: { uuid: string; services: Array<Record<string, unknown>> }) =>
|
||||
api.put<ApiResponse<ResourceServiceOffering[]>>(`/api/v1/resource/${uuid}/services`, { services }),
|
||||
onSuccess: () => {
|
||||
toast.success('سرویسهای منبع ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['resource-services'] });
|
||||
qc.invalidateQueries({ queryKey: [RESOURCES_KEY] });
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ سرویسها ناموفق بود'),
|
||||
});
|
||||
|
||||
return { offerings: query.data?.data ?? [], loading: query.isLoading, save };
|
||||
}
|
||||
|
||||
export function useResourceTypes() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY });
|
||||
|
||||
@@ -10,9 +10,11 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
|
||||
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
||||
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
|
||||
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
import type { ClinicResource } from '../types';
|
||||
|
||||
/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */
|
||||
@@ -49,9 +51,13 @@ export default function ResourcesPage() {
|
||||
|
||||
const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null });
|
||||
const [skillsFor, setSkillsFor] = useState<ClinicResource | null>(null);
|
||||
const [servicesFor, setServicesFor] = useState<ClinicResource | null>(null);
|
||||
const [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
|
||||
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
|
||||
|
||||
const { offerings, save: saveServices } = useResourceServices(servicesFor?.uuid);
|
||||
const { items: serviceOptions } = useAllServiceItems();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = urlState.search.trim();
|
||||
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
|
||||
@@ -185,6 +191,9 @@ export default function ResourcesPage() {
|
||||
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
|
||||
مهارتها
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
|
||||
سرویسها
|
||||
</button>
|
||||
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}/calendar`}>
|
||||
تقویم
|
||||
</Link>
|
||||
@@ -233,6 +242,21 @@ export default function ResourcesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceServicesModal
|
||||
resource={servicesFor}
|
||||
offerings={offerings}
|
||||
services={serviceOptions}
|
||||
saving={saveServices.isPending}
|
||||
onClose={() => setServicesFor(null)}
|
||||
onSave={(lines) =>
|
||||
servicesFor &&
|
||||
saveServices.mutate(
|
||||
{ uuid: servicesFor.uuid, services: lines },
|
||||
{ onSuccess: () => setServicesFor(null) },
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!toDelete}
|
||||
title="حذف منبع"
|
||||
|
||||
@@ -1238,3 +1238,25 @@ export interface ReportEnvelope<T> {
|
||||
to: number;
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
/** یک ردیف «این منبع این سرویس را میدهد» با مقدار مؤثر و منبعِ هر عدد. */
|
||||
export interface ResourceServiceOffering {
|
||||
service_uuid: string;
|
||||
service_name: string;
|
||||
/** null یعنی ارث از سطح بالاتر، نه صفر */
|
||||
duration_minutes: number | null;
|
||||
price_rials: number | null;
|
||||
active: boolean;
|
||||
effective_duration_minutes: number | null;
|
||||
effective_price_rials: number;
|
||||
duration_source: string | null;
|
||||
price_source: string;
|
||||
}
|
||||
|
||||
/** گزینهٔ انتخاب سرویس در مودال منبع. */
|
||||
export interface ServiceItemOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
duration_minutes?: number | null;
|
||||
price_rials?: number | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user