feat(frontend): add async admin form foundations

This commit is contained in:
2026-06-14 00:04:22 +03:30
parent 25bd46ea2a
commit 9080b0caea
9 changed files with 668 additions and 80 deletions

View File

@@ -0,0 +1,96 @@
"use client";
import * as React from "react";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import DatePicker from "react-multi-date-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type AdminDateTimeFieldProps = {
label: string;
value?: string | null;
onChange: (value: string | null) => void;
required?: boolean;
disabled?: boolean;
};
function splitDateTime(value?: string | null) {
if (!value) {
return { date: null as DateObject | null, time: "" };
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return { date: null, time: "" };
}
return {
date: new DateObject({ date, calendar: persian, locale: persian_fa }),
time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`,
};
}
function combineDateTime(date: DateObject | null, time: string) {
if (!date || !time || !/^\d{2}:\d{2}$/.test(time)) return null;
const gregorian = date.toDate();
const [hours, minutes] = time.split(":").map(Number);
gregorian.setHours(hours, minutes, 0, 0);
return gregorian.toISOString();
}
export default function AdminDateTimeField({
label,
value,
onChange,
required,
disabled,
}: AdminDateTimeFieldProps) {
const initial = React.useMemo(() => splitDateTime(value), [value]);
const [date, setDate] = React.useState<DateObject | null>(initial.date);
const [time, setTime] = React.useState(initial.time);
React.useEffect(() => {
setDate(initial.date);
setTime(initial.time);
}, [initial.date, initial.time]);
const emitChange = (nextDate: DateObject | null, nextTime: string) => {
onChange(combineDateTime(nextDate, nextTime));
};
return (
<div className="space-y-2">
<Label>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</Label>
<div className="grid gap-2 sm:grid-cols-[1fr_120px]">
<DatePicker
value={date}
onChange={(next) => {
const nextDate = next instanceof DateObject ? next : null;
setDate(nextDate);
emitChange(nextDate, time);
}}
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-right"
disabled={disabled}
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="تاریخ"
containerClassName="w-full"
/>
<Input
dir="ltr"
type="time"
value={time}
disabled={disabled}
onChange={(event) => {
setTime(event.target.value);
emitChange(date, event.target.value);
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export type AsyncComboboxOption = {
value: string;
label: string;
description?: string;
};
type AsyncSearchableComboboxProps = {
value?: string | null;
onChange: (value: string | null) => void;
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
count: number;
results: AsyncComboboxOption[];
}>;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
allowClear?: boolean;
pageSize?: number;
className?: string;
};
export default function AsyncSearchableCombobox({
value,
onChange,
loadOptions,
placeholder = "انتخاب کنید",
searchPlaceholder = "جستجو...",
emptyText = "موردی پیدا نشد.",
disabled = false,
allowClear = true,
pageSize = 20,
className,
}: AsyncSearchableComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [options, setOptions] = React.useState<AsyncComboboxOption[]>([]);
const [count, setCount] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
React.useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
const selected = React.useMemo(
() => options.find((option) => option.value === value),
[options, value],
);
const fetchPage = React.useCallback(
async (offset: number, append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const data = await loadOptions({ search: debouncedSearch, limit: pageSize, offset });
setCount(data.count);
setOptions((current) => {
const next = append ? [...current, ...data.results] : data.results;
const byValue = new Map(next.map((option) => [option.value, option]));
return Array.from(byValue.values());
});
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[debouncedSearch, loadOptions, pageSize],
);
React.useEffect(() => {
if (!open) return;
void fetchPage(0);
}, [fetchPage, open]);
const hasMore = options.length < count;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between gap-2", className)}
>
<span className="truncate text-right">{selected?.label || value || placeholder}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" dir="rtl">
<Command shouldFilter={false}>
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
<CommandList>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
در حال جستجو...
</div>
) : (
<>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{allowClear ? (
<CommandItem
value="__clear"
onSelect={() => {
onChange(null);
setOpen(false);
}}
>
<Check className={cn("ml-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
همه موارد
</CommandItem>
) : null}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onChange(option.value === value ? null : option.value);
setOpen(false);
}}
className="flex items-center justify-between gap-2"
>
<span className="min-w-0 flex-1 text-right">
<span className="block truncate">{option.label}</span>
{option.description ? (
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
<Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
</CommandItem>
))}
</CommandGroup>
{hasMore ? (
<div className="p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
disabled={loadingMore}
onClick={() => void fetchPage(options.length, true)}
>
{loadingMore ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
بارگذاری بیشتر
</Button>
</div>
) : null}
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}