Merge branch 'development' into 'production'

refactor(FE-load-more-option): Add infinite scroll to location and

See merge request mbugroup/lti-web-client!396
This commit is contained in:
Adnan Zahir
2026-04-13 14:08:57 +07:00
7 changed files with 172 additions and 68 deletions
@@ -5,12 +5,12 @@ import { Icon } from '@iconify/react';
import Modal, { useModal } from '@/components/Modal';
import DateInput from '@/components/input/DateInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import {
DashboardFilterType,
@@ -42,6 +42,7 @@ import { cn } from '@/lib/helper';
import DashboardExportStats, {
DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats';
import { ProjectFlock } from '@/types/api/production/project-flock';
// Helper function to normalize values to array
const normalizeToArray = (
@@ -71,6 +72,7 @@ const DashboardProduction = () => {
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location)
);
const [kandangInputValue, setRawKandangInputValue] = useState('');
const [exporting, setExporting] = useState(false);
const allChartsRef = useRef<DashboardExportChartsRef>(null);
const allStatsRef = useRef<DashboardExportStatsRef>(null);
@@ -114,23 +116,25 @@ const DashboardProduction = () => {
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
rawData: projectFlocksRawData,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'',
{
location_id:
selectedLocationIds.length > 0 ? selectedLocationIds.toString() : '',
}
);
const {
setInputValue: setInputValueLocation,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' },
@@ -161,12 +165,68 @@ const DashboardProduction = () => {
const { resetForm } = formik;
const selectedFlockIds = useMemo(
() => normalizeToArray(formik.values.flock),
[formik.values.flock]
);
const derivedKandangOptions = useMemo(() => {
if (!isResponseSuccess(projectFlocksRawData)) return [];
const availableProjectFlocks = projectFlocksRawData.data.filter(
(projectFlock) =>
selectedFlockIds.length === 0 ||
selectedFlockIds.includes(projectFlock.id)
);
const kandangMap = new Map<number, OptionType<number>>();
availableProjectFlocks.forEach((projectFlock) => {
projectFlock.kandangs?.forEach((kandang) => {
if (!kandangMap.has(kandang.id)) {
kandangMap.set(kandang.id, {
value: kandang.id,
label: kandang.name,
});
}
});
});
const normalizedSearch = kandangInputValue.trim().toLowerCase();
const allOptions = Array.from(kandangMap.values());
if (!normalizedSearch) return allOptions;
return allOptions.filter((option) =>
option.label.toLowerCase().includes(normalizedSearch)
);
}, [projectFlocksRawData, selectedFlockIds, kandangInputValue]);
const kandangSelect = useMemo(
() => ({
setInputValue: setRawKandangInputValue,
options: derivedKandangOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
}),
[derivedKandangOptions, isLoadingFlockOptions, loadMoreFlock]
);
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = kandangSelect;
const handleResetFilter = useCallback(() => {
resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW');
setSelectedLocationIds([]);
}, [resetForm, resetFilterValues]);
setRawKandangInputValue('');
filterModal.closeModal();
}, [filterModal, resetForm, resetFilterValues]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
@@ -460,6 +520,7 @@ const DashboardProduction = () => {
formik.setFieldValue('kandang', []);
formik.setFieldValue('comparisonType', '');
setSelectedLocationIds([]);
setRawKandangInputValue('');
}}
color='primary'
className={{
@@ -505,6 +566,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -530,6 +592,7 @@ const DashboardProduction = () => {
// Reset dependent fields when location changes
formik.setFieldValue('flock', []);
formik.setFieldValue('kandang', []);
setRawKandangInputValue('');
}}
errorMessage={formik.errors.location as string}
options={locationOptions}
@@ -541,6 +604,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -561,6 +625,7 @@ const DashboardProduction = () => {
// Reset dependent fields when location changes
formik.setFieldValue('flock', []);
formik.setFieldValue('kandang', []);
setRawKandangInputValue('');
}}
errorMessage={formik.errors.location as string}
options={locationOptions}
@@ -572,6 +637,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -596,9 +662,11 @@ const DashboardProduction = () => {
| null
| undefined
}
onChange={(selected) =>
formik.setFieldValue('flock', selected)
}
onChange={(selected) => {
formik.setFieldValue('flock', selected);
formik.setFieldValue('kandang', []);
setInputValueKandang('');
}}
errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
@@ -611,6 +679,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -622,9 +691,11 @@ const DashboardProduction = () => {
| null
| undefined
}
onChange={(selected) =>
formik.setFieldValue('flock', selected)
}
onChange={(selected) => {
formik.setFieldValue('flock', selected);
formik.setFieldValue('kandang', []);
setInputValueKandang('');
}}
errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
@@ -637,6 +708,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -675,6 +747,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -701,6 +774,7 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -178,12 +178,14 @@ const ExpenseRequestForm = ({
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -408,6 +410,7 @@ const ExpenseRequestForm = ({
options={locationOptions}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
@@ -452,6 +455,7 @@ const ExpenseRequestForm = ({
options={supplierOptions}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreSuppliers}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
@@ -10,6 +10,10 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
MarketingFilterSchema,
} from '@/components/pages/marketing/filter/MarketingFilter';
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
@@ -70,8 +74,8 @@ const MarketingFilterModal = ({
limit: 'limit',
});
const uniqueCustomersOptions = useMemo(() => {
const seen = new Set();
const salesCustomerOptions = useMemo(() => {
const seen = new Set<string | number>();
return customersOptions.filter((customer) => {
if (seen.has(customer.value)) return false;
seen.add(customer.value);
@@ -87,23 +91,19 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' },
];
const formik = useFormik<{
product_ids: OptionType[];
status: OptionType | null;
customer_id: OptionType | null;
}>({
const formik = useFormik<MarketingFilterFormValues>({
initialValues: {
product_ids: [],
status: null,
customer_id: null,
customer: null,
},
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => {
const formattedValues = {
...values,
const formattedValues: MarketingFilter = {
product_ids: values.product_ids.map((item) => Number(item.value)),
status: values.status?.value.toString() || '',
customer_id: Number(values.customer_id?.value),
customer_id: Number(values.customer?.value),
};
onSubmit?.(formattedValues);
@@ -121,7 +121,10 @@ const MarketingFilterModal = ({
};
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('customer_id', val as OptionType);
formik.setFieldValue(
'customer',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -187,9 +190,9 @@ const MarketingFilterModal = ({
label='Customer'
isClearable
placeholder='Pilih customer'
options={uniqueCustomersOptions}
options={salesCustomerOptions}
isLoading={isLoadingCustomersOptions}
value={formik.values.customer_id}
value={formik.values.customer}
onChange={customerChangeHandler}
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
@@ -0,0 +1,14 @@
import { array, mixed, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(),
});
export type MarketingFilterFormValues = {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
};
@@ -33,18 +33,18 @@ import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF';
import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX';
import toast from 'react-hot-toast';
import {
KandangApi,
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProjectFlockKandangApi } from '@/services/api/production/project-flock-kandang';
interface ReportExpenseTabProps {
tabId: string;
@@ -136,7 +136,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect<Kandang>(LocationApi.basePath, 'id', 'name', 'search');
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setSupplierInputValue,
@@ -146,14 +146,14 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(
KandangApi.basePath,
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangs,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'name',
'name_with_period',
'search',
formik.values.location_id?.value
? { location_id: String(formik.values.location_id.value) }
@@ -643,14 +643,14 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={kandangOptions}
isLoading={isLoadingKandangs}
options={projectFlockKandangOptions}
isLoading={isLoadingProjectFlockKandangs}
value={kandangValue}
onChange={(val) => {
formik.setFieldValue('kandang_id', val);
}}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!formik.values.location_id}
className={{ wrapper: 'w-full' }}
@@ -43,15 +43,7 @@ export const ProductionResultFilterSchema = yup.object({
}
return !!value;
}),
kandang_id: yup
.mixed<OptionType>()
.required('Kandang wajib dipilih')
.test('is-not-empty', 'Kandang wajib dipilih', (value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return !!value;
}),
kandang_id: yup.mixed<OptionType>().nullable(),
}) as yup.ObjectSchema<ProductionResultFilterFormType>;
export type ProductionResultFilterValues = yup.InferType<
@@ -46,6 +46,7 @@ import Modal, { useModal } from '@/components/Modal';
import { formatNumber } from '@/lib/helper';
import Pagination from '@/components/Pagination';
import ProductionResultSkeleton from '@/components/pages/report/production-result/skeleton/ProductionResultSkeleton';
import { ProjectFlock } from '@/types/api/production/project-flock';
interface ProductionResultTabProps {
tabId: string;
@@ -238,6 +239,17 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
? String(values.kandang_id.value)
: undefined,
});
const selectedProjectFlockKandangRawData = isResponseSuccess(
projectFlockKandangsRawData
)
? projectFlockKandangsRawData.data.find(
(item) => item.id === values.kandang_id?.value
)
: undefined;
setSelectedProjectFlockKandang(selectedProjectFlockKandangRawData);
filterModal.closeModal();
setIsSubmitted(true);
setPage(1);
@@ -255,6 +267,9 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
formik.validateForm();
};
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
useState<ProjectFlockKandang | undefined>();
// ===== OPTIONS =====
const {
setInputValue: setAreaInputValue,
@@ -279,7 +294,7 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlocks,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>(
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
@@ -300,10 +315,11 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangs,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>(
rawData: projectFlockKandangsRawData,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'kandang.name',
'name_with_period',
'search',
{
area_id: formik.values.area_id?.value
@@ -359,13 +375,15 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
([url]: string[]) => httpClient<BaseApiResponse<ProjectFlockKandang[]>>(url)
);
const projectFlockKandangs = useMemo(
() =>
isResponseSuccess(projectFlockKandangsData)
? projectFlockKandangsData.data
: null,
[projectFlockKandangsData]
);
const projectFlockKandangs = useMemo(() => {
if (selectedProjectFlockKandang) {
return [selectedProjectFlockKandang];
}
return isResponseSuccess(projectFlockKandangsData)
? projectFlockKandangsData.data
: null;
}, [projectFlockKandangsData, selectedProjectFlockKandang]);
const projectFlockKandangMetadata = useMemo(
() =>
@@ -804,7 +822,6 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
/>
<SelectInput
required
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}