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 hasContent = children || actions || footer;
const titleContent = ( 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'> <div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>} {title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>} {subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
@@ -156,7 +160,7 @@ const Card = ({
{collapsible && ( {collapsible && (
<button <button
onClick={() => handleCollapsedChange(!isCollapsed)} 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'} aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
> >
<Icon <Icon
+18 -5
View File
@@ -40,6 +40,7 @@ interface SelectInputBaseProps<T = OptionType> {
bottomLabel?: ReactNode; bottomLabel?: ReactNode;
options: T[]; options: T[];
optionComponent?: OptionComponent<T>; optionComponent?: OptionComponent<T>;
components?: Partial<typeof ReactSelectComponents>;
isDisabled?: boolean; isDisabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
isClearable?: boolean; isClearable?: boolean;
@@ -61,10 +62,13 @@ interface SelectInputBaseProps<T = OptionType> {
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode; startAdornment?: ReactNode;
menuPortalTarget?: HTMLElement | null; menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined; onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
} }
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> { export interface SelectInputProps<T = OptionType>
extends SelectInputBaseProps<T> {
createables?: boolean; createables?: boolean;
value?: T | T[] | null; value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void; onChange?: (val: T | T[] | null) => void;
@@ -130,6 +134,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onChange, onChange,
options, options,
optionComponent, optionComponent,
components: customComponents,
isDisabled, isDisabled,
isLoading, isLoading,
isClearable, isClearable,
@@ -148,6 +153,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onInputChange, onInputChange,
startAdornment, startAdornment,
menuPortalTarget, menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom, onMenuScrollToBottom,
} = props; } = props;
@@ -158,14 +165,18 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const components = useMemo(() => { const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {}; const base = isAnimated ? animatedComponents : {};
const customComponents = { ...base, IndicatorSeparator: () => null }; const mergedComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) { if (startAdornment) {
customComponents.Control = CustomControl; mergedComponents.Control = CustomControl;
} }
return customComponents; if (customComponents) {
}, [isAnimated, startAdornment]); Object.assign(mergedComponents, customComponents);
}
return mergedComponents;
}, [isAnimated, startAdornment, customComponents]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'input-change') setInternalInputValue(val);
@@ -235,6 +246,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
isRtl={isRtl} isRtl={isRtl}
isSearchable={isSearchable} isSearchable={isSearchable}
placeholder={placeholder} placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)} className={cn('w-full', className?.select)}
classNames={{ classNames={{
...(!startAdornment && { ...(!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, useSelect,
OptionType, OptionType,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report'; import { FinanceApi } from '@/services/api/report/finance-report';
import { UserApi } from '@/services/api/user';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
@@ -18,7 +20,6 @@ import {
CustomerPaymentSummary, CustomerPaymentSummary,
} from '@/types/api/report/customer-payment'; } from '@/types/api/report/customer-payment';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
@@ -37,31 +38,34 @@ const CustomerPaymentTab = () => {
// ===== PAGINATION STATE ===== // ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize] = useState(10);
// ===== SUBMISSION STATE ===== // ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE ===== // ===== FILTER STATE =====
const [filterCustomer, setFilterCustomer] = useState<OptionType[]>([]); const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
const [filterSales, setFilterSales] = useState<OptionType[]>([]); []
);
const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState(''); const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState(''); const [filterEndDate, setFilterEndDate] = useState('');
const filterModal = useModal(); const filterModal = useModal();
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } = const {
useSelect(CustomerApi.basePath, 'id', 'name', 'search'); options: customerOptions,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const salesOptions = useMemo( const {
() => [ options: salesOptions,
{ value: 'Sales A', label: 'Sales A' }, isLoadingOptions: isLoadingSales,
{ value: 'Sales B', label: 'Sales B' }, loadMore: loadMoreSales,
{ value: 'Sales C', label: 'Sales C' }, hasMore: hasMoreSales,
// TODO: Fetch sales options from API } = useSelect(UserApi.basePath, 'id', 'name', 'search');
],
[]
);
const dataTypeOptions = useMemo( const dataTypeOptions = useMemo(
() => [{ value: 'do_date', label: 'Tanggal Jual' }], () => [{ value: 'do_date', label: 'Tanggal Jual' }],
@@ -115,6 +119,41 @@ const CustomerPaymentTab = () => {
filterModal.closeModal(); filterModal.closeModal();
}, [filterModal]); }, [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 ===== // ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR( const { data: customerPayment, isLoading } = useSWR(
isSubmitted isSubmitted
@@ -124,7 +163,7 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0 filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',') ? filterCustomer.map((v) => String(v.value)).join(',')
: undefined, : undefined,
sales: sales_id:
filterSales.length > 0 filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',') ? filterSales.map((v) => String(v.value)).join(',')
: undefined, : undefined,
@@ -141,7 +180,7 @@ const CustomerPaymentTab = () => {
([, params]) => ([, params]) =>
FinanceApi.getCustomerPaymentReport( FinanceApi.getCustomerPaymentReport(
params.customer_id, params.customer_id,
params.sales, params.sales_id,
params.filter_by, params.filter_by,
params.start_date, params.start_date,
params.end_date, params.end_date,
@@ -158,11 +197,6 @@ const CustomerPaymentTab = () => {
[customerPayment] [customerPayment]
); );
const meta =
isResponseSuccess(customerPayment) && customerPayment?.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise< const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null CustomerPaymentReport[] | null
@@ -172,7 +206,7 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0 filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',') ? filterCustomer.map((v) => String(v.value)).join(',')
: undefined, : undefined,
sales: sales_id:
filterSales.length > 0 filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',') ? filterSales.map((v) => String(v.value)).join(',')
: undefined, : undefined,
@@ -185,7 +219,7 @@ const CustomerPaymentTab = () => {
const response = await FinanceApi.getCustomerPaymentReport( const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id, params.customer_id,
params.sales, params.sales_id,
params.filter_by, params.filter_by,
params.start_date, params.start_date,
params.end_date, params.end_date,
@@ -260,27 +294,6 @@ const CustomerPaymentTab = () => {
} }
}, [customerPaymentExport]); }, [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 = ( const getTableColumns = (
summary: CustomerPaymentSummary summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => { ): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
@@ -532,14 +545,37 @@ const CustomerPaymentTab = () => {
className={{ wrapper: 'w-full', body: 'p-1!' }} className={{ wrapper: 'w-full', body: 'p-1!' }}
> >
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'> <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} /> <Icon icon='heroicons:funnel' width={18} height={18} />
Filter 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> </Button>
<Dropdown <Dropdown
trigger={ trigger={
<Button variant='outline' isLoading={isAnyExportLoading}> <Button
variant='outline'
isLoading={isAnyExportLoading}
className='rounded-lg'
>
<Icon <Icon
icon='heroicons:cloud-arrow-down' icon='heroicons:cloud-arrow-down'
width={18} width={18}
@@ -550,7 +586,7 @@ const CustomerPaymentTab = () => {
} }
align='end' align='end'
> >
<Menu> <Menu className={'w-full'}>
<MenuItem title='Excel' onClick={handleExportExcel} /> <MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} /> <MenuItem title='PDF' onClick={handleExportPdf} />
</Menu> </Menu>
@@ -608,10 +644,9 @@ const CustomerPaymentTab = () => {
</div> </div>
<div> <div>
<SelectInput <SelectInputCheckbox
label='Customer' label='Customer'
placeholder='Pilih Customer' placeholder='Pilih Customer'
isMulti
options={customerOptions} options={customerOptions}
value={filterCustomer} value={filterCustomer}
onChange={(val) => { onChange={(val) => {
@@ -621,21 +656,23 @@ const CustomerPaymentTab = () => {
}} }}
isLoading={isLoadingCustomers} isLoading={isLoadingCustomers}
isClearable isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div>
<div> <div>
<SelectInput <SelectInputCheckbox
label='Sales' label='Sales'
placeholder='Pilih Sales' placeholder='Pilih Sales'
isMulti
options={salesOptions} options={salesOptions}
value={filterSales} value={filterSales}
onChange={(val) => { onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []); setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}} }}
isLoading={isLoadingSales}
isClearable isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div>
@@ -704,8 +741,12 @@ const CustomerPaymentTab = () => {
<Card <Card
key={customerReport.customer.id} key={customerReport.customer.id}
title={customerReport.customer.name} title={customerReport.customer.name}
subtitle={`${customerReport.customer.address || ''}`} className={{
className={{ wrapper: 'w-full' }} wrapper: 'w-full rounded-2xl',
body: 'p-0',
title:
'py-1.5 px-3 bg-[#0069E0] text-white text-lg font-normal',
}}
variant='bordered' variant='bordered'
collapsible={true} collapsible={true}
> >
@@ -716,7 +757,7 @@ const CustomerPaymentTab = () => {
renderFooter={customerReport.rows.length > 0} renderFooter={customerReport.rows.length > 0}
className={{ className={{
containerClassName: 'w-full', containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4', tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm', tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName: headerColumnClassName:
@@ -738,20 +779,6 @@ const CustomerPaymentTab = () => {
}) })
)} )}
</Card> </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> </div>
); );
}; };
+2 -2
View File
@@ -14,7 +14,7 @@ export class FinanceApiService extends BaseApiService<
async getCustomerPaymentReport( async getCustomerPaymentReport(
customer_id?: string, customer_id?: string,
sales?: string, sales_id?: string,
filter_by?: 'do_date', filter_by?: 'do_date',
start_date?: string, start_date?: string,
end_date?: string, end_date?: string,
@@ -27,7 +27,7 @@ export class FinanceApiService extends BaseApiService<
method: 'GET', method: 'GET',
params: { params: {
customer_id: customer_id, customer_id: customer_id,
sales: sales, sales_id: sales_id,
filter_by: filter_by, filter_by: filter_by,
start_date: start_date, start_date: start_date,
end_date: end_date, end_date: end_date,