- 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.
233 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|