chore(env): update application version to 2.3.1 in production environment

feat(customers): enhance customer list filters with lazy-loaded company options

- Added InputGroup components for searching by full name and email in CustomerListFilters.
- Integrated Select component for company selection with lazy-loading capabilities.
- Implemented company search and load more functionality using useCompaniesInfiniteQuery.
- Updated useCustomerListPresenter to manage company search state and loading indicators.
- Enhanced customer list filtering experience by allowing dynamic company selection.
This commit is contained in:
AmirReza Jamali
2026-06-08 12:48:04 +03:30
parent 72948a9b55
commit a62963a93d
5 changed files with 207 additions and 44 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
NEXT_PUBLIC_APP_VERSION=2.3.0
NEXT_PUBLIC_APP_VERSION=2.3.1
NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com
+94 -41
View File
@@ -133,6 +133,20 @@ type PropsType<T extends FieldValues> = {
searchQueryKey?: readonly unknown[];
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
searchFn?: (query: string) => Promise<ILabelValue[]>;
/**
* External lazy-load mode: the parent owns `items`, searching and pagination.
* Providing `onSearch` and/or `onLoadMore` opts in — the dropdown renders a search
* box, streams `items` as-is and requests more on scroll. Pairs with an infinite query.
*/
onSearch?: (query: string) => void;
/** Fetch the next page; called when the option list is scrolled near its end. */
onLoadMore?: () => void;
/** Whether more pages are available to lazy-load. */
hasMore?: boolean;
/** Initial / search fetch in progress (external mode). */
isLoading?: boolean;
/** Next-page fetch in progress (external mode). */
isLoadingMore?: boolean;
} & (
| { placeholder?: string; defaultValue?: string }
| { placeholder: string; defaultValue?: string }
@@ -163,11 +177,22 @@ export function Select<T extends FieldValues>({
searchDebounceMs = 300,
searchQueryKey,
searchFn,
onSearch,
onLoadMore,
hasMore = false,
isLoading = false,
isLoadingMore = false,
register: _register,
...props
}: PropsType<T>) {
void _register;
/** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */
const isExternal =
typeof onSearch === "function" || typeof onLoadMore === "function";
/** Both `searchable` and external mode render the in-dropdown search box. */
const showSearch = searchable || isExternal;
if (
process.env.NODE_ENV !== "production" &&
searchable &&
@@ -186,6 +211,7 @@ export function Select<T extends FieldValues>({
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
const [value, setValue] = useState(defaultValue || "");
@@ -221,10 +247,21 @@ export function Select<T extends FieldValues>({
});
const listItems = useMemo(() => {
if (isExternal) return items;
if (!searchable) return items;
if (isServerMode && serverDisplayItems) return serverDisplayItems;
return filterSelectItems(items, query);
}, [searchable, isServerMode, serverDisplayItems, items, query]);
}, [isExternal, searchable, isServerMode, serverDisplayItems, items, query]);
/** Lazy-load the next page once the option list nears its bottom (external mode). */
const handleListScroll = useCallback(() => {
if (!isExternal || !onLoadMore || !hasMore || isLoadingMore) return;
const el = listRef.current;
if (!el) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
onLoadMore();
}
}, [isExternal, onLoadMore, hasMore, isLoadingMore]);
const selectedLabel = useMemo(() => {
const hit = listItems.find((i) => String(i.value) === String(value));
@@ -287,13 +324,13 @@ export function Select<T extends FieldValues>({
}, [open, listboxId]);
useEffect(() => {
if (!searchable || !open) return;
if (!showSearch || !open) return;
const idFrame = window.requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
return () => window.cancelAnimationFrame(idFrame);
}, [open, searchable]);
}, [open, showSearch]);
const emitChange = (next: string) => {
if (controlledValue === undefined) {
@@ -323,7 +360,9 @@ export function Select<T extends FieldValues>({
triggerRef.current?.focus();
};
const dropdownItems = searchable ? listItems : items;
const dropdownItems = showSearch ? listItems : items;
/** Mono value chip next to labels only helps the client/server search modes, not external lists. */
const showItemValue = searchable && !isExternal;
const dropdown =
mounted &&
@@ -345,7 +384,7 @@ export function Select<T extends FieldValues>({
panelRect.placement === "above" ? "translateY(-100%)" : undefined,
}}
>
{searchable ? (
{showSearch ? (
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
<label htmlFor={`${listboxId}-search`} className="sr-only">
{searchPlaceholder}
@@ -355,7 +394,10 @@ export function Select<T extends FieldValues>({
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
if (isExternal) onSearch?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
@@ -372,51 +414,62 @@ export function Select<T extends FieldValues>({
/>
</div>
) : null}
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5">
{searchable && isSearchLoading ? (
<ul
ref={listRef}
onScroll={isExternal ? handleListScroll : undefined}
className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5"
>
{(isExternal ? isLoading : searchable && isSearchLoading) ? (
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
Searching
{isExternal ? "Loading…" : "Searching…"}
</li>
) : searchable && isSearchError ? (
) : !isExternal && searchable && isSearchError ? (
<li className="px-3 py-6 text-center text-sm text-error">
Could not load options. Try again.
</li>
) : dropdownItems.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
{searchable ? "No matches." : "No options."}
{showSearch ? "No matches." : "No options."}
</li>
) : (
dropdownItems.map((item) => {
const v = String(item.value);
const isActive = v === String(value);
return (
<li key={v}>
<button
type="button"
role="option"
aria-selected={isActive}
data-cy={dataCy ? `${dataCy}-option` : undefined}
data-value={v}
onClick={() => pickItem(v)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
searchable ? "justify-between" : "justify-start",
isActive
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
)}
>
<span className="min-w-0 truncate">{item.label}</span>
{searchable ? (
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
{v}
</span>
) : null}
</button>
<>
{dropdownItems.map((item) => {
const v = String(item.value);
const isActive = v === String(value);
return (
<li key={v}>
<button
type="button"
role="option"
aria-selected={isActive}
data-cy={dataCy ? `${dataCy}-option` : undefined}
data-value={v}
onClick={() => pickItem(v)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
showItemValue ? "justify-between" : "justify-start",
isActive
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
)}
>
<span className="min-w-0 truncate">{item.label}</span>
{showItemValue ? (
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
{v}
</span>
) : null}
</button>
</li>
);
})}
{isExternal && isLoadingMore ? (
<li className="flex items-center justify-center py-2">
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
</li>
);
})
) : null}
</>
)}
</ul>
</div>,
@@ -508,7 +561,7 @@ export function Select<T extends FieldValues>({
>
<span className="min-w-0 flex-1 truncate">
{value ? (
searchable ? (
showItemValue ? (
<>
<span className="text-dark dark:text-white">
{selectedLabel}
@@ -1,9 +1,11 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
import { ILabelValue } from "@/types/shared";
import { getEnumAsArray } from "@/utils/shared";
import {
FieldValues,
@@ -22,11 +24,25 @@ interface CustomerListFiltersProps {
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
/** Lazy-loaded company options for the company dropdown. */
companyItems: ILabelValue[];
onCompanySearch: (query: string) => void;
onCompanyLoadMore: () => void;
companyHasMore?: boolean;
companyIsLoading: boolean;
companyIsLoadingMore: boolean;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
for (const key of ["status", "role", "type"] as const) {
for (const key of [
"status",
"role",
"type",
"full_name",
"email",
"company_id",
] as const) {
const v = values[key];
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
}
@@ -41,6 +57,12 @@ const CustomerListFilters = ({
watch,
setValue,
onClearAll,
companyItems,
onCompanySearch,
onCompanyLoadMore,
companyHasMore,
companyIsLoading,
companyIsLoadingMore,
}: CustomerListFiltersProps) => {
const watched = watch();
const activeCount = useMemo(
@@ -63,6 +85,36 @@ const CustomerListFilters = ({
data-cy="customers-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<InputGroup
{...filterRegister("full_name")}
name="full_name"
label="Full name"
type="text"
placeholder="Search by full name"
/>
<InputGroup
{...filterRegister("email")}
name="email"
label="Email"
type="text"
placeholder="Search by email"
/>
<Select
{...filterRegister("company_id")}
items={companyItems}
label="Company"
name="company_id"
placeholder="Select company"
clearable
value={watch("company_id")}
onClear={() => setValue("company_id", undefined)}
searchPlaceholder="Search companies…"
onSearch={onCompanySearch}
onLoadMore={onCompanyLoadMore}
hasMore={companyHasMore}
isLoading={companyIsLoading}
isLoadingMore={companyIsLoadingMore}
/>
<Select
{...filterRegister("status")}
items={getEnumAsArray(CustomerStatus)}
+12
View File
@@ -75,6 +75,12 @@ const CustomersTable = () => {
openResetPasswordModalForCustomer,
generatedPassword,
isResettingPassword,
companyItems,
onCompanySearch,
onCompanyLoadMore,
companyHasMore,
companyIsLoading,
companyIsLoadingMore,
} = useCustomerListPresenter();
return (
@@ -88,6 +94,12 @@ const CustomersTable = () => {
search={search}
watch={watch}
onClearAll={clearAllFilters}
companyItems={companyItems}
onCompanySearch={onCompanySearch}
onCompanyLoadMore={onCompanyLoadMore}
companyHasMore={companyHasMore}
companyIsLoading={companyIsLoading}
companyIsLoadingMore={companyIsLoadingMore}
/>
<div ref={tableSectionRef}>
<TableSortProvider
@@ -3,11 +3,13 @@
import { SortDirection } from "@/components/ui/tableSortContext";
import {
useAssignCompany,
useCompaniesInfiniteQuery,
useCompanyFullList,
useCustomersQuery,
useDeleteCustomerQuery,
useResetCustomerPassword,
} from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { useHybridPagination } from "@/hooks/useHybridPagination";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
@@ -50,6 +52,35 @@ const useCustomerListPresenter = () => {
setValue: setFilterValue,
} = useForm();
// Lazy-loaded company options for the inline filter dropdown.
const [companySearch, setCompanySearch] = useState("");
const debouncedCompanySearch = useDebounce(companySearch, 400);
const companiesQuery = useCompaniesInfiniteQuery(
debouncedCompanySearch ? { search: debouncedCompanySearch } : undefined,
);
const companyItems = useMemo(
() =>
companiesQuery.data?.pages.flatMap(
(page) =>
page.data.companies?.map((company) => ({
label: company.name,
value: company.id,
})) ?? [],
) ?? [],
[companiesQuery.data],
);
const onCompanySearch = useCallback((query: string) => {
setCompanySearch(query);
}, []);
const onCompanyLoadMore = useCallback(() => {
if (companiesQuery.hasNextPage && !companiesQuery.isFetchingNextPage) {
companiesQuery.fetchNextPage();
}
}, [companiesQuery]);
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
@@ -125,7 +156,15 @@ const useCustomerListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
filterFormReset({ status: undefined, role: undefined, type: undefined });
filterFormReset({
status: undefined,
role: undefined,
type: undefined,
full_name: undefined,
email: undefined,
company_id: undefined,
});
setCompanySearch("");
router.push(pathName);
}, [pathName, router, filterFormReset]);
@@ -228,6 +267,13 @@ const useCustomerListPresenter = () => {
openResetPasswordModalForCustomer,
generatedPassword,
isResettingPassword,
companyItems,
onCompanySearch,
onCompanyLoadMore,
companyHasMore: companiesQuery.hasNextPage,
companyIsLoading:
companiesQuery.isFetching && !companiesQuery.isFetchingNextPage,
companyIsLoadingMore: companiesQuery.isFetchingNextPage,
};
};