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:
hamed
2026-08-01 22:57:55 +03:30
co-authored by Claude Opus 5
parent a6acf3bfe2
commit 26425bec31
10 changed files with 463 additions and 35 deletions
@@ -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>
);
}