Merge branch 'development' into 'staging'

Development

See merge request mbugroup/lti-web-client!182
This commit is contained in:
kris
2026-01-15 06:55:35 +00:00
46 changed files with 2093 additions and 1168 deletions
+3 -1
View File
@@ -38,9 +38,11 @@ const ExpenseEditPage = () => {
!isLoadingExpense && !isLoadingExpense &&
isResponseSuccess(expense) && isResponseSuccess(expense) &&
expense.data.latest_approval.step_number !== 5 && expense.data.latest_approval.step_number !== 5 &&
expense.data.latest_approval.step_number !== 6 &&
(expense.data.latest_approval.step_number === 1 || (expense.data.latest_approval.step_number === 1 ||
expense.data.latest_approval.step_number === 2 || expense.data.latest_approval.step_number === 2 ||
expense.data.latest_approval.step_number === 3); expense.data.latest_approval.step_number === 3 ||
expense.data.latest_approval.step_number === 4);
if (!isLoadingExpense && !isExpenseCanBeEdited) { if (!isLoadingExpense && !isExpenseCanBeEdited) {
router.back(); router.back();
+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
+42
View File
@@ -0,0 +1,42 @@
import Button, { ButtonProps } from '@/components/Button';
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { FormikValues } from 'formik';
export type ButtonFilterProps = ButtonProps & {
values: FormikValues;
onClick: () => void;
};
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
return (
<Button
{...props}
onClick={onClick}
className={cn(
getFilledFormikValuesCount(values) > 0
? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: '',
props.className
)}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
}
/>
Filter
{getFilledFormikValuesCount(values) > 0 && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)}
</span>
)}
</Button>
);
};
export default ButtonFilter;
+1 -1
View File
@@ -18,7 +18,7 @@ const AlertErrorList = ({
if (formErrorList.length === 0) return null; if (formErrorList.length === 0) return null;
return ( return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'> <Alert color='error' className='w-full flex flex-col gap-2 px-4'>
<div className='flex justify-between items-center gap-2 w-full'> <div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} /> <Icon icon='material-symbols:error-outline' width={24} height={24} />
+134 -27
View File
@@ -9,15 +9,20 @@ import Select, {
SingleValue, SingleValue,
components as ReactSelectComponents, components as ReactSelectComponents,
ControlProps, ControlProps,
MenuListProps,
} from 'react-select'; } from 'react-select';
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated'; import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper'; import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite';
import { httpClientFetcher } from '@/services/http/client'; import { httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import {
import { isResponseSuccess } from '@/lib/api-helper'; BaseApiResponse,
ErrorApiResponse,
SuccessApiResponse,
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType { export interface OptionType {
value: string | number; value: string | number;
@@ -35,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;
@@ -56,9 +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;
} }
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;
@@ -93,6 +103,29 @@ const CustomControl = <
); );
}; };
const CustomMenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const { children, selectProps, options } = props;
const { isLoading } = selectProps;
return (
<ReactSelectComponents.MenuList {...props}>
{children}
{options.length > 0 && isLoading && (
<div className='mt-1 px-3 py-2 rounded-md text-center text-gray-400'>
<span className='loading loading-spinner loading-md' />
</div>
)}
</ReactSelectComponents.MenuList>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => { const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const { const {
label, label,
@@ -101,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,
@@ -119,6 +153,9 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onInputChange, onInputChange,
startAdornment, startAdornment,
menuPortalTarget, menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom,
} = props; } = props;
const [internalInputValue, setInternalInputValue] = useState(''); const [internalInputValue, setInternalInputValue] = useState('');
@@ -128,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);
@@ -205,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 && {
@@ -256,6 +299,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
components={{ components={{
...components, ...components,
...(optionComponent ? { Option: optionComponent } : {}), ...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}} }}
{...(startAdornment && { {...(startAdornment && {
shouldShowAdornment, shouldShowAdornment,
@@ -269,6 +313,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}} }}
onMenuScrollToBottom={onMenuScrollToBottom}
/> />
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} {isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
@@ -280,7 +325,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}; };
const useSelect = <T,>( const useSelect = <T,>(
basePath: string, basePath: string | null,
valueKey: keyof T | string, valueKey: keyof T | string,
labelKey: keyof T | string, labelKey: keyof T | string,
searchKey: string = 'search', searchKey: string = 'search',
@@ -288,34 +333,96 @@ const useSelect = <T,>(
) => { ) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const optionsUrlParams = useMemo(() => { const pageKey = 'page';
return new URLSearchParams({ const limitKey = 'limit';
const limit = params?.['limit'] ?? 10;
const getKey = (
pageIndex: number,
previousPageData?: BaseApiResponse<T[]>
) => {
// stop when backend says no more pages
if (previousPageData && isResponseSuccess(previousPageData)) {
const meta = previousPageData.meta;
if (meta && meta.page >= meta.total_pages) return null;
}
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '', [searchKey]: inputValue ?? '',
...params, [pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString(); }).toString();
}, [inputValue, searchKey, params]);
const optionsUrl = `${basePath}?${optionsUrlParams}`; return basePath ? `${basePath}?${qs}` : null;
};
const { data, isLoading } = useSWR(optionsUrl, async (url) => { const {
return await httpClientFetcher<BaseApiResponse<T[]>>(url); data: pages,
}); isLoading,
isValidating,
size,
setSize,
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
httpClientFetcher<BaseApiResponse<T[]>>(url)
);
const options = isResponseSuccess(data) const options = useMemo(() => {
? data.data.map((item) => { if (!pages) return [];
return {
value: getByPath<T, number>(item, valueKey as string), return pages.flatMap((page) =>
label: getByPath<T, string>(item, labelKey as string), isResponseSuccess(page)
}; ? page.data.map((item) => ({
}) value: getByPath<T, number>(item, valueKey as string),
: []; label: getByPath<T, string>(item, labelKey as string),
}))
: []
);
}, [pages, valueKey, labelKey]);
const lastPage = pages?.[pages.length - 1];
const hasMore =
!!lastPage &&
isResponseSuccess(lastPage) &&
!!lastPage.meta &&
lastPage.meta.page < lastPage.meta.total_pages;
const loadMore = () => {
if (!hasMore) return;
setSize(size + 1);
};
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedSuccessRawData = {
...pages?.[latestPagesIndex],
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return { return {
inputValue, inputValue,
setInputValue, setInputValue,
options, options,
isLoadingOptions: isLoading, rawData: isResponseSuccess(pages?.[latestPagesIndex])
rawData: data, ? formattedSuccessRawData
: formattedErrorRawData,
isLoadingOptions: isLoading || isValidating,
isLoadingMore: isValidating && size > 1,
hasMore,
loadMore,
}; };
}; };
@@ -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;
@@ -8,6 +8,7 @@ import {
HppPurchaseData, HppPurchaseData,
ProfitLossDataAmount, ProfitLossDataAmount,
} from '@/types/api/closing'; } from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
type HppTableRow = type HppTableRow =
@@ -55,9 +56,16 @@ const ClosingFinanceTable = ({
}: { }: {
projectFlockId: number; projectFlockId: number;
}) => { }) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR( const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`, `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() => ClosingApi.getFinance(projectFlockId) () =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
); );
const staticHppRows: Array<{ const staticHppRows: Array<{
@@ -283,6 +291,7 @@ const ClosingFinanceTable = ({
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<HppTableRow> <Table<HppTableRow>
data={hppTableData} data={hppTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'No.', header: 'No.',
@@ -299,7 +308,7 @@ const ClosingFinanceTable = ({
}, },
}, },
{ {
header: 'Type', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'), accessorFn: (item) => formatTitleCase(item.type || '-'),
}, },
@@ -455,6 +464,7 @@ const ClosingFinanceTable = ({
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow> <Table<ProfitLossTableRow>
data={profitLossTableData} data={profitLossTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'Jenis', header: 'Jenis',
@@ -5,21 +5,27 @@ import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { Overhead, OverheadTotal } from '@/types/api/closing'; import { Overhead, OverheadTotal } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
interface ClosingOverheadTableProps { interface ClosingOverheadTableProps {
type?: 'detail';
projectFlockId: number; projectFlockId: number;
} }
const ClosingOverheadTable = ({ const ClosingOverheadTable = ({
type,
projectFlockId, projectFlockId,
}: ClosingOverheadTableProps) => { }: ClosingOverheadTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: overhead, isLoading: isLoadingOverhead } = useSWR( const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`, `${ClosingApi.basePath}/${projectFlockId}${kandangId ? `/${kandangId}` : ''}/overhead`,
() => ClosingApi.getOverhead(projectFlockId), () =>
ClosingApi.getOverhead(
projectFlockId,
kandangId ? Number(kandangId) : undefined
),
{ {
keepPreviousData: true, keepPreviousData: true,
} }
@@ -148,6 +154,7 @@ const ClosingOverheadTable = ({
'whitespace-nowrap' 'whitespace-nowrap'
), ),
}} }}
isLoading={isLoadingOverhead}
renderFooter={ renderFooter={
isResponseSuccess(overhead) isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0 ? overhead.data?.overheads.length > 0
@@ -197,7 +197,7 @@ const ClosingProductionDataTabContent = ({
value={formatNumber(performance.mor_diff)} value={formatNumber(performance.mor_diff)}
unitClassName='hidden' unitClassName='hidden'
/> />
<DataRow {/* <DataRow
label='AWG Std' label='AWG Std'
value={formatNumber(performance.awg_std)} value={formatNumber(performance.awg_std)}
unit='Gr/Hari' unit='Gr/Hari'
@@ -206,7 +206,7 @@ const ClosingProductionDataTabContent = ({
label='AWG Act' label='AWG Act'
value={formatNumber(performance.awg_act)} value={formatNumber(performance.awg_act)}
unit='Gr/Hari' unit='Gr/Hari'
/> /> */}
<DataRow <DataRow
label='Feed Intake Std' label='Feed Intake Std'
value={formatNumber(performance.feed_intake_std)} value={formatNumber(performance.feed_intake_std)}
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6', wrapper: 'col-span-12 sm:col-span-6',
@@ -8,7 +8,7 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -30,6 +30,11 @@ import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import ButtonFilter from '@/components/helper/ButtonFilter';
import toast from 'react-hot-toast';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -49,6 +54,7 @@ const DashboardProduction = () => {
); );
const [endpointUrl, setEndpointUrl] = useState('/dashboards'); const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]); const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
const [exporting, setExporting] = useState(false);
// ===== FETCH DATA ===== // ===== FETCH DATA =====
const { const {
@@ -64,22 +70,32 @@ const DashboardProduction = () => {
: undefined; : undefined;
// ===== SELECT ===== // ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const { const {
setInputValue: setInputValueFlock,
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueLocation,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', { } = useSelect(LocationApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } = const {
useSelect(KandangApi.basePath, 'id', 'name', '', { setInputValue: setInputValueKandang,
limit: 'limit', options: kandangOptions,
location_id: selectedLocationIds ? selectedLocationIds.toString() : '', isLoadingOptions: isLoadingKandangOptions,
}); loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [ const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' }, { value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' }, { value: 'FLOCK', label: 'Flock' },
@@ -143,12 +159,27 @@ const DashboardProduction = () => {
console.log(endpointUrl); console.log(endpointUrl);
filterModal.closeModal(); filterModal.closeModal();
refreshDashboardProductionData(); refreshDashboardProductionData();
formik.resetForm();
}; };
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Export PDF =====
const handleExportPDF = () => {
setExporting(true);
};
// Wait for state to render, then trigger print
useEffect(() => {
if (exporting) {
const timer = setTimeout(() => {
window.print();
setExporting(false);
}, 100);
return () => clearTimeout(timer);
}
}, [exporting]);
if (isLoadingDashboardProductionData) { if (isLoadingDashboardProductionData) {
return ( return (
<div className='w-full min-h-screen flex items-center justify-center'> <div className='w-full min-h-screen flex items-center justify-center'>
@@ -156,71 +187,39 @@ const DashboardProduction = () => {
</div> </div>
); );
} }
return ( return (
<> <>
<section className='w-full p-4 space-y-6'> <section className='w-full p-4 space-y-6'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'> <div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
<div></div> <div></div>
<div className='flex flex-row justify-end gap-2'> <div className='flex flex-row justify-end gap-2'>
<Button <ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline' variant='outline'
className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg' className='min-w-28 rounded-lg'
onClick={() => filterModal.openModal()}
/>
<Dropdown
trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'>
<Icon icon='heroicons:arrow-down-tray' />
Export
<Icon icon='heroicons:chevron-down' />
</Button>
}
className={{
content: 'w-full',
}}
> >
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} /> <Menu className={exporting ? 'hidden' : ''}>
Export <MenuItem title='PDF' onClick={handleExportPDF} />
<Icon icon='heroicons:chevron-down' width={20} height={20} /> </Menu>
</Button> </Dropdown>
</div> </div>
</div> </div>
@@ -287,7 +286,7 @@ const DashboardProduction = () => {
{/* Rentang Waktu */} {/* Rentang Waktu */}
<div className='px-4'> <div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label> <label className='flex items-center gap-2 mb-3'>Tanggal</label>
<div className='flex items-center gap-2'> <div className='flex items-start gap-2'>
<DateInput <DateInput
name='startDate' name='startDate'
placeholder='Tanggal Mulai' placeholder='Tanggal Mulai'
@@ -302,7 +301,7 @@ const DashboardProduction = () => {
Boolean(formik.touched.startDate) Boolean(formik.touched.startDate)
} }
/> />
<span className='hidden md:block text-center'></span> <div className='hidden md:block mt-3 text-center'></div>
<DateInput <DateInput
name='endDate' name='endDate'
placeholder='Tanggal Akhir' placeholder='Tanggal Akhir'
@@ -383,6 +382,8 @@ const DashboardProduction = () => {
<SelectInput <SelectInput
label='Farm' label='Farm'
value={formik.values.location} value={formik.values.location}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
onChange={(selected) => { onChange={(selected) => {
formik.setFieldValue('location', selected); formik.setFieldValue('location', selected);
// Update selectedLocationIds for kandang filter // Update selectedLocationIds for kandang filter
@@ -422,6 +423,8 @@ const DashboardProduction = () => {
formik.setFieldValue('flock', selected) formik.setFieldValue('flock', selected)
} }
errorMessage={formik.errors.flock as string} errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
options={flockOptions} options={flockOptions}
isLoading={isLoadingFlockOptions} isLoading={isLoadingFlockOptions}
isMulti={ isMulti={
@@ -450,6 +453,8 @@ const DashboardProduction = () => {
formik.setFieldValue('kandang', selected) formik.setFieldValue('kandang', selected)
} }
errorMessage={formik.errors.kandang as string} errorMessage={formik.errors.kandang as string}
onInputChange={setInputValueKandang}
onMenuScrollToBottom={loadMoreKandang}
options={kandangOptions} options={kandangOptions}
isLoading={isLoadingKandangOptions} isLoading={isLoadingKandangOptions}
isMulti={ isMulti={
@@ -465,7 +470,9 @@ const DashboardProduction = () => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <div className='w-full p-4'>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
@@ -283,261 +283,311 @@ const DashboardLineChart = ({
})()} })()}
</div> </div>
{/* Chart */} {/* Chart Container with Empty State Overlay */}
<ResponsiveContainer width='100%' height={350}> <div className='relative'>
<LineChart {/* Chart */}
data={(() => { <ResponsiveContainer width='100%' height={350}>
// Transform data based on analysisMode <LineChart
if (analysisMode === 'OVERVIEW') { data={(() => {
// For OVERVIEW mode, use the selected chart data // Transform data based on analysisMode
if (isOverviewCharts(data.charts)) { if (analysisMode === 'OVERVIEW') {
const selectedChartData = data.charts[chartData]; // For OVERVIEW mode, use the selected chart data
if (!selectedChartData || !selectedChartData.dataset) return []; if (isOverviewCharts(data.charts)) {
return selectedChartData.dataset; const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
} }
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()} })()}
ticks={(() => { margin={{
// Calculate dynamic ticks based on domain top: 5,
let seriesData: DashboardChartsSeries[] = []; right: 10,
let dataset: DashboardChartsDataset[] = []; left: 0,
bottom: 5,
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} >
itemStyle={{ color: 'white', fontSize: '12px' }} <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
labelFormatter={(value) => `Week ${value}`} <XAxis
formatter={( dataKey='week'
value: number | undefined, tick={{ fontSize: 11, fill: '#9ca3af' }}
name: string | undefined tickLine={false}
) => { axisLine={{ stroke: '#e5e7eb' }}
if (value === undefined || name === undefined) return ['', '']; label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
// Get series data to find the unit if (
let seriesData: DashboardChartsSeries[] = []; analysisMode === 'OVERVIEW' &&
if ( isOverviewCharts(data.charts)
analysisMode === 'OVERVIEW' && ) {
isOverviewCharts(data.charts) seriesData = data.charts[chartData]?.series || [];
) { dataset = data.charts[chartData]?.dataset || [];
seriesData = data.charts[chartData]?.series || []; } else if (
} else if ( analysisMode === 'COMPARISON' &&
analysisMode === 'COMPARISON' && isComparisonCharts(data.charts)
isComparisonCharts(data.charts) ) {
) { const comparisonChart =
const comparisonChart = data.charts.location ||
data.charts.location || data.charts.flock ||
data.charts.flock || data.charts.kandang;
data.charts.kandang; seriesData = comparisonChart?.series || [];
seriesData = comparisonChart?.series || []; dataset = comparisonChart?.dataset || [];
} }
// Find the series that matches this line's name // Get all values from visible series
const series = seriesData.find((s) => s.label === name); const visibleSeriesIds = Array.from(visibleSeries);
const unit = series?.unit || ''; const allValues: number[] = [];
return [`${value} ${unit}`, name]; dataset.forEach((item: DashboardChartsDataset) => {
}} visibleSeriesIds.forEach((seriesId) => {
/> const value = item[seriesId];
{/* Dynamic Line rendering based on visible series */} if (typeof value === 'number') {
{(() => { allValues.push(value);
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
} }
activeDot={isStandard ? undefined : { r: 5 }} });
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (value === undefined || name === undefined) return ['', ''];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const unit = series?.unit || '';
return [`${value} ${unit}`, name];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={24}
height={24}
/> />
); </div>
});
})()} {/* Empty state text */}
</LineChart> <h3 className='text-gray-900 font-semibold text-base mb-2'>
</ResponsiveContainer> Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</div>
);
}
return null;
})()}
</div>
</Card> </Card>
); );
}; };
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage, InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage, setInventoryAdjustmentFormErrorMessage,
] = useState(''); ] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true); const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok'); const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler // Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
}); });
// Fetch Data // Fetch Data
const productCategoriesUrl = `${ const {
ProductCategoryApi.basePath setInputValue: setProductCategoryInputValue,
}?${new URLSearchParams({ options: productCategoryOptions,
search: '', isLoadingOptions: isLoadingProductCategoryOptions,
}).toString()}`; loadMore: loadMoreProductCategories,
const { data: productCategories, isLoading: isLoadingProductCategories } = } = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setProductInputValue,
product_category_id: selectedProductCategories, options: productOptions,
}).toString()}`; isLoadingOptions: isLoadingProductOptions,
const { data: products, isLoading: isLoadingProducts } = useSWR( loadMore: loadMoreProducts,
productUrl, } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
ProductApi.getAllFetcher product_category_id: formik.values.product_category_id
); ? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setWarehouseInputValue,
limit: '100', options: warehouseOptions,
}).toString()}`; isLoadingOptions: isLoadingWarehouseOptions,
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( loadMore: loadMoreWarehouses,
warehouseUrl, } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
// Options Handler // Options Handler
const productCategoryChangeHandler = ( const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null; const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled); setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0); formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect // Effect
useEffect(() => { useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) { if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false); setDisabledProduct(false);
formik.setFieldValue( formik.setFieldValue(
'product_id', 'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
); );
formik.setFieldValue('note', initialValues.note); formik.setFieldValue('note', initialValues.note);
} }
}, [ }, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function // Utils Function
const formatNumber = (value: string) => { const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk' label='Kategori Produk'
value={formik.values.product_category as OptionType} value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler} onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories} onInputChange={setProductCategoryInputValue}
options={optionsProductCategory} options={productCategoryOptions}
isLoading={isLoadingProductCategories} onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={ isError={
formik.touched.product_category && formik.touched.product_category &&
Boolean(formik.errors.product_category) Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk' label='Produk'
value={formik.values.product as OptionType} value={formik.values.product as OptionType}
onChange={productChangeHandler} onChange={productChangeHandler}
options={optionsProduct} onInputChange={setProductInputValue}
isLoading={isLoadingProducts} options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)} isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string} errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct} isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse' label='Warehouse'
value={formik.values.warehouse as OptionType} value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
options={optionsWarehouse} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouses} options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={ isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse) formik.touched.warehouse && Boolean(formik.errors.warehouse)
} }
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant'; import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper'; import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]); const [formErrorList, setFormErrorList] = useState<string[]>([]);
@@ -93,10 +91,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
const { const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses, isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
// ===== SELECT INPUT DATA ===== // ===== SELECT INPUT DATA =====
const { const {
@@ -107,12 +106,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
category: 'BOP', category: 'BOP',
}); });
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING ===== // ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => { const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map(); if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -269,25 +262,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}); });
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) ===== // ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const getProductWarehousesUrl = useCallback(() => { const {
const productWarehouseParams = new URLSearchParams({ setInputValue: setProductWarehouseSelectInputValue,
search: productWarehouseSelectInputValue, isLoadingOptions: isLoadingProductWarehouses,
}); loadMore: loadMoreProductWarehouses,
if (formik.values.source_warehouse_id) { rawData: productWarehouses,
productWarehouseParams.append( } = useSelect<ProductWarehouse>(
'warehouse_id', formik.values.source_warehouse_id ? ProductWarehouseApi.basePath : null,
formik.values.source_warehouse_id.toString() 'id',
); 'name',
'search',
{
warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString()
: '',
} }
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; );
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
const productWarehousesUrl = getProductWarehousesUrl();
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
useSWR(
formik.values.source_warehouse_id ? productWarehousesUrl : null,
ProductWarehouseApi.getAllFetcher
);
const productWarehouseOptions = isResponseSuccess(productWarehouses) const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({ ? productWarehouses?.data.map((pw) => ({
@@ -1006,6 +996,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}} }}
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
isError={ isError={
formik.touched.source_warehouse_id && formik.touched.source_warehouse_id &&
@@ -1104,6 +1095,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
formik.touched.destination_warehouse_id && formik.touched.destination_warehouse_id &&
Boolean(formik.errors.destination_warehouse_id) Boolean(formik.errors.destination_warehouse_id)
@@ -1263,6 +1255,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}} }}
options={productWarehouseOptions} options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue} onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
isLoading={isLoadingProductWarehouses} isLoading={isLoadingProductWarehouses}
isDisabled={ isDisabled={
type === 'detail' || type === 'detail' ||
@@ -23,13 +23,17 @@ import TextInput from '@/components/input/TextInput';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import useSWR from 'swr'; import useSWR from 'swr';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
import { TYPE_OPTIONS } from '@/config/constant'; import { TYPE_OPTIONS } from '@/config/constant';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
interface CustomerFormProps { interface CustomerFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -47,25 +51,15 @@ const CustomerForm = ({
// Setup State // Setup State
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState(''); const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [picSelectInputValue, setPicSelectInputValue] = useState('');
// Fetch Data const {
const picUrl = `${UserApi.basePath}?${new URLSearchParams({ setInputValue: setPicSelectInputValue,
search: picSelectInputValue ?? '', options: picOptions,
})}`; isLoadingOptions: isLoadingPicOptions,
loadMore: loadMorePic,
const { data: pic, isLoading: isLoadingPic } = useSWR( } = useSelect<User>(UserApi.basePath, 'id', 'name');
picUrl,
UserApi.getAllFetcher
);
// -- Options data mapping // -- Options data mapping
const picOptions = isResponseSuccess(pic)
? pic?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const typeOptions = TYPE_OPTIONS; const typeOptions = TYPE_OPTIONS;
// Handler Event // Handler Event
@@ -240,11 +234,12 @@ const CustomerForm = ({
required required
placeholder='Pilih PIC' placeholder='Pilih PIC'
label='PIC' label='PIC'
value={formik.values.pic ?? undefined} value={formik.values.pic?.value ? formik.values.pic : undefined}
onChange={picChangeHandler} onChange={picChangeHandler}
options={picOptions} options={picOptions}
onInputChange={setPicSelectInputValue} onInputChange={setPicSelectInputValue}
isLoading={isLoadingPic} onMenuScrollToBottom={loadMorePic}
isLoading={isLoadingPicOptions}
isError={formik.touched.picId && Boolean(formik.errors.picId)} isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string} errorMessage={formik.errors.picId as string}
isDisabled={formType === 'detail'} isDisabled={formType === 'detail'}
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -31,6 +34,7 @@ import { UserApi } from '@/services/api/user';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
interface KandangFormProps { interface KandangFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -128,23 +132,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// location // location
const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); const {
setInputValue: setLocationSelectInputValue,
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ options: locationOptions,
search: locationSelectInputValue ?? '', isLoadingOptions: isLoadingLocationOptions,
}).toString()}`; loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
@@ -155,23 +148,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
}; };
// PIC // PIC
const [picSelectInputValue, setPicSelectInputValue] = useState(''); const {
setInputValue: setPicSelectInputValue,
const picsUrl = `${UserApi.basePath}?${new URLSearchParams({ options: picOptions,
search: picSelectInputValue ?? '', isLoadingOptions: isLoadingPicOptions,
}).toString()}`; loadMore: loadMorePics,
} = useSelect<User>(UserApi.basePath, 'id', 'name');
const { data: pics, isLoading: isLoadingPics } = useSWR(
picsUrl,
LocationApi.getAllFetcher
);
const picOptions = isResponseSuccess(pics)
? pics?.data.map((pic) => ({
value: pic.id,
label: pic.name,
}))
: [];
const picChangeHandler = (val: OptionType | OptionType[] | null) => { const picChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('pic', true); formik.setFieldTouched('pic', true);
@@ -233,7 +215,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama kandang'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -249,7 +231,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isError={ isError={
formik.touched.locationId && Boolean(formik.errors.locationId) formik.touched.locationId && Boolean(formik.errors.locationId)
} }
@@ -280,7 +263,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
onChange={picChangeHandler} onChange={picChangeHandler}
options={picOptions} options={picOptions}
onInputChange={setPicSelectInputValue} onInputChange={setPicSelectInputValue}
isLoading={isLoadingPics} onMenuScrollToBottom={loadMorePics}
isLoading={isLoadingPicOptions}
isError={formik.touched.picId && Boolean(formik.errors.picId)} isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string} errorMessage={formik.errors.picId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -29,6 +32,7 @@ import { AreaApi, LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Area } from '@/types/api/master-data/area';
interface LocationFormProps { interface LocationFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -117,23 +121,12 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); const {
setInputValue: setAreaSelectInputValue,
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({ options: areaOptions,
search: areaSelectInputValue ?? '', isLoadingOptions: isLoadingAreaOptions,
}).toString()}`; loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('area', true); formik.setFieldTouched('area', true);
@@ -224,7 +217,8 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
onChange={areaChangeHandler} onChange={areaChangeHandler}
options={areaOptions} options={areaOptions}
onInputChange={setAreaSelectInputValue} onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas} onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)} isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string} errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -31,6 +34,8 @@ import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant'; import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Supplier } from '@/types/api/master-data/supplier';
import { Uom } from '@/types/api/master-data/uom';
interface NonstockFormProps { interface NonstockFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -129,23 +134,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// UOM // UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState(''); const {
setInputValue: setUomSelectInputValue,
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ options: uomOptions,
search: uomSelectInputValue ?? '', isLoadingOptions: isLoadingUomOptions,
}).toString()}`; loadMore: loadMoreUoms,
} = useSelect<Uom>(UomApi.basePath, 'id', 'name');
const { data: uoms, isLoading: isLoadingUoms } = useSWR(
uomsUrl,
UomApi.getAllFetcher
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({
value: uom.id,
label: uom.name,
}))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
@@ -156,25 +150,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
}; };
// supplier // supplier
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const {
setInputValue: setSupplierSelectInputValue,
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ options: supplierOptions,
search: supplierSelectInputValue ?? '', isLoadingOptions: isLoadingSupplierOptions,
}).toString()}`; loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'BOP')
.map((supplier) => ({
value: supplier.id,
label: supplier.name,
}))
: [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('suppliers', true); formik.setFieldTouched('suppliers', true);
@@ -248,7 +229,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama nonstock'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -264,7 +245,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
onInputChange={setUomSelectInputValue} onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms} isLoading={isLoadingUomOptions}
onMenuScrollToBottom={loadMoreUoms}
isError={formik.touched.uomId && Boolean(formik.errors.uomId)} isError={formik.touched.uomId && Boolean(formik.errors.uomId)}
errorMessage={formik.errors.uomId as string} errorMessage={formik.errors.uomId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -278,7 +260,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions ?? []} options={supplierOptions ?? []}
onInputChange={setSupplierSelectInputValue} onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers} onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isError={ isError={
formik.touched.suppliers && Boolean(formik.errors.suppliers) formik.touched.suppliers && Boolean(formik.errors.suppliers)
} }
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = { type ProductFormSchemaType = {
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
uom?: { uom?: {
value: number; value: number;
label: string; label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null; } | null;
product_category_id: number; product_category_id: number;
product_price: number | string; product_price: number | string;
selling_price: number | string; selling_price?: number | string;
tax: number | string; tax?: number | string;
expiry_period: number | string; expiry_period?: number | string;
supplier_ids: number[]; suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[]; flags: string[];
}; };
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({ Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'), brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string(),
uom: Yup.object({ uom: Yup.object({
value: Yup.number() value: Yup.number()
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'), .min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .typeError('Harga hanya boleh angka!')
.typeError('Harga jual wajib diisi!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'), .min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .typeError('Pajak hanya boleh angka!')
.typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa hanya boleh angka!')
.typeError('Periode kadaluarsa wajib diisi!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'), .min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array() suppliers: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(
.min(1, 'Minimal harus ada 1 supplier!') Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flags: Yup.array()
@@ -40,6 +40,9 @@ import {
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ProductFormProps { interface ProductFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -100,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
selling_price: initialValues?.selling_price ?? '', selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? '', tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? '', expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], suppliers: initialValues?.suppliers
? initialValues.suppliers.map((supplier) => ({
supplier: {
value: supplier.id,
label: supplier.name,
},
price: supplier.price,
}))
: [],
flags: initialValues?.flags ?? [], flags: initialValues?.flags ?? [],
}), }),
[initialValues] [initialValues]
@@ -119,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: parseInt(values.selling_price.toString()) || 0, selling_price: values.selling_price
tax: parseInt(values.tax.toString()) || 0, ? parseInt(values.selling_price.toString()) || 0
expiry_period: parseInt(values.expiry_period.toString()) || 0, : undefined,
supplier_ids: values.supplier_ids.filter( tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
(id): id is number => typeof id === 'number' expiry_period: values.expiry_period
), ? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
})),
flags: values.flags.filter((f): f is string => typeof f === 'string'), flags: values.flags.filter((f): f is string => typeof f === 'string'),
}; };
switch (type) { switch (type) {
@@ -145,6 +161,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
setInputValue: setUomSelectInputValue, setInputValue: setUomSelectInputValue,
options: uomOptions, options: uomOptions,
isLoadingOptions: isLoadingUoms, isLoadingOptions: isLoadingUoms,
loadMore: loadMoreUoms,
} = useSelect(UomApi.basePath, 'id', 'name'); } = useSelect(UomApi.basePath, 'id', 'name');
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
@@ -158,6 +175,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
setInputValue: setCategorySelectInputValue, setInputValue: setCategorySelectInputValue,
options: categoryOptions, options: categoryOptions,
isLoadingOptions: isLoadingCategories, isLoadingOptions: isLoadingCategories,
loadMore: loadMoreCategories,
} = useSelect(ProductCategoryApi.basePath, 'id', 'name'); } = useSelect(ProductCategoryApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true); formik.setFieldTouched('product_category', true);
@@ -167,24 +185,38 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}; };
// Supplier (multi select) - using SWR to filter by category // Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const {
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; setInputValue: setSupplierSelectInputValue,
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( options: supplierOptions,
suppliersUrl, isLoadingOptions: isLoadingSuppliers,
SupplierApi.getAllFetcher loadMore: loadMoreSuppliers,
); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
const supplierOptions = isResponseSuccess(suppliers) category: 'SAPRONAK',
? suppliers?.data });
.filter((sup) => sup.category === 'SAPRONAK')
.map((sup) => ({ value: sup.id, label: sup.name })) const filteredSupplierOptions = useMemo(() => {
: []; return supplierOptions.filter((opt) => {
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { return !formik.values.suppliers.some(
const arr = Array.isArray(val) ? val : val ? [val] : []; (s) => s.supplier?.value === opt.value
formik.setFieldTouched('supplier_ids', true); );
formik.setFieldValue( });
'supplier_ids', }, [supplierOptions, formik.values.suppliers]);
arr.map((v) => (v as OptionType).value)
); const addSupplierHandler = () => {
formik.setFieldValue('suppliers', [
...formik.values.suppliers,
{
supplier_id: '',
price: formik.values.product_price,
},
]);
};
const deleteSupplierItemHandler = (idx: number) => {
const path = 'suppliers';
// trims values, errors, and touched at idx
removeArrayItemAndSync(formik, path, idx);
}; };
const deleteProductClickHandler = () => { const deleteProductClickHandler = () => {
@@ -200,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
router.push('/master-data/product'); router.push('/master-data/product');
}; };
const isSupplierRepeaterError = (
column: 'supplier' | 'price',
supplierIdx: number
) => {
return (
formik.touched.suppliers?.[supplierIdx]?.[column] &&
Boolean(
formik.errors.suppliers?.[supplierIdx] instanceof Object &&
formik.errors.suppliers?.[supplierIdx]?.[column]
)
);
};
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
@@ -270,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <TextInput
required
label='SKU' label='SKU'
name='sku' name='sku'
placeholder='Masukkan SKU...' placeholder='Masukkan SKU...'
@@ -291,6 +335,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
onInputChange={setUomSelectInputValue} onInputChange={setUomSelectInputValue}
onMenuScrollToBottom={loadMoreUoms}
isLoading={isLoadingUoms} isLoading={isLoadingUoms}
isError={ isError={
(formik.touched.uom || formik.touched.uom_id) && (formik.touched.uom || formik.touched.uom_id) &&
@@ -308,6 +353,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
options={categoryOptions} options={categoryOptions}
onInputChange={setCategorySelectInputValue} onInputChange={setCategorySelectInputValue}
onMenuScrollToBottom={loadMoreCategories}
isLoading={isLoadingCategories} isLoading={isLoadingCategories}
isError={ isError={
(formik.touched.product_category || (formik.touched.product_category ||
@@ -341,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<NumberInput <NumberInput
required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
placeholder='Masukkan harga jual...' placeholder='Masukkan harga jual...'
@@ -363,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div> </div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
placeholder='Masukkan pajak...' placeholder='Masukkan pajak...'
@@ -380,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<NumberInput <NumberInput
required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
placeholder='Masukkan periode kadaluarsa...' placeholder='Masukkan periode kadaluarsa...'
@@ -400,27 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div> </div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-1 gap-4'>
<SelectInput
required
label='Supplier'
placeholder='Pilih supplier...'
isMulti
value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
@@ -443,6 +466,129 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable isClearable
/> />
</div> </div>
<div className='grid sm:grid-cols-1 gap-4'>
{type !== 'detail' && formik.values.suppliers.length === 0 && (
<Button
type='button'
color='success'
onClick={addSupplierHandler}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah
Supplier
</Button>
)}
{formik.values.suppliers.length > 0 && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>Supplier</h4>
</div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Supplier
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga
</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.suppliers.map((supplier, idx) => (
<tr key={idx}>
<td className='p-2 w-full max-w-1/2'>
<SelectInput
placeholder='Pilih Supplier'
options={filteredSupplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
value={formik.values.suppliers[idx].supplier}
onChange={(val) => {
formik.setFieldValue(
`suppliers.${idx}.supplier`,
val
);
}}
isError={isSupplierRepeaterError(
'supplier',
idx
)}
isClearable
isDisabled={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
<td className='p-2 w-full max-w-1/2'>
<NumberInput
required
name={`suppliers.${idx}.price`}
placeholder='Masukkan harga...'
value={formik.values.suppliers[idx].price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={isSupplierRepeaterError('price', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => deleteSupplierItemHandler(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='w-full flex flex-row justify-center'>
<Button
type='button'
color='success'
onClick={addSupplierHandler}
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah Supplier
</Button>
</div>
)}
</Card>
)}
</div>
</div> </div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -35,6 +38,8 @@ import { cn } from '@/lib/helper';
import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant'; import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Area } from '@/types/api/master-data/area';
import { Kandang } from '@/types/api/master-data/kandang';
interface WarehouseFormProps { interface WarehouseFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -221,61 +226,28 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// Area // Area
const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); const {
setInputValue: setAreaSelectInputValue,
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({ options: areaOptions,
search: areaSelectInputValue ?? '', isLoadingOptions: isLoadingAreaOptions,
}).toString()}`; loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
// Location // Location
const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); const {
setInputValue: setLocationSelectInputValue,
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ options: locationOptions,
search: locationSelectInputValue ?? '', isLoadingOptions: isLoadingLocationOptions,
}).toString()}`; loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
// Kandang // Kandang
const [kandangSelectInputValue, setKandangSelectInputValue] = useState(''); const {
setInputValue: setKandangSelectInputValue,
const kandangsUrl = `${KandangApi.basePath}?${new URLSearchParams({ options: kandangOptions,
search: kandangSelectInputValue ?? '', isLoadingOptions: isLoadingKandangOptions,
}).toString()}`; loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const { data: kandangs, isLoading: isLoadingKandangs } = useSWR(
kandangsUrl,
KandangApi.getAllFetcher
);
const kandangOptions = isResponseSuccess(kandangs)
? kandangs?.data.map((kandang) => ({
value: kandang.id,
label: kandang.name,
}))
: [];
const typeChangeHandler = (val: OptionType | OptionType[] | null) => { const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('type', true); formik.setFieldTouched('type', true);
@@ -358,7 +330,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama warehouse'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -393,7 +365,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={areaChangeHandler} onChange={areaChangeHandler}
options={areaOptions} options={areaOptions}
onInputChange={setAreaSelectInputValue} onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas} onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)} isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string} errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -409,7 +382,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isError={ isError={
formik.touched.locationId && Boolean(formik.errors.locationId) formik.touched.locationId && Boolean(formik.errors.locationId)
} }
@@ -427,7 +401,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
options={kandangOptions} options={kandangOptions}
onInputChange={setKandangSelectInputValue} onInputChange={setKandangSelectInputValue}
isLoading={isLoadingKandangs} onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangOptions}
isError={ isError={
formik.touched.kandangId && Boolean(formik.errors.kandangId) formik.touched.kandangId && Boolean(formik.errors.kandangId)
} }
@@ -678,12 +678,13 @@ const RecordingTable = () => {
{ {
header: 'Nama Project', header: 'Nama Project',
cell: (props) => cell: (props) =>
`Project ${props.row.original.project_flock_kandang_id}`, props.row.original.project_flock?.flock_name || '-',
}, },
{ {
header: 'Kategori', header: 'Kategori',
cell: (props) => { cell: (props) => {
const category = props.row.original.project_flock_category; const category =
props.row.original.project_flock?.project_flock_category;
if (!category) return '-'; if (!category) return '-';
const color = category === 'LAYING' ? 'info' : 'warning'; const color = category === 'LAYING' ? 'info' : 'warning';
return ( return (
@@ -706,7 +707,8 @@ const RecordingTable = () => {
{ {
header: 'Populasi Awal', header: 'Populasi Awal',
cell: (props) => cell: (props) =>
props.row.original.total_chick_qty?.toLocaleString() || '-', props.row.original.project_flock?.total_chick_qty?.toLocaleString() ||
'-',
}, },
{ {
header: 'Status Approval', header: 'Status Approval',
@@ -117,8 +117,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
// ===== PAYLOAD CREATION HELPERS ===== // ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback( const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => { (values: RecordingGrowingFormValues) => {
const today = new Date().toISOString().split('T')[0];
return { return {
project_flock_kandang_id: values.project_flock_kandang_id, project_flock_kandang_id: values.project_flock_kandang_id,
record_date: today,
stocks: (values.stocks ?? []).map((stock) => ({ stocks: (values.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id, product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0, qty: Number(stock.qty) || 0,
@@ -134,8 +136,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const createLayingPayload = useCallback( const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => { (values: RecordingLayingFormValues) => {
const today = new Date().toISOString().split('T')[0];
return { return {
project_flock_kandang_id: values.project_flock_kandang_id, project_flock_kandang_id: values.project_flock_kandang_id,
record_date: today,
stocks: (values.stocks ?? []).map((stock) => ({ stocks: (values.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id, product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0, qty: Number(stock.qty) || 0,
@@ -252,9 +256,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
: undefined; : undefined;
const projectFlockKandangDetailUrl = useMemo(() => { const projectFlockKandangDetailUrl = useMemo(() => {
if (type === 'add' || !initialValues?.project_flock_kandang_id) return null; if (
return `${ProjectFlockKandangApi.basePath}/${initialValues.project_flock_kandang_id}`; type === 'add' ||
}, [type, initialValues?.project_flock_kandang_id]); !initialValues?.project_flock?.project_flock_kandang_id
)
return null;
return `${ProjectFlockKandangApi.basePath}/${initialValues.project_flock.project_flock_kandang_id}`;
}, [type, initialValues?.project_flock?.project_flock_kandang_id]);
const { data: projectFlockKandangDetailData } = useSWR( const { data: projectFlockKandangDetailData } = useSWR(
projectFlockKandangDetailUrl, projectFlockKandangDetailUrl,
@@ -404,12 +412,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, [approvedProjectFlockKandangsData]); }, [approvedProjectFlockKandangsData]);
const isLayingCategory = const isLayingCategory =
initialValues?.project_flock_category === 'LAYING' || initialValues?.project_flock?.project_flock_category === 'LAYING' ||
projectFlockKandangLookup?.project_flock?.category === 'LAYING' || projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
projectFlockKandangDetail?.project_flock?.category === 'LAYING'; projectFlockKandangDetail?.project_flock?.category === 'LAYING';
const isGrowingCategory = const isGrowingCategory =
initialValues?.project_flock_category === 'GROWING' || initialValues?.project_flock?.project_flock_category === 'GROWING' ||
projectFlockKandangLookup?.project_flock?.category === 'GROWING' || projectFlockKandangLookup?.project_flock?.category === 'GROWING' ||
projectFlockKandangDetail?.project_flock?.category === 'GROWING'; projectFlockKandangDetail?.project_flock?.category === 'GROWING';
@@ -555,7 +563,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
todayRecordings.forEach((recording) => { todayRecordings.forEach((recording) => {
const recordingDate = recording.record_datetime?.split('T')[0]; const recordingDate = recording.record_datetime?.split('T')[0];
if (recordingDate === today) { if (recordingDate === today) {
recordedIds.add(recording.project_flock_kandang_id); recordedIds.add(recording.project_flock.project_flock_kandang_id);
} }
}); });
@@ -1005,7 +1013,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const hasSameDayRecording = isResponseSuccess(existingRecordings) const hasSameDayRecording = isResponseSuccess(existingRecordings)
? existingRecordings.data?.some( ? existingRecordings.data?.some(
(recording: Recording) => (recording: Recording) =>
recording.project_flock_kandang_id === recording.project_flock.project_flock_kandang_id ===
projectFlockKandangId && projectFlockKandangId &&
recording.day === nextDayRecording.next_day recording.day === nextDayRecording.next_day
) )
@@ -1543,13 +1551,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<Badge <Badge
variant='soft' variant='soft'
color={ color={
initialValues.project_flock_category === 'LAYING' initialValues.project_flock
?.project_flock_category === 'LAYING'
? 'info' ? 'info'
: 'warning' : 'warning'
} }
size='sm' size='sm'
> >
{initialValues.project_flock_category} {initialValues.project_flock?.project_flock_category}
</Badge> </Badge>
</p> </p>
</div> </div>
@@ -1579,7 +1588,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type === 'detail' && initialValues && ( {type === 'detail' && initialValues && (
<div <div
className={`grid gap-6 mb-6 grid-cols-1 ${ className={`grid gap-6 mb-6 grid-cols-1 ${
initialValues.project_flock_category === 'LAYING' initialValues.project_flock?.project_flock_category === 'LAYING'
? 'xl:grid-cols-3' ? 'xl:grid-cols-3'
: 'xl:grid-cols-2' : 'xl:grid-cols-2'
}`} }`}
@@ -1614,8 +1623,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'> <td className='text-center py-3 text-gray-600'>
{initialValues.fcr_std && initialValues.fcr_std > 0 {initialValues.project_flock?.fcr?.fcr_std &&
? formatNumber(initialValues.fcr_std) initialValues.project_flock?.fcr?.fcr_std > 0
? formatNumber(
initialValues.project_flock?.fcr?.fcr_std
)
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -1630,9 +1642,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'> <td className='text-center py-3 text-gray-600'>
{initialValues.feed_intake_std && {initialValues.project_flock?.production_standart
initialValues.feed_intake_std > 0 ?.feed_intake_std &&
? formatNumber(initialValues.feed_intake_std) initialValues.project_flock?.production_standart
?.feed_intake_std > 0
? formatNumber(
initialValues.project_flock?.production_standart
?.feed_intake_std
)
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -1650,59 +1667,39 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div> </div>
<div className='p-4'> <div className='p-4'>
<table className='w-full text-sm'> <table className='w-full text-sm'>
<thead>
<tr className='border-b border-gray-200'>
<th
colSpan={2}
className='text-center py-2 font-semibold text-gray-600'
>
DEPLESI KUMULATIF
</th>
</tr>
<tr className='border-b border-gray-200'>
<th className='text-center py-2 font-semibold text-xs text-gray-500'>
Total
</th>
<th className='text-center py-2 font-semibold text-xs text-gray-500'>
(%)
</th>
</tr>
</thead>
<tbody> <tbody>
<tr> <tr>
<td className='text-center py-3 border-r border-gray-100'> <td className='py-2 font-medium'>Deplesi Kumulatif</td>
<td className='text-right py-2'>
<span className='font-semibold'> <span className='font-semibold'>
{initialValues.total_depletion_qty && {initialValues.cum_depletion_rate &&
initialValues.total_depletion_qty > 0 initialValues.cum_depletion_rate > 0
? formatNumber(initialValues.total_depletion_qty) ? `${initialValues.cum_depletion_rate.toFixed(2)}%`
: '-'} : '-'}
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'>
{initialValues.cum_depletion_rate &&
initialValues.cum_depletion_rate > 0
? initialValues.cum_depletion_rate.toFixed(2)
: '-'}
</td>
</tr> </tr>
<tr> <tr>
<td <td className='py-2 font-medium'>Total Depletion</td>
colSpan={2} <td className='text-right py-2 font-semibold'>
className='text-center py-3 border-r border-gray-200 text-gray-600' {initialValues.total_depletion_qty &&
> initialValues.total_depletion_qty > 0
Total Ayam ? formatNumber(initialValues.total_depletion_qty)
</td>
</tr>
<tr>
<td
colSpan={2}
className='text-center py-3 font-semibold'
>
{initialValues.total_chick_qty &&
initialValues.total_chick_qty > 0
? formatNumber(initialValues.total_chick_qty)
: '-'} : '-'}
</td> </td>
<td></td>
</tr>
<tr className='border-t border-gray-200'>
<td className='py-2 text-gray-600'>Total Ayam</td>
<td className='text-right py-2 font-semibold'>
{initialValues.project_flock?.total_chick_qty &&
initialValues.project_flock?.total_chick_qty > 0
? formatNumber(
initialValues.project_flock?.total_chick_qty
)
: '-'}
</td>
<td></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -1712,7 +1709,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Egg Production Section - Only for LAYING category */} {/* Egg Production Section - Only for LAYING category */}
{type === 'detail' && {type === 'detail' &&
initialValues && initialValues &&
initialValues.project_flock_category === 'LAYING' && ( initialValues.project_flock?.project_flock_category ===
'LAYING' && (
<div className='border border-gray-200 rounded-lg bg-white'> <div className='border border-gray-200 rounded-lg bg-white'>
<div className='px-4 py-3 border-b border-gray-200'> <div className='px-4 py-3 border-b border-gray-200'>
<span className='card-title font-bold text-xl'> <span className='card-title font-bold text-xl'>
@@ -1744,9 +1742,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'> <td className='text-center py-3 text-gray-600'>
{initialValues.egg_mass_std && {initialValues.project_flock?.production_standart
initialValues.egg_mass_std > 0 ?.egg_mass_std &&
? formatNumber(initialValues.egg_mass_std) initialValues.project_flock?.production_standart
?.egg_mass_std > 0
? formatNumber(
initialValues.project_flock
?.production_standart?.egg_mass_std
)
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -1763,9 +1766,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'> <td className='text-center py-3 text-gray-600'>
{initialValues.egg_weight_std && {initialValues.project_flock?.production_standart
initialValues.egg_weight_std > 0 ?.egg_weight_std &&
? formatNumber(initialValues.egg_weight_std) initialValues.project_flock?.production_standart
?.egg_weight_std > 0
? formatNumber(
initialValues.project_flock
?.production_standart?.egg_weight_std
)
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -1780,9 +1788,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'> <td className='text-center py-3 text-gray-600'>
{initialValues.hen_day_std !== undefined && {initialValues.project_flock?.production_standart
initialValues.hen_day_std > 0 ?.hen_day_std !== undefined &&
? `${initialValues.hen_day_std}%` initialValues.project_flock?.production_standart
?.hen_day_std > 0
? `${initialValues.project_flock?.production_standart?.hen_day_std}%`
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -1797,9 +1807,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span> </span>
</td> </td>
<td className='text-center py-3 text-gray-600'> <td className='text-center py-3 text-gray-600'>
{initialValues.hen_house_std !== undefined && {initialValues.project_flock?.production_standart
initialValues.hen_house_std > 0 ?.hen_house_std !== undefined &&
? `${initialValues.hen_house_std}%` initialValues.project_flock?.production_standart
?.hen_house_std > 0
? `${initialValues.project_flock?.production_standart?.hen_house_std}%`
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { pdf } from '@react-pdf/renderer'; import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -28,7 +28,10 @@ import {
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { Customer } from '@/types/api/master-data/customer'; import { Customer } from '@/types/api/master-data/customer';
import { MarketingReportApi } from '@/services/api/report/marketing-report'; import { MarketingReportApi } from '@/services/api/report/marketing-report';
import { MARKETING_TYPE_OPTIONS } from '@/config/constant'; import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import { httpClient } from '@/services/http/client'; import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
@@ -84,6 +87,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
options: areaOptions, options: areaOptions,
isLoadingOptions: isLoadingAreaOptions, isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name'); } = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -98,6 +102,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -115,6 +120,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setWarehouseInputValue, setInputValue: setWarehouseInputValue,
options: warehouseOptions, options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions, isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -132,6 +138,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setCustomerInputValue, setInputValue: setCustomerInputValue,
options: customerOptions, options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions, isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name'); } = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => { const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -150,6 +157,15 @@ const DailyMarketingReportContent = () => {
updateFilter('end_date', e.target.value ? e.target.value : ''); updateFilter('end_date', e.target.value ? e.target.value : '');
}; };
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
useState<OptionType | null>(null);
const marketingDateFilterTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingDateFilterType(val as OptionType);
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
};
const [selectedMarketingType, setSelectedMarketingType] = const [selectedMarketingType, setSelectedMarketingType] =
useState<OptionType | null>(null); useState<OptionType | null>(null);
const marketingTypeChangeHandler = ( const marketingTypeChangeHandler = (
@@ -252,6 +268,23 @@ const DailyMarketingReportContent = () => {
resetFilter(); resetFilter();
}; };
useEffect(() => {
if (
tableFilterState.filter_by === 'realization_date' ||
tableFilterState.filter_by === 'so_date'
) {
setSelectedMarketingDateFilterType({
label:
tableFilterState.filter_by === 'realization_date'
? 'Tanggal Realisasi'
: 'Tanggal SO',
value: tableFilterState.filter_by,
});
} else {
setSelectedMarketingDateFilterType(null);
}
}, [tableFilterState.filter_by]);
return ( return (
<div className='w-full border border-gray-200 p-4'> <div className='w-full border border-gray-200 p-4'>
<div> <div>
@@ -269,6 +302,7 @@ const DailyMarketingReportContent = () => {
value={selectedArea} value={selectedArea}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -283,6 +317,7 @@ const DailyMarketingReportContent = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -297,6 +332,7 @@ const DailyMarketingReportContent = () => {
value={selectedWarehouse} value={selectedWarehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -311,6 +347,7 @@ const DailyMarketingReportContent = () => {
value={selectedCustomer} value={selectedCustomer}
onChange={customerChangeHandler} onChange={customerChangeHandler}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -341,6 +378,18 @@ const DailyMarketingReportContent = () => {
</div> </div>
<div className='grid grid-cols-12 gap-4'> <div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Filter Tanggal'
placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
value={selectedMarketingDateFilterType}
onChange={marketingDateFilterTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput <SelectInput
label='Tipe Marketing' label='Tipe Marketing'
placeholder='Pilih Tipe Marketing' placeholder='Pilih Tipe Marketing'
@@ -71,19 +71,22 @@ const DailyMarketingsTable = ({
cell: (props) => `${props.row.original.aging_days} hari`, cell: (props) => `${props.row.original.aging_days} hari`,
}, },
{ {
accessorKey: 'warehouse.name', accessorKey: 'warehouse',
header: 'Gudang', header: 'Gudang',
cell: ({ row }) => row.original.warehouse.name,
}, },
{ {
accessorKey: 'customer.name', accessorKey: 'customer',
header: 'Pelanggan', header: 'Pelanggan',
cell: ({ row }) => row.original.customer.name,
}, },
{ {
accessorKey: 'do_number', accessorKey: 'do_number',
header: 'No. DO', header: 'No. DO',
enableSorting: false,
}, },
{ {
accessorKey: 'sales', accessorKey: 'sales_person',
header: 'Sales/Marketing', header: 'Sales/Marketing',
cell: (props) => props.row.original.sales.name, cell: (props) => props.row.original.sales.name,
}, },
@@ -97,10 +100,12 @@ const DailyMarketingsTable = ({
{ {
accessorKey: 'marketing_type', accessorKey: 'marketing_type',
header: 'Marketing Type', header: 'Marketing Type',
enableSorting: false,
}, },
{ {
accessorKey: 'product.name', accessorKey: 'product',
header: 'Produk', header: 'Produk',
cell: ({ row }) => row.original.product.name,
}, },
{ {
accessorKey: 'qty', accessorKey: 'qty',
@@ -115,12 +120,12 @@ const DailyMarketingsTable = ({
}, },
}, },
{ {
accessorKey: 'average_weight_kg', accessorKey: 'average_weight',
header: 'Bobot Rata-Rata (Kg)', header: 'Bobot Rata-Rata (Kg)',
cell: (props) => formatNumber(props.row.original.average_weight_kg), cell: (props) => formatNumber(props.row.original.average_weight_kg),
}, },
{ {
accessorKey: 'total_weight_kg', accessorKey: 'total_weight',
header: 'Bobot Total (Kg)', header: 'Bobot Total (Kg)',
cell: (props) => formatNumber(props.row.original.total_weight_kg), cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => { footer: () => {
@@ -132,12 +137,12 @@ const DailyMarketingsTable = ({
}, },
}, },
{ {
accessorKey: 'sales_price_per_kg', accessorKey: 'sales_price',
header: 'Harga Jual (Rp)', header: 'Harga Jual (Rp)',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
}, },
{ {
accessorKey: 'hpp_price_per_kg', accessorKey: 'hpp_price',
header: 'HPP (Rp)', header: 'HPP (Rp)',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => { footer: () => {
@@ -163,6 +168,8 @@ const DailyMarketingsTable = ({
]; ];
useEffect(() => { useEffect(() => {
console.log({ sorting });
if (sorting.length === 1) { if (sorting.length === 1) {
onFilterByChange(sorting[0].id); onFilterByChange(sorting[0].id);
onSortByChange(sorting[0].desc ? 'desc' : 'asc'); onSortByChange(sorting[0].desc ? 'desc' : 'asc');
@@ -33,7 +33,7 @@ const MarketingReportContent = () => {
const [activeTab, setActiveTab] = useState<string>('daily'); const [activeTab, setActiveTab] = useState<string>('daily');
return ( return (
<section className='w-full max-w-7xl pb-16'> <section className='w-full max-w-full pb-16'>
<Tabs <Tabs
activeTabId={activeTab} activeTabId={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
@@ -26,6 +26,15 @@ import MenuItem from '@/components/menu/MenuItem';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { generateReportExpensePDF } from './pdf/ReportExpenseExport'; import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
import toast from 'react-hot-toast'; 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';
const ReportExpenseTable = () => { const ReportExpenseTable = () => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
@@ -64,16 +73,33 @@ const ReportExpenseTable = () => {
}); });
// ===== SELECT OPTIONS ===== // ===== SELECT OPTIONS =====
const { options: optionsLocation, isLoadingOptions: isLoadingLocation } = const {
useSelect(`/master-data/locations`, 'id', 'name'); setInputValue: setLocationInputValue,
const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } = options: locationOptions,
useSelect(`/master-data/suppliers`, 'id', 'name'); isLoadingOptions: isLoadingLocationOptions,
const { options: optionsKandang, isLoadingOptions: isLoadingKandang } = loadMore: loadMoreLocations,
useSelect(`/master-data/kandangs`, 'id', 'name', '', { } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
location_id: filterState.location_id,
}); const {
const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } = setInputValue: setSupplierInputValue,
useSelect(`/master-data/nonstocks`, 'id', 'name'); options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const categoryOptions = useMemo( const categoryOptions = useMemo(
() => [ () => [
@@ -86,31 +112,31 @@ const ReportExpenseTable = () => {
// Mendapatkan value option select dari filter state // Mendapatkan value option select dari filter state
const selectedLocation = useMemo( const selectedLocation = useMemo(
() => () =>
optionsLocation.find( locationOptions.find(
(opt) => String(opt.value) === filterState.location_id (opt) => String(opt.value) === filterState.location_id
) || null, ) || null,
[optionsLocation, filterState.location_id] [locationOptions, filterState.location_id]
); );
const selectedSupplier = useMemo( const selectedSupplier = useMemo(
() => () =>
optionsSupplier.find( supplierOptions.find(
(opt) => String(opt.value) === filterState.supplier_id (opt) => String(opt.value) === filterState.supplier_id
) || null, ) || null,
[optionsSupplier, filterState.supplier_id] [supplierOptions, filterState.supplier_id]
); );
const selectedKandang = useMemo( const selectedKandang = useMemo(
() => () =>
optionsKandang.find( kandangOptions.find(
(opt) => String(opt.value) === filterState.kandang_id (opt) => String(opt.value) === filterState.kandang_id
) || null, ) || null,
[optionsKandang, filterState.kandang_id] [kandangOptions, filterState.kandang_id]
); );
const selectedNonstock = useMemo( const selectedNonstock = useMemo(
() => () =>
optionsNonstock.find( nonstockOptions.find(
(opt) => String(opt.value) === filterState.nonstock_id (opt) => String(opt.value) === filterState.nonstock_id
) || null, ) || null,
[optionsNonstock, filterState.nonstock_id] [nonstockOptions, filterState.nonstock_id]
); );
const selectedCategory = useMemo( const selectedCategory = useMemo(
() => () =>
@@ -756,38 +782,46 @@ const ReportExpenseTable = () => {
<SelectInput <SelectInput
isClearable isClearable
label='Lokasi' label='Lokasi'
options={optionsLocation} options={locationOptions}
isLoading={isLoadingLocation} isLoading={isLoadingLocationOptions}
placeholder='Lokasi' placeholder='Lokasi'
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
/> />
<SelectInput <SelectInput
isClearable isClearable
label='Kandang' label='Kandang'
options={optionsKandang} options={kandangOptions}
isLoading={isLoadingKandang} isLoading={isLoadingKandangOptions}
placeholder='Kandang' placeholder='Kandang'
value={selectedKandang} value={selectedKandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
/> />
<SelectInput <SelectInput
isClearable isClearable
label='Supplier' label='Supplier'
options={optionsSupplier} options={supplierOptions}
isLoading={isLoadingSupplier} isLoading={isLoadingSupplierOptions}
placeholder='Supplier' placeholder='Supplier'
value={selectedSupplier} value={selectedSupplier}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
/> />
<SelectInput <SelectInput
isClearable isClearable
label='Produk' label='Produk'
options={optionsNonstock} options={nonstockOptions}
isLoading={isLoadingNonstock} isLoading={isLoadingNonstockOptions}
placeholder='Produk' placeholder='Produk'
value={selectedNonstock} value={selectedNonstock}
onChange={nonstockChangeHandler} onChange={nonstockChangeHandler}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
/> />
<SelectInput <SelectInput
isClearable isClearable
@@ -8,16 +8,16 @@ const FinanceTabs = () => {
const tabs = [ const tabs = [
{ {
id: '1', id: '1',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab />,
},
{
id: '2',
label: 'Rekapitulasi Hutang Ke Supplier', label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab />, content: <DebtSupplierTab />,
}, },
{
id: '2',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab />,
},
]; ];
return ( return (
@@ -136,41 +136,132 @@ const pdfStyles = StyleSheet.create({
backgroundColor: '#F0F0F0', backgroundColor: '#F0F0F0',
fontWeight: 'bold', fontWeight: 'bold',
}, },
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'center',
marginRight: 4,
},
badgeLunas: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
},
badgeBelumLunas: {
backgroundColor: '#F97316',
color: '#FFFFFF',
},
textError: {
color: '#DC2626',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
}); });
interface CustomerPaymentExportPDFParams { interface CustomerPaymentExportPDFParams {
data: CustomerPaymentReport[]; data: CustomerPaymentReport[];
params?: {
customer_name?: string;
sales?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
};
} }
const getParameterText = (
params?: CustomerPaymentExportPDFParams['params']
) => {
const paramsText = [];
if (params?.customer_name) {
paramsText.push(`Customer: ${params.customer_name}`);
} else {
paramsText.push('Semua Customer');
}
if (params?.sales) {
paramsText.push(`Sales: ${params.sales}`);
}
if (params?.start_date && params?.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
const endDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Periode: ${startDate} - ${endDate}`);
} else if (params?.start_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${startDate}`);
}
const currentDate = formatDate(new Date(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const createPDFDocument = (params: CustomerPaymentExportPDFParams) => { const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
return ( return (
<Document> <Document>
{params.data.map((customerReport, customerIndex) => ( {params.data.map((customerReport, customerIndex) => (
<Page <Page
key={customerIndex} key={customerIndex}
size='A4' size='A3'
orientation='landscape' orientation='landscape'
style={pdfStyles.page} style={pdfStyles.page}
> >
{/* Title and Customer Info */} {/* Title and Parameters */}
<View style={pdfStyles.titleSection}> <View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}> <Text style={pdfStyles.mainTitle}>
Laporan &gt; Kontrol Pembayaran Customer Laporan &gt; Kontrol Pembayaran Customer
</Text> </Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Periode:{' '}
{params.params?.start_date
? formatDate(params.params.start_date, 'DD MMM YYYY')
: '-'}{' '}
s.d{' '}
{params.params?.end_date
? formatDate(params.params.end_date, 'DD MMM YYYY')
: '-'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>Filter Tanggal: Tanggal DO</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Customer: {params.params?.customer_name || 'Semua Customer'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text>
</View>
</View>
<Text style={pdfStyles.supplierTitle}> <Text style={pdfStyles.supplierTitle}>
{customerReport.customer.name} {customerReport.customer.name}
</Text> </Text>
<Text style={pdfStyles.supplierInfo}> <Text style={pdfStyles.supplierInfo}>
{customerReport.customer.address || ''} Alamat: {customerReport.customer.address || '-'}
</Text> </Text>
{customerReport.summary && (
<Text style={pdfStyles.supplierInfo}>
Total Saldo Piutang:{' '}
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
</Text>
)}
</View> </View>
{/* Table */} {/* Table */}
@@ -181,10 +272,10 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>No</Text> <Text>No</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl DO/Bayar</Text> <Text>Tanggal DO</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl Realisasi</Text> <Text>Tanggal Realisasi</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
<Text>Aging</Text> <Text>Aging</Text>
@@ -193,16 +284,16 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Referensi</Text> <Text>Referensi</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>No. Polisi</Text> <Text>No Polisi</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text> <Text>Qty</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Berat (Kg)</Text> <Text>Berat</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>AVG</Text> <Text>Rata-Rata</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text> <Text>Harga Awal</Text>
@@ -214,7 +305,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Harga Akhir</Text> <Text>Harga Akhir</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>PPN (%)</Text> <Text>Pajak</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Total</Text> <Text>Total</Text>
@@ -223,10 +314,10 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Pembayaran</Text> <Text>Pembayaran</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Saldo Piutang</Text> <Text>Saldo</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Ket</Text> <Text>Keterangan</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Pengambilan</Text> <Text>Pengambilan</Text>
@@ -301,10 +392,29 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>{formatCurrency(item.payment)}</Text> <Text>{formatCurrency(item.payment)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.accounts_receivable)}</Text> <Text style={pdfStyles.textError}>
{formatCurrency(item.accounts_receivable)}
</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.notes || '-'}</Text> {item.notes ? (
<Text>{item.notes}</Text>
) : (
<View
style={[
pdfStyles.badge,
item.accounts_receivable === 0
? pdfStyles.badgeLunas
: pdfStyles.badgeBelumLunas,
]}
>
<Text>
{item.accounts_receivable === 0
? 'Lunas'
: 'Belum Lunas'}
</Text>
</View>
)}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pickup_info || '-'}</Text> <Text>{item.pickup_info || '-'}</Text>
@@ -378,7 +488,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text> <Text style={pdfStyles.textError}>
{formatCurrency( {formatCurrency(
customerReport.summary.total_accounts_receivable customerReport.summary.total_accounts_receivable
)} )}
@@ -176,7 +176,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text>No. PO</Text> <Text>No. PO</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Tgl Terima</Text> <Text>Tgl Terima/Bayar</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Tgl PO</Text> <Text>Tgl PO</Text>
@@ -191,19 +191,19 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text>Gudang</Text> <Text>Gudang</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Tgl Jatuh Tempo</Text> <Text>Jatuh Tempo</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Status Jatuh Tempo</Text> <Text>Status Jatuh Tempo</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Total Harga</Text> <Text>Nominal Pembelian (Rp)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Pembayaran</Text> <Text>Pembayaran (Rp)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Hutang</Text> <Text>Sisa Saldo Hutang (Rp)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Status</Text> <Text>Status</Text>
@@ -213,6 +213,65 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
</View> </View>
</View> </View>
{/* Initial Balance Row */}
<View style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text>
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color: supplierReport.initial_balance < 0 ? 'red' : 'black',
},
]}
>
<Text>
{formatCurrency(supplierReport.initial_balance || 0)}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
</View>
{/* Table Body */} {/* Table Body */}
{supplierReport.rows.map((item, index) => ( {supplierReport.rows.map((item, index) => (
<View <View
@@ -297,10 +356,10 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View <View
style={[ style={[
pdfStyles.tableCellRight, pdfStyles.tableCellRight,
{ flex: 1.5, color: item.debt_price < 0 ? 'red' : 'black' }, { flex: 1.5, color: item.balance < 0 ? 'red' : 'black' },
]} ]}
> >
<Text>{formatCurrency(item.debt_price)}</Text> <Text>{formatCurrency(item.balance)}</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.status || '-'}</Text> <Text>{item.status || '-'}</Text>
@@ -2,7 +2,7 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { DebtSupplier } from '@/types/api/report/debt-supplier'; import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
interface DebtSupplierExportExcelParams { interface DebtSupplierExportExcelParams {
data: DebtSupplier[]; data: DebtSupplier[];
@@ -21,12 +21,29 @@ export const generateDebtSupplierExcel = (
const supplierData = supplierReport.rows; const supplierData = supplierReport.rows;
const supplierName = supplierReport.supplier.name || 'Unknown Supplier'; const supplierName = supplierReport.supplier.name || 'Unknown Supplier';
const excelData: { [key: string]: string | number }[] = supplierData.map( const excelData: { [key: string]: string | number }[] = [
(item, index) => ({ {
No: '',
'Nomor PR': '',
'Nomor PO': '',
'Tanggal Terima/Bayar': '',
'Tanggal PO': '',
'Aging (Hari)': '',
Area: '',
Gudang: '',
'Jatuh Tempo': '',
'Status Jatuh Tempo': '',
'Nominal Pembelian (Rp)': '',
'Pembayaran (Rp)': '',
'Sisa Saldo Hutang (Rp)': supplierReport.initial_balance || 0,
Status: '',
'Nomor Perjalanan': '',
},
...supplierData.map((item, index) => ({
No: index + 1, No: index + 1,
'Nomor PR': item.pr_number || '', 'Nomor PR': item.pr_number || '',
'Nomor PO': item.po_number || '', 'Nomor PO': item.po_number || '',
'Tanggal Terima': item.received_date 'Tanggal Terima/Bayar': item.received_date
? item.received_date != '-' ? item.received_date != '-'
? formatDate(item.received_date, 'MM/DD/YYYY') ? formatDate(item.received_date, 'MM/DD/YYYY')
: '-' : '-'
@@ -39,35 +56,35 @@ export const generateDebtSupplierExcel = (
'Aging (Hari)': item.aging || 0, 'Aging (Hari)': item.aging || 0,
Area: item.area?.name || '', Area: item.area?.name || '',
Gudang: item.warehouse?.name || '', Gudang: item.warehouse?.name || '',
'Tanggal Jatuh Tempo': item.due_date 'Jatuh Tempo': item.due_date
? item.due_date != '-' ? item.due_date != '-'
? formatDate(item.due_date, 'MM/DD/YYYY') ? formatDate(item.due_date, 'MM/DD/YYYY')
: '-' : '-'
: '-', : '-',
'Status Jatuh Tempo': item.due_status || '', 'Status Jatuh Tempo': item.due_status || '',
'Total Harga': item.total_price || 0, 'Nominal Pembelian (Rp)': item.total_price || 0,
'Harga Pembayaran': item.payment_price || 0, 'Pembayaran (Rp)': item.payment_price || 0,
'Harga Hutang': item.debt_price || 0, 'Sisa Saldo Hutang (Rp)': item.debt_price || 0,
Status: item.status || '', Status: item.status || '',
'Nomor Perjalanan': item.travel_number || '', 'Nomor Perjalanan': item.travel_number || '',
}) })),
); ];
if (supplierReport.total) { if (supplierReport.total) {
excelData.push({ excelData.push({
No: 'Total', No: 'Total',
'Nomor PR': '', 'Nomor PR': '',
'Nomor PO': '', 'Nomor PO': '',
'Tanggal Terima': '', 'Tanggal Terima/Bayar': '',
'Tanggal PO': '', 'Tanggal PO': '',
'Aging (Hari)': supplierReport.total.aging || 0, 'Aging (Hari)': supplierReport.total.aging || 0,
Area: '', Area: '',
Gudang: '', Gudang: '',
'Tanggal Jatuh Tempo': '', 'Jatuh Tempo': '',
'Status Jatuh Tempo': '', 'Status Jatuh Tempo': '',
'Total Harga': supplierReport.total.total_price || 0, 'Nominal Pembelian (Rp)': supplierReport.total.total_price || 0,
'Harga Pembayaran': supplierReport.total.payment_price || 0, 'Pembayaran (Rp)': supplierReport.total.payment_price || 0,
'Harga Hutang': supplierReport.total.debt_price || 0, 'Sisa Saldo Hutang (Rp)': supplierReport.total.debt_price || 0,
Status: '', Status: '',
'Nomor Perjalanan': '', 'Nomor Perjalanan': '',
}); });
@@ -79,16 +96,16 @@ export const generateDebtSupplierExcel = (
{ wch: 5 }, // No { wch: 5 }, // No
{ wch: 15 }, // Nomor PR { wch: 15 }, // Nomor PR
{ wch: 15 }, // Nomor PO { wch: 15 }, // Nomor PO
{ wch: 15 }, // Tanggal PR { wch: 15 }, // Tanggal Terima/Bayar
{ wch: 15 }, // Tanggal PO { wch: 15 }, // Tanggal PO
{ wch: 12 }, // Aging { wch: 12 }, // Aging
{ wch: 15 }, // Area { wch: 15 }, // Area
{ wch: 15 }, // Gudang { wch: 15 }, // Gudang
{ wch: 18 }, // Tanggal Jatuh Tempo { wch: 18 }, // Jatuh Tempo
{ wch: 18 }, // Status Jatuh Tempo { wch: 18 }, // Status Jatuh Tempo
{ wch: 15 }, // Total Harga { wch: 15 }, // Nominal Pembelian (Rp)
{ wch: 15 }, // Harga Pembayaran { wch: 15 }, // Pembayaran (Rp)
{ wch: 15 }, // Harga Hutang { wch: 15 }, // Sisa Saldo Hutang (Rp)
{ wch: 12 }, // Status { wch: 12 }, // Status
{ wch: 15 }, // Nomor Perjalanan { wch: 15 }, // Nomor Perjalanan
]; ];
@@ -0,0 +1,36 @@
import { OptionType } from '@/components/input/SelectInput';
import * as yup from 'yup';
export type DebtSupplierFilterType = {
startDate: string | null | undefined;
endDate: string | null | undefined;
supplierIds: OptionType[] | null | undefined;
filterBy: OptionType | null | undefined;
};
export const DebtSupplierFilterSchema: yup.ObjectSchema<DebtSupplierFilterType> =
yup.object({
startDate: yup.string().optional().notRequired(),
endDate: yup.string().optional().notRequired(),
supplierIds: yup
.array()
.of(
yup.object({
value: yup.mixed<string | number>().required(),
label: yup.string().required(),
})
)
.optional()
.notRequired(),
filterBy: yup
.object({
value: yup.mixed<string | number>().required(),
label: yup.string().required(),
})
.optional()
.notRequired(),
});
export type DebtSupplierFilterValues = yup.InferType<
typeof DebtSupplierFilterSchema
>;
@@ -2,13 +2,16 @@ import { useState, useMemo, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Badge from '@/components/Badge';
import SelectInput, { 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';
@@ -17,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';
@@ -36,38 +38,74 @@ 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 [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const filterModal = useModal(); const filterModal = useModal();
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } = const {
useSelect(CustomerApi.basePath, 'id', 'name', 'search'); options: customerOptions,
setInputValue: setCustomerInputValue,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const salesOptions = useMemo( const {
() => [ options: salesOptions,
{ value: 'Sales A', label: 'Sales A' }, setInputValue: setSalesInputValue,
{ value: 'Sales B', label: 'Sales B' }, isLoadingOptions: isLoadingSales,
{ value: 'Sales C', label: 'Sales C' }, loadMore: loadMoreSales,
// TODO: Fetch sales options from API hasMore: hasMoreSales,
], } = useSelect(UserApi.basePath, 'id', 'name', 'search');
[]
);
const dataTypeOptions = useMemo( const dataTypeOptions = useMemo(
() => [{ value: 'do_date', label: 'Tanggal Jual' }], () => [{ value: 'do_date', label: 'Tanggal Jual' }],
[] []
); );
const getPaymentStatusColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info/10 text-info border-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning/10 text-warning border-warning';
}
return 'bg-gray-100 text-gray-600 border-gray-300';
};
const getPaymentStatusIndicatorColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning';
}
return 'bg-gray-400';
};
const getPaymentStatusText = (notes: string) => {
return notes;
};
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
setIsSubmitted(false); setIsSubmitted(false);
@@ -75,27 +113,48 @@ const CustomerPaymentTab = () => {
setFilterSales([]); setFilterSales([]);
setFilterStartDate(''); setFilterStartDate('');
setFilterEndDate(''); setFilterEndDate('');
setFilterErrors({});
}, []); }, []);
const handleApplyFilters = useCallback(() => { const handleApplyFilters = useCallback(() => {
const errors: Record<string, string> = {}; setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}, [filterModal]);
if (!filterStartDate) { // ===== ACTIVE FILTERS COUNT =====
errors.start_date = 'Tanggal mulai wajib diisi'; const activeFiltersCount = useMemo(() => {
} let count = 0;
if (!filterEndDate) {
errors.end_date = 'Tanggal akhir wajib diisi'; // Date filter (start_date + end_date = 1 filter)
if (filterStartDate || filterEndDate) {
count += 1;
} }
setFilterErrors(errors); // Customer filter
if (filterCustomer.length > 0) {
if (Object.keys(errors).length === 0) { count += 1;
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
} }
}, [filterModal, filterStartDate, filterEndDate]);
// 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(
@@ -106,7 +165,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,
@@ -123,7 +182,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,
@@ -140,11 +199,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
@@ -154,7 +208,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,
@@ -167,7 +221,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,
@@ -218,7 +272,22 @@ const CustomerPaymentTab = () => {
return; return;
} }
await generateCustomerPaymentPDF({ data: allDataForExport }); await generateCustomerPaymentPDF({
data: allDataForExport,
params: {
customer_name:
filterCustomer.length > 0
? filterCustomer.map((c) => c.label).join(', ')
: undefined,
sales:
filterSales.length > 0
? filterSales.map((s) => s.label).join(', ')
: undefined,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
filter_by: 'do_date',
},
});
toast.success('PDF berhasil dibuat dan diunduh.'); toast.success('PDF berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.'); toast.error('Gagal membuat PDF. Silakan coba lagi.');
@@ -227,27 +296,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]>[] => {
@@ -435,7 +483,9 @@ const CustomerPaymentTab = () => {
accessorKey: 'accounts_receivable', accessorKey: 'accounts_receivable',
cell: (props) => { cell: (props) => {
const value = props.row.original.accounts_receivable; const value = props.row.original.accounts_receivable;
return <div className='text-right'>{formatCurrency(value)}</div>; return (
<div className='text-right text-error'>{formatCurrency(value)}</div>
);
}, },
footer: () => ( footer: () => (
<div className='text-right font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
@@ -449,7 +499,23 @@ const CustomerPaymentTab = () => {
accessorKey: 'notes', accessorKey: 'notes',
cell: (props) => { cell: (props) => {
const value = props.row.original.notes; const value = props.row.original.notes;
return value || '-';
if (!value) {
return '-';
}
return (
<Badge
statusIndicator={true}
variant='soft'
className={{
badge: `rounded-xl justify-start border border-gray-200 ${getPaymentStatusColor(value)}`,
status: getPaymentStatusIndicatorColor(value),
}}
>
{getPaymentStatusText(value)}
</Badge>
);
}, },
}, },
{ {
@@ -481,14 +547,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}
@@ -499,7 +588,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>
@@ -538,15 +627,9 @@ const CustomerPaymentTab = () => {
value={filterStartDate} value={filterStartDate}
onChange={(e) => { onChange={(e) => {
setFilterStartDate(e.target.value); setFilterStartDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
{filterErrors.start_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.start_date}
</p>
)}
</div> </div>
<div> <div>
@@ -556,23 +639,16 @@ const CustomerPaymentTab = () => {
value={filterEndDate} value={filterEndDate}
onChange={(e) => { onChange={(e) => {
setFilterEndDate(e.target.value); setFilterEndDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
{filterErrors.end_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.end_date}
</p>
)}
</div> </div>
</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) => {
@@ -580,23 +656,27 @@ const CustomerPaymentTab = () => {
Array.isArray(val) ? val : val ? [val] : [] Array.isArray(val) ? val : val ? [val] : []
); );
}} }}
onInputChange={setCustomerInputValue}
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] : []);
}} }}
onInputChange={setSalesInputValue}
isLoading={isLoadingSales}
isClearable isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div>
@@ -659,15 +739,18 @@ const CustomerPaymentTab = () => {
total_accounts_receivable: 0, total_accounts_receivable: 0,
}; };
const totalAccountsReceivable = summary.total_accounts_receivable;
const tableColumns = getTableColumns(summary); const tableColumns = getTableColumns(summary);
return ( return (
<Card <Card
key={customerReport.customer.id} key={customerReport.customer.id}
title={customerReport.customer.name} title={customerReport.customer.name}
subtitle={`${customerReport.customer.address || ''}\nSaldo Piutang: ${formatCurrency(totalAccountsReceivable)}`} 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}
> >
@@ -678,7 +761,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:
@@ -700,20 +783,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>
); );
}; };
@@ -13,7 +13,11 @@ import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier'; import {
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX'; import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF'; import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -21,8 +25,15 @@ import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import Pagination from '@/components/Pagination';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier'; import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { Supplier } from '@/types/api/master-data/supplier';
const DebtSupplierTab = () => { const DebtSupplierTab = () => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
@@ -30,26 +41,23 @@ const DebtSupplierTab = () => {
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE ===== // ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterSupplier, setFilterSupplier] = useState<OptionType[]>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [filterDateType, setFilterDateType] = useState<OptionType>();
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const filterModal = useModal(); const filterModal = useModal();
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } = const {
useSelect(SupplierApi.basePath, 'id', 'name', '', { setInputValue: setSupplierInputValue,
limit: 'limit', options: supplierOptions,
}); isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo( const dataTypeOptions = useMemo(
() => [ () => [
@@ -59,48 +67,51 @@ const DebtSupplierTab = () => {
[] []
); );
// ===== FILTER HANDLERS ===== const handleFilterModalOpen = () => {
const handleResetFilters = useCallback(() => { filterModal.openModal();
setIsSubmitted(false); };
setFilterSupplier([]);
setFilterStartDate('');
setFilterEndDate('');
setFilterErrors({});
}, []);
const handleApplyFilters = useCallback(() => { // ===== FORMIK SETUP =====
const errors: Record<string, string> = {}; const formik = useFormik<DebtSupplierFilterType>({
initialValues: {
if (!filterStartDate) { startDate: null,
errors.start_date = 'Tanggal mulai wajib diisi'; endDate: null,
} supplierIds: null,
if (!filterEndDate) { filterBy: null,
errors.end_date = 'Tanggal akhir wajib diisi'; },
} validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => {
setFilterErrors(errors); setFilterParams({
start_date: values.startDate?.toString() || undefined,
if (Object.keys(errors).length === 0) { end_date: values.endDate?.toString() || undefined,
setIsSubmitted(true); supplier_ids:
setCurrentPage(1); values.supplierIds?.map((v) => String(v.value)).join(',') ||
undefined,
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal(); filterModal.closeModal();
} setIsSubmitted(true);
}, [filterModal, filterStartDate, filterEndDate]); },
onReset: (values) => {
setFilterParams({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
setIsSubmitted(false);
},
});
// ===== DATA FETCHING ===== // ===== DATA FETCHING =====
const { data: debtSupplier, isLoading } = useSWR( const { data: debtSupplier, isLoading } = useSWR(
isSubmitted isSubmitted
? () => { ? () => {
const params = { const params = {
supplier_ids: supplier_ids: filterParams.supplier_ids,
filterSupplier.length > 0 filter_by: filterParams.filter_by,
? filterSupplier.map((v) => String(v.value)).join(',') start_date: filterParams.start_date,
: undefined, end_date: filterParams.end_date,
filter_by: filterDateType?.value,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
page: currentPage,
limit: pageSize,
}; };
return ['debt-supplier-report', params]; return ['debt-supplier-report', params];
@@ -109,11 +120,9 @@ const DebtSupplierTab = () => {
([, params]) => ([, params]) =>
DebtSupplierApi.getDebtSupplierReport( DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids, params.supplier_ids,
params.filter_by?.toString(), params.filter_by,
params.start_date, params.start_date,
params.end_date, params.end_date
params.page,
params.limit
) )
); );
@@ -135,13 +144,15 @@ const DebtSupplierTab = () => {
> => { > => {
const params = { const params = {
supplier_ids: supplier_ids:
filterSupplier.length > 0 formik.values.supplierIds && formik.values.supplierIds.length > 0
? filterSupplier.map((v) => String(v.value)).join(',') ? formik.values.supplierIds.map((v) => String(v.value)).join(',')
: undefined, : undefined,
filter_by: filterDateType?.value?.toString(), filter_by: formik.values.filterBy?.value?.toString() || undefined,
start_date: filterStartDate || undefined, start_date: formik.values.startDate || undefined,
end_date: filterEndDate || undefined, end_date: formik.values.endDate || undefined,
date_type: filterDateType ? filterDateType.value : undefined, date_type: formik.values.filterBy
? formik.values.filterBy.value
: undefined,
limit: 100, limit: 100,
page: 1, page: 1,
}; };
@@ -150,15 +161,18 @@ const DebtSupplierTab = () => {
params.supplier_ids, params.supplier_ids,
params.filter_by, params.filter_by,
params.start_date, params.start_date,
params.end_date, params.end_date
params.page,
params.limit
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[]) ? (response.data as unknown as DebtSupplier[])
: null; : null;
}, [filterSupplier, filterStartDate, filterEndDate]); }, [
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
@@ -207,37 +221,18 @@ const DebtSupplierTab = () => {
} }
}, [debtSupplierExport]); }, [debtSupplierExport]);
// ===== 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 = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [ const getTableColumns = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [
{ {
id: 'no', id: 'no',
header: 'No', header: 'No',
cell: (props) => props.row.index + 1, enableSorting: false,
cell: (props) => props.row.index,
}, },
{ {
id: 'pr_number', id: 'pr_number',
header: 'Nomor PR', header: 'Nomor PR',
accessorKey: 'pr_number', accessorKey: 'pr_number',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.pr_number; const value = props.row.original.pr_number;
return value || '-'; return value || '-';
@@ -247,6 +242,7 @@ const DebtSupplierTab = () => {
id: 'po_number', id: 'po_number',
header: 'Nomor PO', header: 'Nomor PO',
accessorKey: 'po_number', accessorKey: 'po_number',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.po_number; const value = props.row.original.po_number;
return value || '-'; return value || '-';
@@ -254,8 +250,9 @@ const DebtSupplierTab = () => {
}, },
{ {
id: 'received_date', id: 'received_date',
header: 'Tanggal Terima', header: 'Tanggal Terima/Bayar',
accessorKey: 'received_date', accessorKey: 'received_date',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.received_date; const value = props.row.original.received_date;
return value return value
@@ -269,6 +266,7 @@ const DebtSupplierTab = () => {
id: 'po_date', id: 'po_date',
header: 'Tanggal PO', header: 'Tanggal PO',
accessorKey: 'po_date', accessorKey: 'po_date',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.po_date; const value = props.row.original.po_date;
return value return value
@@ -282,6 +280,7 @@ const DebtSupplierTab = () => {
id: 'aging', id: 'aging',
header: 'Aging', header: 'Aging',
accessorKey: 'aging', accessorKey: 'aging',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.aging; const value = props.row.original.aging;
return <div className='text-center'>{formatNumber(value)} Hari</div>; return <div className='text-center'>{formatNumber(value)} Hari</div>;
@@ -295,6 +294,7 @@ const DebtSupplierTab = () => {
id: 'area', id: 'area',
header: 'Area', header: 'Area',
accessorKey: 'area', accessorKey: 'area',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.area?.name; const value = props.row.original.area?.name;
return value || '-'; return value || '-';
@@ -304,6 +304,7 @@ const DebtSupplierTab = () => {
id: 'warehouse', id: 'warehouse',
header: 'Gudang', header: 'Gudang',
accessorKey: 'warehouse', accessorKey: 'warehouse',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.warehouse?.name; const value = props.row.original.warehouse?.name;
return value || '-'; return value || '-';
@@ -311,8 +312,9 @@ const DebtSupplierTab = () => {
}, },
{ {
id: 'due_date', id: 'due_date',
header: 'Tanggal Jatuh Tempo', header: 'Jatuh Tempo',
accessorKey: 'due_date', accessorKey: 'due_date',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.due_date; const value = props.row.original.due_date;
return value return value
@@ -326,6 +328,7 @@ const DebtSupplierTab = () => {
id: 'due_status', id: 'due_status',
header: 'Status Jatuh Tempo', header: 'Status Jatuh Tempo',
accessorKey: 'due_status', accessorKey: 'due_status',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.due_status; const value = props.row.original.due_status;
return value || '-'; return value || '-';
@@ -333,8 +336,9 @@ const DebtSupplierTab = () => {
}, },
{ {
id: 'total_price', id: 'total_price',
header: 'Total Harga', header: 'Nominal Pembelian',
accessorKey: 'total_price', accessorKey: 'total_price',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.total_price; const value = props.row.original.total_price;
return ( return (
@@ -354,8 +358,9 @@ const DebtSupplierTab = () => {
}, },
{ {
id: 'payment_price', id: 'payment_price',
header: 'Harga Pembayaran', header: 'Pembayaran',
accessorKey: 'payment_price', accessorKey: 'payment_price',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.payment_price; const value = props.row.original.payment_price;
return ( return (
@@ -374,11 +379,12 @@ const DebtSupplierTab = () => {
}, },
}, },
{ {
id: 'debt_price', id: 'balance',
header: 'Harga Hutang', header: 'Sisa Saldo Hutang',
accessorKey: 'debt_price', accessorKey: 'balance',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.debt_price; const value = props.row.original.balance;
return ( return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}> <div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)} {formatCurrency(value)}
@@ -398,6 +404,7 @@ const DebtSupplierTab = () => {
id: 'status', id: 'status',
header: 'Status', header: 'Status',
accessorKey: 'status', accessorKey: 'status',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.status; const value = props.row.original.status;
return value || '-'; return value || '-';
@@ -407,6 +414,7 @@ const DebtSupplierTab = () => {
id: 'travel_number', id: 'travel_number',
header: 'Nomor Perjalanan', header: 'Nomor Perjalanan',
accessorKey: 'travel_number', accessorKey: 'travel_number',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.travel_number; const value = props.row.original.travel_number;
return value || '-'; return value || '-';
@@ -421,10 +429,11 @@ const DebtSupplierTab = () => {
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}> <ButtonFilter
<Icon icon='heroicons:funnel' width={18} height={18} /> values={formik.values}
Filter onClick={handleFilterModalOpen}
</Button> variant='outline'
/>
<Dropdown <Dropdown
trigger={ trigger={
@@ -471,9 +480,14 @@ const DebtSupplierTab = () => {
collapsible={true} collapsible={true}
> >
<Table <Table
data={supplierReport.rows} data={[
{
balance: supplierReport.initial_balance,
} as DebtRow,
...supplierReport.rows,
]}
columns={getTableColumns(supplierReport)} columns={getTableColumns(supplierReport)}
pageSize={supplierReport.rows.length} pageSize={supplierReport.rows.length + 1}
renderFooter={supplierReport.rows.length > 0} renderFooter={supplierReport.rows.length > 0}
className={{ className={{
containerClassName: 'w-full', containerClassName: 'w-full',
@@ -493,26 +507,38 @@ const DebtSupplierTab = () => {
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
renderCustomRow={(row) => {
if (row.index == 0) {
return (
<tr
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
key={row.index}
>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={12}
></td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'>
<div
className={`text-right ${row.original.balance < 0 ? 'text-red-500' : ''}`}
>
{formatCurrency(row.original.balance)}
</div>
</td>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={2}
></td>
</tr>
);
}
}}
/> />
</Card> </Card>
); );
}) })
)} )}
</div> </div>
{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>
)}
{/* Filter Modal */} {/* Filter Modal */}
<Modal <Modal
@@ -522,7 +548,11 @@ const DebtSupplierTab = () => {
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm',
}} }}
> >
<div className='space-y-6'> <form
className='space-y-6'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'> <div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'>
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center gap-2 text-primary'>
@@ -542,37 +572,31 @@ const DebtSupplierTab = () => {
<div> <div>
<DateInput <DateInput
label='Tanggal' label='Tanggal'
name='start_date' name='startDate'
value={filterStartDate} value={formik.values.startDate || ''}
onChange={(e) => { onChange={(e) => {
setFilterStartDate(e.target.value); formik.setFieldValue('startDate', e.target.value || null);
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
/> />
{filterErrors.start_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.start_date}
</p>
)}
</div> </div>
<div className='mt-auto'> <div className='mt-auto'>
<DateInput <DateInput
label=' ' label=' '
name='end_date' name='endDate'
value={filterEndDate} value={formik.values.endDate || ''}
onChange={(e) => { onChange={(e) => {
setFilterEndDate(e.target.value); formik.setFieldValue('endDate', e.target.value || null);
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={formik.touched.endDate && !!formik.errors.endDate}
errorMessage={formik.errors.endDate}
/> />
{filterErrors.end_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.end_date}
</p>
)}
</div> </div>
</div> </div>
@@ -582,15 +606,22 @@ const DebtSupplierTab = () => {
placeholder='Pilih Supplier' placeholder='Pilih Supplier'
isMulti isMulti
options={supplierOptions} options={supplierOptions}
value={filterSupplier} value={formik.values.supplierIds || []}
onChange={(val) => { onChange={(val) => {
setFilterSupplier( formik.setFieldValue(
Array.isArray(val) ? val : val ? [val] : [] 'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
); );
}} }}
isLoading={isLoadingSuppliers} onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/> />
</div> </div>
@@ -599,12 +630,17 @@ const DebtSupplierTab = () => {
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions} options={dataTypeOptions}
value={filterDateType} value={formik.values.filterBy || null}
onChange={(val) => { onChange={(val) => {
setFilterDateType(val ? (val as OptionType) : undefined); formik.setFieldValue(
'filterBy',
val ? (val as OptionType) : null
);
}} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/> />
</div> </div>
</div> </div>
@@ -614,18 +650,15 @@ const DebtSupplierTab = () => {
<Button <Button
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilters} type='reset'
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button className='me-4 min-w-36 rounded-lg' type='submit'>
className='me-4 min-w-36 rounded-lg'
onClick={handleApplyFilters}
>
Apply Filter Apply Filter
</Button> </Button>
</div> </div>
</div> </form>
</Modal> </Modal>
</> </>
); );
@@ -62,6 +62,7 @@ const ProductionResultContent = () => {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
options: areaOptions, options: areaOptions,
isLoadingOptions: isLoadingAreaOptions, isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name'); } = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -78,6 +79,7 @@ const ProductionResultContent = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', { } = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '', area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
}); });
@@ -94,6 +96,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockInputValue, setInputValue: setProjectFlockInputValue,
options: projectFlockOptions, options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions, isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>( } = useSelect<BaseKandang>(
ProjectFlockApi.basePath, ProjectFlockApi.basePath,
'id', 'id',
@@ -120,6 +123,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockKandangInputValue, setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions, options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangOptions, isLoadingOptions: isLoadingProjectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>( } = useSelect<BaseKandang>(
ProjectFlockKandangApi.basePath, ProjectFlockKandangApi.basePath,
'id', 'id',
@@ -235,6 +239,7 @@ const ProductionResultContent = () => {
value={selectedArea} value={selectedArea}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -251,6 +256,7 @@ const ProductionResultContent = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
isDisabled={!selectedArea} isDisabled={!selectedArea}
className={{ className={{
@@ -270,6 +276,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlock} value={selectedProjectFlock}
onChange={projectFlockChangeHandler} onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue} onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable isClearable
isDisabled={!selectedArea || !selectedLocation} isDisabled={!selectedArea || !selectedLocation}
className={{ className={{
@@ -289,6 +296,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlockKandang} value={selectedProjectFlockKandang}
onChange={projectFlockKandangChangeHandler} onChange={projectFlockKandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue} onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable isClearable
isDisabled={!selectedProjectFlock} isDisabled={!selectedProjectFlock}
className={{ className={{
@@ -58,18 +58,26 @@ const HppPerKandangTab = () => {
}, },
}); });
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( const {
AreaApi.basePath, setInputValue: setAreaInputValue,
'id', options: areaOptions,
'name', isLoadingOptions: isLoadingAreas,
'search' loadMore: loadMoreAreas,
); } = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const { options: locationOptions, isLoadingOptions: isLoadingLocations } = const {
useSelect(LocationApi.basePath, 'id', 'name', 'search'); setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } = const {
useSelect(KandangApi.basePath, 'id', 'name', 'search'); setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect(KandangApi.basePath, 'id', 'name', 'search');
const showUnrecordedOptions: OptionType[] = [ const showUnrecordedOptions: OptionType[] = [
{ value: 'false', label: 'Sembunyikan' }, { value: 'false', label: 'Sembunyikan' },
@@ -810,6 +818,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value)) .includes(String(opt.value))
)} )}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreas} isLoading={isLoadingAreas}
isClearable isClearable
/> />
@@ -824,6 +834,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value)) .includes(String(opt.value))
)} )}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
isClearable isClearable
/> />
@@ -838,6 +850,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value)) .includes(String(opt.value))
)} )}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangs} isLoading={isLoadingKandangs}
isClearable isClearable
/> />
+11
View File
@@ -457,3 +457,14 @@ export const MARKETING_TYPE_OPTIONS = [
value: 'trading', value: 'trading',
}, },
]; ];
export const MARKETING_DATE_FILTER_TYPE_OPTIONS = [
{
label: 'Tanggal Realisasi',
value: 'realization_date',
},
{
label: 'Tanggal SO',
value: 'so_date',
},
];
+16 -16
View File
@@ -5,22 +5,22 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/dashboard/': ['lti.dashboard.list'], '/dashboard/': ['lti.dashboard.list'],
// Daily Checklist // Daily Checklist
// TODO: use real daily checklist permission name '/daily-checklist/dashboard/': ['lti.daily_checklist.dashboard.list'],
// '/daily-checklist/': ['lti.daily_checklist.list'], '/daily-checklist/daily-checklist/': ['lti.daily_checklist.create'],
// '/daily-checklist/dashboard/': ['lti.daily_checklist.list'], '/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'],
// '/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'], '/daily-checklist/list-daily-checklist/detail/': [
// '/daily-checklist/list-daily-checklist/detail/': ['lti.daily_checklist.detail'], 'lti.daily_checklist.detail',
// '/daily-checklist/reports/': ['lti.daily_checklist.reports'], ],
// '/daily-checklist/master-data/employee/': ['lti.dashboard.master_data.employee'], '/daily-checklist/reports/': ['lti.daily_checklist.reports'],
// '/daily-checklist/master-data/activity/': ['lti.dashboard.master_data.activity'], '/daily-checklist/master-data/employee/': [
'/daily-checklist/dashboard/': ['lti.dashboard.list'], 'lti.daily_checklist.master_data.employee',
'/daily-checklist/daily-checklist/': ['lti.dashboard.list'], ],
'/daily-checklist/list-daily-checklist/': ['lti.dashboard.list'], '/daily-checklist/master-data/activity/': [
'/daily-checklist/list-daily-checklist/detail/': ['lti.dashboard.list'], 'lti.daily_checklist.master_data.activity',
'/daily-checklist/reports/': ['lti.dashboard.list'], ],
'/daily-checklist/master-data/employee/': ['lti.dashboard.list'], '/daily-checklist/master-data/configuration/': [
'/daily-checklist/master-data/activity/': ['lti.dashboard.list'], 'lti.daily_checklist.master_data.configuration',
'/daily-checklist/master-data/configuration/': ['lti.dashboard.list'], ],
// Production // Production
// Production - Project Flock // Production - Project Flock
+64 -1
View File
@@ -1,4 +1,4 @@
import { FormikErrors } from 'formik'; import { FormikErrors, FormikValues } from 'formik';
export type ErrorMessage = { export type ErrorMessage = {
key: string; key: string;
@@ -69,3 +69,66 @@ export function getUniqueFormikErrors<T>(errors: FormikErrors<T>): string[] {
export function getAllFormikErrors<T>(errors: FormikErrors<T>): ErrorMessage[] { export function getAllFormikErrors<T>(errors: FormikErrors<T>): ErrorMessage[] {
return parseFormikErrors(errors); return parseFormikErrors(errors);
} }
/**
* Check if a value is considered "filled" (not empty)
* @param value - Value to check
* @returns True if value is filled, false otherwise
*/
function isValueFilled(value: unknown): boolean {
// Check for null or undefined
if (value === null || value === undefined) {
return false;
}
// Check for empty string
if (typeof value === 'string' && value.trim() === '') {
return false;
}
// Check for empty array
if (Array.isArray(value) && value.length === 0) {
return false;
}
// Check for empty object (but not Date or other special objects)
if (
typeof value === 'object' &&
!Array.isArray(value) &&
!(value instanceof Date) &&
Object.keys(value).length === 0
) {
return false;
}
return true;
}
/**
* Count the number of filled (non-empty) values in Formik values object
* @param values - Formik values object
* @returns Number of filled values
* @example
* const values = {
* name: 'John',
* email: '',
* age: null,
* tags: ['tag1', 'tag2'],
* emptyArray: [],
* };
* getFilledFormikValuesCount(values); // Returns 2 (name and tags)
*/
export function getFilledFormikValuesCount<T extends FormikValues>(
values: T
): number {
let count = 0;
Object.keys(values).forEach((key) => {
const value = values[key];
if (isValueFilled(value)) {
count++;
}
});
return count;
}
+6 -4
View File
@@ -131,10 +131,11 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
} }
async getOverhead( async getOverhead(
id: number id: number,
kandangId?: number
): Promise<BaseApiResponse<ClosingOverhead> | undefined> { ): Promise<BaseApiResponse<ClosingOverhead> | undefined> {
try { try {
const path = `${this.basePath}/${id}/overhead`; const path = `${this.basePath}/${id}${kandangId ? `/${kandangId}` : ''}/overhead`;
return await httpClient<BaseApiResponse<ClosingOverhead>>(path, { return await httpClient<BaseApiResponse<ClosingOverhead>>(path, {
method: 'GET', method: 'GET',
}); });
@@ -147,10 +148,11 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
} }
async getFinance( async getFinance(
id: number id: number,
kandangId?: number
): Promise<BaseApiResponse<ClosingFinance> | undefined> { ): Promise<BaseApiResponse<ClosingFinance> | undefined> {
try { try {
const path = `${this.basePath}/${id}/keuangan`; const path = `${this.basePath}/${id}${kandangId ? `/${kandangId}` : ''}/keuangan`;
return await httpClient<BaseApiResponse<ClosingFinance>>(path, { return await httpClient<BaseApiResponse<ClosingFinance>>(path, {
method: 'GET', method: 'GET',
}); });
+1 -5
View File
@@ -15,9 +15,7 @@ export class DebtSupplierApiService extends BaseApiService<
supplier_ids?: string, supplier_ids?: string,
filter_by?: string, filter_by?: string,
start_date?: string, start_date?: string,
end_date?: string, end_date?: string
page?: number,
limit?: number
): Promise<BaseApiResponse<DebtSupplier[]> | undefined> { ): Promise<BaseApiResponse<DebtSupplier[]> | undefined> {
return await this.customRequest<BaseApiResponse<DebtSupplier[]>>( return await this.customRequest<BaseApiResponse<DebtSupplier[]>>(
`debt-supplier`, `debt-supplier`,
@@ -28,8 +26,6 @@ export class DebtSupplierApiService extends BaseApiService<
filter_by: filter_by, filter_by: filter_by,
start_date: start_date, start_date: start_date,
end_date: end_date, end_date: end_date,
page: page,
limit: limit,
}, },
} }
); );
+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,
+1 -3
View File
@@ -44,9 +44,7 @@ export class MarketingSaleReportService extends BaseApiService<
} }
} }
export const SaleReportApi = new MarketingSaleReportService( export const SaleReportApi = new MarketingSaleReportService('reports');
'reports/marketings'
);
// export const SaleReportApi = new MarketingSaleReportService( // export const SaleReportApi = new MarketingSaleReportService(
// 'http://localhost:4010/api/reports/marketings' // 'http://localhost:4010/api/reports/marketings'
+12 -9
View File
@@ -1,20 +1,20 @@
import { BaseMetadata } from '@/types/api/api-general'; import { BaseMetadata } from '@/types/api/api-general';
import { Uom } from '@/types/api/master-data/uom'; import { Uom } from '@/types/api/master-data/uom';
import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategory } from '@/types/api/master-data/product-category';
import { Supplier } from '@/types/api/master-data/supplier'; import { BaseSupplier, Supplier } from '@/types/api/master-data/supplier';
export type BaseProduct = { export type BaseProduct = {
id: number; id: number;
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
product_price: number; product_price: number;
selling_price?: number; selling_price?: number;
tax?: number; tax?: number;
expiry_period: number; expiry_period?: number;
uom: Uom; uom: Uom;
product_category: ProductCategory; product_category: ProductCategory;
suppliers: Supplier[]; suppliers: (BaseSupplier & { price: number })[];
flags: string[]; flags: string[];
}; };
@@ -23,14 +23,17 @@ export type Product = BaseMetadata & BaseProduct;
export type CreateProductPayload = { export type CreateProductPayload = {
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
uom_id: number; uom_id: number;
product_category_id: number; product_category_id: number;
product_price: number; product_price: number;
selling_price: number; selling_price?: number;
tax: number; tax?: number;
expiry_period: number; expiry_period?: number;
supplier_ids: number[]; suppliers: {
supplier_id: number;
price: number;
}[];
flags: string[]; flags: string[];
}; };
+33 -12
View File
@@ -1,34 +1,52 @@
import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general'; import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Warehouse } from '@/types/api/master-data/warehouse';
export type ProductionStandard = {
id: number;
week: number;
name: string;
hen_day_std: number;
hen_house_std: number;
feed_intake_std: number;
max_depletion_std: number;
egg_mass_std: number;
egg_weight_std: number;
};
export type FCR = {
id: number;
name: string;
fcr_std: number;
};
export type ProjectFlock = {
project_flock_kandang_id: number;
flock_name: string;
project_flock_category: 'GROWING' | 'LAYING';
period: number;
production_standart: ProductionStandard;
fcr: FCR;
total_chick_qty: number;
};
export type ProductionMetrics = { export type ProductionMetrics = {
total_depletion_qty: number; total_depletion_qty: number;
cum_depletion_rate: number; cum_depletion_rate: number;
cum_intake: number; cum_intake: number;
fcr_value: number; fcr_value: number;
fcr_std?: number;
total_chick_qty: number;
hen_day?: number; hen_day?: number;
hen_house?: number; hen_house?: number;
feed_intake?: number; feed_intake?: number;
feed_intake_std?: number;
egg_mass?: number; egg_mass?: number;
egg_weight?: number; egg_weight?: number;
hen_day_std?: number;
hen_house_std?: number;
egg_mass_std?: number;
egg_weight_std?: number;
daily_gain?: number;
avg_daily_gain?: number;
cum_depletion?: number;
}; };
export type BaseRecording = { export type BaseRecording = {
id: number; id: number;
project_flock_kandang_id: number; project_flock: ProjectFlock;
record_datetime: string; record_datetime: string;
day: number; day: number;
project_flock_category?: 'GROWING' | 'LAYING';
} & ProductionMetrics; } & ProductionMetrics;
export type RecordingDepletion = { export type RecordingDepletion = {
@@ -68,6 +86,8 @@ export type Recording = BaseMetadata &
BaseRecording & { BaseRecording & {
approval?: BaseApproval; approval?: BaseApproval;
created_user: User; created_user: User;
warehouse?: Warehouse;
product_category?: 'GROWING' | 'LAYING';
depletions?: RecordingDepletion[]; depletions?: RecordingDepletion[];
stocks?: RecordingStock[]; stocks?: RecordingStock[];
eggs?: RecordingEgg[]; eggs?: RecordingEgg[];
@@ -81,6 +101,7 @@ export type NextDayRecording = {
export type CreateGrowingRecordingPayload = { export type CreateGrowingRecordingPayload = {
project_flock_kandang_id: number; project_flock_kandang_id: number;
record_date: string;
stocks?: { stocks?: {
product_warehouse_id: number; product_warehouse_id: number;
qty: number; qty: number;
+8
View File
@@ -33,3 +33,11 @@ export interface DebtRow {
travel_number: string; travel_number: string;
balance: number; balance: number;
} }
// Filter Param
export interface DebtSupplierFilter {
start_date?: string;
end_date?: string;
supplier_ids?: string;
filter_by?: string;
}