Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import type { ReactNode } from 'react'
|
|
|
|
import { useState } from 'react'
|
|
import { jalaaliMonthLength, toGregorian, toJalaali } from 'jalaali-js'
|
|
import Picker from 'react-mobile-picker'
|
|
import { Controller, useFormContext, type Control, type UseFormReturn } from 'react-hook-form'
|
|
|
|
import ConsumerModal from '@/components/consumer/ConsumerModal'
|
|
import ConsumerPickerTrigger from '@/components/events/create/ConsumerPickerTrigger'
|
|
import Button from '@/components/formElements/Button'
|
|
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
|
|
import CalendarEditIcon from '@/components/icons/CalendarEditIcon'
|
|
import CalendarIcon from '@/components/icons/CalendarIcon'
|
|
import ClockIcon from '@/components/icons/ClockIcon'
|
|
import CloseLinearIcon from '@/components/icons/CloseLinearIcon'
|
|
import TimeCircleIcon from '@/components/icons/TimeCircleIcon'
|
|
import { buildOutsideLabel } from '@/components/formElements/input/inputUtils'
|
|
import { texts } from '@/texts'
|
|
import { formatNumber } from '@/helpers'
|
|
import { cn } from '@/lib/cn'
|
|
|
|
const HOURS = Array.from({ length: 24 }, (_, index) => String(index))
|
|
const MINUTES = Array.from({ length: 60 }, (_, index) => String(index))
|
|
const MONTHS = Array.from({ length: 12 }, (_, index) => String(index + 1))
|
|
const PERSIAN_MONTHS = texts.common.jalaliMonths
|
|
|
|
export interface DatePickerValue {
|
|
day: string
|
|
month: string
|
|
year: string
|
|
[key: string]: string
|
|
}
|
|
|
|
export interface TimePickerValue {
|
|
hour: string
|
|
minute: string
|
|
[key: string]: string
|
|
}
|
|
|
|
type Variant = 'flat' | 'bordered' | 'faded' | 'underlined'
|
|
|
|
interface Props {
|
|
className?: string
|
|
/** RHF mode: pass control or render under FormProvider. */
|
|
control?: Control
|
|
description?: ReactNode
|
|
disabled?: boolean
|
|
/** Controlled mode (no RHF): inline error text. */
|
|
errorMessage?: string
|
|
kind: 'eventDatePicker' | 'eventTimePicker'
|
|
label?: string
|
|
minDate?: string
|
|
minValue?: number
|
|
name?: string
|
|
placeholder?: string
|
|
readonly?: boolean
|
|
required?: boolean
|
|
size?: 'sm' | 'md' | 'lg'
|
|
triggerStyle?: 'default' | 'consumer'
|
|
/** Controlled date ISO string or time minutes-of-day. */
|
|
value?: string | number
|
|
variant: Variant
|
|
onValueChange?: (value: string | number) => void
|
|
}
|
|
|
|
const toPersianNumber = (value: number | string) => formatNumber(Number(value))
|
|
const padTimePart = (value: number | string) => String(value).padStart(2, '0')
|
|
|
|
function validDateOrToday(value: unknown): Date {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = new Date(value)
|
|
|
|
if (Number.isFinite(parsed.getTime())) return parsed
|
|
}
|
|
|
|
return new Date()
|
|
}
|
|
|
|
export function getEventDatePickerValue(value: unknown): DatePickerValue {
|
|
const { jy, jm, jd } = toJalaali(validDateOrToday(value))
|
|
|
|
return { day: String(jd), month: String(jm), year: String(jy) }
|
|
}
|
|
|
|
export function eventDatePickerValueToIso(value: DatePickerValue, previousValue: unknown): string {
|
|
const previousDate = validDateOrToday(previousValue)
|
|
const fallbackYear = toJalaali(previousDate).jy
|
|
const year = Number(value.year) || fallbackYear
|
|
const month = Number(value.month)
|
|
const day = Math.min(Number(value.day), jalaaliMonthLength(year, month))
|
|
const { gy, gm, gd } = toGregorian(year, month, day)
|
|
|
|
return new Date(gy, gm - 1, gd).toISOString()
|
|
}
|
|
|
|
export function getEventTimePickerValue(value: unknown): TimePickerValue {
|
|
const minutes = typeof value === 'number' && Number.isFinite(value) ? Math.min(1439, Math.max(0, Math.floor(value))) : 0
|
|
|
|
return {
|
|
hour: String(Math.floor(minutes / 60)),
|
|
minute: String(minutes % 60),
|
|
}
|
|
}
|
|
|
|
function dateAtStartOfDay(value: unknown): Date {
|
|
const date = validDateOrToday(value)
|
|
|
|
date.setHours(0, 0, 0, 0)
|
|
|
|
return date
|
|
}
|
|
|
|
export function constrainEventDatePickerValue(value: unknown, minDate: unknown): DatePickerValue {
|
|
const minimum = dateAtStartOfDay(minDate)
|
|
const hasExplicitValue =
|
|
(typeof value === 'string' && value.trim() !== '') || (typeof value === 'number' && Number.isFinite(value)) || value instanceof Date
|
|
const current = hasExplicitValue ? dateAtStartOfDay(value) : minimum
|
|
|
|
return getEventDatePickerValue(current < minimum ? minimum.toISOString() : current.toISOString())
|
|
}
|
|
|
|
function pickerDateToTimestamp(value: DatePickerValue): number {
|
|
const { gy, gm, gd } = toGregorian(Number(value.year), Number(value.month), Number(value.day))
|
|
|
|
return new Date(gy, gm - 1, gd).setHours(0, 0, 0, 0)
|
|
}
|
|
|
|
function WheelItem({ value, label, distance, disabled = false }: { value: string; label: string; distance: number; disabled?: boolean }) {
|
|
return (
|
|
<Picker.Item
|
|
key={value}
|
|
aria-disabled={disabled}
|
|
className="flex items-center justify-center text-center"
|
|
data-disabled={disabled ? 'true' : undefined}
|
|
value={value}
|
|
>
|
|
{({ selected }) => (
|
|
<span
|
|
className={cn(
|
|
'font-medium leading-normal transition-all duration-150',
|
|
selected
|
|
? 'text-xl font-semibold text-consumer-text'
|
|
: distance === 1
|
|
? 'text-lg text-secondary-40'
|
|
: 'text-base text-secondary-40',
|
|
disabled && !selected && 'text-secondary-40'
|
|
)}
|
|
data-selected={selected ? 'true' : undefined}
|
|
>
|
|
{label}
|
|
</span>
|
|
)}
|
|
</Picker.Item>
|
|
)
|
|
}
|
|
|
|
export function DateWheel({
|
|
value,
|
|
minDate,
|
|
onChange,
|
|
}: {
|
|
value: DatePickerValue
|
|
minDate: Date
|
|
onChange: (value: DatePickerValue) => void
|
|
}) {
|
|
const minimum = getEventDatePickerValue(minDate.toISOString())
|
|
const selectedYear = Number(value.year)
|
|
const days = Array.from({ length: jalaaliMonthLength(selectedYear, Number(value.month)) }, (_, index) => String(index + 1))
|
|
|
|
const handleChange = (nextValue: DatePickerValue, key: string) => {
|
|
const month = Number(nextValue.month)
|
|
const year =
|
|
key === 'month' ? (month < Number(minimum.month) ? Number(minimum.year) + 1 : Number(minimum.year)) : Number(nextValue.year)
|
|
const maxDay = jalaaliMonthLength(year, month)
|
|
const minimumDay = year === Number(minimum.year) && month === Number(minimum.month) ? Number(minimum.day) : 1
|
|
|
|
onChange({
|
|
...nextValue,
|
|
day: String(Math.max(minimumDay, Math.min(Number(nextValue.day), maxDay))),
|
|
year: String(year),
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-2"
|
|
dir="rtl"
|
|
>
|
|
<div className="grid grid-cols-[1fr_20px_1fr] text-center text-sm font-medium text-black">
|
|
<span>{texts.common.day}</span>
|
|
<span aria-hidden />
|
|
<span>{texts.common.month}</span>
|
|
</div>
|
|
<WheelFrame>
|
|
<Picker
|
|
className="consumer-wheel-picker"
|
|
height={216}
|
|
itemHeight={44}
|
|
value={value}
|
|
wheelMode="natural"
|
|
onChange={handleChange}
|
|
>
|
|
<Picker.Column name="day">
|
|
{days.map((day, index) => (
|
|
<WheelItem
|
|
key={day}
|
|
disabled={pickerDateToTimestamp({ ...value, day }) < minDate.getTime()}
|
|
distance={Math.abs(index - (Number(value.day) - 1))}
|
|
label={toPersianNumber(day)}
|
|
value={day}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
<div className="flex w-5 items-center justify-center text-xl font-semibold text-black">/</div>
|
|
<Picker.Column name="month">
|
|
{MONTHS.map((month, index) => (
|
|
<WheelItem
|
|
key={month}
|
|
distance={Math.abs(index - (Number(value.month) - 1))}
|
|
label={PERSIAN_MONTHS[Number(month) - 1]}
|
|
value={month}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
</Picker>
|
|
</WheelFrame>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function timePickerValueToMinutes(value: TimePickerValue): number {
|
|
return Number(value.hour) * 60 + Number(value.minute)
|
|
}
|
|
|
|
export function TimeWheel({
|
|
value,
|
|
minValue,
|
|
onChange,
|
|
}: {
|
|
value: TimePickerValue
|
|
minValue: number
|
|
onChange: (value: TimePickerValue) => void
|
|
}) {
|
|
const handleChange = (nextValue: TimePickerValue, key: string) => {
|
|
if (key === 'hour' && timePickerValueToMinutes(nextValue) < minValue) {
|
|
const minimum = getEventTimePickerValue(minValue)
|
|
|
|
onChange(Number(nextValue.hour) === Number(minimum.hour) ? minimum : nextValue)
|
|
|
|
return
|
|
}
|
|
|
|
onChange(nextValue)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-2"
|
|
dir="ltr"
|
|
>
|
|
<div className="grid grid-cols-[1fr_20px_1fr] text-center text-sm font-medium text-black">
|
|
<span>{texts.common.hour}</span>
|
|
<span className="w-5" />
|
|
<span>{texts.common.minute}</span>
|
|
</div>
|
|
<WheelFrame>
|
|
<Picker
|
|
className="consumer-wheel-picker"
|
|
height={216}
|
|
itemHeight={44}
|
|
value={value}
|
|
wheelMode="natural"
|
|
onChange={handleChange}
|
|
>
|
|
<Picker.Column name="hour">
|
|
{HOURS.map((hour, index) => (
|
|
<WheelItem
|
|
key={hour}
|
|
disabled={Number(hour) * 60 + 59 < minValue}
|
|
distance={Math.abs(index - Number(value.hour))}
|
|
label={toPersianNumber(hour)}
|
|
value={hour}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
<div className="flex w-5 items-center justify-center text-xl font-semibold text-black">:</div>
|
|
<Picker.Column name="minute">
|
|
{MINUTES.map((minute, index) => (
|
|
<WheelItem
|
|
key={minute}
|
|
disabled={Number(value.hour) * 60 + Number(minute) < minValue}
|
|
distance={Math.abs(index - Number(value.minute))}
|
|
label={toPersianNumber(minute)}
|
|
value={minute}
|
|
/>
|
|
))}
|
|
</Picker.Column>
|
|
</Picker>
|
|
</WheelFrame>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function WheelFrame({ children }: { children: ReactNode }) {
|
|
return <div className="relative overflow-hidden">{children}</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 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 useOptionalFormControl(controlProp?: Control): Control | null {
|
|
const context = useFormContext() as UseFormReturn | null
|
|
|
|
return controlProp ?? context?.control ?? null
|
|
}
|
|
|
|
function ScheduleFieldView({
|
|
value,
|
|
errorMessage,
|
|
onChange,
|
|
onBlur,
|
|
...props
|
|
}: Omit<Props, 'value' | 'onValueChange' | 'errorMessage' | 'control' | 'name'> & {
|
|
value: unknown
|
|
errorMessage?: string
|
|
onChange: (value: string | number) => void
|
|
onBlur?: () => void
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [dateDraft, setDateDraft] = useState<DatePickerValue>({ day: '1', month: '1', year: '1400' })
|
|
const [timeDraft, setTimeDraft] = useState<TimePickerValue>({ hour: '0', minute: '0' })
|
|
const isDate = props.kind === 'eventDatePicker'
|
|
const invalid = Boolean(errorMessage)
|
|
|
|
const openPicker = () => {
|
|
if (props.disabled || props.readonly) return
|
|
|
|
if (isDate) {
|
|
setDateDraft(constrainEventDatePickerValue(value, props.minDate))
|
|
} else {
|
|
const minimum = Math.min(1440, Math.max(0, props.minValue ?? 0))
|
|
const current = typeof value === 'number' ? value : minimum
|
|
|
|
setTimeDraft(getEventTimePickerValue(Math.max(current, minimum)))
|
|
}
|
|
setIsOpen(true)
|
|
}
|
|
|
|
const accept = () => {
|
|
if (isDate) {
|
|
onChange(eventDatePickerValueToIso(dateDraft, value))
|
|
} else {
|
|
onChange(Number(timeDraft.hour) * 60 + Number(timeDraft.minute))
|
|
}
|
|
onBlur?.()
|
|
setIsOpen(false)
|
|
}
|
|
|
|
let displayValue = ''
|
|
|
|
if (isDate && typeof value === 'string' && value.trim()) {
|
|
const selected = getEventDatePickerValue(value)
|
|
|
|
displayValue = `${toPersianNumber(selected.day)} ${PERSIAN_MONTHS[Number(selected.month) - 1]}`
|
|
} else if (!isDate && typeof value === 'number') {
|
|
const selected = getEventTimePickerValue(value)
|
|
|
|
displayValue = `${toPersianNumber(padTimePart(selected.hour))}:${toPersianNumber(padTimePart(selected.minute))}`
|
|
}
|
|
|
|
const minimumDate = dateAtStartOfDay(props.minDate)
|
|
const minimumTime = Math.min(1440, Math.max(0, props.minValue ?? 0))
|
|
const draftIsValid = isDate
|
|
? pickerDateToTimestamp(dateDraft) >= minimumDate.getTime()
|
|
: timePickerValueToMinutes(timeDraft) >= minimumTime
|
|
|
|
return (
|
|
<div className={cn('relative flex flex-col gap-1', props.className)}>
|
|
<div className={cn('labelClass flex items-center', invalid && '!text-fourth-900')}>
|
|
{buildOutsideLabel(props.label, isDate ? <CalendarIcon className="size-4" /> : <ClockIcon className="size-4" />, props.required, 0)}
|
|
</div>
|
|
{props.triggerStyle === 'consumer' ? (
|
|
<ConsumerPickerTrigger
|
|
aria-label={props.label ?? (isDate ? texts.common.selectDate : texts.common.selectTime)}
|
|
className={cn(invalid && 'ring-1 ring-danger')}
|
|
disabled={props.disabled}
|
|
onClick={openPicker}
|
|
>
|
|
<span className={displayValue ? undefined : 'text-tertiary-900/70'}>
|
|
{displayValue || props.placeholder || (isDate ? texts.common.selectDate : texts.common.selectTime)}
|
|
</span>
|
|
</ConsumerPickerTrigger>
|
|
) : (
|
|
<Button
|
|
fullWidth
|
|
aria-label={props.label ?? (isDate ? texts.common.selectDate : texts.common.selectTime)}
|
|
className={triggerClasses(props.size, props.variant, invalid)}
|
|
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">{isDate ? <CalendarIcon className="size-4" /> : <ClockIcon className="size-4" />}</span>
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{errorMessage ? <p className="text-xs text-fourth-900">{errorMessage}</p> : null}
|
|
{props.description ? <p className="text-tiny text-default-500">{props.description}</p> : null}
|
|
|
|
<ConsumerModal
|
|
bodyClassName="overflow-hidden"
|
|
cancelIcon={isDate ? <CloseLinearIcon /> : <AngleLeftIcon />}
|
|
cancelLabel={isDate ? texts.common.cancel : texts.common.back}
|
|
isOpen={isOpen}
|
|
submitDisabled={!draftIsValid}
|
|
submitLabel="انتخاب"
|
|
title={props.label ?? (isDate ? texts.common.selectDate : texts.common.selectTime)}
|
|
titleIcon={isDate ? <CalendarEditIcon className="size-4" /> : <TimeCircleIcon className="size-4" />}
|
|
onOpenChange={setIsOpen}
|
|
onSubmit={accept}
|
|
>
|
|
{isDate ? (
|
|
<DateWheel
|
|
minDate={minimumDate}
|
|
value={dateDraft}
|
|
onChange={setDateDraft}
|
|
/>
|
|
) : (
|
|
<TimeWheel
|
|
minValue={minimumTime}
|
|
value={timeDraft}
|
|
onChange={setTimeDraft}
|
|
/>
|
|
)}
|
|
</ConsumerModal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function EventScheduleFieldControl(props: Props) {
|
|
const control = useOptionalFormControl(props.control)
|
|
|
|
if (props.onValueChange) {
|
|
return (
|
|
<ScheduleFieldView
|
|
{...props}
|
|
errorMessage={props.errorMessage}
|
|
value={props.value}
|
|
onChange={props.onValueChange}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (!control || !props.name) {
|
|
throw new Error('EventScheduleFieldControl needs FormProvider/control+name, or onValueChange')
|
|
}
|
|
|
|
return (
|
|
<Controller
|
|
control={control}
|
|
name={props.name}
|
|
render={({ field, fieldState: { error } }) => (
|
|
<ScheduleFieldView
|
|
{...props}
|
|
errorMessage={error?.message}
|
|
value={field.value as string | number | undefined}
|
|
onBlur={field.onBlur}
|
|
onChange={(nextValue) => {
|
|
field.onChange(nextValue)
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
}
|