Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import type { ReactNode } from 'react'
|
||
|
||
import { Controller, type Control } from 'react-hook-form'
|
||
|
||
import { Autocomplete, AutocompleteItem } from '@/components/heroui/Autocomplete'
|
||
import { Checkbox, Radio, RadioGroup, Switch } from '@/components/heroui/SelectionControls'
|
||
import { InputOtp } from '@/components/heroui/InputOtp'
|
||
import { Select, SelectItem } from '@/components/heroui/Select'
|
||
import { cn } from '@/lib/cn'
|
||
import { texts } from '@/texts'
|
||
import { buildOutsideLabel, getOptionValue, type SelectOptionItem } from '@/components/formElements/input/inputUtils'
|
||
import { coerceToString } from '@/helpers'
|
||
import { useDialogPortalContainer } from '@/components/formElements/input/useDialogPortalContainer'
|
||
|
||
type Kind = 'otp' | 'combobox' | 'select' | 'radio' | 'checkbox' | 'switch'
|
||
interface Props {
|
||
ariaLabel?: string
|
||
autoFocus?: boolean
|
||
className?: string
|
||
control: Control
|
||
description?: ReactNode
|
||
disabled?: boolean
|
||
finalInputWrapper: string
|
||
getValues: (name: string) => unknown
|
||
iconEnd?: ReactNode
|
||
iconStart?: ReactNode
|
||
inputType?: 'text' | 'email' | 'number' | 'password' | 'tel'
|
||
kind: Kind
|
||
label?: string
|
||
loading: boolean
|
||
multiple?: boolean
|
||
name: string
|
||
options: SelectOptionItem[]
|
||
orientation: 'horizontal' | 'vertical'
|
||
otpLength: number
|
||
placeholder?: string
|
||
radius: 'none' | 'sm' | 'md' | 'lg' | 'full'
|
||
readonly?: boolean
|
||
required?: boolean
|
||
searchOptions: SelectOptionItem[]
|
||
selectKey: string
|
||
selectValue: string
|
||
size?: 'sm' | 'md' | 'lg'
|
||
variant: 'flat' | 'bordered' | 'faded' | 'underlined'
|
||
onSearch: (value: string) => void
|
||
}
|
||
|
||
function OutsideLabel({ invalid, label, required }: { invalid: boolean; label?: string; required?: boolean }) {
|
||
if (!label) return null
|
||
|
||
return <span className={cn('labelClass mb-1 block', invalid && '!text-fourth')}>{buildOutsideLabel(label, null, required)}</span>
|
||
}
|
||
|
||
export default function ChoiceFieldControl(props: Props) {
|
||
const { portalContainer, setHostEl } = useDialogPortalContainer()
|
||
|
||
return (
|
||
<Controller
|
||
control={props.control}
|
||
name={props.name}
|
||
render={({ field, fieldState: { error } }) => {
|
||
const invalid = !!error
|
||
const ariaLabel = props.ariaLabel ?? props.label ?? props.name
|
||
|
||
if (props.kind === 'otp') {
|
||
return (
|
||
<div className="relative flex flex-col">
|
||
<div className="labelClass">{buildOutsideLabel(props.label, null, props.required)}</div>
|
||
<InputOtp
|
||
{...field}
|
||
fullWidth
|
||
autoComplete="one-time-code"
|
||
autoFocus={props.autoFocus}
|
||
className={props.className}
|
||
classNames={{ segmentWrapper: 'flex-row-reverse' }}
|
||
description={props.description}
|
||
errorMessage={error?.message}
|
||
inputMode="numeric"
|
||
isDisabled={props.disabled}
|
||
isInvalid={invalid}
|
||
isReadOnly={props.readonly}
|
||
isRequired={props.required}
|
||
length={props.otpLength}
|
||
placeholder={props.placeholder}
|
||
radius={props.radius}
|
||
size={props.size}
|
||
validationBehavior="aria"
|
||
variant={props.variant}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
if (props.kind === 'combobox') {
|
||
return (
|
||
<div
|
||
ref={setHostEl}
|
||
className={cn('flex flex-col', props.className)}
|
||
>
|
||
<OutsideLabel
|
||
invalid={invalid}
|
||
label={props.label}
|
||
required={props.required}
|
||
/>
|
||
<Autocomplete
|
||
{...field}
|
||
aria-label={ariaLabel}
|
||
autoFocus={props.autoFocus}
|
||
className="w-full"
|
||
defaultItems={props.searchOptions}
|
||
description={props.description}
|
||
endContent={props.iconEnd}
|
||
errorMessage={error?.message}
|
||
isDisabled={props.disabled}
|
||
isInvalid={invalid}
|
||
isLoading={props.loading}
|
||
isReadOnly={props.readonly}
|
||
listboxProps={{ emptyContent: texts.common.emptyChoice }}
|
||
placeholder={props.placeholder}
|
||
popoverProps={{
|
||
placement: 'bottom-start',
|
||
portalContainer,
|
||
shouldBlockScroll: false,
|
||
classNames: { content: 'max-h-[300px] overflow-y-auto' },
|
||
}}
|
||
radius={props.radius}
|
||
selectedKey={field.value}
|
||
size={props.size}
|
||
startContent={props.iconStart}
|
||
type={props.inputType}
|
||
validationBehavior="aria"
|
||
variant={props.variant}
|
||
onInputChange={props.onSearch}
|
||
onSelectionChange={(value) => {
|
||
if (props.multiple) {
|
||
const current = props.getValues(field.name)
|
||
|
||
field.onChange([...(Array.isArray(current) ? current : []), value])
|
||
} else field.onChange(value)
|
||
}}
|
||
>
|
||
{(item) => (
|
||
<AutocompleteItem
|
||
key={coerceToString(getOptionValue(item, props.selectKey))}
|
||
textValue={String(getOptionValue(item, props.selectValue) || getOptionValue(item, props.selectKey))}
|
||
>
|
||
{coerceToString(getOptionValue(item, props.selectValue))}
|
||
</AutocompleteItem>
|
||
)}
|
||
</Autocomplete>
|
||
</div>
|
||
)
|
||
}
|
||
if (props.kind === 'select') {
|
||
const selectedKeys = props.multiple
|
||
? field.value
|
||
: field.value !== undefined && field.value !== null && field.value !== ''
|
||
? [String(field.value)]
|
||
: []
|
||
|
||
return (
|
||
<div
|
||
ref={setHostEl}
|
||
className={cn('flex flex-col', props.className)}
|
||
>
|
||
<OutsideLabel
|
||
invalid={invalid}
|
||
label={props.label}
|
||
required={props.required}
|
||
/>
|
||
<Select
|
||
ref={field.ref}
|
||
aria-label={ariaLabel}
|
||
autoFocus={props.autoFocus}
|
||
className="w-full"
|
||
classNames={{ trigger: props.finalInputWrapper }}
|
||
description={props.description}
|
||
endContent={props.iconEnd}
|
||
errorMessage={error?.message}
|
||
isDisabled={props.disabled}
|
||
isInvalid={invalid}
|
||
name={field.name}
|
||
placeholder={props.placeholder}
|
||
popoverProps={{
|
||
placement: 'bottom-start',
|
||
portalContainer,
|
||
shouldBlockScroll: false,
|
||
classNames: { content: 'max-h-[300px] overflow-y-auto' },
|
||
}}
|
||
radius={props.radius}
|
||
selectedKeys={selectedKeys}
|
||
selectionMode={props.multiple ? 'multiple' : 'single'}
|
||
size={props.size}
|
||
startContent={props.iconStart}
|
||
validationBehavior="aria"
|
||
variant={props.variant}
|
||
onBlur={field.onBlur}
|
||
onSelectionChange={(keys) => {
|
||
if (props.multiple) field.onChange(keys)
|
||
else if (keys === 'all') field.onChange('')
|
||
else field.onChange(String(Array.from(keys as Set<React.Key>)[0] ?? ''))
|
||
}}
|
||
>
|
||
{props.options.map((item, index) => {
|
||
const optionKey = coerceToString(getOptionValue(item, props.selectKey)) || `select-item-${index}`
|
||
const optionLabel = coerceToString(getOptionValue(item, props.selectValue))
|
||
const depth = Number(getOptionValue(item, 'depth') ?? 0)
|
||
|
||
return (
|
||
<SelectItem
|
||
key={optionKey}
|
||
textValue={optionLabel}
|
||
>
|
||
{depth > 0 ? (
|
||
<div
|
||
className="flex items-center gap-2"
|
||
style={{ paddingRight: depth * 20 }}
|
||
>
|
||
<span className="select-none text-tertiary-300">–</span>
|
||
<span className="truncate">{optionLabel}</span>
|
||
</div>
|
||
) : (
|
||
optionLabel
|
||
)}
|
||
</SelectItem>
|
||
)
|
||
})}
|
||
</Select>
|
||
</div>
|
||
)
|
||
}
|
||
if (props.kind === 'radio') {
|
||
return (
|
||
<RadioGroup
|
||
{...field}
|
||
className={props.className}
|
||
classNames={{ label: `labelClass ${invalid ? '!text-fourth' : 'text-secondary-10'}` }}
|
||
description={props.description}
|
||
errorMessage={error?.message}
|
||
isDisabled={props.disabled}
|
||
isInvalid={invalid}
|
||
isReadOnly={props.readonly}
|
||
isRequired={props.required}
|
||
label={props.label}
|
||
orientation={props.orientation}
|
||
size={props.size}
|
||
validationBehavior="aria"
|
||
value={field.value}
|
||
onValueChange={field.onChange}
|
||
>
|
||
{props.options.map((item) => (
|
||
<Radio
|
||
key={coerceToString(getOptionValue(item, props.selectKey) ?? getOptionValue(item, 'name'))}
|
||
value={coerceToString(getOptionValue(item, props.selectKey))}
|
||
>
|
||
{coerceToString(getOptionValue(item, 'name'))}
|
||
</Radio>
|
||
))}
|
||
</RadioGroup>
|
||
)
|
||
}
|
||
if (props.kind === 'checkbox') {
|
||
return (
|
||
<Checkbox
|
||
{...field}
|
||
className={props.className}
|
||
classNames={{ label: 'labelClass' }}
|
||
isDisabled={props.disabled}
|
||
isInvalid={invalid}
|
||
isReadOnly={props.readonly}
|
||
isSelected={field.value}
|
||
size={props.size}
|
||
onValueChange={field.onChange}
|
||
>
|
||
{props.label}
|
||
</Checkbox>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Switch
|
||
className={props.className}
|
||
classNames={{ label: 'labelClass' }}
|
||
isDisabled={props.disabled}
|
||
isSelected={field.value}
|
||
name={field.name}
|
||
size={props.size}
|
||
onBlur={field.onBlur}
|
||
onValueChange={field.onChange}
|
||
>
|
||
{props.label}
|
||
</Switch>
|
||
)
|
||
}}
|
||
/>
|
||
)
|
||
}
|