Merge branch 'feat/customer-control-ui-adjustment' into 'development'

[FEAT/FE] Customer Control UI Adjustment (Kontrol Pembayaran Customer)

See merge request mbugroup/lti-web-client!174
This commit is contained in:
Rivaldi A N S
2026-01-14 06:36:33 +00:00
5 changed files with 204 additions and 78 deletions
+6 -2
View File
@@ -148,7 +148,11 @@ const Card = ({
const hasContent = children || actions || footer;
const titleContent = (
<div className='group flex items-center !justify-between w-full'>
<div
className={
`group flex items-center justify-between! w-full` + getTitleClasses()
}
>
<div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
@@ -156,7 +160,7 @@ const Card = ({
{collapsible && (
<button
onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle'
className={`btn btn-ghost btn-sm btn-circle` + getTitleClasses()}
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
>
<Icon
+18 -5
View File
@@ -40,6 +40,7 @@ interface SelectInputBaseProps<T = OptionType> {
bottomLabel?: ReactNode;
options: T[];
optionComponent?: OptionComponent<T>;
components?: Partial<typeof ReactSelectComponents>;
isDisabled?: boolean;
isLoading?: boolean;
isClearable?: boolean;
@@ -61,10 +62,13 @@ interface SelectInputBaseProps<T = OptionType> {
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
export interface SelectInputProps<T = OptionType>
extends SelectInputBaseProps<T> {
createables?: boolean;
value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void;
@@ -130,6 +134,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onChange,
options,
optionComponent,
components: customComponents,
isDisabled,
isLoading,
isClearable,
@@ -148,6 +153,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onInputChange,
startAdornment,
menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom,
} = props;
@@ -158,14 +165,18 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
const customComponents = { ...base, IndicatorSeparator: () => null };
const mergedComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) {
customComponents.Control = CustomControl;
mergedComponents.Control = CustomControl;
}
return customComponents;
}, [isAnimated, startAdornment]);
if (customComponents) {
Object.assign(mergedComponents, customComponents);
}
return mergedComponents;
}, [isAnimated, startAdornment, customComponents]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val);
@@ -235,6 +246,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)}
classNames={{
...(!startAdornment && {
@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import {
OptionProps,
GroupBase,
components as ReactSelectComponents,
} from 'react-select';
import SelectInput, { OptionType, SelectInputProps } from './SelectInput';
import { cn } from '@/lib/helper';
interface SelectInputCheckboxProps<T = OptionType>
extends Omit<
SelectInputProps<T>,
'closeMenuOnSelect' | 'hideSelectedOptions' | 'optionComponent'
> {
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
}
const CheckboxOption = <
T extends OptionType,
IsMulti extends boolean,
Group extends GroupBase<T>,
>(
props: OptionProps<T, IsMulti, Group>
) => {
const { isSelected, label, innerRef, innerProps, className } = props;
return (
<div
ref={innerRef}
{...innerProps}
className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer',
className
)}
>
<input
type='checkbox'
checked={isSelected}
onChange={() => null}
className='checkbox checkbox-sm checkbox-primary pointer-events-none'
/>
<label className='cursor-pointer flex-1 select-none'>{label}</label>
</div>
);
};
const SelectInputCheckbox = <T extends OptionType>(
props: SelectInputCheckboxProps<T>
) => {
const {
closeMenuOnSelect = false,
hideSelectedOptions = false,
isMulti = true,
className,
...restProps
} = props;
const customComponents = useMemo(() => {
return {
Option: CheckboxOption as typeof ReactSelectComponents.Option,
};
}, []);
return (
<SelectInput<T>
{...restProps}
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={{
...className,
select: cn(className?.select, 'select-checkbox'),
}}
components={customComponents}
/>
);
};
export default SelectInputCheckbox;
@@ -7,9 +7,11 @@ import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
import { UserApi } from '@/services/api/user';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
@@ -18,7 +20,6 @@ import {
CustomerPaymentSummary,
} from '@/types/api/report/customer-payment';
import { isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
@@ -37,31 +38,34 @@ const CustomerPaymentTab = () => {
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [pageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterCustomer, setFilterCustomer] = useState<OptionType[]>([]);
const [filterSales, setFilterSales] = useState<OptionType[]>([]);
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
[]
);
const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const filterModal = useModal();
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const salesOptions = useMemo(
() => [
{ value: 'Sales A', label: 'Sales A' },
{ value: 'Sales B', label: 'Sales B' },
{ value: 'Sales C', label: 'Sales C' },
// TODO: Fetch sales options from API
],
[]
);
const {
options: salesOptions,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
hasMore: hasMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const dataTypeOptions = useMemo(
() => [{ value: 'do_date', label: 'Tanggal Jual' }],
@@ -115,6 +119,41 @@ const CustomerPaymentTab = () => {
filterModal.closeModal();
}, [filterModal]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Date filter (start_date + end_date = 1 filter)
if (filterStartDate || filterEndDate) {
count += 1;
}
// Customer filter
if (filterCustomer.length > 0) {
count += 1;
}
// Sales filter
if (filterSales.length > 0) {
count += 1;
}
// Filter by (always count if submitted)
if (isSubmitted) {
count += 1;
}
return count;
}, [
filterStartDate,
filterEndDate,
filterCustomer,
filterSales,
isSubmitted,
]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
isSubmitted
@@ -124,7 +163,7 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales:
sales_id:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
@@ -141,7 +180,7 @@ const CustomerPaymentTab = () => {
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.sales_id,
params.filter_by,
params.start_date,
params.end_date,
@@ -158,11 +197,6 @@ const CustomerPaymentTab = () => {
[customerPayment]
);
const meta =
isResponseSuccess(customerPayment) && customerPayment?.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
@@ -172,7 +206,7 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales:
sales_id:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
@@ -185,7 +219,7 @@ const CustomerPaymentTab = () => {
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.sales_id,
params.filter_by,
params.start_date,
params.end_date,
@@ -260,27 +294,6 @@ const CustomerPaymentTab = () => {
}
}, [customerPaymentExport]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (
summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
@@ -532,14 +545,37 @@ const CustomerPaymentTab = () => {
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button variant='outline' onClick={filterModal.openModal}>
<Button
variant='outline'
onClick={filterModal.openModal}
className={
hasFilters
? 'bg-linear-to-b from-[#0069E0]/40 to-white text-[#0069E0] rounded-lg'
: 'rounded-lg'
}
>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
{hasFilters && (
<Badge
variant='default'
className={{
badge:
'rounded-lg px-1.5 py-2.5 text-xs font-semibold bg-error text-white',
}}
>
{activeFiltersCount}
</Badge>
)}
</Button>
<Dropdown
trigger={
<Button variant='outline' isLoading={isAnyExportLoading}>
<Button
variant='outline'
isLoading={isAnyExportLoading}
className='rounded-lg'
>
<Icon
icon='heroicons:cloud-arrow-down'
width={18}
@@ -550,7 +586,7 @@ const CustomerPaymentTab = () => {
}
align='end'
>
<Menu>
<Menu className={'w-full'}>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
@@ -608,10 +644,9 @@ const CustomerPaymentTab = () => {
</div>
<div>
<SelectInput
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
isMulti
options={customerOptions}
value={filterCustomer}
onChange={(val) => {
@@ -621,21 +656,23 @@ const CustomerPaymentTab = () => {
}}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
</div>
<div>
<SelectInput
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
isMulti
options={salesOptions}
value={filterSales}
onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
</div>
@@ -704,8 +741,12 @@ const CustomerPaymentTab = () => {
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`${customerReport.customer.address || ''}`}
className={{ wrapper: 'w-full' }}
className={{
wrapper: 'w-full rounded-2xl',
body: 'p-0',
title:
'py-1.5 px-3 bg-[#0069E0] text-white text-lg font-normal',
}}
variant='bordered'
collapsible={true}
>
@@ -716,7 +757,7 @@ const CustomerPaymentTab = () => {
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
@@ -738,20 +779,6 @@ const CustomerPaymentTab = () => {
})
)}
</Card>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</div>
);
};
+2 -2
View File
@@ -14,7 +14,7 @@ export class FinanceApiService extends BaseApiService<
async getCustomerPaymentReport(
customer_id?: string,
sales?: string,
sales_id?: string,
filter_by?: 'do_date',
start_date?: string,
end_date?: string,
@@ -27,7 +27,7 @@ export class FinanceApiService extends BaseApiService<
method: 'GET',
params: {
customer_id: customer_id,
sales: sales,
sales_id: sales_id,
filter_by: filter_by,
start_date: start_date,
end_date: end_date,