Files
clinicpro/assets/admin/components/ui/SearchableSelect.tsx
T
hamed a6a965a2aa feat: add admin subscription granting feature
- Implemented the ability for admins to grant subscriptions to doctors and clinics without payment.
- Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions.
- Updated the subscription model to track the admin who granted the subscription.
- Enhanced the subscription report to include details about granted subscriptions.
- Introduced a new `is_granted` field to indicate if a subscription was granted by an admin.
- Updated the database schema to support the new functionality with a migration.
- Added tests to ensure the correct behavior of the subscription granting process.
2026-08-09 13:43:30 +03:30

151 lines
5.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;
/**
* جستجوی سمت سرور: با هر تایپ صدا زده می‌شود تا مصرف‌کننده `options` تازه بدهد.
*
* وقتی داده می‌شود، فیلترِ داخلی react-select خاموش می‌شود؛ وگرنه نتیجهٔ سرور
* دوباره روی متنِ تایپ‌شده فیلتر می‌شد و گزینه‌هایی که سرور با معیارِ دیگری
* (مثلاً شمارهٔ موبایل) پیدا کرده بود ناپدید می‌شدند.
*/
onInputChange?: (input: string) => void;
}
export default function SearchableSelect({
options,
value,
onChange,
placeholder = 'انتخاب کنید...',
isLoading,
isDisabled,
isClearable,
noOptionsMessage = 'موردی یافت نشد',
inputId,
height = 42,
ariaLabel,
ariaLabelledBy,
onInputChange,
}: 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}
onInputChange={onInputChange ? (input, meta) => {
// react-select ورودی را هنگام بستن منو و blur هم «تغییر» می‌داند؛ آن دو را
// رد نکنیم، هر بار بستنِ منو لیست را به حالت خالی برمی‌گرداند.
if (meta.action === 'input-change') { onInputChange(input); }
} : undefined}
filterOption={onInputChange ? null : undefined}
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)}
/>
);
}