Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
'use client'
|
|
|
|
import type { KeyboardEvent, ReactNode } from 'react'
|
|
|
|
import { useState } from 'react'
|
|
import { Controller, type Control } from 'react-hook-form'
|
|
|
|
import { Input, Textarea } from '@/components/heroui/Input'
|
|
import { NumberInput } from '@/components/heroui/NumberInput'
|
|
import { buildOutsideLabel, formatNumberInputDisplayValue } from '@/components/formElements/input/inputUtils'
|
|
import Button from '@/components/formElements/Button'
|
|
import EyeCrossedIcon from '@/components/icons/EyeCrossedIcon'
|
|
import EyeIcon from '@/components/icons/EyeIcon'
|
|
import { convertPersianToEnglish } from '@/helpers'
|
|
import { formatTomanInWords } from '@/helpers/numberToPersianWords'
|
|
import { texts } from '@/texts'
|
|
|
|
function isTomanAmountLabel(label?: string): boolean {
|
|
return typeof label === 'string' && label.includes(texts.common.toman)
|
|
}
|
|
|
|
function buildAmountDescription(value: unknown, label: string | undefined, existing?: ReactNode): ReactNode {
|
|
if (!isTomanAmountLabel(label)) return existing
|
|
|
|
const amountInWords = formatTomanInWords(value)
|
|
|
|
if (!amountInWords && !existing) return undefined
|
|
if (!amountInWords) return existing
|
|
if (!existing) return amountInWords
|
|
|
|
return (
|
|
<>
|
|
<span className="block">{amountInWords}</span>
|
|
<span className="mt-1 block">{existing}</span>
|
|
</>
|
|
)
|
|
}
|
|
|
|
interface Props {
|
|
ariaLabel?: string
|
|
autoFocus?: boolean
|
|
className?: string
|
|
control: Control
|
|
description?: ReactNode
|
|
direction: 'rtl' | 'ltr'
|
|
disabled?: boolean
|
|
englishDigitsOnly: boolean
|
|
finalInputWrapper: string
|
|
formatOptions?: Intl.NumberFormatOptions
|
|
iconEnd?: ReactNode
|
|
iconStart?: ReactNode
|
|
inputType?: 'text' | 'email' | 'number' | 'password' | 'tel'
|
|
isClearable: boolean
|
|
kind: 'input' | 'numberInput' | 'textarea'
|
|
label?: string
|
|
labelIcon?: ReactNode
|
|
maxValue?: number
|
|
minValue?: number
|
|
name: string
|
|
placeholder?: string
|
|
radius: 'none' | 'sm' | 'md' | 'lg' | 'full'
|
|
readonly?: boolean
|
|
required?: boolean
|
|
size?: 'sm' | 'md' | 'lg'
|
|
textAreaMinRows: number
|
|
variant: 'flat' | 'bordered' | 'faded' | 'underlined'
|
|
onBlur?: () => void
|
|
onClear?: () => void
|
|
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void
|
|
}
|
|
|
|
export default function TextFieldControl(props: Props) {
|
|
const [passwordVisible, setPasswordVisible] = useState(false)
|
|
const { control, kind, name } = props
|
|
|
|
return (
|
|
<Controller
|
|
control={control}
|
|
name={name}
|
|
render={({ field, fieldState: { error } }) => {
|
|
const description = buildAmountDescription(field.value, props.label, props.description)
|
|
const common = {
|
|
autoFocus: props.autoFocus,
|
|
className: props.className,
|
|
description,
|
|
errorMessage: error?.message,
|
|
isDisabled: props.disabled,
|
|
isInvalid: !!error,
|
|
isReadOnly: props.readonly,
|
|
isRequired: false,
|
|
labelPlacement: 'outside' as const,
|
|
placeholder: props.placeholder,
|
|
radius: props.radius,
|
|
size: props.size,
|
|
validationBehavior: 'aria' as const,
|
|
variant: props.variant,
|
|
}
|
|
|
|
if (kind === 'textarea') {
|
|
const canClear = props.isClearable && !props.readonly
|
|
|
|
return (
|
|
<Textarea
|
|
{...field}
|
|
{...common}
|
|
classNames={{ inputWrapper: props.finalInputWrapper, label: 'labelClass' }}
|
|
isClearable={canClear}
|
|
label={buildOutsideLabel(props.label, null, props.required)}
|
|
minRows={props.textAreaMinRows}
|
|
onBlur={() => {
|
|
field.onBlur()
|
|
props.onBlur?.()
|
|
}}
|
|
{...(canClear
|
|
? {
|
|
onClear: () => {
|
|
field.onChange('')
|
|
},
|
|
}
|
|
: {})}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (kind === 'numberInput') {
|
|
const { onChange, value, ...fieldRest } = field
|
|
|
|
if (props.readonly) {
|
|
return (
|
|
<Input
|
|
{...fieldRest}
|
|
{...common}
|
|
isReadOnly
|
|
aria-label={props.ariaLabel}
|
|
classNames={{ innerWrapper: props.direction, inputWrapper: props.finalInputWrapper, label: 'labelClass' }}
|
|
endContent={props.iconEnd}
|
|
label={buildOutsideLabel(props.label, props.labelIcon ?? null, props.required)}
|
|
startContent={props.iconStart}
|
|
type="text"
|
|
value={formatNumberInputDisplayValue(value, props.formatOptions)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<NumberInput
|
|
{...fieldRest}
|
|
{...common}
|
|
classNames={{ inputWrapper: props.finalInputWrapper, label: 'labelClass' }}
|
|
endContent={props.iconEnd}
|
|
formatOptions={props.formatOptions}
|
|
isClearable={props.isClearable}
|
|
label={buildOutsideLabel(props.label, props.labelIcon ?? null, props.required)}
|
|
maxValue={props.maxValue}
|
|
minValue={props.minValue}
|
|
startContent={props.iconStart}
|
|
value={value ?? undefined}
|
|
{...(props.isClearable
|
|
? {
|
|
onClear: () => {
|
|
if (props.onClear) {
|
|
props.onClear()
|
|
} else {
|
|
onChange(undefined)
|
|
}
|
|
},
|
|
}
|
|
: {})}
|
|
onValueChange={onChange}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const isTel = props.inputType === 'tel'
|
|
// tel: LTR digit order. Alignment comes from styles/input.scss via
|
|
// :placeholder-shown (empty → right, typed → left). Do not set inline
|
|
// text-align — it fights the stylesheet when the DOM value updates first.
|
|
const canClear = props.isClearable && !props.readonly && props.inputType !== 'password' && !isTel
|
|
const isRtlLayout = props.direction === 'rtl' && !isTel
|
|
|
|
return (
|
|
<Input
|
|
{...field}
|
|
{...common}
|
|
aria-label={props.ariaLabel}
|
|
classNames={{
|
|
innerWrapper: isRtlLayout ? 'flex-row-reverse' : '',
|
|
input: isRtlLayout ? 'text-right' : isTel ? undefined : 'text-left',
|
|
inputWrapper: props.finalInputWrapper,
|
|
label: 'labelClass',
|
|
}}
|
|
dir={isTel ? 'ltr' : undefined}
|
|
endContent={
|
|
props.inputType === 'password' ? (
|
|
<Button
|
|
iconOnly
|
|
aria-label={passwordVisible ? texts.common.hidePassword : texts.common.showPassword}
|
|
className="rounded-full bg-transparent focus:outline-none"
|
|
color="default"
|
|
size="sm"
|
|
variant="light"
|
|
onClick={() => {
|
|
setPasswordVisible((value) => !value)
|
|
}}
|
|
>
|
|
{passwordVisible ? (
|
|
<EyeCrossedIcon className="size-4 text-text-light-25" />
|
|
) : (
|
|
<EyeIcon className="size-4 text-text-light-25" />
|
|
)}
|
|
</Button>
|
|
) : (
|
|
props.iconEnd
|
|
)
|
|
}
|
|
inputMode={isTel ? 'tel' : props.englishDigitsOnly ? 'numeric' : undefined}
|
|
isClearable={canClear}
|
|
label={buildOutsideLabel(props.label, props.labelIcon ?? null, props.required)}
|
|
max={props.maxValue}
|
|
min={props.minValue}
|
|
pattern={props.englishDigitsOnly ? '[0-9]*' : undefined}
|
|
startContent={props.iconStart}
|
|
type={passwordVisible ? 'text' : props.inputType}
|
|
onBlur={() => {
|
|
field.onBlur()
|
|
props.onBlur?.()
|
|
}}
|
|
onChange={(event) => {
|
|
let value = event.target.value
|
|
|
|
if (props.englishDigitsOnly || isTel) value = convertPersianToEnglish(value).replace(/\D/g, '')
|
|
else if (props.inputType === 'number') value = String(Number(value))
|
|
field.onChange(value)
|
|
}}
|
|
onKeyDown={props.onKeyDown}
|
|
{...(canClear
|
|
? {
|
|
onClear: () => {
|
|
if (props.onClear) {
|
|
props.onClear()
|
|
} else {
|
|
field.onChange('')
|
|
}
|
|
},
|
|
}
|
|
: {})}
|
|
/>
|
|
)
|
|
}}
|
|
/>
|
|
)
|
|
}
|