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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user