- Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly. - Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations. - Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings. - Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges. - Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts. - Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers. - Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability.
136 lines
4.7 KiB
TypeScript
136 lines
4.7 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import Select, { StylesConfig, GroupBase } from 'react-select';
|
|
import { useUiStore } from '../../stores/uiStore';
|
|
|
|
export interface SelectOption {
|
|
value: string | number;
|
|
label: string;
|
|
}
|
|
|
|
interface Props {
|
|
options: SelectOption[];
|
|
value?: string | number | null;
|
|
onChange?: (value: string | number | null) => void;
|
|
placeholder?: string;
|
|
isLoading?: boolean;
|
|
isDisabled?: boolean;
|
|
isClearable?: boolean;
|
|
noOptionsMessage?: string;
|
|
inputId?: string;
|
|
height?: number;
|
|
/**
|
|
* نام دسترسپذیر فیلد. react-select ورودی داخلی خودش را بدون label رندر میکند و
|
|
* placeholder را هم بهصورت div میگذارد نه attribute، پس بدون این، فیلد برای
|
|
* screen reader بینام میماند. پیشفرض روی placeholder میافتد.
|
|
*/
|
|
ariaLabel?: string;
|
|
/** اگر label قابلمشاهدهای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */
|
|
ariaLabelledBy?: string;
|
|
}
|
|
|
|
export default function SearchableSelect({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = 'انتخاب کنید...',
|
|
isLoading,
|
|
isDisabled,
|
|
isClearable,
|
|
noOptionsMessage = 'موردی یافت نشد',
|
|
inputId,
|
|
height = 42,
|
|
ariaLabel,
|
|
ariaLabelledBy,
|
|
}: Props) {
|
|
const darkMode = useUiStore((s) => s.darkMode);
|
|
|
|
// مقایسهٔ نرم (string↔number): مقدار پیشفرض ممکن است number باشد ولی value گزینه String
|
|
// (مثلاً id بیمه). تطبیق سختگیرانه در این حالت گزینه را خالی نشان میداد.
|
|
const selected = useMemo(
|
|
() => (value == null || value === ''
|
|
? null
|
|
: options.find((o) => String(o.value) === String(value)) ?? null),
|
|
[options, value],
|
|
);
|
|
|
|
const styles: StylesConfig<SelectOption, false, GroupBase<SelectOption>> = {
|
|
control: (base, state) => ({
|
|
...base,
|
|
background: state.isFocused ? 'var(--surface)' : 'var(--surface-2)',
|
|
borderColor: state.isFocused ? 'var(--primary)' : 'var(--border)',
|
|
boxShadow: state.isFocused ? '0 0 0 4px var(--ring)' : 'none',
|
|
borderRadius: 'var(--r-sm)',
|
|
minHeight: height,
|
|
fontSize: 14,
|
|
cursor: 'pointer',
|
|
'&:hover': {
|
|
borderColor: state.isFocused ? 'var(--primary)' : 'var(--border-2)',
|
|
},
|
|
}),
|
|
valueContainer: (base) => ({ ...base, padding: '0 14px' }),
|
|
menu: (base) => ({
|
|
...base,
|
|
background: 'var(--surface)',
|
|
border: '1px solid var(--border)',
|
|
boxShadow: 'var(--shadow-lg)',
|
|
borderRadius: 'var(--r)',
|
|
overflow: 'hidden',
|
|
marginTop: 4,
|
|
}),
|
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
option: (base, state) => ({
|
|
...base,
|
|
background: state.isSelected
|
|
? 'var(--primary-soft2)'
|
|
: state.isFocused
|
|
? 'var(--surface-2)'
|
|
: 'transparent',
|
|
color: state.isSelected ? 'var(--primary-700)' : 'var(--text)',
|
|
fontWeight: state.isSelected ? 700 : 400,
|
|
fontSize: 14,
|
|
cursor: 'pointer',
|
|
padding: '9px 14px',
|
|
}),
|
|
singleValue: (base) => ({ ...base, color: 'var(--text)' }),
|
|
input: (base) => ({ ...base, color: 'var(--text)', margin: 0, padding: 0 }),
|
|
placeholder: (base) => ({ ...base, color: 'var(--text-3)' }),
|
|
indicatorSeparator: (base) => ({ ...base, background: 'var(--border)' }),
|
|
dropdownIndicator: (base) => ({
|
|
...base,
|
|
color: 'var(--text-3)',
|
|
padding: '0 10px',
|
|
'&:hover': { color: 'var(--text-2)' },
|
|
}),
|
|
clearIndicator: (base) => ({
|
|
...base,
|
|
color: 'var(--text-3)',
|
|
padding: '0 6px',
|
|
'&:hover': { color: 'var(--danger)' },
|
|
}),
|
|
loadingIndicator: (base) => ({ ...base, color: 'var(--primary)' }),
|
|
noOptionsMessage: (base) => ({ ...base, color: 'var(--text-3)', fontSize: 13 }),
|
|
loadingMessage: (base) => ({ ...base, color: 'var(--text-3)', fontSize: 13 }),
|
|
};
|
|
|
|
return (
|
|
<Select<SelectOption>
|
|
options={options}
|
|
value={selected}
|
|
onChange={(opt) => onChange?.(opt ? opt.value : null)}
|
|
placeholder={placeholder}
|
|
isLoading={isLoading}
|
|
isDisabled={isDisabled}
|
|
isClearable={isClearable}
|
|
styles={styles}
|
|
isRtl
|
|
menuPortalTarget={typeof document !== 'undefined' ? document.body : undefined}
|
|
menuPosition="fixed"
|
|
noOptionsMessage={() => noOptionsMessage}
|
|
loadingMessage={() => 'در حال بارگذاری...'}
|
|
inputId={inputId}
|
|
aria-labelledby={ariaLabelledBy}
|
|
aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? placeholder)}
|
|
/>
|
|
);
|
|
}
|