Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import type { PropsWithChildren } from 'react'
|
|
|
|
import { useCallback, useEffect } from 'react'
|
|
import { FormProvider, useForm, useFormContext, type UseFormReturn } from 'react-hook-form'
|
|
import dynamic from 'next/dynamic'
|
|
|
|
import type { CustomInputProps, InputKind } from '@/components/formElements/input/inputTypes'
|
|
import { useRemoteSelectOptions } from '@/components/formElements/input/useRemoteSelectOptions'
|
|
import TextFieldControl from '@/components/formElements/input/TextFieldControl'
|
|
import ChoiceFieldControl from '@/components/formElements/input/ChoiceFieldControl'
|
|
import { texts, format } from '@/texts'
|
|
import useWindowSize from '@/hooks/useWindowSize'
|
|
|
|
const DateFieldControl = dynamic(() => import('@/components/formElements/input/DateFieldControl'), { ssr: false })
|
|
const EventScheduleFieldControl = dynamic(() => import('@/components/events/schedule/EventScheduleFieldControl'), {
|
|
ssr: false,
|
|
})
|
|
const JalaliBirthDateFieldControl = dynamic(() => import('@/components/formElements/input/JalaliBirthDateFieldControl'), {
|
|
ssr: false,
|
|
})
|
|
|
|
function defaultValueForKind(kind: InputKind): unknown {
|
|
if (kind === 'checkbox' || kind === 'switch') return false
|
|
if (kind === 'numberInput') return undefined
|
|
|
|
return ''
|
|
}
|
|
|
|
function useOptionalFormContext(): UseFormReturn | null {
|
|
const context = useFormContext() as UseFormReturn | null
|
|
|
|
return context?.control ? context : null
|
|
}
|
|
|
|
type BoundProps = PropsWithChildren<CustomInputProps>
|
|
|
|
function FormBoundInput({
|
|
generalType,
|
|
inputType,
|
|
variant = 'bordered',
|
|
label,
|
|
name,
|
|
placeholder: outerPlaceholder,
|
|
description,
|
|
className,
|
|
iconStart,
|
|
iconEnd,
|
|
selectKey = 'code',
|
|
selectValue = 'name',
|
|
filterName = 'title',
|
|
filterValue = '',
|
|
apiField = 'data',
|
|
searchMode = 'api',
|
|
direction = 'rtl',
|
|
pageSize = 20,
|
|
otpLength = 4,
|
|
url,
|
|
size,
|
|
radius = 'md',
|
|
autoFocus,
|
|
disabled,
|
|
required,
|
|
readonly,
|
|
eventPickerStyle = 'default',
|
|
multiple,
|
|
onlyYearPicker,
|
|
minValue,
|
|
maxValue,
|
|
formatOptions,
|
|
maxDate,
|
|
minDate = '1300/1/1',
|
|
textAreaMinRows = 5,
|
|
selectOptions,
|
|
radioOptions,
|
|
isOutsideFilter,
|
|
ariaLabel,
|
|
inputWrapper,
|
|
labelIcon = null,
|
|
isClearable = true,
|
|
englishDigitsOnly = false,
|
|
onBlur,
|
|
onClear,
|
|
onKeyDown,
|
|
orientation = 'horizontal',
|
|
}: BoundProps) {
|
|
const { width } = useWindowSize()
|
|
const { control, getValues } = useFormContext()
|
|
|
|
const defaultInputWrapper =
|
|
variant === 'bordered'
|
|
? // InputGroup focuses via :focus-within; TextArea via :focus / data-focused.
|
|
// Invalid: single red border only (global CSS kills HeroUI invalid-field-ring outline).
|
|
'shadow-none border hover:!border-primary-300 focus:!border-primary focus-within:!border-primary data-[focused=true]:!border-primary data-[focus-within=true]:!border-primary data-[invalid=true]:!border-fourth data-[invalid=true]:hover:!border-fourth data-[invalid=true]:focus-within:!border-fourth'
|
|
: 'shadow-none'
|
|
|
|
const finalInputWrapper = inputWrapper || defaultInputWrapper
|
|
|
|
const isChoiceField = generalType === 'select' || generalType === 'combobox'
|
|
const placeholder =
|
|
outerPlaceholder ?? (isChoiceField ? undefined : typeof label === 'string' ? format(texts.common.enterField, { label }) : '')
|
|
|
|
const getCurrentValue = useCallback(() => getValues(name), [getValues, name])
|
|
const {
|
|
loading,
|
|
options: searchOptions,
|
|
search: handleComboboxChange,
|
|
} = useRemoteSelectOptions({
|
|
apiField,
|
|
disabled,
|
|
enabled: generalType === 'combobox',
|
|
filterName,
|
|
filterValue,
|
|
getCurrentValue,
|
|
isOutsideFilter,
|
|
pageSize,
|
|
searchMode,
|
|
selectKey,
|
|
selectValue,
|
|
url,
|
|
})
|
|
|
|
switch (generalType) {
|
|
case 'input':
|
|
case 'numberInput':
|
|
return (
|
|
<TextFieldControl
|
|
ariaLabel={ariaLabel}
|
|
autoFocus={autoFocus}
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
direction={direction}
|
|
disabled={disabled}
|
|
englishDigitsOnly={englishDigitsOnly}
|
|
finalInputWrapper={finalInputWrapper}
|
|
formatOptions={formatOptions}
|
|
iconEnd={iconEnd}
|
|
iconStart={iconStart}
|
|
inputType={inputType}
|
|
isClearable={isClearable}
|
|
kind={generalType}
|
|
label={label}
|
|
labelIcon={labelIcon}
|
|
maxValue={maxValue}
|
|
minValue={minValue}
|
|
name={name}
|
|
placeholder={placeholder}
|
|
radius={radius}
|
|
readonly={readonly}
|
|
required={required}
|
|
size={size}
|
|
textAreaMinRows={textAreaMinRows}
|
|
variant={variant}
|
|
onBlur={onBlur}
|
|
onClear={onClear}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
)
|
|
|
|
case 'otp':
|
|
case 'combobox':
|
|
return (
|
|
<ChoiceFieldControl
|
|
ariaLabel={ariaLabel}
|
|
autoFocus={autoFocus}
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
disabled={disabled}
|
|
finalInputWrapper={finalInputWrapper}
|
|
getValues={getValues}
|
|
iconEnd={iconEnd}
|
|
iconStart={iconStart}
|
|
inputType={inputType}
|
|
kind={generalType}
|
|
label={label}
|
|
loading={loading}
|
|
multiple={multiple}
|
|
name={name}
|
|
options={selectOptions ?? []}
|
|
orientation={orientation}
|
|
otpLength={otpLength}
|
|
placeholder={placeholder}
|
|
radius={radius}
|
|
readonly={readonly}
|
|
required={required}
|
|
searchOptions={searchOptions}
|
|
selectKey={selectKey}
|
|
selectValue={selectValue}
|
|
size={size}
|
|
variant={variant}
|
|
onSearch={handleComboboxChange}
|
|
/>
|
|
)
|
|
|
|
case 'textarea':
|
|
return (
|
|
<TextFieldControl
|
|
ariaLabel={ariaLabel}
|
|
autoFocus={autoFocus}
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
direction={direction}
|
|
disabled={disabled}
|
|
englishDigitsOnly={englishDigitsOnly}
|
|
finalInputWrapper={finalInputWrapper}
|
|
iconEnd={iconEnd}
|
|
iconStart={iconStart}
|
|
isClearable={isClearable}
|
|
kind="textarea"
|
|
label={label}
|
|
name={name}
|
|
placeholder={placeholder}
|
|
radius={radius}
|
|
readonly={readonly}
|
|
required={required}
|
|
size={size}
|
|
textAreaMinRows={textAreaMinRows}
|
|
variant={variant}
|
|
onBlur={onBlur}
|
|
/>
|
|
)
|
|
|
|
case 'select':
|
|
case 'radio':
|
|
case 'checkbox':
|
|
case 'switch':
|
|
return (
|
|
<ChoiceFieldControl
|
|
ariaLabel={ariaLabel}
|
|
autoFocus={autoFocus}
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
disabled={disabled}
|
|
finalInputWrapper={finalInputWrapper}
|
|
getValues={getValues}
|
|
iconEnd={iconEnd}
|
|
iconStart={iconStart}
|
|
inputType={inputType}
|
|
kind={generalType}
|
|
label={label}
|
|
loading={loading}
|
|
multiple={multiple}
|
|
name={name}
|
|
options={generalType === 'radio' ? (radioOptions ?? []) : (selectOptions ?? [])}
|
|
orientation={orientation}
|
|
otpLength={otpLength}
|
|
placeholder={placeholder}
|
|
radius={radius}
|
|
readonly={readonly}
|
|
required={required}
|
|
searchOptions={searchOptions}
|
|
selectKey={selectKey}
|
|
selectValue={selectValue}
|
|
size={size}
|
|
variant={variant}
|
|
onSearch={handleComboboxChange}
|
|
/>
|
|
)
|
|
|
|
case 'datePickerPro':
|
|
case 'datePicker':
|
|
case 'timePicker':
|
|
return (
|
|
<DateFieldControl
|
|
autoFocus={autoFocus}
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
disabled={disabled}
|
|
finalInputWrapper={finalInputWrapper}
|
|
kind={generalType}
|
|
label={label}
|
|
labelIcon={labelIcon}
|
|
maxDate={maxDate}
|
|
minDate={minDate}
|
|
name={name}
|
|
onlyYearPicker={onlyYearPicker}
|
|
placeholder={placeholder}
|
|
radius={radius}
|
|
readonly={readonly}
|
|
required={required}
|
|
size={size}
|
|
variant={variant}
|
|
viewportWidth={width}
|
|
/>
|
|
)
|
|
|
|
case 'eventDatePicker':
|
|
case 'eventTimePicker':
|
|
return (
|
|
<EventScheduleFieldControl
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
disabled={disabled}
|
|
kind={generalType}
|
|
label={label}
|
|
minDate={minDate}
|
|
minValue={minValue}
|
|
name={name}
|
|
placeholder={placeholder}
|
|
readonly={readonly}
|
|
required={required}
|
|
size={size}
|
|
triggerStyle={eventPickerStyle}
|
|
variant={variant}
|
|
/>
|
|
)
|
|
|
|
case 'birthDatePicker':
|
|
return (
|
|
<JalaliBirthDateFieldControl
|
|
className={className}
|
|
control={control}
|
|
description={description}
|
|
disabled={disabled}
|
|
label={label}
|
|
name={name}
|
|
placeholder={placeholder}
|
|
readonly={readonly}
|
|
required={required}
|
|
size={size}
|
|
variant={variant}
|
|
/>
|
|
)
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Standalone / controlled bridge: keeps one public Input API without requiring
|
|
* the caller to set up FormProvider for simple local state.
|
|
*/
|
|
function StandaloneInput(props: BoundProps) {
|
|
const { name, generalType, value, onValueChange } = props
|
|
const methods = useForm({
|
|
defaultValues: { [name]: value ?? defaultValueForKind(generalType) },
|
|
values: value !== undefined ? { [name]: value } : undefined,
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!onValueChange) return
|
|
|
|
const subscription = methods.watch((values, info) => {
|
|
if (info.name && info.name !== name) return
|
|
onValueChange(values[name])
|
|
})
|
|
|
|
return () => {
|
|
subscription.unsubscribe()
|
|
}
|
|
}, [methods, name, onValueChange])
|
|
|
|
return (
|
|
<FormProvider {...methods}>
|
|
<FormBoundInput {...props} />
|
|
</FormProvider>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* App-facing form field. Prefer this over `@/components/heroui/*` controls.
|
|
* - Inside FormProvider: binds via `name` (react-hook-form)
|
|
* - Outside FormProvider (or with `value` / `onValueChange`): works standalone
|
|
*/
|
|
const Input = (props: BoundProps) => {
|
|
const form = useOptionalFormContext()
|
|
const isControlled = props.value !== undefined || props.onValueChange !== undefined
|
|
|
|
// Outside FormProvider, or explicitly controlled from parent state → standalone bridge.
|
|
if (!form || isControlled) {
|
|
return <StandaloneInput {...props} />
|
|
}
|
|
|
|
return <FormBoundInput {...props} />
|
|
}
|
|
|
|
export default Input
|