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_TOAST_AUTO_CLOSE_DURATION=2500
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com 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[]; searchQueryKey?: readonly unknown[];
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */ /** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
searchFn?: (query: string) => Promise<ILabelValue[]>; 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 }
| { placeholder: string; defaultValue?: string } | { placeholder: string; defaultValue?: string }
@@ -163,11 +177,22 @@ export function Select<T extends FieldValues>({
searchDebounceMs = 300, searchDebounceMs = 300,
searchQueryKey, searchQueryKey,
searchFn, searchFn,
onSearch,
onLoadMore,
hasMore = false,
isLoading = false,
isLoadingMore = false,
register: _register, register: _register,
...props ...props
}: PropsType<T>) { }: PropsType<T>) {
void _register; 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 ( if (
process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "production" &&
searchable && searchable &&
@@ -186,6 +211,7 @@ export function Select<T extends FieldValues>({
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue); const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
const [value, setValue] = useState(defaultValue || ""); const [value, setValue] = useState(defaultValue || "");
@@ -221,10 +247,21 @@ export function Select<T extends FieldValues>({
}); });
const listItems = useMemo(() => { const listItems = useMemo(() => {
if (isExternal) return items;
if (!searchable) return items; if (!searchable) return items;
if (isServerMode && serverDisplayItems) return serverDisplayItems; if (isServerMode && serverDisplayItems) return serverDisplayItems;
return filterSelectItems(items, query); 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 selectedLabel = useMemo(() => {
const hit = listItems.find((i) => String(i.value) === String(value)); const hit = listItems.find((i) => String(i.value) === String(value));
@@ -287,13 +324,13 @@ export function Select<T extends FieldValues>({
}, [open, listboxId]); }, [open, listboxId]);
useEffect(() => { useEffect(() => {
if (!searchable || !open) return; if (!showSearch || !open) return;
const idFrame = window.requestAnimationFrame(() => { const idFrame = window.requestAnimationFrame(() => {
searchInputRef.current?.focus(); searchInputRef.current?.focus();
searchInputRef.current?.select(); searchInputRef.current?.select();
}); });
return () => window.cancelAnimationFrame(idFrame); return () => window.cancelAnimationFrame(idFrame);
}, [open, searchable]); }, [open, showSearch]);
const emitChange = (next: string) => { const emitChange = (next: string) => {
if (controlledValue === undefined) { if (controlledValue === undefined) {
@@ -323,7 +360,9 @@ export function Select<T extends FieldValues>({
triggerRef.current?.focus(); 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 = const dropdown =
mounted && mounted &&
@@ -345,7 +384,7 @@ export function Select<T extends FieldValues>({
panelRect.placement === "above" ? "translateY(-100%)" : undefined, panelRect.placement === "above" ? "translateY(-100%)" : undefined,
}} }}
> >
{searchable ? ( {showSearch ? (
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]"> <div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
<label htmlFor={`${listboxId}-search`} className="sr-only"> <label htmlFor={`${listboxId}-search`} className="sr-only">
{searchPlaceholder} {searchPlaceholder}
@@ -355,7 +394,10 @@ export function Select<T extends FieldValues>({
ref={searchInputRef} ref={searchInputRef}
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setQuery(e.target.value);
if (isExternal) onSearch?.(e.target.value);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
@@ -372,51 +414,62 @@ export function Select<T extends FieldValues>({
/> />
</div> </div>
) : null} ) : null}
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5"> <ul
{searchable && isSearchLoading ? ( 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"> <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" /> <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> </li>
) : searchable && isSearchError ? ( ) : !isExternal && searchable && isSearchError ? (
<li className="px-3 py-6 text-center text-sm text-error"> <li className="px-3 py-6 text-center text-sm text-error">
Could not load options. Try again. Could not load options. Try again.
</li> </li>
) : dropdownItems.length === 0 ? ( ) : dropdownItems.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6"> <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> </li>
) : ( ) : (
dropdownItems.map((item) => { <>
const v = String(item.value); {dropdownItems.map((item) => {
const isActive = v === String(value); const v = String(item.value);
return ( const isActive = v === String(value);
<li key={v}> return (
<button <li key={v}>
type="button" <button
role="option" type="button"
aria-selected={isActive} role="option"
data-cy={dataCy ? `${dataCy}-option` : undefined} aria-selected={isActive}
data-value={v} data-cy={dataCy ? `${dataCy}-option` : undefined}
onClick={() => pickItem(v)} data-value={v}
className={cn( onClick={() => pickItem(v)}
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition", className={cn(
searchable ? "justify-between" : "justify-start", "flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
isActive showItemValue ? "justify-between" : "justify-start",
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white" isActive
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]", ? "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="min-w-0 truncate">{item.label}</span>
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6"> {showItemValue ? (
{v} <span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
</span> {v}
) : null} </span>
</button> ) : 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> </li>
); ) : null}
}) </>
)} )}
</ul> </ul>
</div>, </div>,
@@ -508,7 +561,7 @@ export function Select<T extends FieldValues>({
> >
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{value ? ( {value ? (
searchable ? ( showItemValue ? (
<> <>
<span className="text-dark dark:text-white"> <span className="text-dark dark:text-white">
{selectedLabel} {selectedLabel}
@@ -1,9 +1,11 @@
"use client"; "use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select"; import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button"; import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel"; import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums"; import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
import { ILabelValue } from "@/types/shared";
import { getEnumAsArray } from "@/utils/shared"; import { getEnumAsArray } from "@/utils/shared";
import { import {
FieldValues, FieldValues,
@@ -22,11 +24,25 @@ interface CustomerListFiltersProps {
watch: UseFormWatch<FieldValues>; watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>; setValue: UseFormSetValue<any>;
onClearAll: () => void; 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 { function countActive(values: Record<string, unknown>): number {
let n = 0; 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]; const v = values[key];
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++; if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
} }
@@ -41,6 +57,12 @@ const CustomerListFilters = ({
watch, watch,
setValue, setValue,
onClearAll, onClearAll,
companyItems,
onCompanySearch,
onCompanyLoadMore,
companyHasMore,
companyIsLoading,
companyIsLoadingMore,
}: CustomerListFiltersProps) => { }: CustomerListFiltersProps) => {
const watched = watch(); const watched = watch();
const activeCount = useMemo( const activeCount = useMemo(
@@ -63,6 +85,36 @@ const CustomerListFilters = ({
data-cy="customers-filter-form" data-cy="customers-filter-form"
> >
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> <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 <Select
{...filterRegister("status")} {...filterRegister("status")}
items={getEnumAsArray(CustomerStatus)} items={getEnumAsArray(CustomerStatus)}
+12
View File
@@ -75,6 +75,12 @@ const CustomersTable = () => {
openResetPasswordModalForCustomer, openResetPasswordModalForCustomer,
generatedPassword, generatedPassword,
isResettingPassword, isResettingPassword,
companyItems,
onCompanySearch,
onCompanyLoadMore,
companyHasMore,
companyIsLoading,
companyIsLoadingMore,
} = useCustomerListPresenter(); } = useCustomerListPresenter();
return ( return (
@@ -88,6 +94,12 @@ const CustomersTable = () => {
search={search} search={search}
watch={watch} watch={watch}
onClearAll={clearAllFilters} onClearAll={clearAllFilters}
companyItems={companyItems}
onCompanySearch={onCompanySearch}
onCompanyLoadMore={onCompanyLoadMore}
companyHasMore={companyHasMore}
companyIsLoading={companyIsLoading}
companyIsLoadingMore={companyIsLoadingMore}
/> />
<div ref={tableSectionRef}> <div ref={tableSectionRef}>
<TableSortProvider <TableSortProvider
@@ -3,11 +3,13 @@
import { SortDirection } from "@/components/ui/tableSortContext"; import { SortDirection } from "@/components/ui/tableSortContext";
import { import {
useAssignCompany, useAssignCompany,
useCompaniesInfiniteQuery,
useCompanyFullList, useCompanyFullList,
useCustomersQuery, useCustomersQuery,
useDeleteCustomerQuery, useDeleteCustomerQuery,
useResetCustomerPassword, useResetCustomerPassword,
} from "@/hooks/queries"; } from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { useHybridPagination } from "@/hooks/useHybridPagination"; import { useHybridPagination } from "@/hooks/useHybridPagination";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations"; import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api"; import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
@@ -50,6 +52,35 @@ const useCustomerListPresenter = () => {
setValue: setFilterValue, setValue: setFilterValue,
} = useForm(); } = 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 tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null); const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null); const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
@@ -125,7 +156,15 @@ const useCustomerListPresenter = () => {
}; };
const clearAllFilters = useCallback(() => { 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); router.push(pathName);
}, [pathName, router, filterFormReset]); }, [pathName, router, filterFormReset]);
@@ -228,6 +267,13 @@ const useCustomerListPresenter = () => {
openResetPasswordModalForCustomer, openResetPasswordModalForCustomer,
generatedPassword, generatedPassword,
isResettingPassword, isResettingPassword,
companyItems,
onCompanySearch,
onCompanyLoadMore,
companyHasMore: companiesQuery.hasNextPage,
companyIsLoading:
companiesQuery.isFetching && !companiesQuery.isFetchingNextPage,
companyIsLoadingMore: companiesQuery.isFetchingNextPage,
}; };
}; };