admin/components/formElements/input/JalaliBirthDateFieldControl.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

225 lines
7.5 KiB
TypeScript

'use client'
import type { ReactNode } from 'react'
import type { Control } from 'react-hook-form'
import { useState } from 'react'
import { jalaaliMonthLength } from 'jalaali-js'
import Picker from 'react-mobile-picker'
import { Controller } from 'react-hook-form'
import Button from '@/components/formElements/Button'
import CalendarIcon from '@/components/icons/CalendarIcon'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import { buildOutsideLabel } from '@/components/formElements/input/inputUtils'
import {
PERSIAN_MONTHS,
birthDateYears,
clampJalaliDateParts,
defaultBirthDateParts,
isoDateToJalaliParts,
jalaliPartsToIsoDate,
type JalaliDateParts,
} from '@/lib/birthDate'
import { cn } from '@/lib/cn'
import { texts } from '@/texts'
type Variant = 'flat' | 'bordered' | 'faded' | 'underlined'
interface Props {
className?: string
control: Control
description?: ReactNode
disabled?: boolean
label?: string
name: string
placeholder?: string
readonly?: boolean
required?: boolean
size?: 'sm' | 'md' | 'lg'
variant: Variant
}
const MONTHS = Array.from({ length: 12 }, (_, index) => String(index + 1))
const YEARS = birthDateYears()
const toPersianNumber = (value: number | string) => Number(value).toLocaleString('fa-IR', { useGrouping: false })
function WheelItem({ value, label }: { value: string; label: string }) {
return (
<Picker.Item
key={value}
className="flex items-center justify-center text-center"
value={value}
>
{({ selected }) => (
<span className={cn('transition-all duration-150', selected ? 'text-lg font-bold text-primary' : 'text-sm text-default-400')}>
{label}
</span>
)}
</Picker.Item>
)
}
function WheelFrame({ children }: { children: ReactNode }) {
return (
<div className="relative overflow-hidden rounded-2xl bg-default-50">
<div
aria-hidden
className="pointer-events-none absolute inset-x-3 top-1/2 z-0 h-11 -translate-y-1/2 rounded-xl border-y border-primary/20 bg-primary/5"
/>
<div className="relative z-10">{children}</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-20 h-16 bg-gradient-to-b from-default-50 to-transparent"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-16 bg-gradient-to-t from-default-50 to-transparent"
/>
</div>
)
}
function BirthDateWheel({ value, onChange }: { value: JalaliDateParts; onChange: (value: JalaliDateParts) => void }) {
const days = Array.from({ length: jalaaliMonthLength(Number(value.year), Number(value.month)) }, (_, index) => String(index + 1))
return (
<div>
<div className="mb-2 grid grid-cols-3 text-center text-xs font-medium text-default-500">
<span>{texts.common.day}</span>
<span>{texts.common.month}</span>
<span>{texts.common.year}</span>
</div>
<WheelFrame>
<Picker
height={216}
itemHeight={44}
value={value}
wheelMode="natural"
onChange={(nextValue: JalaliDateParts) => {
onChange(clampJalaliDateParts(nextValue))
}}
>
<Picker.Column name="day">
{days.map((day) => (
<WheelItem
key={day}
label={toPersianNumber(day)}
value={day}
/>
))}
</Picker.Column>
<Picker.Column name="month">
{MONTHS.map((month) => (
<WheelItem
key={month}
label={PERSIAN_MONTHS[Number(month) - 1]}
value={month}
/>
))}
</Picker.Column>
<Picker.Column name="year">
{YEARS.map((year) => (
<WheelItem
key={year}
label={toPersianNumber(year)}
value={year}
/>
))}
</Picker.Column>
</Picker>
</WheelFrame>
</div>
)
}
function triggerClasses(size: Props['size'], variant: Variant, invalid: boolean) {
return cn(
'flex w-full items-center justify-between gap-3 px-3 text-right text-sm font-normal outline-none transition',
size === 'sm' ? 'h-9' : size === 'lg' ? 'h-12' : 'h-10',
variant === 'underlined' ? 'rounded-none border-b bg-transparent' : 'rounded-xl',
variant === 'bordered' && 'border bg-transparent',
variant === 'faded' && 'border bg-default-100',
variant === 'flat' && 'bg-default-100',
invalid ? 'border-fourth-900 text-fourth-900' : 'border-default-200 text-text-main',
'disabled:cursor-not-allowed disabled:opacity-50'
)
}
function formatSelectedLabel(value: JalaliDateParts) {
return `${toPersianNumber(value.day)} ${PERSIAN_MONTHS[Number(value.month) - 1]} ${toPersianNumber(value.year)}`
}
export default function JalaliBirthDateFieldControl(props: Props) {
const [isOpen, setIsOpen] = useState(false)
const [draft, setDraft] = useState<JalaliDateParts>(defaultBirthDateParts())
return (
<Controller
control={props.control}
name={props.name}
render={({ field, fieldState: { error } }) => {
const selected = isoDateToJalaliParts(field.value)
const displayValue = selected ? formatSelectedLabel(selected) : ''
const openPicker = () => {
if (props.disabled || props.readonly) return
setDraft(selected ?? defaultBirthDateParts())
setIsOpen(true)
}
const accept = () => {
field.onChange(jalaliPartsToIsoDate(draft))
field.onBlur()
setIsOpen(false)
}
return (
<div className={cn('relative flex flex-col gap-1', props.className)}>
<div className={cn('labelClass flex items-center', error && '!text-fourth-900')}>
{buildOutsideLabel(props.label, <CalendarIcon className="size-4" />, props.required, 0)}
</div>
<Button
fullWidth
aria-label={props.label ?? texts.common.selectBirthDate}
className={triggerClasses(props.size, props.variant, Boolean(error))}
disabled={props.disabled}
variant="bordered"
onClick={openPicker}
>
<span className="flex w-full items-center justify-between gap-2">
<span className={displayValue ? undefined : 'text-default-400'}>{displayValue || props.placeholder}</span>
<span className="text-default-400">
<CalendarIcon className="size-4" />
</span>
</span>
</Button>
{error ? <p className="text-tiny text-fourth-900">{error.message}</p> : null}
{props.description ? <p className="text-tiny text-default-500">{props.description}</p> : null}
<ConsumerModal
acceptBtnText={texts.common.confirm}
bodyClassName="flex-none overflow-hidden"
className="h-auto px-4"
isOpen={isOpen}
rejectBtnText={texts.common.cancel}
size="md"
title={props.label ?? texts.common.birthDate}
onAccept={accept}
onOpenChange={setIsOpen}
onReject={() => {
setIsOpen(false)
}}
>
<BirthDateWheel
value={draft}
onChange={setDraft}
/>
</ConsumerModal>
</div>
)
}}
/>
)
}