Added house function: add, update, view (admin function)

update npm package
This commit is contained in:
2026-02-05 21:10:45 +07:00
parent 018f693998
commit 7b14b30320
104 changed files with 3447 additions and 2518 deletions

View File

@@ -0,0 +1,184 @@
'use client';
import { cn } from '@lib/utils';
import { CaretDownIcon, MagnifyingGlassIcon } from '@phosphor-icons/react';
import { useCallback, useEffect, useRef, useState } from 'react';
export type SelectUserItem = {
id: string;
name: string;
email: string;
};
const userLabel = (u: { name: string; email: string }) =>
`${u.name} - ${u.email}`;
export type SelectUserProps = {
value: string;
onValueChange: (userId: string) => void;
values: SelectUserItem[];
placeholder?: string;
/** Khi truyền cùng onKeywordChange: tìm kiếm theo API (keyword gửi lên server) */
keyword?: string;
onKeywordChange?: (value: string) => void;
searchPlaceholder?: string;
name?: string;
id?: string;
'aria-invalid'?: boolean;
disabled?: boolean;
className?: string;
};
export function SelectUser({
value,
onValueChange,
values,
placeholder,
keyword,
onKeywordChange,
searchPlaceholder = 'Tìm theo tên hoặc email...',
name,
id,
'aria-invalid': ariaInvalid,
disabled = false,
className,
}: SelectUserProps) {
const [open, setOpen] = useState(false);
const [localQuery, setLocalQuery] = useState('');
const wrapperRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const useServerSearch = keyword !== undefined && onKeywordChange != null;
const searchValue = useServerSearch ? keyword : localQuery;
const setSearchValue = useServerSearch ? onKeywordChange! : setLocalQuery;
const selectedUser =
value != null && value !== '' ? values.find((u) => u.id === value) : null;
const displayValue = selectedUser ? userLabel(selectedUser) : '';
const filtered = useServerSearch
? values
: (() => {
const q = localQuery.trim().toLowerCase();
return q === ''
? values
: values.filter(
(u) =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q),
);
})();
const close = useCallback(() => {
setOpen(false);
if (!useServerSearch) setLocalQuery('');
}, [useServerSearch]);
useEffect(() => {
if (!open) return;
searchInputRef.current?.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
close();
}
};
document.addEventListener('mousedown', onMouseDown);
return () => document.removeEventListener('mousedown', onMouseDown);
}, [open, close]);
const handleSelect = (userId: string) => {
onValueChange(userId);
close();
};
const controlId = id ?? name;
const listboxId = controlId ? `${controlId}-listbox` : undefined;
return (
<div ref={wrapperRef} className={cn('relative', className)}>
{name != null && (
<input type="hidden" name={name} value={value} readOnly />
)}
<div
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-invalid={ariaInvalid}
aria-controls={open ? listboxId : undefined}
aria-disabled={disabled}
id={controlId}
className={cn(
'border-input bg-input/20 dark:bg-input/30 flex h-7 w-full cursor-pointer items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs/relaxed outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50',
)}
onClick={() => !disabled && setOpen((o) => !o)}
>
<span className="min-w-0 flex-1 truncate text-left">
{displayValue || (
<span className="text-muted-foreground">{placeholder}</span>
)}
</span>
<CaretDownIcon
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-transform',
open && 'rotate-180',
)}
/>
</div>
{open && (
<div
id={listboxId}
role="listbox"
className="border-input bg-popover text-popover-foreground absolute top-full z-50 mt-1 max-h-60 w-full min-w-[var(--radix-popper-anchor-width)] overflow-hidden rounded-lg border shadow-md"
>
<div className="border-input flex items-center gap-1 border-b px-2 py-1">
<MagnifyingGlassIcon className="text-muted-foreground size-3.5 shrink-0" />
<input
ref={searchInputRef}
type="search"
autoComplete="off"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
className="min-w-0 flex-1 bg-transparent py-1 text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-52 overflow-y-auto p-1">
{filtered.length === 0 ? (
<div className="text-muted-foreground py-4 text-center text-xs">
Không kết quả
</div>
) : (
filtered.map((u) => (
<button
key={u.id}
type="button"
role="option"
aria-selected={value === u.id}
className={cn(
'hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-xs/relaxed outline-none',
value === u.id && 'bg-accent text-accent-foreground',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(u.id);
}}
>
{userLabel(u)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}