Files
clinicpro/assets/admin/components/ui/SearchableSelect.tsx
T
hamed 773f9d4d16 feat: update session payment logic to ensure accurate payable amounts and reflect consumables in cost breakdown
- 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.
2026-07-19 13:29:46 +03:30

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)}
/>
);
}