Files
clinicpro/assets/admin/components/resources/ResourceServicesPanel.tsx
T
hamed b49abee52f refactor: update pricing input handling to use PriceInput component
- Replaced raw input fields for pricing with PriceInput component across various forms and modals to ensure consistent formatting and accessibility.
- Updated tests to reflect changes in pricing input handling, ensuring values are displayed in toman with proper formatting.
- Enhanced accessibility by adding aria-labels and aria-invalid attributes to PriceInput components.
- Adjusted UI elements to improve layout and user experience, particularly in forms related to service items and scheduling.
- Changed labels from "نمایش در نوبت‌دهی" to "نمایش در نوبت‌دهی آنلاین" for clarity.
2026-08-09 08:38:23 +03:30

233 lines
10 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import PriceInput from '../ui/PriceInput';
import SearchableSelect from '../ui/SearchableSelect';
import Switch from '../ui/Switch';
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
type Line = {
service_uuid: string;
service_name: string;
duration_minutes: string;
/** مقدارِ داخل فیلد به **تومان** است؛ تبدیل به ریال فقط موقع ذخیره انجام می‌شود. */
price_tomans: string;
active: boolean;
effective_duration_minutes: number | null;
effective_price_rials: number;
duration_source: string | null;
price_source: string;
};
export type ServiceOfferingLine = {
service_uuid: string;
duration_minutes: string;
price_rials: string;
active: boolean;
};
interface Props {
resource: ClinicResource | null;
offerings: ResourceServiceOffering[];
services: ServiceItemOption[];
saving: boolean;
/** وقتی داده نشود دکمهٔ «انصراف» نمایش داده نمی‌شود — حالت تب. */
onCancel?: () => void;
onSave: (lines: ServiceOfferingLine[]) => void;
}
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
const SOURCE_LABELS: Record<string, string> = {
resource_option: 'همین منبع',
resource_service: 'منبع، روی سرویس والد',
branch: 'تنظیم محل نوبت‌دهی',
service_default: 'پیش‌فرض سرویس',
};
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد، با مدت و قیمت اختصاصی.
*
* خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
* به‌صورت placeholder با برچسب منبعش نشان داده می‌شود، وگرنه کاربر نمی‌فهمد خانهٔ خالی
* یعنی «تنظیم نشده» یا «رایگان».
*
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد پنل مهارت‌ها.
*/
export default function ResourceServicesPanel({
resource, offerings, services, saving, onCancel, 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),
// ورودی به تومان است تا با placeholder و بقیهٔ پنل یکی باشد؛ پیش از این
// برچسب «ریال» بود و placeholder تومان — یعنی ده برابر اختلاف در یک فیلد.
price_tomans: o.price_rials === null ? '' : String(rialToToman(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 (
<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>
<Switch
inline
checked={line.active}
onChange={(v) => patch(index, { active: v })}
label="فعال"
ariaLabel={`فعال بودن سرویس ${line.service_name}`}
/>
<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>
<PriceInput
className="field"
ariaLabel={`قیمت ${line.service_name} (تومان)`}
value={line.price_tomans === '' ? '' : Number(line.price_tomans)}
onChange={(v) => patch(index, { price_tomans: v === 0 ? '' : String(v) })}
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,
// سرویسِ تازه با پیش‌فرض خودش پر می‌شود تا کاربر عددی جلوی چشمش
// باشد و لازم نباشد از placeholder رونویسی کند. پاک‌کردنش همان
// «ارث از سرویس» را برمی‌گرداند؛ ردیف‌های موجود دست نمی‌خورند.
duration_minutes: picked.duration_minutes == null ? '' : String(picked.duration_minutes),
price_tomans: picked.price_rials == null ? '' : String(rialToToman(picked.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 }}>
{onCancel && (
<button type="button" className="btn secondary" onClick={onCancel}>انصراف</button>
)}
<button
type="button"
className="btn primary"
disabled={saving}
onClick={() =>
onSave(
lines.map((l) => {
const toman = l.price_tomans.trim();
return {
service_uuid: l.service_uuid,
duration_minutes: l.duration_minutes.trim(),
// قرارداد سرور ریال است؛ رشتهٔ خالی یعنی «ارث» و نباید صفر شود.
price_rials: toman === '' ? '' : String(tomanToRial(Number(toman))),
active: l.active,
};
}),
)
}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
);
}