mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'staging' into 'production'
Staging See merge request mbugroup/lti-web-client!214
This commit is contained in:
Generated
+7
@@ -17,6 +17,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"formik": "^2.4.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
@@ -7380,6 +7381,12 @@
|
||||
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"formik": "^2.4.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^3.0.4",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
|
||||
@@ -38,9 +38,11 @@ const ExpenseEditPage = () => {
|
||||
!isLoadingExpense &&
|
||||
isResponseSuccess(expense) &&
|
||||
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 === 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) {
|
||||
router.back();
|
||||
|
||||
@@ -24,8 +24,6 @@ const FinanceDetailPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(finance);
|
||||
|
||||
// if (!finance || isResponseError(finance)) {
|
||||
// router.replace('/404');
|
||||
// return;
|
||||
|
||||
@@ -148,7 +148,11 @@ const Card = ({
|
||||
const hasContent = children || actions || footer;
|
||||
|
||||
const titleContent = (
|
||||
<div className='group flex items-center !justify-between w-full'>
|
||||
<div
|
||||
className={
|
||||
`group flex items-center justify-between! w-full` + getTitleClasses()
|
||||
}
|
||||
>
|
||||
<div className='flex-1'>
|
||||
{title && <h2 className={getTitleClasses()}>{title}</h2>}
|
||||
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
|
||||
@@ -156,7 +160,7 @@ const Card = ({
|
||||
{collapsible && (
|
||||
<button
|
||||
onClick={() => handleCollapsedChange(!isCollapsed)}
|
||||
className='btn btn-ghost btn-sm btn-circle'
|
||||
className={`btn btn-ghost btn-sm btn-circle` + getTitleClasses()}
|
||||
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +18,7 @@ const AlertErrorList = ({
|
||||
if (formErrorList.length === 0) return null;
|
||||
|
||||
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 items-center gap-2'>
|
||||
<Icon icon='material-symbols:error-outline' width={24} height={24} />
|
||||
|
||||
@@ -113,7 +113,15 @@ const DateInput = ({
|
||||
};
|
||||
|
||||
const handleSelectSingle = (selectedDate?: Date) => {
|
||||
if (!selectedDate) return;
|
||||
if (!selectedDate) {
|
||||
setSelected(undefined);
|
||||
setDisplayValue('');
|
||||
const syntheticEvent = {
|
||||
target: { name, value: '' },
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange?.(syntheticEvent);
|
||||
return;
|
||||
}
|
||||
if (minDate && selectedDate < minDate) {
|
||||
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
|
||||
return;
|
||||
@@ -136,7 +144,15 @@ const DateInput = ({
|
||||
};
|
||||
|
||||
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
|
||||
if (!range) return;
|
||||
if (!range) {
|
||||
setSelectedRange({});
|
||||
setDisplayValue('');
|
||||
const syntheticEvent = {
|
||||
target: { name, value: { from: '', to: '' } },
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange?.(syntheticEvent);
|
||||
return;
|
||||
}
|
||||
setSelectedRange(range);
|
||||
|
||||
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
|
||||
|
||||
@@ -9,15 +9,20 @@ import Select, {
|
||||
SingleValue,
|
||||
components as ReactSelectComponents,
|
||||
ControlProps,
|
||||
MenuListProps,
|
||||
} from 'react-select';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
import makeAnimated from 'react-select/animated';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { cn, getByPath } from '@/lib/helper';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { httpClientFetcher } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
BaseApiResponse,
|
||||
ErrorApiResponse,
|
||||
SuccessApiResponse,
|
||||
} from '@/types/api/api-general';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
export interface OptionType {
|
||||
value: string | number;
|
||||
@@ -35,6 +40,7 @@ interface SelectInputBaseProps<T = OptionType> {
|
||||
bottomLabel?: ReactNode;
|
||||
options: T[];
|
||||
optionComponent?: OptionComponent<T>;
|
||||
components?: Partial<typeof ReactSelectComponents>;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
isClearable?: boolean;
|
||||
@@ -56,9 +62,13 @@ interface SelectInputBaseProps<T = OptionType> {
|
||||
onInputChange?: (search: string) => void;
|
||||
startAdornment?: ReactNode;
|
||||
menuPortalTarget?: HTMLElement | null;
|
||||
closeMenuOnSelect?: boolean;
|
||||
hideSelectedOptions?: boolean;
|
||||
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
|
||||
}
|
||||
|
||||
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
|
||||
export interface SelectInputProps<T = OptionType>
|
||||
extends SelectInputBaseProps<T> {
|
||||
createables?: boolean;
|
||||
value?: T | T[] | null;
|
||||
onChange?: (val: T | T[] | null) => void;
|
||||
@@ -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 {
|
||||
label,
|
||||
@@ -101,6 +134,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
onChange,
|
||||
options,
|
||||
optionComponent,
|
||||
components: customComponents,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
isClearable,
|
||||
@@ -119,6 +153,9 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
onInputChange,
|
||||
startAdornment,
|
||||
menuPortalTarget,
|
||||
closeMenuOnSelect,
|
||||
hideSelectedOptions,
|
||||
onMenuScrollToBottom,
|
||||
} = props;
|
||||
|
||||
const [internalInputValue, setInternalInputValue] = useState('');
|
||||
@@ -128,14 +165,18 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
|
||||
const components = useMemo(() => {
|
||||
const base = isAnimated ? animatedComponents : {};
|
||||
const customComponents = { ...base, IndicatorSeparator: () => null };
|
||||
const mergedComponents = { ...base, IndicatorSeparator: () => null };
|
||||
|
||||
if (startAdornment) {
|
||||
customComponents.Control = CustomControl;
|
||||
mergedComponents.Control = CustomControl;
|
||||
}
|
||||
|
||||
return customComponents;
|
||||
}, [isAnimated, startAdornment]);
|
||||
if (customComponents) {
|
||||
Object.assign(mergedComponents, customComponents);
|
||||
}
|
||||
|
||||
return mergedComponents;
|
||||
}, [isAnimated, startAdornment, customComponents]);
|
||||
|
||||
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
|
||||
if (meta.action === 'input-change') setInternalInputValue(val);
|
||||
@@ -205,6 +246,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
isRtl={isRtl}
|
||||
isSearchable={isSearchable}
|
||||
placeholder={placeholder}
|
||||
closeMenuOnSelect={closeMenuOnSelect}
|
||||
hideSelectedOptions={hideSelectedOptions}
|
||||
className={cn('w-full', className?.select)}
|
||||
classNames={{
|
||||
...(!startAdornment && {
|
||||
@@ -256,6 +299,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
components={{
|
||||
...components,
|
||||
...(optionComponent ? { Option: optionComponent } : {}),
|
||||
MenuList: CustomMenuList,
|
||||
}}
|
||||
{...(startAdornment && {
|
||||
shouldShowAdornment,
|
||||
@@ -269,6 +313,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
}}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
/>
|
||||
|
||||
{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,>(
|
||||
basePath: string,
|
||||
basePath: string | null,
|
||||
valueKey: keyof T | string,
|
||||
labelKey: keyof T | string,
|
||||
searchKey: string = 'search',
|
||||
@@ -288,34 +333,96 @@ const useSelect = <T,>(
|
||||
) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const optionsUrlParams = useMemo(() => {
|
||||
return new URLSearchParams({
|
||||
const pageKey = 'page';
|
||||
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 ?? '',
|
||||
...params,
|
||||
[pageKey]: String(pageIndex + 1),
|
||||
[limitKey]: String(limit),
|
||||
}).toString();
|
||||
}, [inputValue, searchKey, params]);
|
||||
|
||||
const optionsUrl = `${basePath}?${optionsUrlParams}`;
|
||||
return basePath ? `${basePath}?${qs}` : null;
|
||||
};
|
||||
|
||||
const { data, isLoading } = useSWR(optionsUrl, async (url) => {
|
||||
return await httpClientFetcher<BaseApiResponse<T[]>>(url);
|
||||
});
|
||||
const {
|
||||
data: pages,
|
||||
isLoading,
|
||||
isValidating,
|
||||
size,
|
||||
setSize,
|
||||
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
|
||||
httpClientFetcher<BaseApiResponse<T[]>>(url)
|
||||
);
|
||||
|
||||
const options = isResponseSuccess(data)
|
||||
? data.data.map((item) => {
|
||||
return {
|
||||
value: getByPath<T, number>(item, valueKey as string),
|
||||
label: getByPath<T, string>(item, labelKey as string),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const options = useMemo(() => {
|
||||
if (!pages) return [];
|
||||
|
||||
return pages.flatMap((page) =>
|
||||
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 {
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
options,
|
||||
isLoadingOptions: isLoading,
|
||||
rawData: data,
|
||||
rawData: isResponseSuccess(pages?.[latestPagesIndex])
|
||||
? 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;
|
||||
@@ -3,224 +3,82 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatCurrency, formatTitleCase } from '@/lib/helper';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import {
|
||||
DataSummarySubTotal,
|
||||
HppPurchaseData,
|
||||
ProfitLossDataAmount,
|
||||
} from '@/types/api/closing';
|
||||
import { HppItem, ProfitLossItem } from '@/types/api/closing';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type HppTableRow =
|
||||
| (HppPurchaseData & {
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader?: boolean;
|
||||
})
|
||||
| {
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: true;
|
||||
type?: never;
|
||||
budgeting?: never;
|
||||
realization?: never;
|
||||
}
|
||||
| {
|
||||
type: string;
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: false;
|
||||
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
|
||||
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
|
||||
};
|
||||
|
||||
type ProfitLossTableRow =
|
||||
| (DataSummarySubTotal & {
|
||||
type: string;
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader?: boolean;
|
||||
})
|
||||
| {
|
||||
group_name: string;
|
||||
group_index: number;
|
||||
isGroupHeader: true;
|
||||
type?: never;
|
||||
rp_per_bird?: never;
|
||||
rp_per_kg?: never;
|
||||
amount?: never;
|
||||
};
|
||||
|
||||
const ClosingFinanceTable = ({
|
||||
projectFlockId,
|
||||
}: {
|
||||
projectFlockId: number;
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const { data: finance, isLoading } = useSWR(
|
||||
`/closing/finance/${projectFlockId}`,
|
||||
() => ClosingApi.getFinance(projectFlockId)
|
||||
`/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
|
||||
() =>
|
||||
ClosingApi.getFinance(
|
||||
projectFlockId,
|
||||
kandangId ? Number(kandangId) : undefined
|
||||
)
|
||||
);
|
||||
|
||||
const staticHppRows: Array<{
|
||||
group_name: string;
|
||||
type: string;
|
||||
group_index: number;
|
||||
}> = [
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian PAKAN',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian STARTER',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian DOC',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian PULLET',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
type: 'Pembelian LAYER',
|
||||
group_index: 0,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
type: 'Pengeluaran Overhead',
|
||||
group_index: 1,
|
||||
},
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
type: 'Beban Ekspedisi',
|
||||
group_index: 1,
|
||||
},
|
||||
];
|
||||
const hppTableData: HppItem[] = useMemo(() => {
|
||||
if (isResponseSuccess(finance)) {
|
||||
const customItems = {
|
||||
label: 'HPP dan Pengeluaran',
|
||||
code: 'custom_row',
|
||||
} as HppItem;
|
||||
const purchases = finance.data.hpp.items.filter(
|
||||
(item) => item.category === 'purchase'
|
||||
);
|
||||
const totalBudgeting = {
|
||||
label: 'HPP dan Bahan Baku',
|
||||
code: 'custom_row',
|
||||
} as HppItem;
|
||||
const overheads = finance.data.hpp.items.filter(
|
||||
(item) => item.category === 'overhead'
|
||||
);
|
||||
return [customItems, ...purchases, totalBudgeting, ...overheads];
|
||||
}
|
||||
return [];
|
||||
}, [finance]);
|
||||
|
||||
const hppTableData: HppTableRow[] = [
|
||||
{
|
||||
group_name: 'HPP dan Pengeluaran',
|
||||
group_index: 0,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
...staticHppRows
|
||||
.filter((row) => row.group_index === 0)
|
||||
.map((staticRow) => {
|
||||
const apiData = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp
|
||||
.find((g) => g.group_name === staticRow.group_name)
|
||||
?.data.find((d) => d.type === staticRow.type)
|
||||
: null;
|
||||
|
||||
return {
|
||||
group_name: staticRow.group_name,
|
||||
group_index: staticRow.group_index,
|
||||
type: staticRow.type,
|
||||
budgeting: apiData?.budgeting || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
realization: apiData?.realization || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
isGroupHeader: false as const,
|
||||
};
|
||||
}),
|
||||
{
|
||||
group_name: 'HPP dan Bahan Baku',
|
||||
group_index: 1,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
...staticHppRows
|
||||
.filter((row) => row.group_index === 1)
|
||||
.map((staticRow) => {
|
||||
const apiData = isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.hpp
|
||||
.find((g) => g.group_name === staticRow.group_name)
|
||||
?.data.find((d) => d.type === staticRow.type)
|
||||
: null;
|
||||
|
||||
return {
|
||||
group_name: staticRow.group_name,
|
||||
group_index: staticRow.group_index,
|
||||
type: staticRow.type,
|
||||
budgeting: apiData?.budgeting || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
realization: apiData?.realization || {
|
||||
rp_per_bird: 0,
|
||||
rp_per_kg: 0,
|
||||
amount: 0,
|
||||
},
|
||||
isGroupHeader: false as const,
|
||||
};
|
||||
}),
|
||||
{
|
||||
group_name: 'HPP',
|
||||
group_index: 2,
|
||||
isGroupHeader: true as const,
|
||||
},
|
||||
];
|
||||
|
||||
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
|
||||
? [
|
||||
// Pembelian group
|
||||
...finance.data.profit_loss.data.pembelian.map((item) => ({
|
||||
label: 'Pembelian',
|
||||
group_name: 'Pembelian',
|
||||
group_index: 1,
|
||||
type: item.type,
|
||||
rp_per_bird: item.rp_per_bird,
|
||||
rp_per_kg: item.rp_per_kg,
|
||||
amount: item.amount,
|
||||
isGroupHeader: false as const,
|
||||
})),
|
||||
{
|
||||
label: finance.data.profit_loss.data.summary.gross_profit.label,
|
||||
group_name: 'Penjualan',
|
||||
group_index: 0,
|
||||
isGroupHeader: true as const,
|
||||
type: finance.data.profit_loss.data.summary.gross_profit.label,
|
||||
rp_per_bird:
|
||||
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
|
||||
rp_per_kg:
|
||||
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
|
||||
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
|
||||
},
|
||||
// Penjualan group
|
||||
...finance.data.profit_loss.data.penjualan.map((item) => ({
|
||||
label: 'Penjualan',
|
||||
group_name: 'Penjualan',
|
||||
group_index: 0,
|
||||
type: item.type,
|
||||
rp_per_bird: item.rp_per_bird,
|
||||
rp_per_kg: item.rp_per_kg,
|
||||
amount: item.amount,
|
||||
isGroupHeader: false as const,
|
||||
})),
|
||||
{
|
||||
label: finance.data.profit_loss.data.summary.sub_total.label,
|
||||
group_name: 'Pembelian',
|
||||
group_index: 1,
|
||||
isGroupHeader: true as const,
|
||||
type: finance.data.profit_loss.data.summary.sub_total.label,
|
||||
rp_per_bird:
|
||||
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
|
||||
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
|
||||
amount: finance.data.profit_loss.data.summary.sub_total.amount,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const profitLossTableData: ProfitLossItem[] = useMemo(() => {
|
||||
if (isResponseSuccess(finance)) {
|
||||
const incomes = finance.data.profit_loss.items.filter(
|
||||
(item) => item.type === 'income'
|
||||
);
|
||||
const purchases = finance.data.profit_loss.items.filter(
|
||||
(item) => item.type === 'purchase'
|
||||
);
|
||||
const overheads = finance.data.profit_loss.items.filter(
|
||||
(item) => item.type === 'overhead'
|
||||
);
|
||||
const grossProfit = {
|
||||
label: 'LABA RUGI BRUTO',
|
||||
code: 'custom_row',
|
||||
type: 'gross_profit',
|
||||
rp_per_bird:
|
||||
finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
|
||||
rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
|
||||
amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
|
||||
} as ProfitLossItem;
|
||||
const subtotal = {
|
||||
label: 'Subtotal',
|
||||
code: 'custom_row',
|
||||
type: 'subtotal',
|
||||
rp_per_bird:
|
||||
finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
|
||||
rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
|
||||
amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
|
||||
} as ProfitLossItem;
|
||||
return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
|
||||
}
|
||||
return [];
|
||||
}, [finance]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
@@ -233,35 +91,21 @@ const ClosingFinanceTable = ({
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-6'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div>
|
||||
{isResponseSuccess(finance)
|
||||
? formatTitleCase(
|
||||
finance.data.profit_loss.data.summary.gross_profit
|
||||
.label || '-'
|
||||
)
|
||||
: 'Laba Rugi Brutto'}
|
||||
</div>
|
||||
<div>Laba Rugi Brutto</div>
|
||||
<div className='text-lg font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.gross_profit.amount
|
||||
finance.data.profit_loss.summary.gross_profit.amount
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div>
|
||||
{isResponseSuccess(finance)
|
||||
? formatTitleCase(
|
||||
finance.data.profit_loss.data.summary.net_profit.label ||
|
||||
'-'
|
||||
)
|
||||
: 'Laba Rugi Netto'}
|
||||
</div>
|
||||
<div>Laba Rugi Netto</div>
|
||||
<div className='text-lg font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit.amount
|
||||
finance.data.profit_loss.summary.net_profit.amount
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
@@ -269,11 +113,7 @@ const ClosingFinanceTable = ({
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
isResponseSuccess(finance)
|
||||
? finance.data.hpp_purchases.title
|
||||
: 'HPP Purchases'
|
||||
}
|
||||
title='HPP Purchases'
|
||||
variant='bordered'
|
||||
collapsible
|
||||
className={{
|
||||
@@ -281,17 +121,18 @@ const ClosingFinanceTable = ({
|
||||
}}
|
||||
>
|
||||
<div className='mt-6 p-0 mb-0'>
|
||||
<Table<HppTableRow>
|
||||
<Table<HppItem>
|
||||
data={hppTableData}
|
||||
isLoading={isLoading}
|
||||
columns={[
|
||||
{
|
||||
header: 'No.',
|
||||
enableSorting: false,
|
||||
accessorFn: (item, index) => {
|
||||
if (item.isGroupHeader) return '-';
|
||||
if (item.code === 'custom_row') return '-';
|
||||
const dataRowsBefore = hppTableData
|
||||
.slice(0, index)
|
||||
.filter((row) => !row.isGroupHeader).length;
|
||||
.filter((row) => row.code !== 'custom_row').length;
|
||||
return dataRowsBefore + 1;
|
||||
},
|
||||
footer: (props) => {
|
||||
@@ -299,9 +140,9 @@ const ClosingFinanceTable = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
header: 'Jenis',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatTitleCase(item.type || '-'),
|
||||
accessorFn: (item) => formatTitleCase(item.label || '-'),
|
||||
},
|
||||
{
|
||||
header: 'Budgeting',
|
||||
@@ -317,7 +158,7 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'budgeting_rp_per_bird' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
finance.data.hpp.summary?.budgeting
|
||||
?.rp_per_bird || 0
|
||||
)
|
||||
: '-';
|
||||
@@ -333,8 +174,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'budgeting_rp_per_kg' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.rp_per_kg || 0
|
||||
finance.data.hpp.summary?.budgeting?.rp_per_kg ||
|
||||
0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -349,8 +190,7 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'budgeting_amount' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp?.budgeting
|
||||
?.amount || 0
|
||||
finance.data.hpp.summary?.budgeting?.amount || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -371,8 +211,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'realization_rp_per_bird' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.rp_per_bird || 0
|
||||
finance.data.hpp.summary?.realization
|
||||
?.rp_per_bird || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -387,8 +227,8 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'realization_rp_per_kg' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.rp_per_kg || 0
|
||||
finance.data.hpp.summary?.realization
|
||||
?.rp_per_kg || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -403,8 +243,7 @@ const ClosingFinanceTable = ({
|
||||
return props.column.id === 'realization_amount' &&
|
||||
isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.hpp_purchases.summary_hpp
|
||||
?.realization?.amount || 0
|
||||
finance.data.hpp.summary?.realization?.amount || 0
|
||||
)
|
||||
: '-';
|
||||
},
|
||||
@@ -414,7 +253,7 @@ const ClosingFinanceTable = ({
|
||||
]}
|
||||
renderCustomRow={(row) => {
|
||||
const rowData = row.original;
|
||||
if (rowData.isGroupHeader) {
|
||||
if (rowData.code === 'custom_row') {
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
@@ -428,7 +267,7 @@ const ClosingFinanceTable = ({
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatTitleCase(rowData.group_name ?? '-')}
|
||||
{formatTitleCase(rowData.label ?? '-')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -441,11 +280,7 @@ const ClosingFinanceTable = ({
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
title={
|
||||
isResponseSuccess(finance)
|
||||
? finance.data.profit_loss.title
|
||||
: 'Profit/Loss'
|
||||
}
|
||||
title='Profit/Loss'
|
||||
variant='bordered'
|
||||
collapsible
|
||||
className={{
|
||||
@@ -453,38 +288,32 @@ const ClosingFinanceTable = ({
|
||||
}}
|
||||
>
|
||||
<div className='mt-6 p-0 mb-0'>
|
||||
<Table<ProfitLossTableRow>
|
||||
<Table<ProfitLossItem>
|
||||
data={profitLossTableData}
|
||||
isLoading={isLoading}
|
||||
columns={[
|
||||
{
|
||||
header: 'Jenis',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => item.type,
|
||||
accessorFn: (item) => item.label,
|
||||
cell: (item) => (
|
||||
<div className=''>
|
||||
{formatTitleCase(item.row.original.type || '-')}
|
||||
{formatTitleCase(item.row.original.label || '-')}
|
||||
</div>
|
||||
),
|
||||
footer: (item) => (
|
||||
<div className='font-bold uppercase'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatTitleCase(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
.label || '-'
|
||||
)
|
||||
: '-'}
|
||||
</div>
|
||||
footer: () => (
|
||||
<div className='font-bold uppercase'>LABA RUGI NETTO</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Rp/Ekor',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
|
||||
footer: (item) => (
|
||||
footer: () => (
|
||||
<div className='font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
finance.data.profit_loss.summary.net_profit
|
||||
.rp_per_bird || 0
|
||||
)
|
||||
: formatCurrency(0)}
|
||||
@@ -495,11 +324,11 @@ const ClosingFinanceTable = ({
|
||||
header: 'Rp/Kg',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
|
||||
footer: (item) => (
|
||||
footer: () => (
|
||||
<div className='font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
finance.data.profit_loss.summary.net_profit
|
||||
.rp_per_kg || 0
|
||||
)
|
||||
: formatCurrency(0)}
|
||||
@@ -510,11 +339,11 @@ const ClosingFinanceTable = ({
|
||||
header: 'Jumlah (Rp)',
|
||||
enableSorting: false,
|
||||
accessorFn: (item) => formatCurrency(item.amount || 0),
|
||||
footer: (item) => (
|
||||
footer: () => (
|
||||
<div className='font-bold'>
|
||||
{isResponseSuccess(finance)
|
||||
? formatCurrency(
|
||||
finance.data.profit_loss.data.summary.net_profit
|
||||
finance.data.profit_loss.summary.net_profit
|
||||
.amount || 0
|
||||
)
|
||||
: formatCurrency(0)}
|
||||
@@ -524,55 +353,30 @@ const ClosingFinanceTable = ({
|
||||
]}
|
||||
renderCustomRow={(row) => {
|
||||
const rowData = row.original;
|
||||
if (rowData.isGroupHeader) {
|
||||
if (rowData.amount) {
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={TABLE_DEFAULT_STYLING.footerRowClassName}
|
||||
>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold ps-6 uppercase'>
|
||||
{formatTitleCase(rowData.label ?? '-')}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.rp_per_bird ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.rp_per_kg ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.amount ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (rowData.code === 'custom_row') {
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={TABLE_DEFAULT_STYLING.bodyRowClassName}
|
||||
className={TABLE_DEFAULT_STYLING.footerRowClassName}
|
||||
>
|
||||
<td
|
||||
colSpan={4}
|
||||
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
|
||||
>
|
||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
||||
<div className='font-bold ps-6 uppercase'>
|
||||
{formatTitleCase(rowData.label ?? '-')}
|
||||
</div>
|
||||
</td>
|
||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
||||
<div className='font-bold'>
|
||||
{formatTitleCase(rowData.group_name ?? '-')}
|
||||
{formatCurrency(rowData.rp_per_bird ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.rp_per_kg ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
|
||||
<div className='font-bold'>
|
||||
{formatCurrency(rowData.amount ?? 0)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import Card from '@/components/Card';
|
||||
import Collapse from '@/components/Collapse';
|
||||
|
||||
import { cn, formatNumber } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
|
||||
|
||||
interface ClosingIncomingSapronaksSummaryTableProps {
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingIncomingSapronaksSummaryTable = ({
|
||||
projectFlockId,
|
||||
}: ClosingIncomingSapronaksSummaryTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
nameSort: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: incomingSapronakSummaries,
|
||||
isLoading: isLoadingIncomingSapronakSummaries,
|
||||
} = useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
||||
ClosingApi.getAllIncomingSapronakSummaryFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
|
||||
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
|
||||
[
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) => props.row.index + 1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'category',
|
||||
header: 'Kategori',
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_qty',
|
||||
header: 'Total Kuantitas',
|
||||
cell: (props) =>
|
||||
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
|
||||
},
|
||||
];
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
}, [sorting, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOpen(
|
||||
isResponseSuccess(incomingSapronakSummaries)
|
||||
? incomingSapronakSummaries.data.length > 0
|
||||
: false
|
||||
);
|
||||
}
|
||||
}, [incomingSapronakSummaries, isResponseSuccess]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={
|
||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||
<div className='card-title'>Ringkasan Sapronak Masuk</div>
|
||||
|
||||
<Icon
|
||||
icon='material-symbols:keyboard-arrow-down'
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn('text-primary transition-transform', {
|
||||
'-rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className='w-full!'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<div className='w-full p-0'>
|
||||
<Table<ClosingIncomingSapronakSummary>
|
||||
data={
|
||||
isResponseSuccess(incomingSapronakSummaries)
|
||||
? incomingSapronakSummaries?.data
|
||||
: []
|
||||
}
|
||||
columns={incomingSapronaksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
page={
|
||||
isResponseSuccess(incomingSapronakSummaries)
|
||||
? incomingSapronakSummaries?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(incomingSapronakSummaries)
|
||||
? incomingSapronakSummaries?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingIncomingSapronakSummaries}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(incomingSapronakSummaries) &&
|
||||
incomingSapronakSummaries?.data?.length === 0,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingIncomingSapronaksSummaryTable;
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
@@ -23,6 +24,9 @@ interface ClosingIncomingSapronaksTableProps {
|
||||
const ClosingIncomingSapronaksTable = ({
|
||||
projectFlockId,
|
||||
}: ClosingIncomingSapronaksTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -43,7 +47,7 @@ const ClosingIncomingSapronaksTable = ({
|
||||
|
||||
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
|
||||
useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
||||
ClosingApi.getAllIncomingSapronakFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import Card from '@/components/Card';
|
||||
import Collapse from '@/components/Collapse';
|
||||
|
||||
import { cn, formatNumber } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
|
||||
|
||||
interface ClosingOutgoingSapronaksSummaryTableProps {
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingOutgoingSapronaksSummaryTable = ({
|
||||
projectFlockId,
|
||||
}: ClosingOutgoingSapronaksSummaryTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: '',
|
||||
nameSort: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
nameSort: 'sort_name',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: outgoingSapronakSummaries,
|
||||
isLoading: isLoadingOutgoingSapronakSummaries,
|
||||
} = useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
||||
ClosingApi.getAllIncomingSapronakSummaryFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
|
||||
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
|
||||
[
|
||||
{
|
||||
header: '#',
|
||||
cell: (props) => props.row.index + 1,
|
||||
},
|
||||
{
|
||||
accessorKey: 'category',
|
||||
header: 'Kategori',
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_qty',
|
||||
header: 'Total Kuantitas',
|
||||
cell: (props) =>
|
||||
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
|
||||
},
|
||||
];
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
// track sorting
|
||||
useEffect(() => {
|
||||
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||
|
||||
if (!isNameSorted) {
|
||||
updateFilter('nameSort', '');
|
||||
} else {
|
||||
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||
}
|
||||
}, [sorting, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOpen(
|
||||
isResponseSuccess(outgoingSapronakSummaries)
|
||||
? outgoingSapronakSummaries.data.length > 0
|
||||
: false
|
||||
);
|
||||
}
|
||||
}, [outgoingSapronakSummaries, isResponseSuccess]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={
|
||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||
<div className='card-title'>Ringkasan Sapronak Keluar</div>
|
||||
|
||||
<Icon
|
||||
icon='material-symbols:keyboard-arrow-down'
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn('text-primary transition-transform', {
|
||||
'-rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className='w-full!'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<div className='w-full p-0'>
|
||||
<Table<ClosingOutgoingSapronakSummary>
|
||||
data={
|
||||
isResponseSuccess(outgoingSapronakSummaries)
|
||||
? outgoingSapronakSummaries?.data
|
||||
: []
|
||||
}
|
||||
columns={outgoingSapronaksColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
page={
|
||||
isResponseSuccess(outgoingSapronakSummaries)
|
||||
? outgoingSapronakSummaries?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(outgoingSapronakSummaries)
|
||||
? outgoingSapronakSummaries?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingOutgoingSapronakSummaries}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
isResponseSuccess(outgoingSapronakSummaries) &&
|
||||
outgoingSapronakSummaries?.data?.length === 0,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClosingOutgoingSapronaksSummaryTable;
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
|
||||
@@ -23,6 +24,9 @@ interface ClosingOutgoingSapronaksTableProps {
|
||||
const ClosingOutgoingSapronaksTable = ({
|
||||
projectFlockId,
|
||||
}: ClosingOutgoingSapronaksTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -43,7 +47,7 @@ const ClosingOutgoingSapronaksTable = ({
|
||||
|
||||
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
|
||||
useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
|
||||
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
||||
ClosingApi.getAllOutgoingSapronakFetcher,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -5,122 +5,187 @@ import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { Overhead, OverheadTotal } from '@/types/api/closing';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ClosingOverheadTableProps {
|
||||
type?: 'detail';
|
||||
projectFlockId: number;
|
||||
}
|
||||
|
||||
const ClosingOverheadTable = ({
|
||||
type,
|
||||
projectFlockId,
|
||||
}: ClosingOverheadTableProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/overhead`,
|
||||
() => ClosingApi.getOverhead(projectFlockId),
|
||||
`${ClosingApi.basePath}/${projectFlockId}${kandangId ? `/${kandangId}` : ''}/overhead`,
|
||||
() =>
|
||||
ClosingApi.getOverhead(
|
||||
projectFlockId,
|
||||
kandangId ? Number(kandangId) : undefined
|
||||
),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to create columns with footer support
|
||||
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [
|
||||
// Group untuk kolom tanpa footer
|
||||
{
|
||||
header: 'Nama Item',
|
||||
accessorFn: (props) => props.item_name,
|
||||
footer: 'Total Pengeluaran Overhead',
|
||||
},
|
||||
{
|
||||
header: 'Satuan',
|
||||
accessorFn: (props) => props.uom_name,
|
||||
},
|
||||
{
|
||||
header: 'Budget Pengajuan',
|
||||
footer: '',
|
||||
columns: [
|
||||
{
|
||||
id: 'budget_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.budget_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'budget_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.budget_unit_price
|
||||
? formatCurrency(props.budget_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'budget_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.budget_total_amount
|
||||
? formatCurrency(props.budget_total_amount)
|
||||
: '-',
|
||||
footer: total ? () => formatCurrency(total.budget_total_amount) : '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Realisasi',
|
||||
footer: '',
|
||||
columns: [
|
||||
{
|
||||
id: 'actual_date',
|
||||
header: 'Tanggal',
|
||||
accessorFn: (props) =>
|
||||
props.actual_date
|
||||
? formatDate(props.actual_date, 'DD MMM, YYYY')
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.actual_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'actual_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.actual_unit_price
|
||||
? formatCurrency(props.actual_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.actual_total_amount
|
||||
? formatCurrency(props.actual_total_amount)
|
||||
: '-',
|
||||
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cost_per_bird',
|
||||
header: 'Rp/Ekor',
|
||||
accessorFn: (props) =>
|
||||
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
|
||||
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
|
||||
},
|
||||
];
|
||||
const createColumns = (
|
||||
total?: OverheadTotal,
|
||||
kandangId?: number
|
||||
): ColumnDef<Overhead>[] => {
|
||||
const flockColumn: ColumnDef<Overhead>[] = [
|
||||
{
|
||||
header: 'Budget Pengajuan',
|
||||
footer: '',
|
||||
columns: [
|
||||
{
|
||||
id: 'budget_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.budget_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'budget_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.budget_unit_price
|
||||
? formatCurrency(props.budget_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'budget_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.budget_total_amount
|
||||
? formatCurrency(props.budget_total_amount)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => formatCurrency(total.budget_total_amount)
|
||||
: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Realisasi',
|
||||
footer: '',
|
||||
columns: [
|
||||
{
|
||||
id: 'actual_date',
|
||||
header: 'Tanggal',
|
||||
accessorFn: (props) =>
|
||||
props.actual_date
|
||||
? formatDate(props.actual_date, 'DD MMM, YYYY')
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.actual_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'actual_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.actual_unit_price
|
||||
? formatCurrency(props.actual_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.actual_total_amount
|
||||
? formatCurrency(props.actual_total_amount)
|
||||
: '-',
|
||||
footer: total
|
||||
? () => formatCurrency(total.actual_total_amount)
|
||||
: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const kandangColumn: ColumnDef<Overhead>[] = [
|
||||
{
|
||||
id: 'actual_date',
|
||||
header: 'Tanggal',
|
||||
accessorFn: (props) =>
|
||||
props.actual_date
|
||||
? formatDate(props.actual_date, 'DD MMM, YYYY')
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_quantity',
|
||||
header: 'Jumlah',
|
||||
accessorFn: (props) =>
|
||||
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
|
||||
footer: total ? () => formatNumber(total.actual_quantity) : '',
|
||||
},
|
||||
{
|
||||
id: 'actual_unit_price',
|
||||
header: 'Harga Satuan',
|
||||
accessorFn: (props) =>
|
||||
props.actual_unit_price
|
||||
? formatCurrency(props.actual_unit_price)
|
||||
: '-',
|
||||
footer: '',
|
||||
},
|
||||
{
|
||||
id: 'actual_total_amount',
|
||||
header: 'Total',
|
||||
accessorFn: (props) =>
|
||||
props.actual_total_amount
|
||||
? formatCurrency(props.actual_total_amount)
|
||||
: '-',
|
||||
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
|
||||
},
|
||||
];
|
||||
|
||||
const finalColumns: ColumnDef<Overhead>[] = [
|
||||
// Group untuk kolom tanpa footer
|
||||
{
|
||||
header: 'No',
|
||||
accessorFn: (_, index) => index,
|
||||
cell: (props) => props.row.index + 1,
|
||||
},
|
||||
{
|
||||
header: 'Nama Item',
|
||||
accessorFn: (props) => props.item_name,
|
||||
footer: 'Total Pengeluaran Overhead',
|
||||
},
|
||||
{
|
||||
header: 'Satuan',
|
||||
accessorFn: (props) => props.uom_name,
|
||||
},
|
||||
...(kandangId ? kandangColumn : flockColumn),
|
||||
{
|
||||
id: 'cost_per_bird',
|
||||
header: 'Rp/Ekor',
|
||||
accessorFn: (props) =>
|
||||
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
|
||||
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
|
||||
},
|
||||
];
|
||||
return finalColumns;
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(overhead)
|
||||
? createColumns(overhead.data?.total)
|
||||
? createColumns(
|
||||
overhead.data?.total,
|
||||
kandangId ? Number(kandangId) : undefined
|
||||
)
|
||||
: createColumns(),
|
||||
[overhead]
|
||||
);
|
||||
@@ -148,6 +213,7 @@ const ClosingOverheadTable = ({
|
||||
'whitespace-nowrap'
|
||||
),
|
||||
}}
|
||||
isLoading={isLoadingOverhead}
|
||||
renderFooter={
|
||||
isResponseSuccess(overhead)
|
||||
? overhead.data?.overheads.length > 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { ClosingApi } from '@/services/api/closing';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
@@ -12,9 +13,12 @@ interface ClosingProductionDataTabContentProps {
|
||||
const ClosingProductionDataTabContent = ({
|
||||
projectFlockId,
|
||||
}: ClosingProductionDataTabContentProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const kandangId = searchParams.get('kandangId');
|
||||
|
||||
const { data: productionData, isLoading } = useSWR(
|
||||
`${ClosingApi.basePath}/${projectFlockId}/production-data`,
|
||||
() => ClosingApi.getProductionData(projectFlockId)
|
||||
`${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
|
||||
() => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -197,7 +201,7 @@ const ClosingProductionDataTabContent = ({
|
||||
value={formatNumber(performance.mor_diff)}
|
||||
unitClassName='hidden'
|
||||
/>
|
||||
<DataRow
|
||||
{/* <DataRow
|
||||
label='AWG Std'
|
||||
value={formatNumber(performance.awg_std)}
|
||||
unit='Gr/Hari'
|
||||
@@ -206,7 +210,7 @@ const ClosingProductionDataTabContent = ({
|
||||
label='AWG Act'
|
||||
value={formatNumber(performance.awg_act)}
|
||||
unit='Gr/Hari'
|
||||
/>
|
||||
/> */}
|
||||
<DataRow
|
||||
label='Feed Intake Std'
|
||||
value={formatNumber(performance.feed_intake_std)}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
|
||||
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
|
||||
import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable';
|
||||
import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable';
|
||||
|
||||
interface ClosingSapronakTableProps {
|
||||
projectFlockId?: number;
|
||||
@@ -16,7 +18,15 @@ const ClosingSapronakTabContent = ({
|
||||
<>
|
||||
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
|
||||
|
||||
<ClosingIncomingSapronaksSummaryTable
|
||||
projectFlockId={projectFlockId}
|
||||
/>
|
||||
|
||||
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
|
||||
|
||||
<ClosingOutgoingSapronaksSummaryTable
|
||||
projectFlockId={projectFlockId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
||||
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
|
||||
value={selectedLocation}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6',
|
||||
|
||||
@@ -82,12 +82,12 @@ const SalesReportTable = ({
|
||||
<div className='font-semibold text-gray-900'>Total Penjualan</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
accessorKey: 'age',
|
||||
header: 'Umur',
|
||||
cell: (props) => props.getValue() || '-',
|
||||
},
|
||||
// {
|
||||
// id: 'age',
|
||||
// accessorKey: 'age',
|
||||
// header: 'Umur',
|
||||
// cell: (props) => props.getValue() || '-',
|
||||
// },
|
||||
{
|
||||
id: 'do_number',
|
||||
accessorKey: 'do_number',
|
||||
|
||||
@@ -8,19 +8,22 @@ import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardApi } from '@/services/api/dashboard';
|
||||
import { useFormik } from 'formik';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
|
||||
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
|
||||
import {
|
||||
DashboardFilterType,
|
||||
getDashboardFilterSchema,
|
||||
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
|
||||
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
|
||||
import DashboardAllCharts, {
|
||||
DashboardAllChartsRef,
|
||||
} from '@/components/pages/dashboard/chart/DashboardAllCharts';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
||||
import {
|
||||
DashboardFilter,
|
||||
@@ -30,6 +33,11 @@ import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import { useDashboardStore } from '@/stores/dashboard';
|
||||
|
||||
// Helper function to normalize values to array
|
||||
const normalizeToArray = (
|
||||
@@ -44,11 +52,22 @@ const normalizeToArray = (
|
||||
|
||||
const DashboardProduction = () => {
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== DASHBOARD STORE =====
|
||||
const { filterValues, setFilterValues, resetFilterValues } =
|
||||
useDashboardStore();
|
||||
|
||||
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
|
||||
'OVERVIEW'
|
||||
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
|
||||
);
|
||||
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
|
||||
normalizeToArray(filterValues.location)
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const statsRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const allChartsRef = useRef<DashboardAllChartsRef>(null);
|
||||
|
||||
// ===== FETCH DATA =====
|
||||
const {
|
||||
@@ -64,22 +83,32 @@ const DashboardProduction = () => {
|
||||
: undefined;
|
||||
|
||||
// ===== SELECT =====
|
||||
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
|
||||
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
|
||||
limit: 'limit',
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
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,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } =
|
||||
useSelect(KandangApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
const {
|
||||
setInputValue: setInputValueKandang,
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
const comparisonTypeOptions = [
|
||||
{ value: 'FARM', label: 'Farm' },
|
||||
{ value: 'FLOCK', label: 'Flock' },
|
||||
@@ -89,20 +118,21 @@ const DashboardProduction = () => {
|
||||
// ===== FORMIK =====
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
flock: [] as OptionType[],
|
||||
location: [] as OptionType[],
|
||||
kandang: [] as OptionType[],
|
||||
analysisMode: analysisMode,
|
||||
comparisonType: '',
|
||||
lokasiIds: [],
|
||||
flockIds: [],
|
||||
kandangIds: [],
|
||||
startDate: filterValues.startDate || '',
|
||||
endDate: filterValues.endDate || '',
|
||||
flock: filterValues.flock || ([] as OptionType[]),
|
||||
location: filterValues.location || ([] as OptionType[]),
|
||||
kandang: filterValues.kandang || ([] as OptionType[]),
|
||||
analysisMode: filterValues.analysisMode || analysisMode,
|
||||
comparisonType: filterValues.comparisonType || '',
|
||||
locationIds: filterValues.locationIds || [],
|
||||
flockIds: filterValues.flockIds || [],
|
||||
kandangIds: filterValues.kandangIds || [],
|
||||
} as DashboardFilterType,
|
||||
validationSchema: getDashboardFilterSchema(analysisMode),
|
||||
onSubmit: (values) => {
|
||||
console.log(values);
|
||||
// Save filter values to store
|
||||
setFilterValues(values);
|
||||
|
||||
handleApplyFilter({
|
||||
start_date: values.startDate || '',
|
||||
@@ -118,13 +148,13 @@ const DashboardProduction = () => {
|
||||
|
||||
const handleResetFilter = () => {
|
||||
formik.resetForm();
|
||||
resetFilterValues(); // Clear stored filter values
|
||||
setAnalysisMode('OVERVIEW');
|
||||
setEndpointUrl('/dashboards');
|
||||
setSelectedLocationIds([]);
|
||||
};
|
||||
|
||||
const handleApplyFilter = (values: DashboardFilter) => {
|
||||
console.log(values);
|
||||
|
||||
// Build query params object, only include non-empty values
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
@@ -140,15 +170,37 @@ const DashboardProduction = () => {
|
||||
if (values.comparison_type) params.comparison_type = values.comparison_type;
|
||||
|
||||
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
|
||||
console.log(endpointUrl);
|
||||
filterModal.closeModal();
|
||||
refreshDashboardProductionData();
|
||||
formik.resetForm();
|
||||
};
|
||||
|
||||
// ===== Load filter from store on mount =====
|
||||
useEffect(() => {
|
||||
if (!filterValues) return;
|
||||
handleApplyFilter({
|
||||
start_date: filterValues.startDate,
|
||||
end_date: filterValues.endDate,
|
||||
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
|
||||
location_ids: normalizeToArray(filterValues.location),
|
||||
flock_ids: normalizeToArray(filterValues.flock),
|
||||
kandang_ids: normalizeToArray(filterValues.kandang),
|
||||
comparison_type: filterValues.comparisonType,
|
||||
});
|
||||
}, [filterValues]);
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
|
||||
// ===== Export PDF =====
|
||||
const handleExportPDF = async () => {
|
||||
await generateDashboardPDF({
|
||||
filterValues: formik.values,
|
||||
statsRef,
|
||||
allChartsRef,
|
||||
setExporting,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingDashboardProductionData) {
|
||||
return (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
@@ -156,103 +208,108 @@ const DashboardProduction = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full p-4 space-y-6'>
|
||||
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
|
||||
<div></div>
|
||||
|
||||
<div className='flex flex-row justify-end gap-2'>
|
||||
<Button
|
||||
<ButtonFilter
|
||||
values={{
|
||||
...formik.values,
|
||||
analysisMode: undefined,
|
||||
}}
|
||||
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'
|
||||
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} />
|
||||
Export
|
||||
<Icon icon='heroicons:chevron-down' width={20} height={20} />
|
||||
</Button>
|
||||
<Menu className={exporting ? 'hidden' : ''}>
|
||||
<MenuItem title='PDF' onClick={handleExportPDF} />
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Stats */}
|
||||
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} />
|
||||
<div ref={statsRef}>
|
||||
<DashboardStats
|
||||
data={dashboardProductionData?.statistics_data ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Use DashboardLineChart component or skeleton */}
|
||||
{isLoadingDashboardProductionData ? (
|
||||
<DashboardLineChartSkeleton />
|
||||
) : dashboardProductionData &&
|
||||
dashboardProductionData.charts &&
|
||||
Object.keys(dashboardProductionData.charts).length > 0 ? (
|
||||
<DashboardLineChart
|
||||
analysisMode={
|
||||
isResponseSuccess(dashboardProductionResponse)
|
||||
? dashboardProductionResponse.meta
|
||||
? (
|
||||
dashboardProductionResponse.meta as unknown as DashboardMeta
|
||||
).filters?.analysis_mode
|
||||
<div ref={chartRef}>
|
||||
{isLoadingDashboardProductionData ? (
|
||||
<DashboardLineChartSkeleton />
|
||||
) : dashboardProductionData &&
|
||||
dashboardProductionData.charts &&
|
||||
Object.keys(dashboardProductionData.charts).length > 0 ? (
|
||||
<DashboardLineChart
|
||||
analysisMode={
|
||||
isResponseSuccess(dashboardProductionResponse)
|
||||
? dashboardProductionResponse.meta
|
||||
? (
|
||||
dashboardProductionResponse.meta as unknown as DashboardMeta
|
||||
).filters?.analysis_mode
|
||||
: analysisMode
|
||||
: analysisMode
|
||||
: analysisMode
|
||||
}
|
||||
data={dashboardProductionData}
|
||||
/>
|
||||
) : (
|
||||
<DashboardLineChartSkeleton
|
||||
meta={
|
||||
isResponseSuccess(dashboardProductionResponse)
|
||||
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
data={dashboardProductionData}
|
||||
selectedKandang={
|
||||
analysisMode === 'OVERVIEW'
|
||||
? (formik.values.kandang as OptionType)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DashboardLineChartSkeleton
|
||||
meta={
|
||||
isResponseSuccess(dashboardProductionResponse)
|
||||
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
|
||||
{dashboardProductionData && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: 0,
|
||||
width: '1200px', // Fixed width for consistent PDF rendering
|
||||
}}
|
||||
>
|
||||
<DashboardAllCharts
|
||||
ref={allChartsRef}
|
||||
data={dashboardProductionData}
|
||||
analysisMode={
|
||||
isResponseSuccess(dashboardProductionResponse)
|
||||
? dashboardProductionResponse.meta
|
||||
? (
|
||||
dashboardProductionResponse.meta as unknown as DashboardMeta
|
||||
).filters?.analysis_mode
|
||||
: analysisMode
|
||||
: analysisMode
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -287,7 +344,7 @@ const DashboardProduction = () => {
|
||||
{/* Rentang Waktu */}
|
||||
<div className='px-4'>
|
||||
<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
|
||||
name='startDate'
|
||||
placeholder='Tanggal Mulai'
|
||||
@@ -302,7 +359,7 @@ const DashboardProduction = () => {
|
||||
Boolean(formik.touched.startDate)
|
||||
}
|
||||
/>
|
||||
<span className='hidden md:block text-center'>—</span>
|
||||
<div className='hidden md:block mt-3 text-center'>—</div>
|
||||
<DateInput
|
||||
name='endDate'
|
||||
placeholder='Tanggal Akhir'
|
||||
@@ -383,6 +440,8 @@ const DashboardProduction = () => {
|
||||
<SelectInput
|
||||
label='Farm'
|
||||
value={formik.values.location}
|
||||
onInputChange={setInputValueLocation}
|
||||
onMenuScrollToBottom={loadMoreLocation}
|
||||
onChange={(selected) => {
|
||||
formik.setFieldValue('location', selected);
|
||||
// Update selectedLocationIds for kandang filter
|
||||
@@ -422,6 +481,8 @@ const DashboardProduction = () => {
|
||||
formik.setFieldValue('flock', selected)
|
||||
}
|
||||
errorMessage={formik.errors.flock as string}
|
||||
onInputChange={setInputValueFlock}
|
||||
onMenuScrollToBottom={loadMoreFlock}
|
||||
options={flockOptions}
|
||||
isLoading={isLoadingFlockOptions}
|
||||
isMulti={
|
||||
@@ -450,6 +511,8 @@ const DashboardProduction = () => {
|
||||
formik.setFieldValue('kandang', selected)
|
||||
}
|
||||
errorMessage={formik.errors.kandang as string}
|
||||
onInputChange={setInputValueKandang}
|
||||
onMenuScrollToBottom={loadMoreKandang}
|
||||
options={kandangOptions}
|
||||
isLoading={isLoadingKandangOptions}
|
||||
isMulti={
|
||||
@@ -465,7 +528,9 @@ const DashboardProduction = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
<div className='w-full p-4'>
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
|
||||
@@ -473,7 +538,6 @@ const DashboardProduction = () => {
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='ms-4 min-w-36 rounded-lg'
|
||||
onClick={handleResetFilter}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import Card from '@/components/Card';
|
||||
import {
|
||||
Dashboard,
|
||||
DashboardOverviewCharts,
|
||||
DashboardComparisonCharts,
|
||||
DashboardChartsSeries,
|
||||
DashboardChartsDataset,
|
||||
} from '@/types/api/dashboard/dashboard';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
type DashboardAllChartsProps = {
|
||||
data: Dashboard;
|
||||
analysisMode: string;
|
||||
};
|
||||
|
||||
export type DashboardAllChartsRef = {
|
||||
getChartRefs: () => {
|
||||
key: string;
|
||||
ref: HTMLDivElement | null;
|
||||
label: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
// Type guard to check if charts is DashboardOverviewCharts
|
||||
function isOverviewCharts(
|
||||
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
|
||||
): charts is DashboardOverviewCharts {
|
||||
if (!charts) return false;
|
||||
return (
|
||||
'deplesi' in charts ||
|
||||
'body_weight' in charts ||
|
||||
'fcr' in charts ||
|
||||
'performance' in charts ||
|
||||
'quality_control' in charts
|
||||
);
|
||||
}
|
||||
|
||||
// Type guard to check if charts is DashboardComparisonCharts
|
||||
function isComparisonCharts(
|
||||
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
|
||||
): charts is DashboardComparisonCharts {
|
||||
if (!charts) return false;
|
||||
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
|
||||
}
|
||||
|
||||
const lineColors: Record<string, string> = {
|
||||
body_weight: '#10B981',
|
||||
std_body_weight: '#10B981',
|
||||
act_laying: '#1062B9',
|
||||
std_laying: '#1062B9',
|
||||
act_egg_weight: '#10B981',
|
||||
std_egg_weight: '#10B981',
|
||||
act_feed_intake: '#F52419',
|
||||
std_feed_intake: '#F52419',
|
||||
act_uniformity: '#F59E0B',
|
||||
std_uniformity: '#F59E0B',
|
||||
act_fcr: '#10B981',
|
||||
std_fcr: '#10B981',
|
||||
act_fcr_cum: '#F52419',
|
||||
std_fcr_cum: '#10B981',
|
||||
normal: '#10B981',
|
||||
abnormal: '#F52419',
|
||||
act_deplesi: '#10B981',
|
||||
std_deplesi: '#10B981',
|
||||
};
|
||||
|
||||
const defaultLineColors: string[] = [
|
||||
'#10B981',
|
||||
'#1062B9',
|
||||
'#F52419',
|
||||
'#F59E0B',
|
||||
'#7F56D9',
|
||||
];
|
||||
|
||||
// Helper function to get line color
|
||||
const getLineColor = (seriesId: string | number, index: number): string => {
|
||||
const predefinedColor = lineColors[seriesId];
|
||||
if (predefinedColor) {
|
||||
return predefinedColor;
|
||||
}
|
||||
return defaultLineColors[index % defaultLineColors.length];
|
||||
};
|
||||
|
||||
// Mapping for chart type labels
|
||||
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
|
||||
body_weight: 'Body Weight',
|
||||
performance: 'Performance',
|
||||
fcr: 'FCR',
|
||||
quality_control: 'Quality Control',
|
||||
deplesi: 'Deplesi',
|
||||
};
|
||||
|
||||
const DashboardAllCharts = forwardRef<
|
||||
DashboardAllChartsRef,
|
||||
DashboardAllChartsProps
|
||||
>(({ data, analysisMode }, ref) => {
|
||||
// Create refs for charts - use string keys for flexibility
|
||||
const chartRefs = useRef<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>({});
|
||||
|
||||
// Determine chart keys and labels based on analysis mode
|
||||
const getChartConfig = () => {
|
||||
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
|
||||
const overviewKeys: (keyof DashboardOverviewCharts)[] = [
|
||||
'body_weight',
|
||||
'performance',
|
||||
'fcr',
|
||||
'quality_control',
|
||||
'deplesi',
|
||||
];
|
||||
return overviewKeys.map((key) => ({
|
||||
key,
|
||||
label: chartTypeLabels[key],
|
||||
chartData: (data.charts as DashboardOverviewCharts)[key],
|
||||
}));
|
||||
} else if (
|
||||
analysisMode === 'COMPARISON' &&
|
||||
isComparisonCharts(data.charts)
|
||||
) {
|
||||
// For comparison mode, find which comparison type has data
|
||||
const comparisonKey = data.charts.farm
|
||||
? 'farm'
|
||||
: data.charts.flock
|
||||
? 'flock'
|
||||
: 'kandang';
|
||||
|
||||
const comparisonLabels: Record<string, string> = {
|
||||
farm: 'Farm Comparison',
|
||||
flock: 'Flock Comparison',
|
||||
kandang: 'Kandang Comparison',
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
key: comparisonKey,
|
||||
label: comparisonLabels[comparisonKey],
|
||||
chartData: data.charts[comparisonKey],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const chartConfig = getChartConfig();
|
||||
|
||||
// Expose method to get all chart refs
|
||||
useImperativeHandle(ref, () => ({
|
||||
getChartRefs: () => {
|
||||
return chartConfig
|
||||
.map(({ key, label }) => ({
|
||||
key,
|
||||
ref: chartRefs.current[key] || null,
|
||||
label,
|
||||
}))
|
||||
.filter((item) => item.ref !== null);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{chartConfig.map(({ key, label, chartData }) => {
|
||||
if (
|
||||
!chartData ||
|
||||
!chartData.dataset ||
|
||||
chartData.dataset.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seriesData: DashboardChartsSeries[] = chartData.series || [];
|
||||
const dataset: DashboardChartsDataset[] = chartData.dataset || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
chartRefs.current[key] = el;
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={{
|
||||
wrapper: 'w-full rounded-lg',
|
||||
}}
|
||||
variant='bordered'
|
||||
>
|
||||
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
|
||||
<div className='text-lg font-semibold'>
|
||||
{label}{' '}
|
||||
<Icon
|
||||
icon='heroicons:information-circle'
|
||||
width={20}
|
||||
height={20}
|
||||
className='inline text-neutral-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className='flex flex-wrap gap-3 mb-6'>
|
||||
{seriesData.map((series, index) => {
|
||||
const isStandard = series.id
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes('std');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={series.id}
|
||||
className='flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-400 bg-neutral-50'
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-0.5 ${
|
||||
isStandard ? 'border-t-2 border-dashed' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isStandard
|
||||
? 'transparent'
|
||||
: getLineColor(series.id, index),
|
||||
borderColor: isStandard
|
||||
? getLineColor(series.id, index)
|
||||
: 'transparent',
|
||||
}}
|
||||
></div>
|
||||
<span className='text-sm text-neutral-900 font-medium'>
|
||||
{series.label}
|
||||
</span>
|
||||
<Icon
|
||||
icon='heroicons:information-circle'
|
||||
width={16}
|
||||
height={16}
|
||||
className='text-neutral-400'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<LineChart
|
||||
data={dataset}
|
||||
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={(() => {
|
||||
const allValues: number[] = [];
|
||||
dataset.forEach((item: DashboardChartsDataset) => {
|
||||
seriesData.forEach((series) => {
|
||||
const value = item[series.id];
|
||||
if (typeof value === 'number') {
|
||||
allValues.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (allValues.length === 0) return [0, 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);
|
||||
|
||||
return [domainMin, domainMax];
|
||||
})()}
|
||||
/>
|
||||
{seriesData.map((series, index) => {
|
||||
const isStandard = series.id
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.includes('std');
|
||||
const dataKey = series.id.toString();
|
||||
|
||||
return (
|
||||
<Line
|
||||
key={series.id}
|
||||
type='monotone'
|
||||
dataKey={dataKey}
|
||||
name={series.label}
|
||||
stroke={getLineColor(series.id, index)}
|
||||
opacity={isStandard ? 0.5 : 1}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={isStandard ? '5 5' : undefined}
|
||||
dot={
|
||||
isStandard
|
||||
? false
|
||||
: {
|
||||
r: 3,
|
||||
fill: '#fff',
|
||||
stroke: getLineColor(series.id, index),
|
||||
strokeWidth: 2,
|
||||
}
|
||||
}
|
||||
activeDot={isStandard ? undefined : { r: 5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DashboardAllCharts.displayName = 'DashboardAllCharts';
|
||||
|
||||
export default DashboardAllCharts;
|
||||
@@ -1,8 +1,10 @@
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import {
|
||||
Dashboard,
|
||||
DashboardOverviewCharts,
|
||||
@@ -25,20 +27,29 @@ import {
|
||||
type DashboardLineChartProps = {
|
||||
analysisMode: 'OVERVIEW' | 'COMPARISON';
|
||||
data: Dashboard;
|
||||
selectedKandang?: OptionType;
|
||||
};
|
||||
|
||||
// Type guard to check if charts is DashboardOverviewCharts
|
||||
function isOverviewCharts(
|
||||
charts: DashboardOverviewCharts | DashboardComparisonCharts
|
||||
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
|
||||
): charts is DashboardOverviewCharts {
|
||||
return 'deplesi' in charts;
|
||||
if (!charts) return false;
|
||||
return (
|
||||
'deplesi' in charts ||
|
||||
'body_weight' in charts ||
|
||||
'fcr' in charts ||
|
||||
'performance' in charts ||
|
||||
'quality_control' in charts
|
||||
);
|
||||
}
|
||||
|
||||
// Type guard to check if charts is DashboardComparisonCharts
|
||||
function isComparisonCharts(
|
||||
charts: DashboardOverviewCharts | DashboardComparisonCharts
|
||||
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
|
||||
): charts is DashboardComparisonCharts {
|
||||
return 'location' in charts || 'flock' in charts || 'kandang' in charts;
|
||||
if (!charts) return false;
|
||||
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
|
||||
}
|
||||
|
||||
const lineColors: Record<string, string> = {
|
||||
@@ -94,6 +105,7 @@ const getLineColor = (
|
||||
const DashboardLineChart = ({
|
||||
analysisMode,
|
||||
data,
|
||||
selectedKandang,
|
||||
}: DashboardLineChartProps) => {
|
||||
const [chartData, setChartData] =
|
||||
useState<keyof DashboardOverviewCharts>('body_weight');
|
||||
@@ -123,7 +135,7 @@ const DashboardLineChart = ({
|
||||
isComparisonCharts(data.charts)
|
||||
) {
|
||||
const comparisonChart =
|
||||
data.charts.location || data.charts.flock || data.charts.kandang;
|
||||
data.charts.farm || data.charts.flock || data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
}
|
||||
|
||||
@@ -224,7 +236,7 @@ const DashboardLineChart = ({
|
||||
isComparisonCharts(data.charts)
|
||||
) {
|
||||
const comparisonChart =
|
||||
data.charts.location || data.charts.flock || data.charts.kandang;
|
||||
data.charts.farm || data.charts.flock || data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
}
|
||||
|
||||
@@ -283,261 +295,382 @@ const DashboardLineChart = ({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<LineChart
|
||||
data={(() => {
|
||||
// Transform data based on analysisMode
|
||||
if (analysisMode === 'OVERVIEW') {
|
||||
// For OVERVIEW mode, use the selected chart data
|
||||
if (isOverviewCharts(data.charts)) {
|
||||
const selectedChartData = data.charts[chartData];
|
||||
if (!selectedChartData || !selectedChartData.dataset) return [];
|
||||
return selectedChartData.dataset;
|
||||
{/* Chart Container with Empty State Overlay */}
|
||||
<div className='relative'>
|
||||
{/* Chart */}
|
||||
<ResponsiveContainer width='100%' height={350}>
|
||||
<LineChart
|
||||
data={(() => {
|
||||
// Transform data based on analysisMode
|
||||
if (analysisMode === 'OVERVIEW') {
|
||||
// For OVERVIEW mode, use the selected chart data
|
||||
if (isOverviewCharts(data.charts)) {
|
||||
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.farm ||
|
||||
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={(() => {
|
||||
// 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',
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 10,
|
||||
left: 0,
|
||||
bottom: 5,
|
||||
}}
|
||||
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 ['', ''];
|
||||
>
|
||||
<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[] = [];
|
||||
|
||||
// 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 || [];
|
||||
}
|
||||
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.farm ||
|
||||
data.charts.flock ||
|
||||
data.charts.kandang;
|
||||
seriesData = comparisonChart?.series || [];
|
||||
dataset = comparisonChart?.dataset || [];
|
||||
}
|
||||
|
||||
// Find the series that matches this line's name
|
||||
const series = seriesData.find((s) => s.label === name);
|
||||
const unit = series?.unit || '';
|
||||
// Get all values from visible series
|
||||
const visibleSeriesIds = Array.from(visibleSeries);
|
||||
const allValues: number[] = [];
|
||||
|
||||
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,
|
||||
}
|
||||
dataset.forEach((item: DashboardChartsDataset) => {
|
||||
visibleSeriesIds.forEach((seriesId) => {
|
||||
const value = item[seriesId];
|
||||
if (typeof value === 'number') {
|
||||
allValues.push(value);
|
||||
}
|
||||
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.farm ||
|
||||
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}`}
|
||||
content={(props) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-2 rounded-lg bg-neutral-950 p-4 text-white'>
|
||||
<p className='text-neutral-300 text-xs font-semibold text-start'>
|
||||
{analysisMode === 'OVERVIEW'
|
||||
? selectedKandang
|
||||
? selectedKandang.label || 'Overview Performance'
|
||||
: 'Overview Performance'
|
||||
: 'Comparison Performance'}
|
||||
</p>
|
||||
<ul className='flex flex-col gap-1'>
|
||||
{props.payload.map((item, index) => {
|
||||
if (item.name.startsWith('STD. ')) return null;
|
||||
// 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.farm ||
|
||||
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 === item.name
|
||||
);
|
||||
const color = series?.id
|
||||
? getLineColor(series.id, index, analysisMode)
|
||||
: '#9ca3af';
|
||||
const unit = series?.unit;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.name}
|
||||
className='flex w-full justify-between items-center flex-row gap-6 p-0'
|
||||
>
|
||||
<span className='flex flex-row gap-1 items-center'>
|
||||
<div
|
||||
className='h-4 w-4 m-0 rounded-md'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
></div>
|
||||
<div className='m-0'>
|
||||
{formatNumber(item.value)}
|
||||
{unit}
|
||||
</div>
|
||||
</span>
|
||||
<span className='m-0'>{item.name}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className='text-neutral-300 text-xs text-start'>
|
||||
Week {props.label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
}}
|
||||
formatter={(
|
||||
value: number | undefined,
|
||||
name: string | undefined
|
||||
) => {
|
||||
if (
|
||||
value === undefined ||
|
||||
name === undefined ||
|
||||
name.startsWith('STD. ')
|
||||
)
|
||||
return [undefined, undefined];
|
||||
|
||||
// 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.farm ||
|
||||
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 id = series?.id || '';
|
||||
|
||||
return [value, id];
|
||||
}}
|
||||
/>
|
||||
{/* 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.farm || 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.farm || 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 */}
|
||||
<h3 className='text-gray-900 font-semibold text-base mb-2'>
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import { toPng } from 'html-to-image';
|
||||
import toast from 'react-hot-toast';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
|
||||
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts';
|
||||
|
||||
interface DashboardPDFExportParams {
|
||||
filterValues: DashboardFilterType;
|
||||
statsRef: React.RefObject<HTMLDivElement | null>;
|
||||
allChartsRef: React.RefObject<DashboardAllChartsRef | null>;
|
||||
setExporting: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const generateDashboardPDF = async ({
|
||||
filterValues,
|
||||
statsRef,
|
||||
allChartsRef,
|
||||
setExporting,
|
||||
}: DashboardPDFExportParams): Promise<void> => {
|
||||
try {
|
||||
setExporting(true);
|
||||
toast.loading('Generating PDF...', { id: 'export-pdf' });
|
||||
|
||||
// Wait for DOM to update
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const margin = 10;
|
||||
let yPosition = margin;
|
||||
|
||||
// Add title
|
||||
pdf.setFontSize(16);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.text('Dashboard Produksi', margin, yPosition);
|
||||
yPosition += 10;
|
||||
|
||||
// Add filter information (horizontal layout)
|
||||
pdf.setFontSize(6);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
|
||||
const filterItems: string[] = [];
|
||||
|
||||
// Period
|
||||
if (filterValues.startDate || filterValues.endDate) {
|
||||
const periodText = `Periode: ${
|
||||
filterValues.startDate
|
||||
? formatDate(filterValues.startDate, 'DD MMM YYYY')
|
||||
: '-'
|
||||
} s.d ${
|
||||
filterValues.endDate
|
||||
? formatDate(filterValues.endDate, 'DD MMM YYYY')
|
||||
: '-'
|
||||
}`;
|
||||
filterItems.push(periodText);
|
||||
}
|
||||
|
||||
// Analysis Mode
|
||||
const analysisModeText = `Analysis Mode: ${
|
||||
filterValues.analysisMode === 'OVERVIEW'
|
||||
? 'Performance Overview'
|
||||
: 'Performance Comparison'
|
||||
}`;
|
||||
filterItems.push(analysisModeText);
|
||||
|
||||
// Comparison Type (only for COMPARISON mode)
|
||||
if (
|
||||
filterValues.analysisMode === 'COMPARISON' &&
|
||||
filterValues.comparisonType
|
||||
) {
|
||||
const comparisonTypeLabel =
|
||||
filterValues.comparisonType === 'FARM'
|
||||
? 'Farm'
|
||||
: filterValues.comparisonType === 'FLOCK'
|
||||
? 'Flock'
|
||||
: filterValues.comparisonType === 'KANDANG'
|
||||
? 'Kandang'
|
||||
: filterValues.comparisonType;
|
||||
filterItems.push(`Compared By: ${comparisonTypeLabel}`);
|
||||
}
|
||||
|
||||
// Farm
|
||||
if (filterValues.location) {
|
||||
const locationText = Array.isArray(filterValues.location)
|
||||
? filterValues.location.map((loc) => loc.label).join(', ')
|
||||
: filterValues.location.label;
|
||||
filterItems.push(`Farm: ${locationText || '-'}`);
|
||||
}
|
||||
|
||||
// Flock
|
||||
if (
|
||||
filterValues.flock &&
|
||||
(Array.isArray(filterValues.flock)
|
||||
? filterValues.flock.length > 0
|
||||
: filterValues.flock)
|
||||
) {
|
||||
const flockText = Array.isArray(filterValues.flock)
|
||||
? filterValues.flock.map((f) => f.label).join(', ')
|
||||
: filterValues.flock.label;
|
||||
filterItems.push(`Flock: ${flockText || '-'}`);
|
||||
}
|
||||
|
||||
// Kandang
|
||||
if (
|
||||
filterValues.kandang &&
|
||||
(Array.isArray(filterValues.kandang)
|
||||
? filterValues.kandang.length > 0
|
||||
: filterValues.kandang)
|
||||
) {
|
||||
const kandangText = Array.isArray(filterValues.kandang)
|
||||
? filterValues.kandang.map((k) => k.label).join(', ')
|
||||
: filterValues.kandang.label;
|
||||
filterItems.push(`Kandang: ${kandangText || '-'}`);
|
||||
}
|
||||
|
||||
// Generated timestamp
|
||||
filterItems.push(`Dicetak: ${formatDate(new Date(), 'DD MMM YYYY HH:mm')}`);
|
||||
|
||||
// Render filter items horizontally with word wrap and gray background
|
||||
const maxWidth = pageWidth - 2 * margin;
|
||||
let currentLine = '';
|
||||
const lines: string[] = [];
|
||||
|
||||
// First pass: calculate all lines
|
||||
filterItems.forEach((item, index) => {
|
||||
const separator = index > 0 ? ' | ' : '';
|
||||
const testLine = currentLine + separator + item;
|
||||
const testWidth = pdf.getTextWidth(testLine);
|
||||
|
||||
if (testWidth > maxWidth && currentLine !== '') {
|
||||
lines.push(currentLine);
|
||||
currentLine = item;
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
});
|
||||
|
||||
// Add last line
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
// Calculate background dimensions
|
||||
const lineHeight = 3;
|
||||
const padding = 1;
|
||||
const backgroundHeight = lines.length * lineHeight + padding * 2;
|
||||
|
||||
// Draw gray background
|
||||
pdf.setFillColor(240, 240, 240); // Light gray (RGB: 240, 240, 240)
|
||||
pdf.rect(
|
||||
margin - padding,
|
||||
yPosition - padding - 2,
|
||||
pageWidth - 2 * margin + padding * 2,
|
||||
backgroundHeight,
|
||||
'F'
|
||||
);
|
||||
|
||||
// Render text on top of background
|
||||
lines.forEach((line, index) => {
|
||||
pdf.text(line, margin, yPosition);
|
||||
if (index < lines.length - 1) {
|
||||
yPosition += lineHeight;
|
||||
}
|
||||
});
|
||||
|
||||
yPosition += 10;
|
||||
|
||||
// Capture and add stats if available
|
||||
if (statsRef.current) {
|
||||
const statsImage = await toPng(statsRef.current, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
});
|
||||
const statsImgProps = pdf.getImageProperties(statsImage);
|
||||
const statsWidth = pageWidth - 2 * margin;
|
||||
const statsHeight =
|
||||
(statsImgProps.height * statsWidth) / statsImgProps.width;
|
||||
|
||||
// Check if we need a new page
|
||||
if (yPosition + statsHeight > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
yPosition = margin;
|
||||
}
|
||||
|
||||
pdf.addImage(
|
||||
statsImage,
|
||||
'PNG',
|
||||
margin,
|
||||
yPosition,
|
||||
statsWidth,
|
||||
statsHeight
|
||||
);
|
||||
yPosition += statsHeight + 10;
|
||||
}
|
||||
|
||||
if (allChartsRef.current) {
|
||||
// Get all individual chart refs
|
||||
const chartRefs = allChartsRef.current.getChartRefs();
|
||||
|
||||
// Capture each chart separately and add to PDF
|
||||
for (let i = 0; i < chartRefs.length; i++) {
|
||||
const { ref: chartElement, label } = chartRefs[i];
|
||||
|
||||
if (chartElement) {
|
||||
// Add chart title
|
||||
pdf.setFontSize(12);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
|
||||
const chartImage = await toPng(chartElement, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
});
|
||||
const chartImgProps = pdf.getImageProperties(chartImage);
|
||||
const chartWidth = pageWidth - 2 * margin;
|
||||
const chartHeight =
|
||||
(chartImgProps.height * chartWidth) / chartImgProps.width;
|
||||
|
||||
// Calculate total height needed (title + spacing + chart)
|
||||
const titleHeight = 10;
|
||||
const totalHeight = titleHeight + chartHeight;
|
||||
|
||||
// Check if chart fits on current page
|
||||
if (yPosition + totalHeight > pageHeight - margin) {
|
||||
pdf.addPage();
|
||||
yPosition = margin;
|
||||
}
|
||||
|
||||
// Add title
|
||||
pdf.text(label, margin, yPosition);
|
||||
yPosition += titleHeight;
|
||||
|
||||
// Add chart image
|
||||
pdf.addImage(
|
||||
chartImage,
|
||||
'PNG',
|
||||
margin,
|
||||
yPosition,
|
||||
chartWidth,
|
||||
chartHeight
|
||||
);
|
||||
|
||||
// Update yPosition for next chart (add spacing between charts)
|
||||
yPosition += chartHeight + 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the PDF
|
||||
const fileName = `dashboard-production-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
pdf.save(fileName);
|
||||
|
||||
toast.success('PDF exported successfully!', { id: 'export-pdf' });
|
||||
} catch (error) {
|
||||
toast.error('Failed to export PDF. Please try again.', {
|
||||
id: 'export-pdf',
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,7 @@ export type DashboardFilterType = {
|
||||
analysisMode: string;
|
||||
comparisonType: string | undefined;
|
||||
location: OptionType | OptionType[];
|
||||
lokasiIds: number[] | undefined;
|
||||
locationIds: number[] | undefined;
|
||||
flock: OptionType | OptionType[] | undefined;
|
||||
flockIds: number[] | undefined;
|
||||
kandang: OptionType | OptionType[] | undefined;
|
||||
@@ -25,7 +25,7 @@ export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType
|
||||
then: (schema) => schema.required('Compared by is required'),
|
||||
otherwise: (schema) => schema.optional(),
|
||||
}),
|
||||
lokasiIds: yup.array().optional(),
|
||||
locationIds: yup.array().optional(),
|
||||
flockIds: yup.array().optional(),
|
||||
kandangIds: yup.array().optional(),
|
||||
location: yup
|
||||
@@ -68,7 +68,7 @@ export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterTy
|
||||
then: (schema) => schema.required('Compared by is required'),
|
||||
otherwise: (schema) => schema.optional(),
|
||||
}),
|
||||
lokasiIds: yup.array().optional(),
|
||||
locationIds: yup.array().optional(),
|
||||
flockIds: yup.array().optional(),
|
||||
kandangIds: yup.array().optional(),
|
||||
location: yup
|
||||
|
||||
@@ -43,7 +43,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-7xl pb-16'>
|
||||
<section className='w-full max-w-full pb-16'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
@@ -65,7 +65,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
tabs={expenseDetailTabs}
|
||||
variant='lifted'
|
||||
className={{
|
||||
wrapper: 'max-w-5xl mx-auto mt-4',
|
||||
wrapper: 'mx-auto mt-4',
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
||||
<RequirePermission permissions='lti.expense.update.realization'>
|
||||
<Button
|
||||
@@ -84,7 +84,7 @@ const ExpenseRealizationContent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
|
||||
<div className='overflow-x-auto w-full mx-auto'>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
||||
<div className='w-full mt-8 mx-auto'>
|
||||
<div className='flex flex-row gap-4'>
|
||||
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
|
||||
<div className='w-full flex flex-col gap-2'>
|
||||
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
||||
<h2 className='font-bold text-xl text-center'>
|
||||
Rincian Pengajuan Biaya Operasional
|
||||
</h2>
|
||||
<div className='w-full mt-8 mx-auto grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<h2 className='font-bold text-xl text-center'>
|
||||
Rincian Pengajuan Biaya Operasional
|
||||
</h2>
|
||||
|
||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
||||
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
|
||||
let expenseGrandTotal = 0;
|
||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
||||
{initialValues?.kandangs.map(
|
||||
(kandangExpense, kandangExpenseIdx) => {
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.pengajuans?.forEach(
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
kandangExpense.pengajuans?.forEach(
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kandangExpenseIdx}
|
||||
className='overflow-x-auto w-full mx-auto'
|
||||
>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
colSpan={5}
|
||||
className='font-bold text-center text-base-content text-lg'
|
||||
>
|
||||
Biaya {kandangExpense.name}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Total Biaya</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kandangExpense.pengajuans?.map(
|
||||
(pengajuanItem, pengajuanIdx) => (
|
||||
<tr key={pengajuanIdx}>
|
||||
<td>{pengajuanItem.nonstock.name}</td>
|
||||
<td>{pengajuanItem.qty}</td>
|
||||
<td>{formatCurrency(pengajuanItem.price)}</td>
|
||||
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
|
||||
return (
|
||||
<div
|
||||
key={kandangExpenseIdx}
|
||||
className='overflow-x-auto w-full mx-auto'
|
||||
>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
colSpan={5}
|
||||
className='font-bold text-center text-base-content text-lg'
|
||||
>
|
||||
Biaya {kandangExpense.name}
|
||||
</th>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className='border-y'>
|
||||
<th colSpan={2} className='text-right'>
|
||||
Total Biaya Keseluruhan:
|
||||
</th>
|
||||
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Total Biaya</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kandangExpense.pengajuans?.map(
|
||||
(pengajuanItem, pengajuanIdx) => (
|
||||
<tr key={pengajuanIdx}>
|
||||
<td>{pengajuanItem.nonstock.name}</td>
|
||||
<td>{pengajuanItem.qty}</td>
|
||||
<td>{formatCurrency(pengajuanItem.price)}</td>
|
||||
<td className='w-xs'>
|
||||
{pengajuanItem.notes ?? '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className='border-y'>
|
||||
<th colSpan={2} className='text-right'>
|
||||
Total Biaya Keseluruhan:
|
||||
</th>
|
||||
<th colSpan={2}>
|
||||
{formatCurrency(expenseGrandTotal)}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
||||
<h2 className='font-bold text-xl text-center'>
|
||||
Rincian Realisasi Biaya Operasional
|
||||
</h2>
|
||||
<div>
|
||||
<h2 className='font-bold text-xl text-center'>
|
||||
Rincian Realisasi Biaya Operasional
|
||||
</h2>
|
||||
|
||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
||||
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
|
||||
let expenseGrandTotal = 0;
|
||||
<div className='w-full mt-2 flex flex-col gap-4'>
|
||||
{initialValues?.kandangs.map(
|
||||
(kandangExpense, kandangExpenseIdx) => {
|
||||
let expenseGrandTotal = 0;
|
||||
|
||||
kandangExpense.realisasi?.forEach(
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
kandangExpense.realisasi?.forEach(
|
||||
(item) => (expenseGrandTotal += item.qty * item.price)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kandangExpenseIdx}
|
||||
className='overflow-x-auto w-full mx-auto'
|
||||
>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
colSpan={5}
|
||||
className='font-bold text-center text-base-content text-lg'
|
||||
>
|
||||
Biaya {kandangExpense.name}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Total Biaya</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kandangExpense.realisasi?.map(
|
||||
(realisasiItem, realisasiIdx) => (
|
||||
<tr key={realisasiIdx}>
|
||||
<td>{realisasiItem.nonstock.name}</td>
|
||||
<td>{realisasiItem.qty}</td>
|
||||
<td>{formatCurrency(realisasiItem.price)}</td>
|
||||
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
|
||||
return (
|
||||
<div
|
||||
key={kandangExpenseIdx}
|
||||
className='overflow-x-auto w-full mx-auto'
|
||||
>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
colSpan={5}
|
||||
className='font-bold text-center text-base-content text-lg'
|
||||
>
|
||||
Biaya {kandangExpense.name}
|
||||
</th>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className='border-y'>
|
||||
<th colSpan={2} className='text-right'>
|
||||
Total Biaya Keseluruhan:
|
||||
</th>
|
||||
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<th>Nonstock</th>
|
||||
<th>Total Kuantitas</th>
|
||||
<th>Total Biaya</th>
|
||||
<th>Catatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kandangExpense.realisasi?.map(
|
||||
(realisasiItem, realisasiIdx) => (
|
||||
<tr key={realisasiIdx}>
|
||||
<td>{realisasiItem.nonstock.name}</td>
|
||||
<td>{realisasiItem.qty}</td>
|
||||
<td>{formatCurrency(realisasiItem.price)}</td>
|
||||
<td className='w-xs'>
|
||||
{realisasiItem.notes ?? '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className='border-y'>
|
||||
<th colSpan={2} className='text-right'>
|
||||
Total Biaya Keseluruhan:
|
||||
</th>
|
||||
<th colSpan={2}>
|
||||
{formatCurrency(expenseGrandTotal)}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -273,7 +273,7 @@ const ExpenseRequestContent = ({
|
||||
<>
|
||||
<div>
|
||||
{initialValues && !isLoadingApprovalHistory && approvalHistory && (
|
||||
<div className='w-full max-w-5xl my-4 mx-auto'>
|
||||
<div className='w-full my-4 mx-auto'>
|
||||
<ApprovalSteps approvals={approvalHistory} />
|
||||
</div>
|
||||
)}
|
||||
@@ -281,7 +281,7 @@ const ExpenseRequestContent = ({
|
||||
<div className='w-full mt-4 flex flex-col gap-4'>
|
||||
{/* TODO: apply RBAC */}
|
||||
|
||||
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
{isCurrentApprovalOnHeadArea && (
|
||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||
<Button
|
||||
@@ -414,7 +414,7 @@ const ExpenseRequestContent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto w-full max-w-5xl mx-auto'>
|
||||
<div className='overflow-x-auto w-full mx-auto'>
|
||||
<table className='table table-sm table-zebra'>
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -608,7 +608,7 @@ const ExpenseRequestContent = ({
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-full max-w-5xl mt-8 mx-auto'>
|
||||
<div className='w-full mt-8 mx-auto'>
|
||||
<h2 className='font-bold text-xl text-center'>
|
||||
Rincian Pengajuan Biaya Operasional
|
||||
</h2>
|
||||
@@ -654,7 +654,7 @@ const ExpenseRequestContent = ({
|
||||
<td>{pengajuanItem.qty}</td>
|
||||
<td>{formatCurrency(pengajuanItem.price)}</td>
|
||||
<td className='w-xs'>
|
||||
{pengajuanItem.note ?? '-'}
|
||||
{pengajuanItem.notes ?? '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -54,17 +54,19 @@ const RowOptionsMenu = ({
|
||||
rejectClickHandler: () => void;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
const showEditButton =
|
||||
props.row.original.latest_approval.step_number !== 6 &&
|
||||
(props.row.original.latest_approval.step_number === 1 ||
|
||||
props.row.original.latest_approval.step_number === 2 ||
|
||||
props.row.original.latest_approval.step_number === 3 ||
|
||||
props.row.original.latest_approval.step_number === 4);
|
||||
const showEditButton = props.row.original.latest_approval
|
||||
? props.row.original.latest_approval.step_number !== 6 &&
|
||||
(props.row.original.latest_approval.step_number === 1 ||
|
||||
props.row.original.latest_approval.step_number === 2 ||
|
||||
props.row.original.latest_approval.step_number === 3 ||
|
||||
props.row.original.latest_approval.step_number === 4)
|
||||
: false;
|
||||
|
||||
// TODO: apply RBAC
|
||||
const showRealizationButton =
|
||||
props.row.original.latest_approval.action !== 'REJECTED' &&
|
||||
props.row.original.latest_approval.step_number === 4;
|
||||
const showRealizationButton = props.row.original.latest_approval
|
||||
? props.row.original.latest_approval.action !== 'REJECTED' &&
|
||||
props.row.original.latest_approval.step_number === 4
|
||||
: false;
|
||||
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
@@ -278,6 +280,7 @@ const ExpensesTable = () => {
|
||||
cell: ({ row }) => {
|
||||
const isCheckboxDisabled =
|
||||
!row.getCanSelect() ||
|
||||
!row.original.latest_approval ||
|
||||
row.original.latest_approval.action === 'REJECTED';
|
||||
|
||||
return (
|
||||
@@ -413,6 +416,8 @@ const ExpensesTable = () => {
|
||||
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
|
||||
row
|
||||
) => {
|
||||
if (!row.original.latest_approval) return false;
|
||||
|
||||
return (
|
||||
row.original.latest_approval.action !== 'REJECTED' &&
|
||||
row.original.latest_approval.step_number !== 6
|
||||
@@ -692,14 +697,6 @@ const ExpensesTable = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Biaya Operasional'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'sm:max-w-3xs' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-12 justify-end gap-2'>
|
||||
@@ -753,17 +750,12 @@ const ExpensesTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Baris'
|
||||
options={ROWS_OPTIONS}
|
||||
value={{
|
||||
label: String(tableFilterState.pageSize),
|
||||
value: tableFilterState.pageSize,
|
||||
}}
|
||||
onChange={pageSizeChangeHandler}
|
||||
className={{
|
||||
wrapper: 'col-span-12 max-w-28 justify-self-end',
|
||||
}}
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Cari Biaya Operasional'
|
||||
value={tableFilterState.search}
|
||||
onChange={searchChangeHandler}
|
||||
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper';
|
||||
interface ExpenseKandangsTableProps {
|
||||
locationId?: number;
|
||||
type: 'add' | 'edit' | 'detail';
|
||||
formType?: 'request' | 'realization';
|
||||
selectedKandangs: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
@@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps {
|
||||
|
||||
const ExpenseKandangsTable = ({
|
||||
type,
|
||||
formType = 'request',
|
||||
locationId,
|
||||
selectedKandangs,
|
||||
onChange,
|
||||
@@ -172,69 +174,84 @@ const ExpenseKandangsTable = ({
|
||||
updateSortingFilter('picSort', picSortFilter);
|
||||
}, [sorting, updateSortingFilter]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={{
|
||||
wrapper: className?.wrapper,
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={
|
||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||
<div className='card-title'>Pilih Kandang</div>
|
||||
// Tampilkan tabel jika:
|
||||
// 1. Mode request pertama kali (type='add' dan formType='request')
|
||||
// 2. Atau sudah ada kandang yang dipilih
|
||||
const shouldShowTable =
|
||||
(type === 'add' && formType === 'request') ||
|
||||
(selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id));
|
||||
|
||||
<Icon
|
||||
icon='material-symbols:keyboard-arrow-down'
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn('text-primary transition-transform', {
|
||||
'-rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className='w-full!'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<Table<Kandang>
|
||||
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
|
||||
columns={kandangsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
return (
|
||||
<>
|
||||
{shouldShowTable && (
|
||||
<Card
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
|
||||
}),
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
headerColumnClassName:
|
||||
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
|
||||
bodyRowClassName: 'border-b border-b-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-6 py-3 first:flex first:flex-row first:justify-start',
|
||||
paginationClassName: cn({
|
||||
hidden:
|
||||
isResponseSuccess(kandangs) &&
|
||||
kandangs?.meta?.total_pages === 1,
|
||||
}),
|
||||
wrapper: className?.wrapper,
|
||||
body: 'p-4 shadow',
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Card>
|
||||
>
|
||||
<Collapse
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={
|
||||
<div className='card-actions p-4 justify-between items-center w-full'>
|
||||
<div className='card-title'>
|
||||
{formType === 'realization'
|
||||
? 'Kandang yang Direalisasikan'
|
||||
: 'Pilih Kandang'}
|
||||
</div>
|
||||
|
||||
<Icon
|
||||
icon='material-symbols:keyboard-arrow-down'
|
||||
width={24}
|
||||
height={24}
|
||||
className={cn('text-primary transition-transform', {
|
||||
'-rotate-180': open,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
className='w-full!'
|
||||
titleClassName='w-full p-0!'
|
||||
>
|
||||
<Table<Kandang>
|
||||
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
|
||||
columns={kandangsColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
|
||||
totalItems={
|
||||
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'mb-20':
|
||||
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
|
||||
}),
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
headerColumnClassName:
|
||||
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
|
||||
bodyRowClassName: 'border-b border-b-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-6 py-3 first:flex first:flex-row first:justify-start',
|
||||
paginationClassName: cn({
|
||||
hidden:
|
||||
isResponseSuccess(kandangs) &&
|
||||
kandangs?.meta?.total_pages === 1,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export const getExpenseRealizationFormInitialValues = (
|
||||
? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
|
||||
: undefined,
|
||||
kandangs: initialValues?.kandangs.map((kandang) => ({
|
||||
id: kandang.kandang_id,
|
||||
id: kandang.id,
|
||||
name: kandang.name,
|
||||
})),
|
||||
supplier: initialValues?.supplier
|
||||
@@ -159,7 +159,7 @@ export const getExpenseRealizationFormInitialValues = (
|
||||
},
|
||||
quantity: realisasiItem.qty,
|
||||
price: realisasiItem.price,
|
||||
notes: realisasiItem.note,
|
||||
notes: realisasiItem.notes,
|
||||
};
|
||||
})
|
||||
: kandangExpense.pengajuans
|
||||
@@ -170,7 +170,7 @@ export const getExpenseRealizationFormInitialValues = (
|
||||
},
|
||||
quantity: expenseItem.qty,
|
||||
price: expenseItem.price,
|
||||
notes: expenseItem.note,
|
||||
notes: expenseItem.notes,
|
||||
}))
|
||||
: [];
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ const ExpenseRealizationForm = ({
|
||||
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
|
||||
|
||||
return (
|
||||
<section className='w-full max-w-5xl'>
|
||||
<section className='w-full'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
@@ -297,6 +297,7 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
<ExpenseKandangsTable
|
||||
type='detail'
|
||||
formType='realization'
|
||||
locationId={formik.values.location?.value}
|
||||
selectedKandangs={formik.values.kandangs ?? []}
|
||||
onChange={kandangsChangeHandler}
|
||||
|
||||
@@ -41,22 +41,25 @@ type ExpenseFormSchemaType = {
|
||||
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||
Yup.object({
|
||||
category: Yup.object({
|
||||
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
|
||||
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
|
||||
value: Yup.string()
|
||||
.oneOf(['BOP', 'NON-BOP'])
|
||||
.required('Kategori wajib diisi!'),
|
||||
label: Yup.string()
|
||||
.oneOf(['BOP', 'NON-BOP'])
|
||||
.required('Kategori wajib diisi!'),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
.required('Kategori wajib diisi!')
|
||||
.typeError('Kategori wajib diisi!'),
|
||||
|
||||
location: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
}).nullable(),
|
||||
|
||||
location_id: Yup.number()
|
||||
.required('Lokasi wajib diisi!')
|
||||
.min(1, 'Lokasi wajib diisi!')
|
||||
.required('Lokasi wajib diisi!')
|
||||
.typeError('Lokasi wajib diisi!'),
|
||||
|
||||
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
|
||||
@@ -73,9 +76,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||
supplier: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
}).nullable(),
|
||||
|
||||
supplier_id: Yup.number()
|
||||
.required('Vendor wajib diisi!')
|
||||
@@ -104,9 +105,12 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||
.of(
|
||||
Yup.object({
|
||||
nonstock: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
value: Yup.number().min(1).required('Nonstock wajib diisi!'),
|
||||
label: Yup.string().required('Nonstock wajib diisi!'),
|
||||
})
|
||||
.nullable()
|
||||
.required('Nonstock wajib diisi!')
|
||||
.typeError('Nonstock wajib diisi!'),
|
||||
nonstock_id: Yup.number()
|
||||
.required('Nonstock wajib diisi!')
|
||||
.min(1, 'Nonstock wajib diisi!')
|
||||
@@ -204,7 +208,7 @@ export const getExpenseFormInitialValues = (
|
||||
nonstock_id: expenseItem.nonstock.id,
|
||||
quantity: expenseItem.qty,
|
||||
price: expenseItem.price,
|
||||
notes: expenseItem.note,
|
||||
notes: expenseItem.notes,
|
||||
}))
|
||||
: [],
|
||||
}))
|
||||
|
||||
@@ -190,30 +190,18 @@ const ExpenseRequestForm = ({
|
||||
formik.setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('location', true);
|
||||
formik.setFieldValue('location', val);
|
||||
const locationChangeHandler = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location ? Number(location.value) : 0;
|
||||
|
||||
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
|
||||
formik.setFieldValue('kandangs', []);
|
||||
|
||||
// Auto-create expense item for location (without kandang)
|
||||
formik.setFieldValue('expense_nonstocks', [
|
||||
{
|
||||
cost_items: [
|
||||
{
|
||||
nonstock: null,
|
||||
nonstock_id: 0,
|
||||
quantity: undefined,
|
||||
price: undefined,
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
formik.setFieldTouched('location', true);
|
||||
formik.setFieldValue('location', location);
|
||||
formik.setFieldTouched('location_id', true);
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const kandangsChangeHandler = (
|
||||
kandangs: { id?: number; name?: string }[]
|
||||
@@ -268,6 +256,7 @@ const ExpenseRequestForm = ({
|
||||
|
||||
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('supplier', true);
|
||||
formik.setFieldTouched('supplier_id', true);
|
||||
formik.setFieldValue('supplier', val);
|
||||
|
||||
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
|
||||
@@ -360,7 +349,7 @@ const ExpenseRequestForm = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full max-w-5xl'>
|
||||
<section className='w-full'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
@@ -407,6 +396,16 @@ const ExpenseRequestForm = ({
|
||||
placeholder='Pilih Kategori'
|
||||
value={formik.values.category}
|
||||
onChange={categoryChangeHandler}
|
||||
isError={
|
||||
formik.touched.category && Boolean(formik.errors.category)
|
||||
}
|
||||
errorMessage={
|
||||
formik.touched.category && formik.errors.category
|
||||
? typeof formik.errors.category === 'object'
|
||||
? 'Kategori wajib diisi!'
|
||||
: (formik.errors.category as string)
|
||||
: undefined
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'BOP',
|
||||
@@ -427,8 +426,13 @@ const ExpenseRequestForm = ({
|
||||
value={formik.values.location}
|
||||
onChange={locationChangeHandler}
|
||||
options={locationOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isError={
|
||||
formik.touched.location_id && Boolean(formik.errors.location_id)
|
||||
}
|
||||
errorMessage={formik.errors.location_id as string}
|
||||
isClearable
|
||||
className={{ wrapper: 'col-span-12 sm:col-span-4' }}
|
||||
/>
|
||||
|
||||
@@ -438,6 +442,12 @@ const ExpenseRequestForm = ({
|
||||
required
|
||||
value={formik.values.transaction_date}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.transaction_date &&
|
||||
Boolean(formik.errors.transaction_date)
|
||||
}
|
||||
errorMessage={formik.errors.transaction_date as string}
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-4',
|
||||
}}
|
||||
@@ -460,8 +470,12 @@ const ExpenseRequestForm = ({
|
||||
value={formik.values.supplier}
|
||||
onChange={supplierChangeHandler}
|
||||
options={supplierOptions}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
onInputChange={setVendorInputValue}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
isError={
|
||||
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
|
||||
}
|
||||
errorMessage={formik.errors.supplier_id as string}
|
||||
className={{ wrapper: 'col-span-12' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
|
||||
true
|
||||
);
|
||||
formik.setFieldTouched(
|
||||
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
|
||||
val
|
||||
@@ -96,7 +100,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
};
|
||||
|
||||
const isExpenseRepeaterInputError = (
|
||||
column: 'nonstock' | 'quantity' | 'price' | 'notes',
|
||||
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
|
||||
kandangExpenseIdx: number,
|
||||
expenseIdx: number
|
||||
) => {
|
||||
@@ -105,11 +109,14 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
expenseIdx
|
||||
]?.[column] &&
|
||||
Boolean(
|
||||
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof
|
||||
Object &&
|
||||
formik.errors.expense_nonstocks?.[kandangExpenseIdx] &&
|
||||
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx] ===
|
||||
'object' &&
|
||||
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
|
||||
expenseIdx
|
||||
] instanceof Object &&
|
||||
] &&
|
||||
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx]
|
||||
.cost_items?.[expenseIdx] === 'object' &&
|
||||
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
|
||||
expenseIdx
|
||||
]?.[column]
|
||||
@@ -117,6 +124,32 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
);
|
||||
};
|
||||
|
||||
const getExpenseRepeaterErrorMessage = (
|
||||
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
|
||||
kandangExpenseIdx: number,
|
||||
expenseIdx: number
|
||||
): string => {
|
||||
const kandangError = formik.errors.expense_nonstocks?.[kandangExpenseIdx];
|
||||
|
||||
if (!kandangError || typeof kandangError !== 'object') return '';
|
||||
|
||||
if (!('cost_items' in kandangError)) return '';
|
||||
|
||||
const costItemsError = kandangError.cost_items?.[expenseIdx];
|
||||
|
||||
if (!costItemsError || typeof costItemsError !== 'object') return '';
|
||||
|
||||
const fieldError = costItemsError[column as keyof typeof costItemsError];
|
||||
|
||||
if (!fieldError) return '';
|
||||
|
||||
if (typeof fieldError === 'object' && fieldError !== null) {
|
||||
return 'Nonstock wajib diisi!';
|
||||
}
|
||||
|
||||
return String(fieldError);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={{
|
||||
@@ -202,10 +235,21 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
val
|
||||
);
|
||||
}}
|
||||
isError={isExpenseRepeaterInputError(
|
||||
'nonstock_id',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
errorMessage={getExpenseRepeaterErrorMessage(
|
||||
'nonstock_id',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
options={nonstockOptions}
|
||||
isLoading={isLoadingNonstockOptions}
|
||||
onInputChange={setNonstockInputValue}
|
||||
className={{ wrapper: 'min-w-48' }}
|
||||
isClearable={true}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -226,6 +270,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
errorMessage={getExpenseRepeaterErrorMessage(
|
||||
'quantity',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
@@ -246,6 +295,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
errorMessage={getExpenseRepeaterErrorMessage(
|
||||
'price',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
inputPrefix={
|
||||
<span className='text-gray-600 font-medium'>
|
||||
Rp
|
||||
@@ -271,6 +325,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
errorMessage={getExpenseRepeaterErrorMessage(
|
||||
'notes',
|
||||
kandangExpenseIdx,
|
||||
expenseIdx
|
||||
)}
|
||||
className={{ wrapper: 'min-w-24' }}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -447,7 +447,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
]}
|
||||
>
|
||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
||||
{pengajuan.note}
|
||||
{pengajuan.notes}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -607,7 +607,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
]}
|
||||
>
|
||||
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
|
||||
{realisasi.note}
|
||||
{realisasi.notes}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -34,7 +34,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
},
|
||||
{
|
||||
label: 'Pihak',
|
||||
value: finance.party.id ? finance.party.name : '-',
|
||||
value: finance.party?.id ? finance.party?.name : '-',
|
||||
},
|
||||
{
|
||||
label: 'Tanggal',
|
||||
@@ -56,25 +56,21 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
},
|
||||
{
|
||||
label: 'Nomor Rekening',
|
||||
value: `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`,
|
||||
value: `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`,
|
||||
},
|
||||
{
|
||||
label: `Rekening ${formatTitleCase(finance.party.type)}`,
|
||||
value: finance.party.account_number,
|
||||
label: `Rekening ${formatTitleCase(finance.party?.type)}`,
|
||||
value: finance.party?.account_number,
|
||||
},
|
||||
{
|
||||
label: 'Nominal',
|
||||
value: formatCurrency(finance.expense_amount),
|
||||
},
|
||||
{
|
||||
label: 'Sisa',
|
||||
value: formatCurrency(finance.income_amount),
|
||||
value: formatCurrency(finance.nominal),
|
||||
},
|
||||
].filter((item) => {
|
||||
// Hide party account number row if transaction type is INJECTION
|
||||
if (
|
||||
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
|
||||
item.label === `Rekening ${formatTitleCase(finance.party.type)}`
|
||||
item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -148,18 +144,19 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
|
||||
</Card>
|
||||
|
||||
<div className='flex flex-row gap-2 justify-end'>
|
||||
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
href={`/finance/detail/edit?financeId=${finance.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
|
||||
finance.party?.type !== 'SUPPLIER' && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='min-w-24'
|
||||
href={`/finance/detail/edit?financeId=${finance.id}`}
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
|
||||
<RequirePermission permissions='lti.finance.initial_balances.update'>
|
||||
<Button
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import { CellContext, Row } from '@tanstack/react-table';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Dropdown from '@/components/dropdown/Dropdown';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import Menu from '@/components/menu/Menu';
|
||||
import MenuItem from '@/components/menu/MenuItem';
|
||||
import Table from '@/components/Table';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { Finance } from '@/types/api/finance/finance';
|
||||
@@ -23,7 +19,6 @@ import {
|
||||
FINANCE_INITIAL_BALANCE_STATUS,
|
||||
FINANCE_INJECTION_STATUS,
|
||||
FINANCE_TRANSACTION_STATUS,
|
||||
ROWS_OPTIONS,
|
||||
} from '@/config/constant';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
@@ -70,19 +65,24 @@ const RowOptionsMenu = ({
|
||||
|
||||
{FINANCE_TRANSACTION_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
) && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
) &&
|
||||
props.row.original.party?.type !== 'SUPPLIER' && (
|
||||
<RequirePermission permissions='lti.finance.payments.update'>
|
||||
<Button
|
||||
href={`/finance/detail/edit?financeId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{FINANCE_INITIAL_BALANCE_STATUS.includes(
|
||||
props.row.original.transaction_type
|
||||
@@ -199,35 +199,37 @@ const FinanceTable = () => {
|
||||
|
||||
// ===== Options =====
|
||||
const transactionTypeOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Transfer', value: 'TRANSFER' },
|
||||
{ label: 'Cash', value: 'CASH' },
|
||||
{ label: 'Card', value: 'CARD' },
|
||||
{ label: 'Cheque', value: 'CHEQUE' },
|
||||
{ label: 'Saldo', value: 'SALDO' },
|
||||
];
|
||||
}, []);
|
||||
const partyTypeOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Customer', value: 'CUSTOMER' },
|
||||
{ label: 'Supplier', value: 'SUPPLIER' },
|
||||
];
|
||||
}, []);
|
||||
const {
|
||||
options: partyTypeOptions,
|
||||
isLoadingOptions: partyTypeIsLoadingOptions,
|
||||
setInputValue: partyTypeInputValue,
|
||||
loadMore: partyTypeLoadMore,
|
||||
} = useSelect(
|
||||
selectedTransactionType
|
||||
? selectedTransactionType.value === 'CUSTOMER'
|
||||
? CustomerApi.basePath
|
||||
: SupplierApi.basePath
|
||||
: '',
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
const sortByOptions = useMemo(() => {
|
||||
return [
|
||||
{ label: 'Tanggal Pembayaran', value: 'payment_date' },
|
||||
{ label: 'Tanggal Dibuat', value: 'created_at' },
|
||||
];
|
||||
}, []);
|
||||
const { options: bankOptions, rawData: bankRawData } = useSelect<Bank>(
|
||||
BankApi.basePath,
|
||||
'id',
|
||||
'alias',
|
||||
'',
|
||||
{
|
||||
limit: 'limit',
|
||||
}
|
||||
);
|
||||
const {
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
setInputValue: bankInputValue,
|
||||
loadMore: bankLoadMore,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'alias');
|
||||
|
||||
// ===== Handler =====
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
@@ -344,10 +346,10 @@ const FinanceTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Pihak',
|
||||
accessorFn: (finance: Finance) => finance.party.name,
|
||||
accessorFn: (finance: Finance) => finance.party?.name,
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
if (props.row.original.party.id) {
|
||||
return <span>{props.row.original.party.name}</span>;
|
||||
if (props.row.original.party?.id) {
|
||||
return <span>{props.row.original.party?.name}</span>;
|
||||
}
|
||||
return <span>{'-'}</span>;
|
||||
},
|
||||
@@ -368,12 +370,12 @@ const FinanceTable = () => {
|
||||
{
|
||||
header: 'Bank',
|
||||
accessorFn: (finance: Finance) =>
|
||||
`${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`,
|
||||
`${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`,
|
||||
},
|
||||
{
|
||||
header: 'Pengeluaran (Rp)',
|
||||
accessorFn: (finance: Finance) =>
|
||||
formatCurrency(finance.expense_amount),
|
||||
formatCurrency(Math.abs(finance.expense_amount)),
|
||||
},
|
||||
{
|
||||
header: 'Pemasukan (Rp)',
|
||||
@@ -476,38 +478,49 @@ const FinanceTable = () => {
|
||||
<div className='grid grid-cols-4 gap-6'>
|
||||
<SelectInput
|
||||
options={transactionTypeOptions}
|
||||
label='Jenis Transaksi'
|
||||
label='Tipe Transaksi'
|
||||
value={selectedTransactionType}
|
||||
onChange={transactionTypeChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
options={partyTypeOptions}
|
||||
label={
|
||||
selectedTransactionType
|
||||
? selectedTransactionType.value === 'CUSTOMER'
|
||||
? 'Pelanggan'
|
||||
: 'Supplier'
|
||||
: 'Pihak'
|
||||
}
|
||||
value={selectedPartyType}
|
||||
onChange={partyTypeChangeHandler}
|
||||
onInputChange={partyTypeInputValue}
|
||||
onMenuScrollToBottom={partyTypeLoadMore}
|
||||
isLoading={partyTypeIsLoadingOptions}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
options={
|
||||
isResponseSuccess(bankRawData)
|
||||
? bankOptions.map((bank) => ({
|
||||
label:
|
||||
bankRawData.data.find((data) => data.id === bank.value)
|
||||
bankRawData.data.find((data) => data.id === bank?.value)
|
||||
?.alias +
|
||||
' - ' +
|
||||
bankRawData.data.find((data) => data.id === bank.value)
|
||||
bankRawData.data.find((data) => data.id === bank?.value)
|
||||
?.account_number +
|
||||
' - ' +
|
||||
bankRawData.data.find((data) => data.id === bank.value)
|
||||
bankRawData.data.find((data) => data.id === bank?.value)
|
||||
?.owner,
|
||||
value: bank.value,
|
||||
value: bank?.value,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
label='Bank'
|
||||
value={selectedBank}
|
||||
onChange={bankChangeHandler}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
options={partyTypeOptions}
|
||||
label='Pihak'
|
||||
value={selectedPartyType}
|
||||
onChange={partyTypeChangeHandler}
|
||||
onInputChange={bankInputValue}
|
||||
onMenuScrollToBottom={bankLoadMore}
|
||||
isClearable
|
||||
/>
|
||||
<DebouncedTextInput
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Alert from '@/components/Alert';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
interface FormFinanceAddProps {
|
||||
type?: 'add' | 'edit';
|
||||
@@ -51,18 +53,22 @@ const FormFinanceAdd = ({
|
||||
initialValues,
|
||||
}: FormFinanceAddProps) => {
|
||||
const router = useRouter();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState('');
|
||||
const [isSupplier, setIsSupplier] = useState(
|
||||
initialValues?.party?.type === 'SUPPLIER'
|
||||
);
|
||||
|
||||
// ===== Formik =====
|
||||
const formikInitialValues = useMemo((): FinanceFormValues => {
|
||||
return {
|
||||
party_type_option:
|
||||
FINANCE_PARTY_TYPE_OPTIONS.find(
|
||||
(option) => option.value === initialValues?.party.type
|
||||
(option) => option.value === initialValues?.party?.type
|
||||
) || null,
|
||||
party_id_option: initialValues?.party
|
||||
? {
|
||||
label: initialValues?.party.name || '',
|
||||
value: initialValues?.party.id || 0,
|
||||
label: initialValues?.party?.name || '',
|
||||
value: initialValues?.party?.id || 0,
|
||||
}
|
||||
: null,
|
||||
payment_date: initialValues?.payment_date || '',
|
||||
@@ -72,11 +78,11 @@ const FormFinanceAdd = ({
|
||||
) || null,
|
||||
bank_id_option: initialValues?.bank
|
||||
? {
|
||||
label: initialValues.bank.name,
|
||||
value: initialValues.bank.id,
|
||||
label: initialValues?.bank?.name,
|
||||
value: initialValues?.bank?.id,
|
||||
}
|
||||
: null,
|
||||
party_account_number: initialValues?.party.account_number || '',
|
||||
party_account_number: initialValues?.party?.account_number || '',
|
||||
reference_number: initialValues?.reference_number || '',
|
||||
nominal: initialValues?.nominal.toString() || '',
|
||||
notes: initialValues?.notes || '',
|
||||
@@ -113,20 +119,22 @@ const FormFinanceAdd = ({
|
||||
options: partyOptions,
|
||||
isLoadingOptions: isLoadingPartyOptions,
|
||||
rawData: partyRawData,
|
||||
setInputValue: setPartyInputValue,
|
||||
loadMore: loadMorePartyOptions,
|
||||
} = useSelect<PartyCommonProps>(
|
||||
formik.values.party_type_option?.value === 'CUSTOMER'
|
||||
? CustomerApi.basePath
|
||||
: SupplierApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'',
|
||||
{ limit: 'limit' }
|
||||
'name'
|
||||
);
|
||||
const {
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
isLoadingOptions: isLoadingBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
|
||||
setInputValue: setBankInputValue,
|
||||
loadMore: loadMoreBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
|
||||
|
||||
// ===== Helper Functions =====
|
||||
const transformFormValuesToPayload = (
|
||||
@@ -151,6 +159,7 @@ const FormFinanceAdd = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,6 +175,7 @@ const FormFinanceAdd = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,6 +215,7 @@ const FormFinanceAdd = ({
|
||||
? formik.errors.party_type_option
|
||||
: ''
|
||||
}
|
||||
isDisabled={type === 'edit' || isSupplier}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
@@ -219,6 +230,8 @@ const FormFinanceAdd = ({
|
||||
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`}
|
||||
options={partyOptions}
|
||||
value={formik.values.party_id_option}
|
||||
onInputChange={setPartyInputValue}
|
||||
onMenuScrollToBottom={loadMorePartyOptions}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_id_option', value);
|
||||
if (isResponseSuccess(partyRawData) && value) {
|
||||
@@ -241,7 +254,7 @@ const FormFinanceAdd = ({
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
isDisabled={!formik.values.party_type_option?.value}
|
||||
isDisabled={!formik.values.party_type_option?.value || isSupplier}
|
||||
/>
|
||||
<DateInput
|
||||
label='Tanggal'
|
||||
@@ -259,6 +272,7 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Metode Pembayaran'
|
||||
@@ -280,6 +294,7 @@ const FormFinanceAdd = ({
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
isDisabled={isSupplier}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Bank'
|
||||
@@ -304,6 +319,8 @@ const FormFinanceAdd = ({
|
||||
: []
|
||||
}
|
||||
value={formik.values.bank_id_option}
|
||||
onInputChange={setBankInputValue}
|
||||
onMenuScrollToBottom={loadMoreBankOptions}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('bank_id_option', value);
|
||||
}}
|
||||
@@ -318,6 +335,7 @@ const FormFinanceAdd = ({
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
isDisabled={isSupplier}
|
||||
/>
|
||||
<TextInput
|
||||
label={`Nomor Rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'Pihak'}`}
|
||||
@@ -338,6 +356,7 @@ const FormFinanceAdd = ({
|
||||
}
|
||||
required
|
||||
readOnly
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<TextInput
|
||||
label='Nomor Referensi'
|
||||
@@ -357,6 +376,7 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<NumberInput
|
||||
label='Nominal'
|
||||
@@ -372,6 +392,7 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<TextArea
|
||||
label='Catatan'
|
||||
@@ -387,8 +408,18 @@ const FormFinanceAdd = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
disabled={isSupplier}
|
||||
/>
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
{serverErrorMessage && (
|
||||
<Alert color='error'>
|
||||
<Icon icon='mdi:alert' />
|
||||
{serverErrorMessage}
|
||||
<Button color='error' onClick={() => setServerErrorMessage('')}>
|
||||
<Icon icon='mdi:close' />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
type='reset'
|
||||
|
||||
+1
-7
@@ -27,13 +27,7 @@ export const InitialBalanceFormSchema = Yup.object().shape({
|
||||
'Pihak wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
bank_id_option: Yup.mixed()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-valid-option',
|
||||
'Bank wajib diisi',
|
||||
(value) => value !== null && value !== undefined
|
||||
),
|
||||
bank_id_option: Yup.mixed().nullable(),
|
||||
reference_number: Yup.string().required('Nomor referensi wajib diisi'),
|
||||
initial_balance_type_option: Yup.mixed()
|
||||
.nullable()
|
||||
|
||||
@@ -29,8 +29,9 @@ import { Bank } from '@/types/api/master-data/bank';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Alert from '@/components/Alert';
|
||||
|
||||
interface FormFinanceAddInitialBalanceProps {
|
||||
type?: 'add' | 'edit';
|
||||
@@ -42,6 +43,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
initialValues,
|
||||
}: FormFinanceAddInitialBalanceProps) => {
|
||||
const router = useRouter();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState('');
|
||||
|
||||
// ===== Formik =====
|
||||
const formikInitialValues = useMemo((): InitialBalanceFormValues => {
|
||||
@@ -53,18 +55,18 @@ const FormFinanceAddInitialBalance = ({
|
||||
return {
|
||||
party_type_option:
|
||||
FINANCE_PARTY_TYPE_OPTIONS.find(
|
||||
(option) => option.value === initialValues?.party.type
|
||||
(option) => option.value === initialValues?.party?.type
|
||||
) || null,
|
||||
party_id_option: initialValues?.party
|
||||
? {
|
||||
label: initialValues.party.name,
|
||||
value: initialValues.party.id,
|
||||
label: initialValues.party?.name,
|
||||
value: initialValues.party?.id,
|
||||
}
|
||||
: null,
|
||||
bank_id_option: initialValues?.bank
|
||||
? {
|
||||
label: initialValues.bank.name,
|
||||
value: initialValues.bank.id,
|
||||
label: initialValues.bank?.name,
|
||||
value: initialValues.bank?.id,
|
||||
}
|
||||
: null,
|
||||
reference_number: initialValues?.reference_number || '',
|
||||
@@ -104,21 +106,25 @@ const FormFinanceAddInitialBalance = ({
|
||||
});
|
||||
|
||||
// ===== Options =====
|
||||
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } =
|
||||
useSelect(
|
||||
formik.values.party_type_option?.value === 'CUSTOMER'
|
||||
? CustomerApi.basePath
|
||||
: SupplierApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'',
|
||||
{ limit: 'limit' }
|
||||
);
|
||||
const {
|
||||
options: partyOptions,
|
||||
isLoadingOptions: isLoadingPartyOptions,
|
||||
setInputValue: setPartyInputValue,
|
||||
loadMore: loadMorePartyOptions,
|
||||
} = useSelect(
|
||||
formik.values.party_type_option?.value === 'CUSTOMER'
|
||||
? CustomerApi.basePath
|
||||
: SupplierApi.basePath,
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
const {
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
isLoadingOptions: isLoadingBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
|
||||
setInputValue: setBankInputValue,
|
||||
loadMore: loadMoreBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
|
||||
|
||||
// ===== Helper Functions =====
|
||||
const transformFormValuesToPayload = (
|
||||
@@ -143,6 +149,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,6 +169,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,6 +197,8 @@ const FormFinanceAddInitialBalance = ({
|
||||
placeholder='Pilih jenis pihak'
|
||||
options={FINANCE_PARTY_TYPE_OPTIONS}
|
||||
value={formik.values.party_type_option}
|
||||
onInputChange={setPartyInputValue}
|
||||
onMenuScrollToBottom={loadMorePartyOptions}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_type_option', value);
|
||||
formik.setFieldValue('party_id_option', null);
|
||||
@@ -205,6 +215,7 @@ const FormFinanceAddInitialBalance = ({
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isDisabled={type === 'edit'}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
@@ -218,6 +229,8 @@ const FormFinanceAddInitialBalance = ({
|
||||
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`}
|
||||
options={partyOptions}
|
||||
value={formik.values.party_id_option}
|
||||
onInputChange={setPartyInputValue}
|
||||
onMenuScrollToBottom={loadMorePartyOptions}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('party_id_option', value);
|
||||
}}
|
||||
@@ -269,7 +282,6 @@ const FormFinanceAddInitialBalance = ({
|
||||
? formik.errors.bank_id_option
|
||||
: ''
|
||||
}
|
||||
required
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
@@ -354,7 +366,18 @@ const FormFinanceAddInitialBalance = ({
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
{serverErrorMessage && (
|
||||
<Alert color='error'>
|
||||
<Icon icon='mdi:alert' />
|
||||
{serverErrorMessage}
|
||||
<Button color='error' onClick={() => setServerErrorMessage('')}>
|
||||
<Icon icon='mdi:close' />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
type='reset'
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { useFormik } from 'formik';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Alert from '@/components/Alert';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
interface FormFinanceInjectionProps {
|
||||
type?: 'add' | 'edit';
|
||||
@@ -37,14 +39,15 @@ const FormFinanceInjection = ({
|
||||
initialValues,
|
||||
}: FormFinanceInjectionProps) => {
|
||||
const router = useRouter();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState('');
|
||||
|
||||
// ===== Formik =====
|
||||
const formikInitialValues = useMemo((): InjectionFormValues => {
|
||||
return {
|
||||
bank_id_option: initialValues?.bank
|
||||
? {
|
||||
label: initialValues.bank.name,
|
||||
value: initialValues.bank.id,
|
||||
label: initialValues.bank?.name,
|
||||
value: initialValues.bank?.id,
|
||||
}
|
||||
: null,
|
||||
adjustment_date: initialValues?.payment_date || '',
|
||||
@@ -80,7 +83,9 @@ const FormFinanceInjection = ({
|
||||
options: bankOptions,
|
||||
rawData: bankRawData,
|
||||
isLoadingOptions: isLoadingBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' });
|
||||
setInputValue: setBankInputValue,
|
||||
loadMore: loadMoreBankOptions,
|
||||
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
|
||||
|
||||
// ===== Helper Functions =====
|
||||
const transformFormValuesToPayload = (
|
||||
@@ -101,6 +106,7 @@ const FormFinanceInjection = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,6 +123,7 @@ const FormFinanceInjection = ({
|
||||
|
||||
if (isResponseError(response)) {
|
||||
toast.error(response.message);
|
||||
setServerErrorMessage(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,6 +169,8 @@ const FormFinanceInjection = ({
|
||||
: []
|
||||
}
|
||||
value={formik.values.bank_id_option}
|
||||
onInputChange={setBankInputValue}
|
||||
onMenuScrollToBottom={loadMoreBankOptions}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('bank_id_option', value);
|
||||
}}
|
||||
@@ -226,6 +235,15 @@ const FormFinanceInjection = ({
|
||||
required
|
||||
/>
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
{serverErrorMessage && (
|
||||
<Alert color='error'>
|
||||
<Icon icon='mdi:alert' />
|
||||
{serverErrorMessage}
|
||||
<Button color='error' onClick={() => setServerErrorMessage('')}>
|
||||
<Icon icon='mdi:close' />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
type='reset'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
||||
import {
|
||||
CreateInventoryAdjustmentPayload,
|
||||
@@ -22,12 +22,18 @@ import {
|
||||
} from '@/services/api/master-data';
|
||||
import Button from '@/components/Button';
|
||||
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 { RadioGroup } from '@/components/input/RadioInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
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 {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
|
||||
InventoryAdjustmentFormErrorMessage,
|
||||
setInventoryAdjustmentFormErrorMessage,
|
||||
] = useState('');
|
||||
const [selectedProductCategories, setSelectedProductCategories] =
|
||||
useState('');
|
||||
const [disabledProduct, setDisabledProduct] = useState(true);
|
||||
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
|
||||
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
|
||||
|
||||
// Submit Handler
|
||||
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
|
||||
});
|
||||
|
||||
// Fetch Data
|
||||
const productCategoriesUrl = `${
|
||||
ProductCategoryApi.basePath
|
||||
}?${new URLSearchParams({
|
||||
search: '',
|
||||
}).toString()}`;
|
||||
const { data: productCategories, isLoading: isLoadingProductCategories } =
|
||||
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
|
||||
const {
|
||||
setInputValue: setProductCategoryInputValue,
|
||||
options: productCategoryOptions,
|
||||
isLoadingOptions: isLoadingProductCategoryOptions,
|
||||
loadMore: loadMoreProductCategories,
|
||||
} = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
|
||||
|
||||
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({
|
||||
search: '',
|
||||
product_category_id: selectedProductCategories,
|
||||
}).toString()}`;
|
||||
const { data: products, isLoading: isLoadingProducts } = useSWR(
|
||||
productUrl,
|
||||
ProductApi.getAllFetcher
|
||||
);
|
||||
const {
|
||||
setInputValue: setProductInputValue,
|
||||
options: productOptions,
|
||||
isLoadingOptions: isLoadingProductOptions,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
|
||||
product_category_id: formik.values.product_category_id
|
||||
? String(formik.values.product_category_id)
|
||||
: '',
|
||||
});
|
||||
|
||||
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
|
||||
search: '',
|
||||
limit: '100',
|
||||
}).toString()}`;
|
||||
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
|
||||
warehouseUrl,
|
||||
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,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setWarehouseInputValue,
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
|
||||
|
||||
// Options Handler
|
||||
const productCategoryChangeHandler = (
|
||||
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
|
||||
|
||||
formik.setFieldValue('product_category', val);
|
||||
|
||||
setSelectedProductCategories((val as OptionType)?.value as string);
|
||||
const disabled = (val as OptionType)?.value == null;
|
||||
setDisabledProduct(disabled);
|
||||
formik.setFieldValue('product_id', 0);
|
||||
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
|
||||
// Effect
|
||||
useEffect(() => {
|
||||
if (initialValues?.product_warehouse?.product?.id) {
|
||||
setSelectedProductCategories(
|
||||
String(initialValues.product_warehouse.product.id)
|
||||
);
|
||||
setDisabledProduct(false);
|
||||
formik.setFieldValue(
|
||||
'product_id',
|
||||
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
|
||||
);
|
||||
formik.setFieldValue('note', initialValues.note);
|
||||
}
|
||||
}, [
|
||||
formik,
|
||||
initialValues,
|
||||
setQuantityLabel,
|
||||
setDisabledProduct,
|
||||
setSelectedProductCategories,
|
||||
]);
|
||||
}, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
|
||||
useEffect(() => {
|
||||
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(products)) {
|
||||
const options = products.data.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
setOptionsProduct(options);
|
||||
}
|
||||
}, [products]);
|
||||
|
||||
// Utils Function
|
||||
const formatNumber = (value: string) => {
|
||||
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
|
||||
label='Kategori Produk'
|
||||
value={formik.values.product_category as OptionType}
|
||||
onChange={productCategoryChangeHandler}
|
||||
onInputChange={setSelectedProductCategories}
|
||||
options={optionsProductCategory}
|
||||
isLoading={isLoadingProductCategories}
|
||||
onInputChange={setProductCategoryInputValue}
|
||||
options={productCategoryOptions}
|
||||
onMenuScrollToBottom={loadMoreProductCategories}
|
||||
isLoading={isLoadingProductCategoryOptions}
|
||||
isError={
|
||||
formik.touched.product_category &&
|
||||
Boolean(formik.errors.product_category)
|
||||
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
|
||||
label='Produk'
|
||||
value={formik.values.product as OptionType}
|
||||
onChange={productChangeHandler}
|
||||
options={optionsProduct}
|
||||
isLoading={isLoadingProducts}
|
||||
onInputChange={setProductInputValue}
|
||||
options={productOptions}
|
||||
onMenuScrollToBottom={loadMoreProducts}
|
||||
isLoading={isLoadingProductOptions}
|
||||
isError={formik.touched.product && Boolean(formik.errors.product)}
|
||||
errorMessage={formik.errors.product as string}
|
||||
isDisabled={type === 'detail' || disabledProduct}
|
||||
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
|
||||
label='Warehouse'
|
||||
value={formik.values.warehouse as OptionType}
|
||||
onChange={warehouseChangeHandler}
|
||||
options={optionsWarehouse}
|
||||
isLoading={isLoadingWarehouses}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
options={warehouseOptions}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
isError={
|
||||
formik.touched.warehouse && Boolean(formik.errors.warehouse)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,14 @@ const DeliveryProductObjectSchema = Yup.object({
|
||||
.typeError('Qty harus berupa angka!'),
|
||||
});
|
||||
|
||||
const DeliveryDocumentSchema = Yup.mixed<File | MovementDocument>()
|
||||
.nullable()
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value): boolean => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
});
|
||||
|
||||
const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
|
||||
delivery_cost: Yup.number()
|
||||
.transform((value) => (isNaN(value) || value === 0 ? undefined : value))
|
||||
@@ -135,13 +143,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
|
||||
}),
|
||||
document_path: Yup.string().nullable().optional(),
|
||||
document_index: Yup.number().optional(),
|
||||
document: Yup.mixed<File | MovementDocument>()
|
||||
.nullable()
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
}),
|
||||
document: DeliveryDocumentSchema,
|
||||
driver_name: Yup.string().required('Nama sopir wajib diisi!'),
|
||||
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
|
||||
supplier: Yup.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
|
||||
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
import { getUniqueFormikErrors } from '@/lib/formik-helper';
|
||||
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 {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
|
||||
const [
|
||||
productWarehouseSelectInputValue,
|
||||
setProductWarehouseSelectInputValue,
|
||||
] = useState('');
|
||||
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
|
||||
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
@@ -93,10 +91,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
|
||||
// ===== USE SELECT HOOKS =====
|
||||
const {
|
||||
inputValue: warehouseSelectInputValue,
|
||||
setInputValue: setWarehouseSelectInputValue,
|
||||
isLoadingOptions: isLoadingWarehouses,
|
||||
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||
loadMore: loadMoreWarehouses,
|
||||
rawData: warehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
|
||||
flag: 'EKSPEDISI',
|
||||
});
|
||||
|
||||
// ===== SELECT INPUT DATA =====
|
||||
const {
|
||||
@@ -107,12 +108,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
category: 'BOP',
|
||||
});
|
||||
|
||||
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
|
||||
const { data: warehouses } = useSWR(
|
||||
warehousesUrl,
|
||||
WarehouseApi.getAllFetcher
|
||||
);
|
||||
|
||||
// ===== DATA PROCESSING =====
|
||||
const warehouseStockMap = useMemo(() => {
|
||||
if (!isResponseSuccess(allProductWarehouses)) return new Map();
|
||||
@@ -268,26 +263,64 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
|
||||
const getProductWarehousesUrl = useCallback(() => {
|
||||
const productWarehouseParams = new URLSearchParams({
|
||||
search: productWarehouseSelectInputValue,
|
||||
});
|
||||
if (formik.values.source_warehouse_id) {
|
||||
productWarehouseParams.append(
|
||||
'warehouse_id',
|
||||
formik.values.source_warehouse_id.toString()
|
||||
);
|
||||
}
|
||||
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`;
|
||||
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
|
||||
const prevSourceWarehouseIdRef = useRef<number | null>(
|
||||
formik.values.source_warehouse_id
|
||||
);
|
||||
|
||||
const productWarehousesUrl = getProductWarehousesUrl();
|
||||
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
|
||||
useSWR(
|
||||
formik.values.source_warehouse_id ? productWarehousesUrl : null,
|
||||
ProductWarehouseApi.getAllFetcher
|
||||
);
|
||||
// ===== RESET PRODUCTS WHEN SOURCE WAREHOUSE CHANGES =====
|
||||
useEffect(() => {
|
||||
const prevSourceWarehouseId = prevSourceWarehouseIdRef.current;
|
||||
const currentSourceWarehouseId = formik.values.source_warehouse_id;
|
||||
|
||||
if (
|
||||
prevSourceWarehouseId !== currentSourceWarehouseId &&
|
||||
prevSourceWarehouseId !== null
|
||||
) {
|
||||
formik.setFieldValue('products', [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('products', false);
|
||||
|
||||
const updatedDeliveries = formik.values.deliveries.map(
|
||||
(delivery: DeliverySchema) => ({
|
||||
...delivery,
|
||||
products: [
|
||||
{
|
||||
product: null,
|
||||
product_id: 0,
|
||||
product_qty: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
formik.setFieldValue('deliveries', updatedDeliveries);
|
||||
formik.setFieldTouched('deliveries', false);
|
||||
}
|
||||
|
||||
prevSourceWarehouseIdRef.current = currentSourceWarehouseId;
|
||||
}, [formik.values.source_warehouse_id, formik.values.deliveries]);
|
||||
|
||||
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
|
||||
const {
|
||||
setInputValue: setProductWarehouseSelectInputValue,
|
||||
isLoadingOptions: isLoadingProductWarehouses,
|
||||
loadMore: loadMoreProductWarehouses,
|
||||
rawData: productWarehouses,
|
||||
} = useSelect<ProductWarehouse>(
|
||||
formik.values.source_warehouse_id ? ProductWarehouseApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
warehouse_id: formik.values.source_warehouse_id
|
||||
? formik.values.source_warehouse_id.toString()
|
||||
: '',
|
||||
}
|
||||
);
|
||||
|
||||
const productWarehouseOptions = isResponseSuccess(productWarehouses)
|
||||
? productWarehouses?.data.map((pw) => ({
|
||||
@@ -357,13 +390,71 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
};
|
||||
};
|
||||
|
||||
const handleTransferDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.setFieldValue('transfer_date', e.target.value);
|
||||
};
|
||||
|
||||
// ===== EVENT HANDLERS =====
|
||||
// Product Handlers
|
||||
const addProduct = () => {
|
||||
const handleTransferDateChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
formik.setFieldValue('transfer_date', e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSourceWarehouseChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const newSourceWarehouseId = (val as WarehouseOptionType)?.value;
|
||||
|
||||
if (
|
||||
newSourceWarehouseId &&
|
||||
newSourceWarehouseId === formik.values.destination_warehouse_id
|
||||
) {
|
||||
const destinationWarehouseName =
|
||||
(formik.values.destination_warehouse as WarehouseOptionType)?.label ||
|
||||
'gudang tujuan';
|
||||
toast.error(
|
||||
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
formik.setFieldTouched('source_warehouse', true);
|
||||
formik.setFieldValue('source_warehouse', val);
|
||||
formik.setFieldTouched('source_warehouse_id', true);
|
||||
formik.setFieldValue('source_warehouse_id', newSourceWarehouseId);
|
||||
},
|
||||
[
|
||||
formik.values.destination_warehouse_id,
|
||||
formik.values.destination_warehouse,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDestinationWarehouseChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const newDestinationWarehouseId = (val as WarehouseOptionType)?.value;
|
||||
|
||||
if (
|
||||
newDestinationWarehouseId &&
|
||||
newDestinationWarehouseId === formik.values.source_warehouse_id
|
||||
) {
|
||||
const sourceWarehouseName =
|
||||
(formik.values.source_warehouse as WarehouseOptionType)?.label ||
|
||||
'gudang asal';
|
||||
toast.error(
|
||||
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
formik.setFieldTouched('destination_warehouse', true);
|
||||
formik.setFieldValue('destination_warehouse', val);
|
||||
formik.setFieldTouched('destination_warehouse_id', true);
|
||||
formik.setFieldValue(
|
||||
'destination_warehouse_id',
|
||||
newDestinationWarehouseId
|
||||
);
|
||||
},
|
||||
[formik.values.source_warehouse_id, formik.values.source_warehouse]
|
||||
);
|
||||
|
||||
const addProduct = useCallback(() => {
|
||||
const newProducts = [
|
||||
...(formik.values.products || []),
|
||||
{
|
||||
@@ -373,22 +464,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
},
|
||||
];
|
||||
formik.setFieldValue('products', newProducts);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeProduct = useCallback(
|
||||
(i: number) => {
|
||||
const updatedProducts =
|
||||
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
|
||||
if (index !== i) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []) ?? [];
|
||||
const removeProduct = useCallback((i: number) => {
|
||||
const updatedProducts =
|
||||
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
|
||||
if (index !== i) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []) ?? [];
|
||||
|
||||
formik.setFieldValue('products', updatedProducts);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
formik.setFieldValue('products', updatedProducts);
|
||||
}, []);
|
||||
|
||||
const bulkRemoveProduct = useCallback(() => {
|
||||
const updatedProducts =
|
||||
@@ -397,10 +485,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
) ?? [];
|
||||
formik.setFieldValue('products', updatedProducts);
|
||||
setSelectedProducts([]);
|
||||
}, [formik, selectedProducts]);
|
||||
}, [formik, selectedProducts, setSelectedProducts]);
|
||||
|
||||
// Delivery Handlers
|
||||
const addDelivery = () => {
|
||||
const handleProductChange = useCallback(
|
||||
(idx: number, val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched(`products.${idx}.product`, true);
|
||||
formik.setFieldValue(`products.${idx}.product`, val);
|
||||
formik.setFieldTouched(`products.${idx}.product_id`, true);
|
||||
formik.setFieldValue(
|
||||
`products.${idx}.product_id`,
|
||||
(val as ProductWarehouseOptionType)?.value
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleProductSelectAllChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedProducts(formik.values.products?.map((_, idx) => idx) ?? []);
|
||||
} else {
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
},
|
||||
[formik.values.products, setSelectedProducts]
|
||||
);
|
||||
|
||||
const handleProductCheckboxChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const idx = Number(e.target.name.replace('product-', ''));
|
||||
if (e.target.checked) {
|
||||
setSelectedProducts((prev) => [...prev, idx]);
|
||||
} else {
|
||||
setSelectedProducts((prev) => prev.filter((i) => i !== idx));
|
||||
}
|
||||
},
|
||||
[setSelectedProducts]
|
||||
);
|
||||
|
||||
const addDelivery = useCallback(() => {
|
||||
formik.setFieldValue('deliveries', [
|
||||
...(formik.values.deliveries || []),
|
||||
{
|
||||
@@ -420,25 +543,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeDelivery = useCallback(
|
||||
(i: number) => {
|
||||
const updatedDeliveries =
|
||||
formik.values.deliveries?.reduce(
|
||||
(acc: DeliverySchema[], item, index) => {
|
||||
if (index !== i) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
) ?? [];
|
||||
const removeDelivery = useCallback((i: number) => {
|
||||
const updatedDeliveries =
|
||||
formik.values.deliveries?.reduce((acc: DeliverySchema[], item, index) => {
|
||||
if (index !== i) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []) ?? [];
|
||||
|
||||
formik.setFieldValue('deliveries', updatedDeliveries);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
formik.setFieldValue('deliveries', updatedDeliveries);
|
||||
}, []);
|
||||
|
||||
const bulkRemoveDelivery = useCallback(() => {
|
||||
const updatedDeliveries =
|
||||
@@ -447,33 +564,101 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
) ?? [];
|
||||
formik.setFieldValue('deliveries', updatedDeliveries);
|
||||
setSelectedDeliveries([]);
|
||||
}, [formik, selectedDeliveries]);
|
||||
}, [formik, selectedDeliveries, setSelectedDeliveries]);
|
||||
|
||||
// Cost Calculation Handlers
|
||||
const handleDeliveryCostChange = useCallback(
|
||||
(idx: number, value: number) => {
|
||||
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
|
||||
|
||||
const delivery = formik.values.deliveries?.[idx];
|
||||
if (delivery) {
|
||||
const productQty = delivery.products.reduce(
|
||||
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
|
||||
0
|
||||
const handleDeliverySelectAllChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDeliveries(
|
||||
formik.values.deliveries?.map((_, idx) => idx) ?? []
|
||||
);
|
||||
if (productQty > 0 && value > 0) {
|
||||
const perItem = value / productQty;
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.delivery_cost_per_item`,
|
||||
perItem
|
||||
);
|
||||
} else if (value === 0) {
|
||||
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
|
||||
}
|
||||
} else {
|
||||
setSelectedDeliveries([]);
|
||||
}
|
||||
},
|
||||
[formik]
|
||||
[formik.values.deliveries, setSelectedDeliveries]
|
||||
);
|
||||
|
||||
const handleDeliveryCheckboxChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const idx = Number(e.target.name.replace('delivery-', ''));
|
||||
if (e.target.checked) {
|
||||
setSelectedDeliveries((prev) => [...prev, idx]);
|
||||
} else {
|
||||
setSelectedDeliveries((prev) => prev.filter((i) => i !== idx));
|
||||
}
|
||||
},
|
||||
[setSelectedDeliveries]
|
||||
);
|
||||
|
||||
const handleDeliveryProductChange = useCallback(
|
||||
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched(
|
||||
`deliveries.${deliveryIdx}.products.0.product`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
|
||||
formik.setFieldTouched(
|
||||
`deliveries.${deliveryIdx}.products.0.product_id`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`deliveries.${deliveryIdx}.products.0.product_id`,
|
||||
(val as OptionType)?.value
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDeliverySupplierChange = useCallback(
|
||||
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true);
|
||||
formik.setFieldValue(`deliveries.${deliveryIdx}.supplier`, val);
|
||||
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true);
|
||||
formik.setFieldValue(
|
||||
`deliveries.${deliveryIdx}.supplier_id`,
|
||||
(val as OptionType)?.value
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDeliveryDocumentChange = useCallback(
|
||||
(deliveryIdx: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Ukuran dokumen maksimal 5 MB!');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
formik.setFieldValue(`deliveries.${deliveryIdx}.document`, file);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDeliveryCostChange = useCallback((idx: number, value: number) => {
|
||||
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
|
||||
|
||||
const delivery = formik.values.deliveries?.[idx];
|
||||
if (delivery) {
|
||||
const productQty = delivery.products.reduce(
|
||||
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
|
||||
0
|
||||
);
|
||||
if (productQty > 0 && value > 0) {
|
||||
const perItem = value / productQty;
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.delivery_cost_per_item`,
|
||||
perItem
|
||||
);
|
||||
} else if (value === 0) {
|
||||
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeliveryCostPerItemChange = useCallback(
|
||||
(idx: number, value: number) => {
|
||||
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
|
||||
@@ -492,7 +677,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[formik]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDeliveryCostChangeWrapper = useCallback(
|
||||
@@ -967,45 +1152,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
label='Gudang'
|
||||
placeholder='Pilih gudang asal...'
|
||||
value={formik.values.source_warehouse}
|
||||
onChange={(val) => {
|
||||
const newSourceWarehouseId = (val as WarehouseOptionType)
|
||||
?.value;
|
||||
|
||||
if (newSourceWarehouseId) {
|
||||
if (
|
||||
newSourceWarehouseId ===
|
||||
formik.values.destination_warehouse_id
|
||||
) {
|
||||
const destinationWarehouseName =
|
||||
(
|
||||
formik.values
|
||||
.destination_warehouse as WarehouseOptionType
|
||||
)?.label || 'gudang tujuan';
|
||||
|
||||
toast.error(
|
||||
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
formik.setFieldTouched('source_warehouse', true);
|
||||
formik.setFieldValue('source_warehouse', val);
|
||||
formik.setFieldTouched('source_warehouse_id', true);
|
||||
formik.setFieldValue(
|
||||
'source_warehouse_id',
|
||||
newSourceWarehouseId
|
||||
);
|
||||
|
||||
if (
|
||||
formik.errors.destination_warehouse_id ===
|
||||
'Gudang tujuan tidak boleh sama dengan gudang asal!'
|
||||
) {
|
||||
formik.setFieldError('destination_warehouse_id', undefined);
|
||||
}
|
||||
}}
|
||||
onChange={handleSourceWarehouseChange}
|
||||
options={warehouseOptions}
|
||||
onInputChange={setWarehouseSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isLoading={isLoadingWarehouses}
|
||||
isError={
|
||||
formik.touched.source_warehouse_id &&
|
||||
@@ -1066,44 +1216,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
label='Gudang'
|
||||
placeholder='Pilih gudang tujuan...'
|
||||
value={formik.values.destination_warehouse}
|
||||
onChange={(val) => {
|
||||
const newDestinationWarehouseId = (val as WarehouseOptionType)
|
||||
?.value;
|
||||
|
||||
if (newDestinationWarehouseId) {
|
||||
if (
|
||||
newDestinationWarehouseId ===
|
||||
formik.values.source_warehouse_id
|
||||
) {
|
||||
const sourceWarehouseName =
|
||||
(formik.values.source_warehouse as WarehouseOptionType)
|
||||
?.label || 'gudang asal';
|
||||
|
||||
toast.error(
|
||||
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
formik.setFieldTouched('destination_warehouse', true);
|
||||
formik.setFieldValue('destination_warehouse', val);
|
||||
formik.setFieldTouched('destination_warehouse_id', true);
|
||||
formik.setFieldValue(
|
||||
'destination_warehouse_id',
|
||||
newDestinationWarehouseId
|
||||
);
|
||||
|
||||
if (
|
||||
formik.errors.destination_warehouse_id ===
|
||||
'Gudang tujuan tidak boleh sama dengan gudang asal!'
|
||||
) {
|
||||
formik.setFieldError('destination_warehouse_id', undefined);
|
||||
}
|
||||
}}
|
||||
onChange={handleDestinationWarehouseChange}
|
||||
options={warehouseOptions}
|
||||
onInputChange={setWarehouseSelectInputValue}
|
||||
isLoading={isLoadingWarehouses}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isError={
|
||||
formik.touched.destination_warehouse_id &&
|
||||
Boolean(formik.errors.destination_warehouse_id)
|
||||
@@ -1173,18 +1290,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
selectedProducts.length &&
|
||||
formik.values.products?.length > 0
|
||||
}
|
||||
onChange={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedProducts(
|
||||
formik.values.products?.map((_, idx) => idx) ??
|
||||
[]
|
||||
);
|
||||
} else {
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
}}
|
||||
onChange={handleProductSelectAllChange}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -1221,17 +1327,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<CheckboxInput
|
||||
name={`product-${idx}`}
|
||||
checked={selectedProducts.includes(idx)}
|
||||
onChange={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedProducts([...selectedProducts, idx]);
|
||||
} else {
|
||||
setSelectedProducts(
|
||||
selectedProducts.filter((i) => i !== idx)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={handleProductCheckboxChange}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -1243,26 +1339,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<SelectInput
|
||||
required
|
||||
value={product.product ?? undefined}
|
||||
onChange={(val) => {
|
||||
formik.setFieldTouched(
|
||||
`products.${idx}.product`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`products.${idx}.product`,
|
||||
val
|
||||
);
|
||||
formik.setFieldTouched(
|
||||
`products.${idx}.product_id`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`products.${idx}.product_id`,
|
||||
(val as ProductWarehouseOptionType)?.value
|
||||
);
|
||||
}}
|
||||
onChange={(val) => handleProductChange(idx, val)}
|
||||
options={productWarehouseOptions}
|
||||
onInputChange={setProductWarehouseSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreProductWarehouses}
|
||||
isLoading={isLoadingProductWarehouses}
|
||||
isDisabled={
|
||||
type === 'detail' ||
|
||||
@@ -1386,19 +1466,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
selectedDeliveries.length &&
|
||||
formik.values.deliveries?.length > 0
|
||||
}
|
||||
onChange={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDeliveries(
|
||||
formik.values.deliveries?.map(
|
||||
(_, idx) => idx
|
||||
) ?? []
|
||||
);
|
||||
} else {
|
||||
setSelectedDeliveries([]);
|
||||
}
|
||||
}}
|
||||
onChange={handleDeliverySelectAllChange}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -1481,20 +1549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<CheckboxInput
|
||||
name={`delivery-${idx}`}
|
||||
checked={selectedDeliveries.includes(idx)}
|
||||
onChange={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDeliveries([
|
||||
...selectedDeliveries,
|
||||
idx,
|
||||
]);
|
||||
} else {
|
||||
setSelectedDeliveries(
|
||||
selectedDeliveries.filter((i) => i !== idx)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={handleDeliveryCheckboxChange}
|
||||
classNames={{
|
||||
wrapper: 'flex justify-center',
|
||||
checkbox: 'checkbox checkbox-sm',
|
||||
@@ -1507,24 +1562,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
required
|
||||
placeholder='Pilih produk...'
|
||||
value={delivery.products[0]?.product ?? undefined}
|
||||
onChange={(val) => {
|
||||
formik.setFieldTouched(
|
||||
`deliveries.${idx}.products.0.product`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.products.0.product`,
|
||||
val
|
||||
);
|
||||
formik.setFieldTouched(
|
||||
`deliveries.${idx}.products.0.product_id`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.products.0.product_id`,
|
||||
(val as OptionType)?.value
|
||||
);
|
||||
}}
|
||||
onChange={(val) =>
|
||||
handleDeliveryProductChange(idx, val)
|
||||
}
|
||||
options={getFilteredProductWarehouseOptions()}
|
||||
isDisabled={type === 'detail'}
|
||||
isClearable
|
||||
@@ -1575,24 +1615,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
required
|
||||
placeholder='Pilih supplier...'
|
||||
value={delivery.supplier}
|
||||
onChange={(val) => {
|
||||
formik.setFieldTouched(
|
||||
`deliveries.${idx}.supplier`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.supplier`,
|
||||
val
|
||||
);
|
||||
formik.setFieldTouched(
|
||||
`deliveries.${idx}.supplier_id`,
|
||||
true
|
||||
);
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.supplier_id`,
|
||||
(val as OptionType)?.value
|
||||
);
|
||||
}}
|
||||
onChange={(val) =>
|
||||
handleDeliverySupplierChange(idx, val)
|
||||
}
|
||||
options={supplierOptions}
|
||||
onInputChange={setSupplierSelectInputValue}
|
||||
isLoading={isLoadingSuppliers}
|
||||
@@ -1684,20 +1709,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
<FileInput
|
||||
accept='.pdf,.jpg,.jpeg,.png'
|
||||
name={`deliveries.${idx}.document`}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Ukuran dokumen maksimal 5 MB!');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
formik.setFieldValue(
|
||||
`deliveries.${idx}.document`,
|
||||
file
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(e) =>
|
||||
handleDeliveryDocumentChange(idx, e)
|
||||
}
|
||||
{...isRepeaterInputError(
|
||||
'deliveries',
|
||||
'document',
|
||||
|
||||
@@ -91,7 +91,7 @@ const InventoryProductDetail = ({
|
||||
<td>:</td>
|
||||
<td>
|
||||
{inventoryProduct?.tax
|
||||
? formatCurrency(inventoryProduct?.tax)
|
||||
? formatNumber(inventoryProduct?.tax) + '%'
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
|
||||
import { TableToolbar } from '@/components/table/TableToolbar';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import {
|
||||
MarketingApi,
|
||||
SalesOrderApi,
|
||||
@@ -33,6 +33,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { useAuth } from '@/services/hooks/useAuth';
|
||||
import { CustomerApi, ProductApi } from '@/services/api/master-data';
|
||||
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import Badge from '@/components/Badge';
|
||||
|
||||
const RowsOptionsMenu = ({
|
||||
type = 'dropdown',
|
||||
@@ -184,12 +185,16 @@ const MarketingTable = () => {
|
||||
const {
|
||||
options: productsOptions,
|
||||
isLoadingOptions: isLoadingProductsOptions,
|
||||
setInputValue: setProductsInputValue,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect(ProductApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
const {
|
||||
options: customersOptions,
|
||||
isLoadingOptions: isLoadingCustomersOptions,
|
||||
setInputValue: setCustomersInputValue,
|
||||
loadMore: loadMoreCustomers,
|
||||
} = useSelect(CustomerApi.basePath, 'id', 'name', '', {
|
||||
limit: 'limit',
|
||||
});
|
||||
@@ -400,6 +405,8 @@ const MarketingTable = () => {
|
||||
.join(',') || ''
|
||||
)
|
||||
}
|
||||
onInputChange={setProductsInputValue}
|
||||
onMenuScrollToBottom={loadMoreProducts}
|
||||
isMulti
|
||||
/>
|
||||
{/* select status */}
|
||||
@@ -444,6 +451,8 @@ const MarketingTable = () => {
|
||||
(value as OptionType)?.value.toString() || ''
|
||||
)
|
||||
}
|
||||
onInputChange={setCustomersInputValue}
|
||||
onMenuScrollToBottom={loadMoreCustomers}
|
||||
/>
|
||||
</TableRowSizeSelector>
|
||||
</div>
|
||||
@@ -512,8 +521,53 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'latest_approval.step_name',
|
||||
accessorKey: 'approval.step_name',
|
||||
header: 'Status',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.latest_approval;
|
||||
const isRejected = approval?.action == 'REJECTED';
|
||||
const isApproved = approval?.action == 'APPROVED';
|
||||
return (
|
||||
<Badge
|
||||
variant='soft'
|
||||
className={{
|
||||
badge:
|
||||
'rounded-lg px-2 w-full flex flex-row justify-start whitespace-nowrap',
|
||||
}}
|
||||
color={
|
||||
isRejected
|
||||
? 'error'
|
||||
: isApproved
|
||||
? approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'primary'
|
||||
: approval?.step_number == 3
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:circle'
|
||||
width={12}
|
||||
height={12}
|
||||
color={
|
||||
approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'primary'
|
||||
: approval?.step_number == 3
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
/>
|
||||
{isRejected
|
||||
? 'Ditolak'
|
||||
: formatTitleCase(approval?.step_name || '')}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'customer.name',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatTitleCase,
|
||||
formatVechicleNumber,
|
||||
} from '@/lib/helper';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import toast from 'react-hot-toast';
|
||||
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
|
||||
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import Badge from '@/components/Badge';
|
||||
|
||||
const MarketingDetail = ({
|
||||
initialValues,
|
||||
@@ -121,6 +123,10 @@ const MarketingDetail = ({
|
||||
);
|
||||
};
|
||||
|
||||
const approval = initialValues?.latest_approval;
|
||||
const isRejected = approval?.action == 'REJECTED';
|
||||
const isApproved = approval?.action == 'APPROVED';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col w-full gap-4'>
|
||||
@@ -230,7 +236,46 @@ const MarketingDetail = ({
|
||||
<tr>
|
||||
<td className='font-semibold'>Status</td>
|
||||
<td>:</td>
|
||||
<td>{initialValues?.latest_approval?.step_name}</td>
|
||||
<td>
|
||||
<Badge
|
||||
variant='soft'
|
||||
className={{
|
||||
badge:
|
||||
'rounded-lg px-2 w-fit flex flex-row justify-start whitespace-nowrap',
|
||||
}}
|
||||
color={
|
||||
isRejected
|
||||
? 'error'
|
||||
: isApproved
|
||||
? approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'primary'
|
||||
: approval?.step_number == 3
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:circle'
|
||||
width={12}
|
||||
height={12}
|
||||
color={
|
||||
approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'primary'
|
||||
: approval?.step_number == 3
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
/>
|
||||
{isRejected
|
||||
? 'Ditolak'
|
||||
: formatTitleCase(approval?.step_name || '')}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='font-semibold'>Tanggal Penjualan</td>
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
type MarketingSchemaType = {
|
||||
customer_id: number | undefined;
|
||||
sales_person_id: number | undefined;
|
||||
sales_person:
|
||||
| {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
| undefined
|
||||
| null;
|
||||
customer:
|
||||
| {
|
||||
value: number;
|
||||
@@ -33,7 +40,11 @@ type DeliveryOrderSchemaType = {
|
||||
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
|
||||
Yup.object({
|
||||
customer_id: Yup.number().required('Customer wajib diisi!'),
|
||||
sales_person_id: Yup.number().required('Sales Person wajib diisi!'),
|
||||
sales_person_id: Yup.number().required('Sales wajib diisi!'),
|
||||
sales_person: Yup.object({
|
||||
value: Yup.number().required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
customer: Yup.object({
|
||||
value: Yup.number().required(),
|
||||
label: Yup.string().required(),
|
||||
|
||||
@@ -50,6 +50,8 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import { CreatedUser } from '@/types/api/api-general';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
|
||||
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
|
||||
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
|
||||
@@ -244,7 +246,15 @@ const MarketingForm = ({
|
||||
const {
|
||||
options: customerOptions,
|
||||
isLoadingOptions: isLoadingCustomerOptions,
|
||||
setInputValue: setInputCustomerValue,
|
||||
loadMore: loadMoreCustomer,
|
||||
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
|
||||
const {
|
||||
options: salesOptions,
|
||||
isLoadingOptions: isLoadingSalesOptions,
|
||||
setInputValue: setInputSalesValue,
|
||||
loadMore: loadMoreSales,
|
||||
} = useSelect<CreatedUser>(UserApi.basePath, 'id', 'name');
|
||||
|
||||
// ================== SETUP FORMIK ==================
|
||||
const formikInitialValues = useMemo<
|
||||
@@ -255,6 +265,12 @@ const MarketingForm = ({
|
||||
notes: initialValues?.notes || undefined,
|
||||
customer_id: initialValues?.customer?.id || undefined,
|
||||
sales_person_id: initialValues?.sales_person?.id || 1,
|
||||
sales_person: initialValues?.sales_person
|
||||
? {
|
||||
value: initialValues.sales_person.id,
|
||||
label: initialValues.sales_person.name,
|
||||
}
|
||||
: null,
|
||||
customer: initialValues?.customer
|
||||
? {
|
||||
value: initialValues.customer.id,
|
||||
@@ -345,6 +361,8 @@ const MarketingForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const memoSalesOrder = formik.values.sales_order;
|
||||
|
||||
// ================== FORM REPEATER HANDLER ==================
|
||||
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
|
||||
setIsLoading(true);
|
||||
@@ -443,18 +461,37 @@ const MarketingForm = ({
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleChangeSalesPerson = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('sales_person_id', (val as OptionType)?.value);
|
||||
formik.setFieldValue('sales_person', val as OptionType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteModal.openModal();
|
||||
}, [deleteModal]);
|
||||
|
||||
// ================== SALES ORDER HANDLER ==================
|
||||
const handleDeleteSO = useCallback((id: number) => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
formik.setFieldValue(
|
||||
'sales_order',
|
||||
currentProducts.filter((p) => p.id != id)
|
||||
);
|
||||
}, []);
|
||||
const handleDeleteSO = useCallback(
|
||||
(id: number) => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
formik.setFieldValue(
|
||||
'sales_order',
|
||||
currentProducts.filter((p) => p.id != id)
|
||||
);
|
||||
},
|
||||
[memoSalesOrder]
|
||||
);
|
||||
const handleEditSO = useCallback(
|
||||
(id: number) => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
const selectedProduct = currentProducts.find((p) => p.id == id);
|
||||
setSelectedMarketingProduct(selectedProduct ?? null);
|
||||
addSOModal.openModal();
|
||||
},
|
||||
[memoSalesOrder]
|
||||
);
|
||||
const handleBulkDeleteSO = useCallback(() => {
|
||||
const currentProducts = formik.values.sales_order;
|
||||
formik.setFieldValue(
|
||||
@@ -464,7 +501,7 @@ const MarketingForm = ({
|
||||
)
|
||||
);
|
||||
setRowSOSelection({});
|
||||
}, [selectedRowSOIds]);
|
||||
}, [selectedRowSOIds, memoSalesOrder]);
|
||||
const handleAddSOClick = useCallback(() => {
|
||||
setSelectedMarketingProduct(null);
|
||||
addSOModal.openModal();
|
||||
@@ -500,7 +537,7 @@ const MarketingForm = ({
|
||||
|
||||
addSOModal.closeModal();
|
||||
},
|
||||
[addSOModal]
|
||||
[addSOModal, memoSalesOrder]
|
||||
);
|
||||
|
||||
// ================== DELIVERY ORDER HANDLER ==================
|
||||
@@ -545,8 +582,30 @@ const MarketingForm = ({
|
||||
},
|
||||
[addDOModal]
|
||||
);
|
||||
|
||||
const memoSalesOrder = formik.values.sales_order;
|
||||
const handleDeleteDO = useCallback(
|
||||
async (id: number) => {
|
||||
setDeliveryOrderValues((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id
|
||||
? {
|
||||
...product,
|
||||
...{
|
||||
unit_price: '',
|
||||
total_weight: '',
|
||||
qty: '',
|
||||
avg_weight: '',
|
||||
total_price: '',
|
||||
delivery_date: '',
|
||||
},
|
||||
}
|
||||
: product
|
||||
)
|
||||
);
|
||||
addDOModal.closeModal();
|
||||
setSelectedDeliveryProduct(null);
|
||||
},
|
||||
[addDOModal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setFieldValue('delivery_order', deliveryOrderValues);
|
||||
@@ -580,6 +639,7 @@ const MarketingForm = ({
|
||||
className={{
|
||||
wrapper: 'bg-white w-full',
|
||||
}}
|
||||
variant='bordered'
|
||||
>
|
||||
<div className='grid sm:grid-cols-2 gap-3 mt-3'>
|
||||
<SelectInput
|
||||
@@ -588,6 +648,8 @@ const MarketingForm = ({
|
||||
isLoading={isLoadingCustomerOptions}
|
||||
value={formik.values.customer}
|
||||
onChange={handleChangeCustomer}
|
||||
onInputChange={setInputCustomerValue}
|
||||
onMenuScrollToBottom={loadMoreCustomer}
|
||||
isError={
|
||||
formik.touched.customer_id && Boolean(formik.errors.customer_id)
|
||||
}
|
||||
@@ -595,7 +657,9 @@ const MarketingForm = ({
|
||||
isClearable
|
||||
placeholder='Pilih Pelanggan'
|
||||
isDisabled={
|
||||
formType === 'add_deliver' || formType === 'edit_deliver'
|
||||
formType === 'add_deliver' ||
|
||||
formType === 'edit_deliver' ||
|
||||
formType === 'edit'
|
||||
}
|
||||
/>
|
||||
<DateInput
|
||||
@@ -617,6 +681,7 @@ const MarketingForm = ({
|
||||
className={{
|
||||
wrapper: 'bg-white w-full',
|
||||
}}
|
||||
variant='bordered'
|
||||
>
|
||||
<MemoizedSalesOrderProductTable
|
||||
formType={formType}
|
||||
@@ -625,6 +690,7 @@ const MarketingForm = ({
|
||||
setRowSelection={setRowSOSelection}
|
||||
selectedRowIds={selectedRowSOIds}
|
||||
onDelete={handleDeleteSO}
|
||||
onEdit={handleEditSO}
|
||||
onBulkDelete={handleBulkDeleteSO}
|
||||
onAddProductClick={handleAddSOClick}
|
||||
/>
|
||||
@@ -644,6 +710,7 @@ const MarketingForm = ({
|
||||
formType={formType}
|
||||
data={deliveryOrderValues}
|
||||
onEdit={handleEditDO}
|
||||
onDelete={handleDeleteDO}
|
||||
onAddProductClick={handleAddDOClick}
|
||||
/>
|
||||
</Card>
|
||||
@@ -651,19 +718,42 @@ const MarketingForm = ({
|
||||
|
||||
{/* Input Notes */}
|
||||
<div className='grid sm:grid-cols-2 gap-3'>
|
||||
<DebouncedTextArea
|
||||
required
|
||||
name='notes'
|
||||
label='Catatan'
|
||||
rows={3}
|
||||
placeholder='Masukan catatan penjualan'
|
||||
value={formik.values.notes}
|
||||
onChange={formik.handleChange}
|
||||
isError={formik.touched.notes && Boolean(formik.errors.notes)}
|
||||
errorMessage={formik.errors.notes}
|
||||
disabled={formType === 'add_deliver' || formType === 'edit_deliver'}
|
||||
/>
|
||||
<div className='flex flex-col h-full justify-between items-end py-6'>
|
||||
<div className='flex flex-col h-full items-end gap-3'>
|
||||
<SelectInput
|
||||
label='Sales'
|
||||
options={salesOptions}
|
||||
isLoading={isLoadingSalesOptions}
|
||||
value={formik.values.sales_person}
|
||||
onChange={handleChangeSalesPerson}
|
||||
onInputChange={setInputSalesValue}
|
||||
onMenuScrollToBottom={loadMoreSales}
|
||||
isError={
|
||||
formik.touched.sales_person_id &&
|
||||
Boolean(formik.errors.sales_person_id)
|
||||
}
|
||||
errorMessage={formik.errors.sales_person_id}
|
||||
isClearable
|
||||
placeholder='Pilih Sales'
|
||||
isDisabled={
|
||||
formType === 'add_deliver' || formType === 'edit_deliver'
|
||||
}
|
||||
/>
|
||||
<DebouncedTextArea
|
||||
required
|
||||
name='notes'
|
||||
label='Catatan'
|
||||
rows={3}
|
||||
placeholder='Masukan catatan penjualan'
|
||||
value={formik.values.notes}
|
||||
onChange={formik.handleChange}
|
||||
isError={formik.touched.notes && Boolean(formik.errors.notes)}
|
||||
errorMessage={formik.errors.notes}
|
||||
disabled={
|
||||
formType === 'add_deliver' || formType === 'edit_deliver'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col h-full justify-end items-end'>
|
||||
<span>Total Penjualan</span>
|
||||
<span className='text-lg font-semibold'>
|
||||
{formatCurrency(grandTotal)}{' '}
|
||||
|
||||
+101
-36
@@ -18,6 +18,11 @@ import * as Yup from 'yup';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import useSWR from 'swr';
|
||||
import { ProductApi } from '@/services/api/master-data';
|
||||
|
||||
const roundWeight = (value: number) => Number(value.toFixed(2));
|
||||
const roundPrice = (value: number) => Math.round(value);
|
||||
|
||||
const DeliveryOrderProductForm = ({
|
||||
formState,
|
||||
@@ -43,6 +48,17 @@ const DeliveryOrderProductForm = ({
|
||||
);
|
||||
const [currentInput, setCurrentInput] = useState<string>('');
|
||||
|
||||
// ============ Fetch Data ============
|
||||
const { data: productData } = useSWR(
|
||||
selectedProduct?.value
|
||||
? ProductApi.basePath + '/' + selectedProduct?.value
|
||||
: null,
|
||||
() =>
|
||||
selectedProduct?.value
|
||||
? ProductApi.getSingle(Number(selectedProduct?.value))
|
||||
: undefined
|
||||
);
|
||||
|
||||
const salesOrder = salesOrders.find(
|
||||
(item) => item.id === initialValues?.marketing_product_id
|
||||
);
|
||||
@@ -90,6 +106,7 @@ const DeliveryOrderProductForm = ({
|
||||
await onUpdateForm?.(values.marketing_product_id as number, values);
|
||||
}
|
||||
handleResetForm();
|
||||
setSelectedProduct(null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,27 +125,65 @@ const DeliveryOrderProductForm = ({
|
||||
marketing_product: undefined,
|
||||
},
|
||||
});
|
||||
setSelectedProduct(null);
|
||||
// setSelectedProduct(null);
|
||||
};
|
||||
|
||||
const handleBlurField = (field: string) => {
|
||||
setCurrentInput(field);
|
||||
const { qty, unit_price, total_price, avg_weight, total_weight } =
|
||||
formik.values;
|
||||
|
||||
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
|
||||
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
|
||||
formik.setFieldValue('total_price', Number(qty) * Number(unit_price));
|
||||
} else if (qty && total_price && field === 'total_price') {
|
||||
formik.setFieldValue('unit_price', Number(total_price) / Number(qty));
|
||||
const qty = Number(formik.values.qty || 0);
|
||||
const avgWeight = Number(formik.values.avg_weight || 0);
|
||||
const totalWeight = Number(formik.values.total_weight || 0);
|
||||
const unitPrice = Number(formik.values.unit_price || 0);
|
||||
const totalPrice = Number(formik.values.total_price || 0);
|
||||
|
||||
if (qty <= 0) return;
|
||||
|
||||
switch (field) {
|
||||
// ===== SOURCE FIELDS =====
|
||||
case 'qty': {
|
||||
if (avgWeight > 0) {
|
||||
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
|
||||
}
|
||||
|
||||
if (unitPrice > 0) {
|
||||
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
|
||||
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
|
||||
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight));
|
||||
} else if (qty && total_weight && field === 'total_weight') {
|
||||
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
|
||||
case 'avg_weight': {
|
||||
if (avgWeight > 0) {
|
||||
const tw = roundWeight(qty * avgWeight);
|
||||
formik.setFieldValue('total_weight', tw);
|
||||
|
||||
if (unitPrice > 0) {
|
||||
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unit_price': {
|
||||
if (unitPrice > 0) {
|
||||
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ===== TOTAL EDITABLE =====
|
||||
case 'total_weight': {
|
||||
if (totalWeight > 0) {
|
||||
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'total_price': {
|
||||
if (totalPrice > 0) {
|
||||
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -183,7 +238,7 @@ const DeliveryOrderProductForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<div className='grid sm:grid-cols-3 gap-4'>
|
||||
<SelectInput
|
||||
options={options}
|
||||
label='Produk'
|
||||
@@ -287,7 +342,9 @@ const DeliveryOrderProductForm = ({
|
||||
isError={Boolean(formik.errors.vehicle_number)}
|
||||
errorMessage={formik.errors.vehicle_number}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className='divider my-6'></div>
|
||||
<div className='grid sm:grid-cols-3 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Kuantitas'
|
||||
@@ -301,33 +358,28 @@ const DeliveryOrderProductForm = ({
|
||||
isError={Boolean(formik.errors.qty)}
|
||||
errorMessage={formik.errors.qty}
|
||||
placeholder='Masukan Kuantitas'
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500'>
|
||||
{isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
bottomLabel={
|
||||
formik.values.marketing_product_id
|
||||
? 'Stok dijual: ' +
|
||||
salesOrders?.find(
|
||||
(item) => item.id === formik.values.marketing_product_id
|
||||
)?.qty
|
||||
)?.qty +
|
||||
' ' +
|
||||
(isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: '')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='divider my-6'></div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Avg. Bobot (Kg)'
|
||||
name='avg_weight'
|
||||
value={formik.values.avg_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
}}
|
||||
onBlur={() => handleBlurField('avg_weight')}
|
||||
isError={Boolean(formik.errors.avg_weight)}
|
||||
errorMessage={formik.errors.avg_weight}
|
||||
placeholder='Masukan Bobot Rata-rata'
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Satuan (Rp)'
|
||||
@@ -342,7 +394,20 @@ const DeliveryOrderProductForm = ({
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
required
|
||||
label='Avg. Bobot (Kg)'
|
||||
name='avg_weight'
|
||||
value={formik.values.avg_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
}}
|
||||
onBlur={() => handleBlurField('avg_weight')}
|
||||
isError={Boolean(formik.errors.avg_weight)}
|
||||
errorMessage={formik.errors.avg_weight}
|
||||
placeholder='Masukan Bobot Rata-rata'
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Total Bobot (Kg)'
|
||||
|
||||
@@ -18,6 +18,7 @@ type SalesOrderProductSchemaType = {
|
||||
avg_weight: string | number | undefined;
|
||||
total_price: string | number | undefined;
|
||||
vehicle_number?: string | undefined;
|
||||
uom?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
|
||||
@@ -57,6 +58,7 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
|
||||
total_price: Yup.number()
|
||||
.min(1, 'Total Penjualan wajib diisi!')
|
||||
.required('Total Penjualan wajib diisi!'),
|
||||
uom: Yup.string().nullable().optional().notRequired(),
|
||||
});
|
||||
|
||||
export type SalesOrderProductFormValues = Yup.InferType<
|
||||
|
||||
+133
-59
@@ -11,7 +11,7 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { WarehouseApi } from '@/services/api/master-data';
|
||||
import { ProductApi, UomApi, WarehouseApi } from '@/services/api/master-data';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
@@ -26,6 +26,10 @@ import PatternInput from '@/components/input/PatternInput';
|
||||
import Alert from '@/components/Alert';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const roundWeight = (value: number) => Number(value.toFixed(2));
|
||||
const roundPrice = (value: number) => Math.round(value);
|
||||
|
||||
const SalesOrderProductForm = ({
|
||||
initialValues,
|
||||
@@ -39,21 +43,35 @@ const SalesOrderProductForm = ({
|
||||
}) => {
|
||||
const [formErrorMessage, setFormErrorMessage] = useState('');
|
||||
const [currentInput, setCurrentInput] = useState<string>('');
|
||||
const [selectedProductWarehouse, setSelectedProductWarehouse] =
|
||||
useState<ProductWarehouse | null>(null);
|
||||
|
||||
// ============ Fetch Data ============
|
||||
const { data: productData } = useSWR(
|
||||
selectedProductWarehouse?.product_id
|
||||
? ProductApi.basePath + '/' + selectedProductWarehouse?.product_id
|
||||
: null,
|
||||
() =>
|
||||
selectedProductWarehouse?.product_id
|
||||
? ProductApi.getSingle(selectedProductWarehouse?.product_id)
|
||||
: undefined
|
||||
);
|
||||
|
||||
// ============ Formik ============
|
||||
const formik = useFormik<SalesOrderProductFormValues>({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
vehicle_number: initialValues?.vehicle_number || undefined,
|
||||
vehicle_number: initialValues?.vehicle_number || '',
|
||||
kandang_id: initialValues?.kandang_id || undefined,
|
||||
kandang: initialValues?.kandang || undefined,
|
||||
product_warehouse: initialValues?.product_warehouse || undefined,
|
||||
kandang: initialValues?.kandang || null,
|
||||
product_warehouse: initialValues?.product_warehouse || null,
|
||||
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
|
||||
unit_price: initialValues?.unit_price || undefined,
|
||||
total_weight: initialValues?.total_weight || undefined,
|
||||
qty: initialValues?.qty || undefined,
|
||||
avg_weight: initialValues?.avg_weight || undefined,
|
||||
total_price: initialValues?.total_price || undefined,
|
||||
unit_price: initialValues?.unit_price || '',
|
||||
total_weight: initialValues?.total_weight || '',
|
||||
qty: initialValues?.qty || '',
|
||||
avg_weight: initialValues?.avg_weight || '',
|
||||
total_price: initialValues?.total_price || '',
|
||||
uom: initialValues?.uom || '',
|
||||
},
|
||||
validationSchema: SalesOrderProductSchema,
|
||||
onSubmit: async (values) => {
|
||||
@@ -69,17 +87,21 @@ const SalesOrderProductForm = ({
|
||||
const {
|
||||
options: kandangSourceOptions,
|
||||
isLoadingOptions: isLoadingKandangSourceOptions,
|
||||
setInputValue: setKandangInputValue,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
options: warehouseSourceOptions,
|
||||
rawData: warehouseSourceRawData,
|
||||
isLoadingOptions: isLoadingWarehouseSourceOptions,
|
||||
setInputValue: setWarehouseInputValue,
|
||||
loadMore: loadMoreWarehouse,
|
||||
} = useSelect<ProductWarehouse>(
|
||||
ProductWarehouseApi.basePath,
|
||||
'id',
|
||||
'product.name',
|
||||
'search',
|
||||
'',
|
||||
{
|
||||
warehouse_id: formik.values.kandang_id?.toString() ?? '',
|
||||
}
|
||||
@@ -112,6 +134,7 @@ const SalesOrderProductForm = ({
|
||||
const productWarehouse = warehouseSourceRawData?.data.find(
|
||||
(item: ProductWarehouse) => item.id === newId
|
||||
);
|
||||
setSelectedProductWarehouse(productWarehouse || null);
|
||||
formik.setFieldValue('qty', productWarehouse?.quantity);
|
||||
handleBlurField('qty');
|
||||
} else {
|
||||
@@ -139,40 +162,78 @@ const SalesOrderProductForm = ({
|
||||
|
||||
const handleBlurField = (field: string) => {
|
||||
setCurrentInput(field);
|
||||
const { qty, unit_price, total_price, avg_weight, total_weight } =
|
||||
formik.values;
|
||||
|
||||
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
|
||||
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
|
||||
formik.setFieldValue(
|
||||
'total_price',
|
||||
(qty as number) * (unit_price as number)
|
||||
);
|
||||
} else if (qty && total_price && field === 'total_price') {
|
||||
formik.setFieldValue(
|
||||
'unit_price',
|
||||
(total_price as number) / (qty as number)
|
||||
);
|
||||
const qty = Number(formik.values.qty || 0);
|
||||
const avgWeight = Number(formik.values.avg_weight || 0);
|
||||
const totalWeight = Number(formik.values.total_weight || 0);
|
||||
const unitPrice = Number(formik.values.unit_price || 0);
|
||||
const totalPrice = Number(formik.values.total_price || 0);
|
||||
|
||||
if (qty <= 0) return;
|
||||
|
||||
switch (field) {
|
||||
// ===== SOURCE FIELDS =====
|
||||
case 'qty': {
|
||||
if (avgWeight > 0) {
|
||||
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
|
||||
}
|
||||
|
||||
if (unitPrice > 0) {
|
||||
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
|
||||
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
|
||||
formik.setFieldValue(
|
||||
'total_weight',
|
||||
(qty as number) * (avg_weight as number)
|
||||
);
|
||||
} else if (qty && total_weight && field === 'total_weight') {
|
||||
formik.setFieldValue(
|
||||
'avg_weight',
|
||||
(total_weight as number) / (qty as number)
|
||||
);
|
||||
case 'avg_weight': {
|
||||
if (avgWeight > 0) {
|
||||
const tw = roundWeight(qty * avgWeight);
|
||||
formik.setFieldValue('total_weight', tw);
|
||||
|
||||
if (unitPrice > 0) {
|
||||
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unit_price': {
|
||||
if (unitPrice > 0) {
|
||||
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ===== TOTAL EDITABLE =====
|
||||
case 'total_weight': {
|
||||
if (totalWeight > 0) {
|
||||
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'total_price': {
|
||||
if (totalPrice > 0) {
|
||||
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
|
||||
formik,
|
||||
{
|
||||
onBeforeSubmit(e) {
|
||||
e.preventDefault();
|
||||
handleBlurField(currentInput);
|
||||
formik.setFieldValue(
|
||||
'uom',
|
||||
isResponseSuccess(productData) ? productData?.data?.uom.name : ''
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -188,7 +249,7 @@ const SalesOrderProductForm = ({
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className='grid sm:grid-cols-2 gap-4 z-200'>
|
||||
<div className='grid sm:grid-cols-3 gap-4 z-200'>
|
||||
<PatternInput
|
||||
name='vehicle_number'
|
||||
label='No. Polisi'
|
||||
@@ -215,6 +276,8 @@ const SalesOrderProductForm = ({
|
||||
value={formik.values.kandang}
|
||||
onChange={kandangChangeHandler}
|
||||
isClearable
|
||||
onInputChange={setKandangInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandang}
|
||||
isError={
|
||||
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
|
||||
}
|
||||
@@ -228,6 +291,8 @@ const SalesOrderProductForm = ({
|
||||
isLoading={isLoadingWarehouseSourceOptions}
|
||||
value={formik.values.product_warehouse}
|
||||
onChange={warehouseChangeHandler}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouse}
|
||||
isClearable
|
||||
placeholder={
|
||||
formik.values.kandang_id
|
||||
@@ -243,6 +308,9 @@ const SalesOrderProductForm = ({
|
||||
}
|
||||
errorMessage={formik.errors.product_warehouse_id}
|
||||
/>
|
||||
</div>
|
||||
<div className='divider my-6'></div>
|
||||
<div className='grid sm:grid-cols-3 gap-4 z-200'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Kuantitas'
|
||||
@@ -256,6 +324,15 @@ const SalesOrderProductForm = ({
|
||||
isError={formik.touched.qty && Boolean(formik.errors.qty)}
|
||||
errorMessage={formik.errors.qty}
|
||||
placeholder='Masukan Kuantitas'
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500'>
|
||||
{isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
bottomLabel={
|
||||
isResponseSuccess(warehouseSourceRawData) &&
|
||||
formik.values.product_warehouse_id
|
||||
@@ -264,32 +341,13 @@ const SalesOrderProductForm = ({
|
||||
(item) => item.id === formik.values.product_warehouse_id
|
||||
)?.quantity ?? 0
|
||||
)} ${
|
||||
warehouseSourceRawData?.data?.find(
|
||||
(item) => item.id === formik.values.product_warehouse_id
|
||||
)?.product?.uom?.name ?? ''
|
||||
isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: ''
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='divider my-6'></div>
|
||||
<div className='grid sm:grid-cols-2 gap-4 z-200'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Avg. Bobot (Kg)'
|
||||
name='avg_weight'
|
||||
value={formik.values.avg_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
}}
|
||||
onBlur={() => handleBlurField('avg_weight')}
|
||||
isError={
|
||||
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
|
||||
}
|
||||
errorMessage={formik.errors.avg_weight}
|
||||
placeholder='Masukan Bobot Rata-rata'
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Satuan (Rp)'
|
||||
@@ -306,6 +364,22 @@ const SalesOrderProductForm = ({
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Avg. Bobot (Kg)'
|
||||
name='avg_weight'
|
||||
value={formik.values.avg_weight}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
setCurrentInput(e.target.name);
|
||||
}}
|
||||
onBlur={() => handleBlurField('avg_weight')}
|
||||
isError={
|
||||
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
|
||||
}
|
||||
errorMessage={formik.errors.avg_weight}
|
||||
placeholder='Masukan Bobot Rata-rata'
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Total Bobot (Kg)'
|
||||
|
||||
@@ -16,6 +16,7 @@ type DeliveryOrderProductTableProps = {
|
||||
data: DeliveryOrderProductFormValues[];
|
||||
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onAddProductClick: () => void;
|
||||
};
|
||||
|
||||
@@ -23,10 +24,13 @@ const DeliveryOrderProductTable = ({
|
||||
data,
|
||||
formType,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddProductClick,
|
||||
}: DeliveryOrderProductTableProps) => {
|
||||
const onEditRef = useRef(onEdit);
|
||||
onEditRef.current = onEdit;
|
||||
const onDeleteRef = useRef(onDelete);
|
||||
onDeleteRef.current = onDelete;
|
||||
|
||||
const canAddData = data.filter((item) => !Boolean(item.qty));
|
||||
|
||||
@@ -144,16 +148,29 @@ const DeliveryOrderProductTable = ({
|
||||
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
|
||||
<>
|
||||
{props.row.original.qty && (
|
||||
<Button
|
||||
color='warning'
|
||||
className='px-2 py-1 text-sm'
|
||||
onClick={() =>
|
||||
onEditRef.current(props.row.original.id as number)
|
||||
}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:edit' width={16} height={16} /> Edit
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
color='warning'
|
||||
className='px-2 py-1 text-sm'
|
||||
onClick={() =>
|
||||
onEditRef.current(props.row.original.id as number)
|
||||
}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:edit' width={16} height={16} /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
color='error'
|
||||
className='px-2 py-1 text-sm'
|
||||
onClick={() =>
|
||||
onDeleteRef.current(props.row.original.id as number)
|
||||
}
|
||||
type='button'
|
||||
disabled={!!props.row.original.do_number}
|
||||
>
|
||||
<Icon icon='mdi:delete' width={16} height={16} /> Hapus
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!props.row.original.qty && '-'}
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,7 @@ type SalesOrderProductTableProps = {
|
||||
>;
|
||||
selectedRowIds: number[];
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onBulkDelete: () => void;
|
||||
onAddProductClick: () => void;
|
||||
};
|
||||
@@ -34,11 +35,14 @@ const SalesOrderProductTable = ({
|
||||
setRowSelection,
|
||||
selectedRowIds,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onBulkDelete,
|
||||
onAddProductClick,
|
||||
}: SalesOrderProductTableProps) => {
|
||||
const onDeleteRef = useRef(onDelete);
|
||||
onDeleteRef.current = onDelete;
|
||||
const onEditRef = useRef(onEdit);
|
||||
onEditRef.current = onEdit;
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
@@ -92,17 +96,26 @@ const SalesOrderProductTable = ({
|
||||
},
|
||||
{
|
||||
accessorFn: (row: SalesOrderProductFormValues) =>
|
||||
formatNumber(parseFloat(row.total_weight as string)),
|
||||
formatNumber(parseFloat(row.total_weight as string), undefined, 0, 5),
|
||||
header: 'Total Bobot (Kg)',
|
||||
},
|
||||
{
|
||||
accessorFn: (row: SalesOrderProductFormValues) =>
|
||||
formatNumber(parseFloat(row.qty as string)),
|
||||
header: 'Kuantitas',
|
||||
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) =>
|
||||
formatNumber(
|
||||
parseFloat(row.original.qty as string),
|
||||
undefined,
|
||||
0,
|
||||
5
|
||||
) +
|
||||
' ' +
|
||||
(row.original.uom ?? ''),
|
||||
},
|
||||
{
|
||||
accessorFn: (row: SalesOrderProductFormValues) =>
|
||||
formatNumber(parseFloat(row.avg_weight as string)),
|
||||
formatNumber(parseFloat(row.avg_weight as string), undefined, 0, 5),
|
||||
header: 'Avg. Bobot (Kg)',
|
||||
},
|
||||
{
|
||||
@@ -116,6 +129,14 @@ const SalesOrderProductTable = ({
|
||||
props: TanStack.CellContext<SalesOrderProductFormValues, unknown>
|
||||
) => (
|
||||
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
|
||||
<Button
|
||||
color='warning'
|
||||
className='p-1'
|
||||
onClick={() => onEditRef.current(props.row.original.id as number)}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:pencil' width={16} height={16} /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
color='error'
|
||||
className='p-1'
|
||||
@@ -124,7 +145,7 @@ const SalesOrderProductTable = ({
|
||||
}
|
||||
type='button'
|
||||
>
|
||||
<Icon icon='mdi:trash' width={16} height={16} />
|
||||
<Icon icon='mdi:trash' width={16} height={16} /> Hapus
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
|
||||
import { format } from 'path';
|
||||
import { date } from 'yup';
|
||||
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface DeliveryOrderExportProps {
|
||||
data?: Marketing;
|
||||
@@ -23,7 +24,7 @@ const DeliveryOrderExport = ({
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (!salesData) {
|
||||
alert('No sales order data available');
|
||||
toast.error('No sales order data available');
|
||||
return;
|
||||
}
|
||||
setIsGeneratingPDF(true);
|
||||
@@ -40,8 +41,7 @@ const DeliveryOrderExport = ({
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Failed to generate PDF. Please try again.');
|
||||
toast.error('Failed to generate PDF. Please try again.');
|
||||
} finally {
|
||||
setIsGeneratingPDF(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SalesOrderExportProps {
|
||||
data?: Marketing;
|
||||
@@ -17,7 +18,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (!salesData) {
|
||||
alert('No sales order data available');
|
||||
toast.error('No sales order data available');
|
||||
return;
|
||||
}
|
||||
setIsGeneratingPDF(true);
|
||||
@@ -32,8 +33,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Failed to generate PDF. Please try again.');
|
||||
toast.error('Failed to generate PDF. Please try again.');
|
||||
} finally {
|
||||
setIsGeneratingPDF(false);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -164,7 +164,14 @@ const AreasTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await AreaApi.delete(selectedArea?.id as number);
|
||||
const deleteResponse = await AreaApi.delete(selectedArea?.id as number);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshAreas();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -177,7 +177,14 @@ const BanksTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await BankApi.delete(selectedBank?.id as number);
|
||||
const deleteResponse = await BankApi.delete(selectedBank?.id as number);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshBanks();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
@@ -186,7 +186,16 @@ const CustomersTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await CustomerApi.delete(selectedCustomer?.id as number);
|
||||
const deleteResponse = await CustomerApi.delete(
|
||||
selectedCustomer?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshCustomers();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -23,13 +23,17 @@ import TextInput from '@/components/input/TextInput';
|
||||
import { cn } from '@/lib/helper';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
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 { UserApi } from '@/services/api/user';
|
||||
import { TYPE_OPTIONS } from '@/config/constant';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { User } from '@/types/api/api-general';
|
||||
|
||||
interface CustomerFormProps {
|
||||
formType?: 'add' | 'edit' | 'detail';
|
||||
@@ -47,25 +51,15 @@ const CustomerForm = ({
|
||||
// Setup State
|
||||
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [picSelectInputValue, setPicSelectInputValue] = useState('');
|
||||
|
||||
// Fetch Data
|
||||
const picUrl = `${UserApi.basePath}?${new URLSearchParams({
|
||||
search: picSelectInputValue ?? '',
|
||||
})}`;
|
||||
|
||||
const { data: pic, isLoading: isLoadingPic } = useSWR(
|
||||
picUrl,
|
||||
UserApi.getAllFetcher
|
||||
);
|
||||
const {
|
||||
setInputValue: setPicSelectInputValue,
|
||||
options: picOptions,
|
||||
isLoadingOptions: isLoadingPicOptions,
|
||||
loadMore: loadMorePic,
|
||||
} = useSelect<User>(UserApi.basePath, 'id', 'name');
|
||||
|
||||
// -- Options data mapping
|
||||
const picOptions = isResponseSuccess(pic)
|
||||
? pic?.data.map((area) => ({
|
||||
value: area.id,
|
||||
label: area.name,
|
||||
}))
|
||||
: [];
|
||||
const typeOptions = TYPE_OPTIONS;
|
||||
|
||||
// Handler Event
|
||||
@@ -240,11 +234,12 @@ const CustomerForm = ({
|
||||
required
|
||||
placeholder='Pilih PIC'
|
||||
label='PIC'
|
||||
value={formik.values.pic ?? undefined}
|
||||
value={formik.values.pic?.value ? formik.values.pic : undefined}
|
||||
onChange={picChangeHandler}
|
||||
options={picOptions}
|
||||
onInputChange={setPicSelectInputValue}
|
||||
isLoading={isLoadingPic}
|
||||
onMenuScrollToBottom={loadMorePic}
|
||||
isLoading={isLoadingPicOptions}
|
||||
isError={formik.touched.picId && Boolean(formik.errors.picId)}
|
||||
errorMessage={formik.errors.picId as string}
|
||||
isDisabled={formType === 'detail'}
|
||||
|
||||
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Fcr } from '@/types/api/master-data/fcr';
|
||||
import { FcrApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -164,7 +164,14 @@ const FcrsTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await FcrApi.delete(selectedFcr?.id as number);
|
||||
const deleteResponse = await FcrApi.delete(selectedFcr?.id as number);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshFcrs();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -19,7 +19,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import Table from '@/components/Table';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
|
||||
const RowsOptions = ({
|
||||
@@ -33,22 +33,6 @@ const RowsOptions = ({
|
||||
}) => {
|
||||
return (
|
||||
<RowOptionsMenuWrapper type={type}>
|
||||
<RequirePermission permissions='lti.master.flocks.update'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.detail'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
|
||||
@@ -65,6 +49,22 @@ const RowsOptions = ({
|
||||
Detail
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.update'>
|
||||
<Button
|
||||
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='justify-start text-sm'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
className='justify-start text-sm'
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.master.flocks.delete'>
|
||||
<Button
|
||||
onClick={deleteClickHandler}
|
||||
@@ -182,7 +182,14 @@ const FlockTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await FlockApi.delete(selectedFlock?.id as number);
|
||||
const deleteResponse = await FlockApi.delete(selectedFlock?.id as number);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshFlocks();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -19,6 +19,8 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import Alert from '@/components/Alert';
|
||||
|
||||
interface FlockCustomProps {
|
||||
formType?: 'add' | 'edit' | 'detail';
|
||||
@@ -37,7 +39,13 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await FlockApi.delete(initialValues?.id as number);
|
||||
const deleteFlockRes = await FlockApi.delete(initialValues?.id as number);
|
||||
if (deleteFlockRes?.status === 'error') {
|
||||
setFlockFormErrorMessage(deleteFlockRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(deleteFlockRes?.message as string);
|
||||
|
||||
deleteModal.closeModal();
|
||||
setIsDeleteLoading(false);
|
||||
@@ -68,12 +76,29 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
|
||||
|
||||
// cek type form yang disubmit
|
||||
switch (formType) {
|
||||
case 'add':
|
||||
await FlockApi.create(payload);
|
||||
case 'add': {
|
||||
const createFlockRes = await FlockApi.create(payload);
|
||||
if (createFlockRes?.status === 'error') {
|
||||
setFlockFormErrorMessage(createFlockRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(createFlockRes?.message as string);
|
||||
break;
|
||||
case 'edit':
|
||||
await FlockApi.update(initialValues?.id as number, payload);
|
||||
}
|
||||
case 'edit': {
|
||||
const updateFlockRes = await FlockApi.update(
|
||||
initialValues?.id as number,
|
||||
payload
|
||||
);
|
||||
if (updateFlockRes?.status === 'error') {
|
||||
setFlockFormErrorMessage(updateFlockRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(updateFlockRes?.message as string);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -174,6 +199,24 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
|
||||
)}
|
||||
|
||||
<AlertErrorList formErrorList={formErrorList} onClose={close} />
|
||||
{flockFormErrorMessage && (
|
||||
<Alert color='error' className='w-full'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
{flockFormErrorMessage}
|
||||
<Button
|
||||
onClick={() => setFlockFormErrorMessage('')}
|
||||
variant='link'
|
||||
className='ml-auto p-0 w-fit text-white'
|
||||
color='none'
|
||||
>
|
||||
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{formType !== 'detail' && (
|
||||
<div
|
||||
@@ -197,17 +240,6 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flockFormErrorMessage && (
|
||||
<div role='alert' className='alert alert-error'>
|
||||
<Icon
|
||||
icon='material-symbols:error-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{flockFormErrorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { KandangApi } from '@/services/api/master-data';
|
||||
import { cn, formatNumber } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -199,7 +199,16 @@ const KandangsTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await KandangApi.delete(selectedKandang?.id as number);
|
||||
const deleteResponse = await KandangApi.delete(
|
||||
selectedKandang?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshKandangs();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -9,7 +9,10 @@ import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
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 ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
@@ -31,6 +34,7 @@ import { UserApi } from '@/services/api/user';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { User } from '@/types/api/api-general';
|
||||
|
||||
interface KandangFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -128,23 +132,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
// location
|
||||
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
|
||||
|
||||
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({
|
||||
search: locationSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: locations, isLoading: isLoadingLocations } = useSWR(
|
||||
locationsUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const locationOptions = isResponseSuccess(locations)
|
||||
? locations?.data.map((location) => ({
|
||||
value: location.id,
|
||||
label: location.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setLocationSelectInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('location', true);
|
||||
@@ -155,23 +148,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
};
|
||||
|
||||
// PIC
|
||||
const [picSelectInputValue, setPicSelectInputValue] = useState('');
|
||||
|
||||
const picsUrl = `${UserApi.basePath}?${new URLSearchParams({
|
||||
search: picSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: pics, isLoading: isLoadingPics } = useSWR(
|
||||
picsUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const picOptions = isResponseSuccess(pics)
|
||||
? pics?.data.map((pic) => ({
|
||||
value: pic.id,
|
||||
label: pic.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setPicSelectInputValue,
|
||||
options: picOptions,
|
||||
isLoadingOptions: isLoadingPicOptions,
|
||||
loadMore: loadMorePics,
|
||||
} = useSelect<User>(UserApi.basePath, 'id', 'name');
|
||||
|
||||
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('pic', true);
|
||||
@@ -233,7 +215,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
required
|
||||
label='Nama'
|
||||
name='name'
|
||||
placeholder='Masukkan nama lokasi'
|
||||
placeholder='Masukkan nama kandang'
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
@@ -249,7 +231,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
onChange={locationChangeHandler}
|
||||
options={locationOptions}
|
||||
onInputChange={setLocationSelectInputValue}
|
||||
isLoading={isLoadingLocations}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isError={
|
||||
formik.touched.locationId && Boolean(formik.errors.locationId)
|
||||
}
|
||||
@@ -280,7 +263,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
|
||||
onChange={picChangeHandler}
|
||||
options={picOptions}
|
||||
onInputChange={setPicSelectInputValue}
|
||||
isLoading={isLoadingPics}
|
||||
onMenuScrollToBottom={loadMorePics}
|
||||
isLoading={isLoadingPicOptions}
|
||||
isError={formik.touched.picId && Boolean(formik.errors.picId)}
|
||||
errorMessage={formik.errors.picId as string}
|
||||
isDisabled={type === 'detail'}
|
||||
|
||||
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -186,7 +186,16 @@ const LocationsTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await LocationApi.delete(selectedLocation?.id as number);
|
||||
const deleteResponse = await LocationApi.delete(
|
||||
selectedLocation?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshLocations();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -9,7 +9,10 @@ import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
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 ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
@@ -29,6 +32,7 @@ import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
|
||||
interface LocationFormProps {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -117,23 +121,12 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
|
||||
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
|
||||
|
||||
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({
|
||||
search: areaSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: areas, isLoading: isLoadingAreas } = useSWR(
|
||||
areasUrl,
|
||||
AreaApi.getAllFetcher
|
||||
);
|
||||
|
||||
const areaOptions = isResponseSuccess(areas)
|
||||
? areas?.data.map((area) => ({
|
||||
value: area.id,
|
||||
label: area.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setAreaSelectInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
|
||||
|
||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('area', true);
|
||||
@@ -224,7 +217,8 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
|
||||
onChange={areaChangeHandler}
|
||||
options={areaOptions}
|
||||
onInputChange={setAreaSelectInputValue}
|
||||
isLoading={isLoadingAreas}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
|
||||
errorMessage={formik.errors.areaId as string}
|
||||
isDisabled={type === 'detail'}
|
||||
|
||||
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -198,7 +198,16 @@ const NonstocksTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await NonstockApi.delete(selectedNonstock?.id as number);
|
||||
const deleteResponse = await NonstockApi.delete(
|
||||
selectedNonstock?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshNonstocks();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -9,7 +9,10 @@ import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
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 ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
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 { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
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 {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -78,7 +83,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||
const formikInitialValues = useMemo<NonstockFormValues>(() => {
|
||||
return {
|
||||
name: initialValues?.name ?? '',
|
||||
uomId: initialValues?.uom_id ?? 0,
|
||||
uomId: initialValues?.uom?.id ?? 0,
|
||||
uom: initialValues?.uom
|
||||
? {
|
||||
value: initialValues?.uom?.id,
|
||||
@@ -129,23 +134,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
// UOM
|
||||
const [uomSelectInputValue, setUomSelectInputValue] = useState('');
|
||||
|
||||
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({
|
||||
search: uomSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: uoms, isLoading: isLoadingUoms } = useSWR(
|
||||
uomsUrl,
|
||||
UomApi.getAllFetcher
|
||||
);
|
||||
|
||||
const uomOptions = isResponseSuccess(uoms)
|
||||
? uoms?.data.map((uom) => ({
|
||||
value: uom.id,
|
||||
label: uom.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setUomSelectInputValue,
|
||||
options: uomOptions,
|
||||
isLoadingOptions: isLoadingUomOptions,
|
||||
loadMore: loadMoreUoms,
|
||||
} = useSelect<Uom>(UomApi.basePath, 'id', 'name');
|
||||
|
||||
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('uom', true);
|
||||
@@ -156,25 +150,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||
};
|
||||
|
||||
// supplier
|
||||
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
|
||||
|
||||
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({
|
||||
search: supplierSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
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 {
|
||||
setInputValue: setSupplierSelectInputValue,
|
||||
options: supplierOptions,
|
||||
isLoadingOptions: isLoadingSupplierOptions,
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('suppliers', true);
|
||||
@@ -248,7 +229,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||
required
|
||||
label='Nama'
|
||||
name='name'
|
||||
placeholder='Masukkan nama lokasi'
|
||||
placeholder='Masukkan nama nonstock'
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
@@ -264,7 +245,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||
onChange={uomChangeHandler}
|
||||
options={uomOptions}
|
||||
onInputChange={setUomSelectInputValue}
|
||||
isLoading={isLoadingUoms}
|
||||
isLoading={isLoadingUomOptions}
|
||||
onMenuScrollToBottom={loadMoreUoms}
|
||||
isError={formik.touched.uomId && Boolean(formik.errors.uomId)}
|
||||
errorMessage={formik.errors.uomId as string}
|
||||
isDisabled={type === 'detail'}
|
||||
@@ -278,7 +260,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
|
||||
onChange={supplierChangeHandler}
|
||||
options={supplierOptions ?? []}
|
||||
onInputChange={setSupplierSelectInputValue}
|
||||
isLoading={isLoadingSuppliers}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isLoading={isLoadingSupplierOptions}
|
||||
isError={
|
||||
formik.touched.suppliers && Boolean(formik.errors.suppliers)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -170,7 +170,16 @@ const ProductCategoryTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await ProductCategoryApi.delete(selectedProductCategory?.id as number);
|
||||
const deleteResponse = await ProductCategoryApi.delete(
|
||||
selectedProductCategory?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProductCategories();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import { ProductApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -230,8 +230,19 @@ const ProductsTable = () => {
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
await ProductApi.delete(selectedProduct?.id as number);
|
||||
|
||||
const deleteResponse = await ProductApi.delete(
|
||||
selectedProduct?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProducts();
|
||||
|
||||
deleteModal.closeModal();
|
||||
toast.success('Successfully delete Product!');
|
||||
setIsDeleteLoading(false);
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
|
||||
type ProductFormSchemaType = {
|
||||
name: string;
|
||||
brand: string;
|
||||
sku: string;
|
||||
sku?: string;
|
||||
uom?: {
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
|
||||
} | null;
|
||||
product_category_id: number;
|
||||
product_price: number | string;
|
||||
selling_price: number | string;
|
||||
tax: number | string;
|
||||
expiry_period: number | string;
|
||||
supplier_ids: number[];
|
||||
selling_price?: number | string;
|
||||
tax?: number | string;
|
||||
expiry_period?: number | string;
|
||||
suppliers: {
|
||||
supplier: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
price: number;
|
||||
}[];
|
||||
flags: string[];
|
||||
};
|
||||
|
||||
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
|
||||
Yup.object({
|
||||
name: Yup.string().required('Nama wajib diisi!'),
|
||||
brand: Yup.string().required('Merek wajib diisi!'),
|
||||
sku: Yup.string().required('SKU wajib diisi!'),
|
||||
sku: Yup.string(),
|
||||
|
||||
uom: Yup.object({
|
||||
value: Yup.number()
|
||||
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
|
||||
.min(1, 'Harga produk tidak boleh kurang dari 1!'),
|
||||
|
||||
selling_price: Yup.number()
|
||||
.required('Harga jual wajib diisi!')
|
||||
.typeError('Harga jual wajib diisi!')
|
||||
.typeError('Harga hanya boleh angka!')
|
||||
.min(1, 'Harga jual tidak boleh kurang dari 1!'),
|
||||
|
||||
tax: Yup.number()
|
||||
.required('Pajak wajib diisi!')
|
||||
.typeError('Pajak wajib diisi!')
|
||||
.typeError('Pajak hanya boleh angka!')
|
||||
.min(0, 'Pajak tidak boleh kurang dari 0!')
|
||||
.max(100, 'Pajak tidak boleh lebih dari 100%!'),
|
||||
|
||||
expiry_period: Yup.number()
|
||||
.required('Periode kadaluarsa wajib diisi!')
|
||||
.typeError('Periode kadaluarsa wajib diisi!')
|
||||
.typeError('Periode kadaluarsa hanya boleh angka!')
|
||||
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
|
||||
|
||||
supplier_ids: Yup.array()
|
||||
.of(Yup.number().required().typeError('Supplier tidak valid!'))
|
||||
.min(1, 'Minimal harus ada 1 supplier!')
|
||||
suppliers: Yup.array()
|
||||
.of(
|
||||
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!'),
|
||||
|
||||
flags: Yup.array()
|
||||
|
||||
@@ -40,6 +40,9 @@ import {
|
||||
import { cn } from '@/lib/helper';
|
||||
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
|
||||
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 {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -100,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
selling_price: initialValues?.selling_price ?? '',
|
||||
tax: initialValues?.tax ?? '',
|
||||
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 ?? [],
|
||||
}),
|
||||
[initialValues]
|
||||
@@ -119,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
uom_id: values.uom_id,
|
||||
product_category_id: values.product_category_id,
|
||||
product_price: parseInt(values.product_price.toString()) || 0,
|
||||
selling_price: parseInt(values.selling_price.toString()) || 0,
|
||||
tax: parseInt(values.tax.toString()) || 0,
|
||||
expiry_period: parseInt(values.expiry_period.toString()) || 0,
|
||||
supplier_ids: values.supplier_ids.filter(
|
||||
(id): id is number => typeof id === 'number'
|
||||
),
|
||||
selling_price: values.selling_price
|
||||
? parseInt(values.selling_price.toString()) || 0
|
||||
: undefined,
|
||||
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
|
||||
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'),
|
||||
};
|
||||
switch (type) {
|
||||
@@ -145,6 +161,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
setInputValue: setUomSelectInputValue,
|
||||
options: uomOptions,
|
||||
isLoadingOptions: isLoadingUoms,
|
||||
loadMore: loadMoreUoms,
|
||||
} = useSelect(UomApi.basePath, 'id', 'name');
|
||||
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('uom', true);
|
||||
@@ -158,6 +175,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
setInputValue: setCategorySelectInputValue,
|
||||
options: categoryOptions,
|
||||
isLoadingOptions: isLoadingCategories,
|
||||
loadMore: loadMoreCategories,
|
||||
} = useSelect(ProductCategoryApi.basePath, 'id', 'name');
|
||||
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('product_category', true);
|
||||
@@ -167,24 +185,38 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
};
|
||||
|
||||
// Supplier (multi select) - using SWR to filter by category
|
||||
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
|
||||
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
|
||||
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
|
||||
suppliersUrl,
|
||||
SupplierApi.getAllFetcher
|
||||
);
|
||||
const supplierOptions = isResponseSuccess(suppliers)
|
||||
? suppliers?.data
|
||||
.filter((sup) => sup.category === 'SAPRONAK')
|
||||
.map((sup) => ({ value: sup.id, label: sup.name }))
|
||||
: [];
|
||||
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const arr = Array.isArray(val) ? val : val ? [val] : [];
|
||||
formik.setFieldTouched('supplier_ids', true);
|
||||
formik.setFieldValue(
|
||||
'supplier_ids',
|
||||
arr.map((v) => (v as OptionType).value)
|
||||
);
|
||||
const {
|
||||
setInputValue: setSupplierSelectInputValue,
|
||||
options: supplierOptions,
|
||||
isLoadingOptions: isLoadingSuppliers,
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
|
||||
category: 'SAPRONAK',
|
||||
});
|
||||
|
||||
const filteredSupplierOptions = useMemo(() => {
|
||||
return supplierOptions.filter((opt) => {
|
||||
return !formik.values.suppliers.some(
|
||||
(s) => s.supplier?.value === opt.value
|
||||
);
|
||||
});
|
||||
}, [supplierOptions, formik.values.suppliers]);
|
||||
|
||||
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 = () => {
|
||||
@@ -200,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
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(() => {
|
||||
formikSetValues(formikInitialValues);
|
||||
}, [formikSetValues, formikInitialValues]);
|
||||
@@ -270,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='SKU'
|
||||
name='sku'
|
||||
placeholder='Masukkan SKU...'
|
||||
@@ -291,6 +335,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
onChange={uomChangeHandler}
|
||||
options={uomOptions}
|
||||
onInputChange={setUomSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreUoms}
|
||||
isLoading={isLoadingUoms}
|
||||
isError={
|
||||
(formik.touched.uom || formik.touched.uom_id) &&
|
||||
@@ -308,6 +353,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
onChange={categoryChangeHandler}
|
||||
options={categoryOptions}
|
||||
onInputChange={setCategorySelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreCategories}
|
||||
isLoading={isLoadingCategories}
|
||||
isError={
|
||||
(formik.touched.product_category ||
|
||||
@@ -341,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga Jual'
|
||||
name='selling_price'
|
||||
placeholder='Masukkan harga jual...'
|
||||
@@ -363,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 gap-4'>
|
||||
<NumberInput
|
||||
required
|
||||
label='Pajak (%)'
|
||||
name='tax'
|
||||
placeholder='Masukkan pajak...'
|
||||
@@ -380,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
<NumberInput
|
||||
required
|
||||
label='Periode Kadaluarsa (hari)'
|
||||
name='expiry_period'
|
||||
placeholder='Masukkan periode kadaluarsa...'
|
||||
@@ -400,27 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
readOnly={type === 'detail'}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid sm:grid-cols-2 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
|
||||
/>
|
||||
<div className='grid sm:grid-cols-1 gap-4'>
|
||||
<SelectInput
|
||||
required
|
||||
label='Flags'
|
||||
@@ -443,6 +466,129 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
isClearable
|
||||
/>
|
||||
</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 className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||
{type !== 'add' && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ProductionStandard } from '@/types/api/master-data/production-standard'
|
||||
import { Icon } from '@iconify/react';
|
||||
import useSWR from 'swr';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
import { useModal } from '@/components/Modal';
|
||||
@@ -94,9 +94,16 @@ const ProductionStandardTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await ProductionStandardApi.delete(
|
||||
const deleteResponse = await ProductionStandardApi.delete(
|
||||
selectedProductionStandard?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProductionStandards();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
+15
-19
@@ -2,34 +2,30 @@ import * as Yup from 'yup';
|
||||
|
||||
// Schema for LAYING category (production_standard_details is required)
|
||||
const LayingRepeaterFormSchema = Yup.object({
|
||||
week: Yup.number().required('Minggu wajib diisi!'),
|
||||
week: Yup.number().required('Wajib diisi!'),
|
||||
production_standard_uniformity_details: Yup.object({
|
||||
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'),
|
||||
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'),
|
||||
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'),
|
||||
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'),
|
||||
target_mean_bw: Yup.number().required('Wajib diisi!'),
|
||||
max_depletion: Yup.number().required('Wajib diisi!'),
|
||||
min_uniformity: Yup.number().required('Wajib diisi!'),
|
||||
feed_intake: Yup.number().required('Wajib diisi!'),
|
||||
}),
|
||||
production_standard_details: Yup.object({
|
||||
target_hen_day_production: Yup.number().required(
|
||||
'Produksi telur per hari wajib diisi!'
|
||||
),
|
||||
target_hen_house_production: Yup.number().required(
|
||||
'Produksi telur per kandang wajib diisi!'
|
||||
),
|
||||
target_egg_weight: Yup.number().required('Berat telur wajib diisi!'),
|
||||
target_egg_mass: Yup.number().required('Massa telur wajib diisi!'),
|
||||
standard_fcr: Yup.number().required('FCR wajib diisi!'),
|
||||
target_hen_day_production: Yup.number().required('Wajib diisi!'),
|
||||
target_hen_house_production: Yup.number().required('Wajib diisi!'),
|
||||
target_egg_weight: Yup.number().required('Wajib diisi!'),
|
||||
target_egg_mass: Yup.number().required('Wajib diisi!'),
|
||||
standard_fcr: Yup.number().required('Wajib diisi!'),
|
||||
}).required(),
|
||||
});
|
||||
|
||||
// Schema for GROWING category (production_standard_details is optional)
|
||||
const GrowingRepeaterFormSchema = Yup.object({
|
||||
week: Yup.number().required('Minggu wajib diisi!'),
|
||||
week: Yup.number().required('Wajib diisi!'),
|
||||
production_standard_uniformity_details: Yup.object({
|
||||
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'),
|
||||
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'),
|
||||
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'),
|
||||
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'),
|
||||
target_mean_bw: Yup.number().required('Wajib diisi!'),
|
||||
max_depletion: Yup.number().required('Wajib diisi!'),
|
||||
min_uniformity: Yup.number().required('Wajib diisi!'),
|
||||
feed_intake: Yup.number().required('Wajib diisi!'),
|
||||
}),
|
||||
production_standard_details: Yup.object({
|
||||
target_hen_day_production: Yup.number().optional(),
|
||||
|
||||
+86
-45
@@ -344,7 +344,7 @@ const ProductionStandardForm = ({
|
||||
const columns = useMemo<ColumnDef<TableRowsType>[]>(() => {
|
||||
const baseColumns: ColumnDef<TableRowsType>[] = [
|
||||
{
|
||||
header: 'Minggu',
|
||||
header: 'Week',
|
||||
accessorKey: 'week',
|
||||
enableSorting: false,
|
||||
},
|
||||
@@ -358,30 +358,40 @@ const ProductionStandardForm = ({
|
||||
header: 'Hen Day',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_details?.target_hen_day_production,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_details?.target_hen_day_production}%`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Hen House',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_details?.target_hen_house_production,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_details?.target_hen_house_production} pc`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Egg Weight',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_details?.target_egg_weight,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_details?.target_egg_weight} g`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Egg Mass',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_details?.target_egg_mass,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_details?.target_egg_mass} g`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'FCR',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_details?.standard_fcr,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_details?.standard_fcr} g`,
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
@@ -393,24 +403,32 @@ const ProductionStandardForm = ({
|
||||
header: 'Mean BW',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_uniformity_details?.target_mean_bw,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_uniformity_details?.target_mean_bw} g`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Max Depletion',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_uniformity_details?.max_depletion,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_uniformity_details?.max_depletion}%`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Min Uniformity',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_uniformity_details?.min_uniformity,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_uniformity_details?.min_uniformity}%`,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Feed Intake',
|
||||
accessorFn: (row) =>
|
||||
row.production_standard_uniformity_details?.feed_intake,
|
||||
cell: ({ row }) =>
|
||||
`${row.original.production_standard_uniformity_details?.feed_intake} g`,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
@@ -728,7 +746,52 @@ const ProductionStandardForm = ({
|
||||
};
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
|
||||
formik,
|
||||
{
|
||||
onBeforeSubmit: (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// For GROWING category, clear production_standard_details errors and set default values
|
||||
if (formik.values.project_category === 'GROWING') {
|
||||
// Set default values for production_standard_details
|
||||
formik.values.details?.forEach((detail) => {
|
||||
detail.production_standard_details = {
|
||||
target_hen_day_production: 0,
|
||||
target_hen_house_production: 0,
|
||||
target_egg_weight: 0,
|
||||
target_egg_mass: 0,
|
||||
standard_fcr: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Clear any errors related to production_standard_details
|
||||
const currentErrors = { ...formik.errors };
|
||||
if (currentErrors.details && Array.isArray(currentErrors.details)) {
|
||||
const cleanedDetails = currentErrors.details
|
||||
.map((detailError) => {
|
||||
if (detailError && typeof detailError === 'object') {
|
||||
const { production_standard_details, ...rest } = detailError;
|
||||
return Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
return detailError;
|
||||
})
|
||||
.filter(
|
||||
(error): error is Exclude<typeof error, undefined> =>
|
||||
error !== undefined
|
||||
);
|
||||
|
||||
currentErrors.details = (
|
||||
cleanedDetails.length > 0 ? cleanedDetails : undefined
|
||||
) as typeof currentErrors.details;
|
||||
}
|
||||
formik.setErrors(currentErrors);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -821,19 +884,20 @@ const ProductionStandardForm = ({
|
||||
key={`row-${row.index}`}
|
||||
className='sticky bottom-0 bg-base-100 shadow-lg'
|
||||
>
|
||||
<td colSpan={colSpan} className='p-6'>
|
||||
<td colSpan={colSpan} className='p-2'>
|
||||
<form
|
||||
className='h-full w-full flex flex-col justify-end'
|
||||
onSubmit={repeaterFormik.handleSubmit}
|
||||
onReset={repeaterFormik.handleReset}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 items-start',
|
||||
formik.values.project_category === 'LAYING'
|
||||
? 'grid-cols-10'
|
||||
: 'grid-cols-5'
|
||||
)}
|
||||
className='grid gap-2 items-start w-full'
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
formik.values.project_category === 'LAYING'
|
||||
? 'repeat(10, minmax(auto, 1fr)) minmax(auto, auto)'
|
||||
: 'repeat(4, minmax(auto, 1fr)) minmax(auto, auto)',
|
||||
}}
|
||||
>
|
||||
<NumberInput
|
||||
name='week'
|
||||
@@ -862,7 +926,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={<Icon icon='mdi:percent' />}
|
||||
bottomLabel='Persen (%)'
|
||||
errorMessage={getProductionDetailsError(
|
||||
repeaterFormik.errors
|
||||
.production_standard_details,
|
||||
@@ -894,11 +958,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
Butir
|
||||
</div>
|
||||
}
|
||||
bottomLabel='Butir (pc)'
|
||||
errorMessage={getProductionDetailsError(
|
||||
repeaterFormik.errors
|
||||
.production_standard_details,
|
||||
@@ -930,11 +990,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
gr
|
||||
</div>
|
||||
}
|
||||
bottomLabel='Gram (g)'
|
||||
errorMessage={getProductionDetailsError(
|
||||
repeaterFormik.errors
|
||||
.production_standard_details,
|
||||
@@ -959,17 +1015,13 @@ const ProductionStandardForm = ({
|
||||
name='production_standard_details.target_egg_mass'
|
||||
label='Egg Mass'
|
||||
placeholder='1'
|
||||
bottomLabel='Gram (g)'
|
||||
value={
|
||||
repeaterFormik.values
|
||||
.production_standard_details?.target_egg_mass
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
gr
|
||||
</div>
|
||||
}
|
||||
errorMessage={getProductionDetailsError(
|
||||
repeaterFormik.errors
|
||||
.production_standard_details,
|
||||
@@ -1000,11 +1052,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
gr
|
||||
</div>
|
||||
}
|
||||
bottomLabel='Gram (g)'
|
||||
errorMessage={getProductionDetailsError(
|
||||
repeaterFormik.errors
|
||||
.production_standard_details,
|
||||
@@ -1038,11 +1086,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
gr
|
||||
</div>
|
||||
}
|
||||
bottomLabel='Gram (g)'
|
||||
errorMessage={
|
||||
repeaterFormik.errors
|
||||
.production_standard_uniformity_details
|
||||
@@ -1072,7 +1116,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={<Icon icon='mdi:percent' />}
|
||||
bottomLabel='Persen (%)'
|
||||
errorMessage={
|
||||
repeaterFormik.errors
|
||||
.production_standard_uniformity_details
|
||||
@@ -1102,7 +1146,7 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={<Icon icon='mdi:percent' />}
|
||||
bottomLabel='Persen (%)'
|
||||
errorMessage={
|
||||
repeaterFormik.errors
|
||||
.production_standard_uniformity_details
|
||||
@@ -1132,11 +1176,8 @@ const ProductionStandardForm = ({
|
||||
}
|
||||
onChange={repeaterFormik.handleChange}
|
||||
onBlur={repeaterFormik.handleBlur}
|
||||
endAdornment={
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
gr/ekor
|
||||
</div>
|
||||
}
|
||||
bottomLabel='Gram/Ekor (g)'
|
||||
endAdornment
|
||||
errorMessage={
|
||||
repeaterFormik.errors
|
||||
.production_standard_uniformity_details
|
||||
@@ -1162,7 +1203,7 @@ const ProductionStandardForm = ({
|
||||
type='button'
|
||||
color='error'
|
||||
variant='outline'
|
||||
className='min-w-24'
|
||||
className='min-w-xs'
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
<Icon icon='mdi:close' /> Batal
|
||||
@@ -1178,7 +1219,7 @@ const ProductionStandardForm = ({
|
||||
<Button
|
||||
type='submit'
|
||||
color={editMode ? 'warning' : 'success'}
|
||||
className='min-w-24'
|
||||
className='min-w-xs'
|
||||
disabled={
|
||||
isAddingRow ||
|
||||
formik.values.project_category === ''
|
||||
@@ -1195,7 +1236,7 @@ const ProductionStandardForm = ({
|
||||
variant='outline'
|
||||
color='primary'
|
||||
onClick={toggleTableHeight}
|
||||
className='absolute bottom-6 right-6'
|
||||
className='absolute bottom-2 right-2'
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
|
||||
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
@@ -205,7 +205,16 @@ const SuppliersTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await SupplierApi.delete(selectedSupplier?.id as number);
|
||||
const deleteResponse = await SupplierApi.delete(
|
||||
selectedSupplier?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshSuppliers();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Uom } from '@/types/api/master-data/uom';
|
||||
import { UomApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -164,7 +164,14 @@ const UomsTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await UomApi.delete(selectedUom?.id as number);
|
||||
const deleteResponse = await UomApi.delete(selectedUom?.id as number);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshUoms();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { WarehouseApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
|
||||
@@ -220,7 +220,16 @@ const WarehousesTable = () => {
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await WarehouseApi.delete(selectedWarehouse?.id as number);
|
||||
const deleteResponse = await WarehouseApi.delete(
|
||||
selectedWarehouse?.id as number
|
||||
);
|
||||
|
||||
if (isResponseError(deleteResponse)) {
|
||||
toast.error(deleteResponse.message);
|
||||
setIsDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshWarehouses();
|
||||
|
||||
deleteModal.closeModal();
|
||||
|
||||
@@ -9,7 +9,10 @@ import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
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 ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
@@ -35,6 +38,8 @@ import { cn } from '@/lib/helper';
|
||||
import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
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 {
|
||||
type?: 'add' | 'edit' | 'detail';
|
||||
@@ -221,61 +226,28 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
|
||||
const { setValues: formikSetValues } = formik;
|
||||
|
||||
// Area
|
||||
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
|
||||
|
||||
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({
|
||||
search: areaSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: areas, isLoading: isLoadingAreas } = useSWR(
|
||||
areasUrl,
|
||||
AreaApi.getAllFetcher
|
||||
);
|
||||
|
||||
const areaOptions = isResponseSuccess(areas)
|
||||
? areas?.data.map((area) => ({
|
||||
value: area.id,
|
||||
label: area.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setAreaSelectInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
|
||||
|
||||
// Location
|
||||
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
|
||||
|
||||
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({
|
||||
search: locationSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: locations, isLoading: isLoadingLocations } = useSWR(
|
||||
locationsUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const locationOptions = isResponseSuccess(locations)
|
||||
? locations?.data.map((location) => ({
|
||||
value: location.id,
|
||||
label: location.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setLocationSelectInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
// Kandang
|
||||
const [kandangSelectInputValue, setKandangSelectInputValue] = useState('');
|
||||
|
||||
const kandangsUrl = `${KandangApi.basePath}?${new URLSearchParams({
|
||||
search: kandangSelectInputValue ?? '',
|
||||
}).toString()}`;
|
||||
|
||||
const { data: kandangs, isLoading: isLoadingKandangs } = useSWR(
|
||||
kandangsUrl,
|
||||
KandangApi.getAllFetcher
|
||||
);
|
||||
|
||||
const kandangOptions = isResponseSuccess(kandangs)
|
||||
? kandangs?.data.map((kandang) => ({
|
||||
value: kandang.id,
|
||||
label: kandang.name,
|
||||
}))
|
||||
: [];
|
||||
const {
|
||||
setInputValue: setKandangSelectInputValue,
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangOptions,
|
||||
loadMore: loadMoreKandangs,
|
||||
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
|
||||
|
||||
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldTouched('type', true);
|
||||
@@ -358,7 +330,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
|
||||
required
|
||||
label='Nama'
|
||||
name='name'
|
||||
placeholder='Masukkan nama lokasi'
|
||||
placeholder='Masukkan nama warehouse'
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
@@ -393,7 +365,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
|
||||
onChange={areaChangeHandler}
|
||||
options={areaOptions}
|
||||
onInputChange={setAreaSelectInputValue}
|
||||
isLoading={isLoadingAreas}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
|
||||
errorMessage={formik.errors.areaId as string}
|
||||
isDisabled={type === 'detail'}
|
||||
@@ -409,7 +382,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
|
||||
onChange={locationChangeHandler}
|
||||
options={locationOptions}
|
||||
onInputChange={setLocationSelectInputValue}
|
||||
isLoading={isLoadingLocations}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isError={
|
||||
formik.touched.locationId && Boolean(formik.errors.locationId)
|
||||
}
|
||||
@@ -427,7 +401,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
|
||||
onChange={kandangChangeHandler}
|
||||
options={kandangOptions}
|
||||
onInputChange={setKandangSelectInputValue}
|
||||
isLoading={isLoadingKandangs}
|
||||
onMenuScrollToBottom={loadMoreKandangs}
|
||||
isLoading={isLoadingKandangOptions}
|
||||
isError={
|
||||
formik.touched.kandangId && Boolean(formik.errors.kandangId)
|
||||
}
|
||||
|
||||
@@ -75,12 +75,12 @@ const ChickinFormKandang = ({
|
||||
<div className='flex flex-row gap-2'>
|
||||
<Badge
|
||||
variant='soft'
|
||||
color='success'
|
||||
color='primary'
|
||||
className={{
|
||||
badge: 'rounded-lg px-2',
|
||||
}}
|
||||
>
|
||||
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '}
|
||||
<Icon icon='mdi:circle' width={12} height={12} color='primary' />{' '}
|
||||
Aktif
|
||||
</Badge>
|
||||
<div className='divider divider-horizontal p-0 m-0'></div>
|
||||
|
||||
@@ -5,14 +5,17 @@ import Button from '@/components/Button';
|
||||
import FloatingActionsButton from '@/components/FloatingActionsButton';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import SelectInput, { OptionType } from '@/components/input/SelectInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import Table from '@/components/Table';
|
||||
import { ROWS_OPTIONS } from '@/config/constant';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import { cn, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production/project-flock';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
@@ -59,9 +62,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
const selectedRowIds = Object.keys(rowSelection)
|
||||
.filter((id) => rowSelection[id])
|
||||
.map((id) => parseInt(id));
|
||||
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
|
||||
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
|
||||
const [kandangSelectInputValue, setKandangSelectInputValue] = useState('');
|
||||
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
||||
null
|
||||
@@ -90,55 +90,25 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
{ revalidateOnMount: true }
|
||||
);
|
||||
|
||||
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({
|
||||
search: areaSelectInputValue,
|
||||
limit: '100',
|
||||
}).toString()}`;
|
||||
const { data: areas, isLoading: isLoadingAreas } = useSWR(
|
||||
areaUrl,
|
||||
AreaApi.getAllFetcher
|
||||
);
|
||||
|
||||
const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({
|
||||
search: locationSelectInputValue,
|
||||
area_id: selectedArea != null ? selectedArea.value.toString() : '',
|
||||
limit: '100',
|
||||
}).toString()}`;
|
||||
const { data: locations, isLoading: isLoadingLocations } = useSWR(
|
||||
locationUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
|
||||
search: kandangSelectInputValue,
|
||||
location_id:
|
||||
selectedLocation != null ? selectedLocation.value.toString() : '',
|
||||
limit: '100',
|
||||
}).toString()}`;
|
||||
const { data: kandangs, isLoading: isLoadingKandang } = useSWR(
|
||||
kandangUrl,
|
||||
KandangApi.getAllFetcher
|
||||
);
|
||||
|
||||
// ===== Data to Options Mapping ======
|
||||
const optionsArea = isResponseSuccess(areas)
|
||||
? areas?.data.map((area) => ({
|
||||
value: area.id,
|
||||
label: area.name,
|
||||
}))
|
||||
: [];
|
||||
const optionsKandang = isResponseSuccess(kandangs)
|
||||
? kandangs?.data.map((kandang) => ({
|
||||
value: kandang.id,
|
||||
label: kandang.name,
|
||||
}))
|
||||
: [];
|
||||
const optionsLocation = isResponseSuccess(locations)
|
||||
? locations?.data.map((location) => ({
|
||||
value: location.id,
|
||||
label: location.name,
|
||||
}))
|
||||
: [];
|
||||
// ===== Fetch Data Select =====
|
||||
const {
|
||||
options: optionsArea,
|
||||
isLoadingOptions: isLoadingArea,
|
||||
setInputValue: setAreaSelectInputValue,
|
||||
loadMore: loadMoreArea,
|
||||
} = useSelect(AreaApi.basePath, 'id', 'name');
|
||||
const {
|
||||
options: optionsLocation,
|
||||
isLoadingOptions: isLoadingLocation,
|
||||
setInputValue: setLocationSelectInputValue,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name');
|
||||
const {
|
||||
options: optionsKandang,
|
||||
isLoadingOptions: isLoadingKandang,
|
||||
setInputValue: setKandangSelectInputValue,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name');
|
||||
|
||||
// ====== HANDLER ======
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -284,7 +254,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
header: 'Status',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.approval;
|
||||
|
||||
const isRejected = approval?.action == 'REJECTED';
|
||||
const isApproved = approval?.action == 'APPROVED';
|
||||
return (
|
||||
<Badge
|
||||
variant='soft'
|
||||
@@ -292,11 +263,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
badge: 'rounded-lg px-2 w-full flex flex-row justify-start',
|
||||
}}
|
||||
color={
|
||||
approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'success'
|
||||
: 'error'
|
||||
isRejected
|
||||
? 'error'
|
||||
: isApproved
|
||||
? approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'primary'
|
||||
: approval?.step_number == 3
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
@@ -307,11 +284,15 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: approval?.step_number == 2
|
||||
? 'success'
|
||||
: 'error'
|
||||
? 'primary'
|
||||
: approval?.step_number == 3
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
/>
|
||||
{approval?.step_name}
|
||||
{isRejected
|
||||
? 'Ditolak'
|
||||
: formatTitleCase(approval?.step_name || '')}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
@@ -385,7 +366,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
<SelectInput
|
||||
label='Area'
|
||||
options={optionsArea}
|
||||
isLoading={isLoadingAreas}
|
||||
isLoading={isLoadingArea}
|
||||
value={selectedArea}
|
||||
onChange={(val) => {
|
||||
setSelectedArea(val as OptionType);
|
||||
@@ -395,12 +376,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
}}
|
||||
onInputChange={setAreaSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreArea}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
options={optionsLocation}
|
||||
isLoading={isLoadingLocations}
|
||||
isLoading={isLoadingLocation}
|
||||
value={selectedLocation}
|
||||
onChange={(val) => {
|
||||
setSelectedLocation(val as OptionType);
|
||||
@@ -410,6 +392,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
}}
|
||||
onInputChange={setLocationSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocation}
|
||||
isClearable
|
||||
/>
|
||||
<SelectInput
|
||||
@@ -425,6 +408,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
}}
|
||||
onInputChange={setKandangSelectInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandang}
|
||||
isClearable
|
||||
/>
|
||||
<DebouncedTextInput
|
||||
|
||||
@@ -156,9 +156,9 @@ const ProjectFlockDetail = ({
|
||||
projectFlock.approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: projectFlock.approval?.step_number == 2
|
||||
? 'success'
|
||||
: projectFlock.approval?.step_number >= 3
|
||||
? 'error'
|
||||
? 'primary'
|
||||
: projectFlock.approval?.step_number == 3
|
||||
? 'success'
|
||||
: undefined
|
||||
}
|
||||
className={{
|
||||
@@ -173,9 +173,9 @@ const ProjectFlockDetail = ({
|
||||
projectFlock.approval?.step_number == 1
|
||||
? 'neutral'
|
||||
: projectFlock.approval?.step_number == 2
|
||||
? 'success'
|
||||
: projectFlock.approval?.step_number >= 3
|
||||
? 'error'
|
||||
? 'primary'
|
||||
: projectFlock.approval?.step_number == 3
|
||||
? 'success'
|
||||
: undefined
|
||||
}
|
||||
/>{' '}
|
||||
@@ -273,7 +273,7 @@ const ProjectFlockDetail = ({
|
||||
<div className='flex flex-row gap-2'>
|
||||
<Badge
|
||||
variant='soft'
|
||||
color={'success'}
|
||||
color={'primary'}
|
||||
className={{
|
||||
badge: 'rounded-lg px-2',
|
||||
}}
|
||||
@@ -282,7 +282,7 @@ const ProjectFlockDetail = ({
|
||||
icon='mdi:circle'
|
||||
width={12}
|
||||
height={12}
|
||||
color={'success'}
|
||||
color={'primary'}
|
||||
/>{' '}
|
||||
Kandang Aktif ({projectFlock.kandangs?.length})
|
||||
</Badge>
|
||||
|
||||
@@ -102,41 +102,54 @@ const ProjectFlockForm = ({
|
||||
);
|
||||
|
||||
// Fetch Data
|
||||
const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } =
|
||||
useSelect(FlockApi.basePath, 'id', 'name');
|
||||
const {
|
||||
setInputValue: setInputValueFlock,
|
||||
isLoadingOptions: isLoadingFlocks,
|
||||
options: optionsFlock,
|
||||
loadMore: loadMoreFlock,
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
|
||||
project_category: selectedCategory,
|
||||
});
|
||||
|
||||
const { options: optionsArea, isLoadingOptions: isLoadingAreas } = useSelect(
|
||||
AreaApi.basePath,
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
const {
|
||||
setInputValue: setInputValueArea,
|
||||
options: optionsArea,
|
||||
isLoadingOptions: isLoadingAreas,
|
||||
loadMore: loadMoreArea,
|
||||
} = useSelect(AreaApi.basePath, 'id', 'name');
|
||||
|
||||
const { options: optionsLocation, isLoadingOptions: isLoadingLocations } =
|
||||
useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
: ((initialValues?.area?.id ?? '') as string),
|
||||
});
|
||||
const {
|
||||
options: optionsLocation,
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
setInputValue: setInputValueLocation,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
: ((initialValues?.area?.id ?? '') as string),
|
||||
});
|
||||
|
||||
const { options: optionsFcr, isLoadingOptions: isLoadingFcrs } = useSelect(
|
||||
FcrApi.basePath,
|
||||
'id',
|
||||
'name'
|
||||
);
|
||||
const {
|
||||
options: optionsFcr,
|
||||
isLoadingOptions: isLoadingFcrs,
|
||||
setInputValue: setInputValueFcr,
|
||||
loadMore: loadMoreFcr,
|
||||
} = useSelect(FcrApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
options: optionsProductionStandards,
|
||||
isLoadingOptions: isLoadingProductionStandards,
|
||||
setInputValue: setInputValueProductionStandard,
|
||||
loadMore: loadMoreProductionStandard,
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
|
||||
search: '',
|
||||
project_category: selectedCategory,
|
||||
});
|
||||
|
||||
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
|
||||
search: '',
|
||||
location_id: selectedLocation == '' ? '0' : selectedLocation,
|
||||
limit: 'limit',
|
||||
limit: '500',
|
||||
}).toString()}`;
|
||||
const {
|
||||
data: kandang,
|
||||
@@ -153,6 +166,8 @@ const ProjectFlockForm = ({
|
||||
options: optionsNonstock,
|
||||
rawData: nonstocks,
|
||||
isLoadingOptions: isLoadingNonstocks,
|
||||
setInputValue: setInputValueNonstock,
|
||||
loadMore: loadMoreNonstock,
|
||||
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -542,15 +557,12 @@ const ProjectFlockForm = ({
|
||||
};
|
||||
|
||||
const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => {
|
||||
console.log(`nonstock_id: ${nonstock_id}, index: ${index}`);
|
||||
if (!nonstock_id) {
|
||||
const updatedBudgets = formik.values.project_budgets
|
||||
.map((budget, i) => {
|
||||
if (i == index) {
|
||||
console.log(`buget: ${null}, index: ${index}, i: ${i}`);
|
||||
return null;
|
||||
} else {
|
||||
console.log(`buget: ${budget}, index: ${index}, i: ${i}`);
|
||||
return budget;
|
||||
}
|
||||
})
|
||||
@@ -722,6 +734,8 @@ const ProjectFlockForm = ({
|
||||
formik.touched.area_id && Boolean(formik.errors.area_id)
|
||||
}
|
||||
errorMessage={formik.errors.area_id as string}
|
||||
onInputChange={setInputValueArea}
|
||||
onMenuScrollToBottom={loadMoreArea}
|
||||
isClearable
|
||||
isDisabled={formType != 'add'}
|
||||
/>
|
||||
@@ -740,6 +754,8 @@ const ProjectFlockForm = ({
|
||||
formik.touched.location_id &&
|
||||
Boolean(formik.errors.location_id)
|
||||
}
|
||||
onInputChange={setInputValueLocation}
|
||||
onMenuScrollToBottom={loadMoreLocation}
|
||||
errorMessage={formik.errors.location_id as string}
|
||||
isClearable
|
||||
isDisabled={formType != 'add' || disabledLocation}
|
||||
@@ -766,6 +782,8 @@ const ProjectFlockForm = ({
|
||||
);
|
||||
}}
|
||||
options={optionsFlock}
|
||||
onInputChange={setInputValueFlock}
|
||||
onMenuScrollToBottom={loadMoreFlock}
|
||||
isLoading={isLoadingFlocks}
|
||||
isError={
|
||||
formik.touched.flock_name && Boolean(formik.errors.flock_name)
|
||||
@@ -781,6 +799,8 @@ const ProjectFlockForm = ({
|
||||
onChange={(val) => {
|
||||
optionChangeHandler(val, 'fcr');
|
||||
}}
|
||||
onInputChange={setInputValueFcr}
|
||||
onMenuScrollToBottom={loadMoreFcr}
|
||||
options={optionsFcr}
|
||||
isLoading={isLoadingFcrs}
|
||||
isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)}
|
||||
@@ -808,6 +828,8 @@ const ProjectFlockForm = ({
|
||||
onChange={(val) => {
|
||||
optionChangeHandler(val, 'production_standard');
|
||||
}}
|
||||
onInputChange={setInputValueProductionStandard}
|
||||
onMenuScrollToBottom={loadMoreProductionStandard}
|
||||
options={optionsProductionStandards}
|
||||
isLoading={isLoadingProductionStandards}
|
||||
isError={
|
||||
@@ -892,6 +914,8 @@ const ProjectFlockForm = ({
|
||||
isLoading={isLoadingNonstocks}
|
||||
placeholder='Pilih barang non stock'
|
||||
value={formik.values.project_budgets[index].nonstock}
|
||||
onInputChange={setInputValueNonstock}
|
||||
onMenuScrollToBottom={loadMoreNonstock}
|
||||
onChange={(val) => {
|
||||
const updatedBudgets = [
|
||||
...formik.values.project_budgets,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RefObject } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { SortingState, CellContext } from '@tanstack/react-table';
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import Modal from '@/components/Modal';
|
||||
@@ -656,34 +656,57 @@ const RecordingTable = () => {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const recording = row.original;
|
||||
const isDisabled = isRecordingApproved(recording);
|
||||
|
||||
const handleToggleSelection = (e: unknown) => {
|
||||
if (!isDisabled) {
|
||||
row.getToggleSelectedHandler()(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn({ 'opacity-50': isDisabled })}>
|
||||
<CheckboxInput
|
||||
name='row'
|
||||
checked={row.getIsSelected()}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
onChange={handleToggleSelection}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '#',
|
||||
header: 'No',
|
||||
cell: (props) =>
|
||||
tableFilterState.pageSize * (tableFilterState.page - 1) +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
header: 'Nama Project',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location?.name || '-',
|
||||
},
|
||||
{
|
||||
header: 'Flock',
|
||||
cell: (props) =>
|
||||
`Project ${props.row.original.project_flock_kandang_id}`,
|
||||
props.row.original.project_flock?.flock_name || '-',
|
||||
},
|
||||
{
|
||||
header: 'Kandang',
|
||||
cell: (props) => props.row.original.kandang?.name || '-',
|
||||
},
|
||||
{
|
||||
header: 'Periode',
|
||||
cell: (props) => props.row.original.project_flock?.period || '-',
|
||||
},
|
||||
{
|
||||
header: 'Kategori',
|
||||
cell: (props) => {
|
||||
const category = props.row.original.project_flock_category;
|
||||
const category =
|
||||
props.row.original.project_flock?.project_flock_category;
|
||||
if (!category) return '-';
|
||||
const color = category === 'LAYING' ? 'info' : 'warning';
|
||||
return (
|
||||
@@ -695,18 +718,280 @@ const RecordingTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Umur (hari)',
|
||||
cell: (props) => props.row.original.day,
|
||||
cell: (props) => {
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
{props.row.original.day} (Minggu ke-
|
||||
{props.row.original.project_flock.production_standart.week})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'record_date',
|
||||
header: 'Waktu Recording',
|
||||
cell: (props) =>
|
||||
formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'),
|
||||
},
|
||||
{
|
||||
header: 'Populasi Awal',
|
||||
header: 'Populasi Akhir',
|
||||
cell: (props) =>
|
||||
props.row.original.total_chick_qty?.toLocaleString() || '-',
|
||||
props.row.original.project_flock?.total_chick_qty != null
|
||||
? formatNumber(props.row.original.project_flock.total_chick_qty)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
id: 'fcr',
|
||||
header: 'FCR',
|
||||
columns: [
|
||||
{
|
||||
id: 'fcr_actual',
|
||||
header: 'Actual',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.fcr_value;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fcr_standard',
|
||||
header: 'Standard',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.project_flock?.fcr?.fcr_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feed_intake',
|
||||
header: 'Feed Intake (KG)',
|
||||
columns: [
|
||||
{
|
||||
id: 'feed_intake_actual',
|
||||
header: 'Actual',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.feed_intake;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'feed_intake_standard',
|
||||
header: 'Standard',
|
||||
cell: (props) => {
|
||||
const value =
|
||||
props.row.original.project_flock?.production_standart
|
||||
?.feed_intake_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mortality',
|
||||
header: 'Mortality',
|
||||
columns: [
|
||||
{
|
||||
id: 'cum_depletion_rate_actual',
|
||||
header: 'Cum Depletion Rate',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.cum_depletion_rate;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'max_depletion_std',
|
||||
header: 'Max Depletion Std',
|
||||
cell: (props) => {
|
||||
const value =
|
||||
props.row.original.project_flock?.production_standart
|
||||
?.max_depletion_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'total_depletion',
|
||||
header: 'Total Depletion',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.total_depletion_qty;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'egg_production',
|
||||
header: 'Egg Production',
|
||||
columns: [
|
||||
{
|
||||
id: 'egg_mass_actual',
|
||||
header: 'Egg Mass Actual',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.egg_mass;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'egg_mass_standard',
|
||||
header: 'Egg Mass Standar',
|
||||
cell: (props) => {
|
||||
const value =
|
||||
props.row.original.project_flock?.production_standart
|
||||
?.egg_mass_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'egg_weight_actual',
|
||||
header: 'Egg Weight Actual',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.egg_weight;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'egg_weight_standard',
|
||||
header: 'Egg Weight Standar',
|
||||
cell: (props) => {
|
||||
const value =
|
||||
props.row.original.project_flock?.production_standart
|
||||
?.egg_weight_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? formatNumber(value)
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hen_performance',
|
||||
header: 'Hen Performance',
|
||||
columns: [
|
||||
{
|
||||
id: 'hen_day_actual',
|
||||
header: 'Hen Day Actual',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.hen_day;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hen_day_standard',
|
||||
header: 'Hen Day Standar',
|
||||
cell: (props) => {
|
||||
const value =
|
||||
props.row.original.project_flock?.production_standart
|
||||
?.hen_day_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hen_house_actual',
|
||||
header: 'Hen House Actual',
|
||||
cell: (props) => {
|
||||
const value = props.row.original.hen_house;
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hen_house_standard',
|
||||
header: 'Hen House Standar',
|
||||
cell: (props) => {
|
||||
const value =
|
||||
props.row.original.project_flock?.production_standart
|
||||
?.hen_house_std;
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Status Approval',
|
||||
@@ -728,21 +1013,6 @@ const RecordingTable = () => {
|
||||
approvalHistoryModal.openModal();
|
||||
};
|
||||
|
||||
const getStatusText = (action: string) => {
|
||||
switch (action) {
|
||||
case 'APPROVED':
|
||||
return 'Disetujui';
|
||||
case 'REJECTED':
|
||||
return 'Ditolak';
|
||||
case 'CREATED':
|
||||
return 'Dibuat';
|
||||
case 'UPDATED':
|
||||
return 'Diperbarui';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='soft'
|
||||
@@ -753,7 +1023,7 @@ const RecordingTable = () => {
|
||||
}}
|
||||
onClick={openApprovalHistory}
|
||||
>
|
||||
{getStatusText(approval.action)}
|
||||
{approval.step_name || approval.action}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
@@ -872,14 +1142,15 @@ const RecordingTable = () => {
|
||||
'mb-20':
|
||||
isResponseSuccess(recordings) && recordings?.data?.length === 0,
|
||||
}),
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||
tableWrapperClassName: 'overflow-x-auto',
|
||||
tableClassName: 'w-full table-auto text-sm',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
headerColumnClassName:
|
||||
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||
bodyRowClassName: 'border-b border-b-gray-200',
|
||||
'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
|
||||
bodyRowClassName:
|
||||
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,22 @@ import {
|
||||
} from '@/types/api/production/recording';
|
||||
|
||||
type RecordingGrowingFormSchemaType = {
|
||||
record_date: string;
|
||||
location?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
location_id: number;
|
||||
project_flock?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
project_flock_id: number;
|
||||
kandang?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
kandang_id: number;
|
||||
project_flock_kandang: {
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -17,16 +33,16 @@ type RecordingGrowingFormSchemaType = {
|
||||
qty: number | string;
|
||||
}[];
|
||||
depletions: {
|
||||
product_warehouse_id: number;
|
||||
qty: number | string;
|
||||
product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
|
||||
eggs: {
|
||||
product_warehouse_id: number;
|
||||
qty: number | string;
|
||||
weight: number | string;
|
||||
product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
weight?: number | string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -36,14 +52,14 @@ export type StockSchema = {
|
||||
};
|
||||
|
||||
export type DepletionSchema = {
|
||||
product_warehouse_id: number;
|
||||
qty: number | string;
|
||||
product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
};
|
||||
|
||||
export type EggSchema = {
|
||||
product_warehouse_id: number;
|
||||
qty: number | string;
|
||||
weight: number | string;
|
||||
product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
weight?: number | string;
|
||||
};
|
||||
|
||||
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
|
||||
@@ -59,32 +75,51 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
|
||||
|
||||
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
.required('Produk depletions wajib diisi!')
|
||||
.min(1, 'Produk depletions wajib diisi!')
|
||||
.typeError('Produk depletions harus berupa angka!'),
|
||||
.optional()
|
||||
.typeError('Depletions harus berupa angka!'),
|
||||
qty: Yup.number()
|
||||
.required('Jumlah depletions wajib diisi!')
|
||||
.min(1, 'Jumlah depletions minimal 1!')
|
||||
.optional()
|
||||
.typeError('Jumlah depletions harus berupa angka!'),
|
||||
});
|
||||
|
||||
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
.required('Kondisi telur wajib diisi!')
|
||||
.min(1, 'Kondisi telur wajib diisi!')
|
||||
.optional()
|
||||
.typeError('Kondisi telur harus berupa angka!'),
|
||||
qty: Yup.number()
|
||||
.required('Jumlah telur wajib diisi!')
|
||||
.min(1, 'Jumlah telur tidak boleh 0!')
|
||||
.typeError('Jumlah telur harus berupa angka!'),
|
||||
weight: Yup.number()
|
||||
.required('Berat telur wajib diisi!')
|
||||
.min(1, 'Berat telur minimal 1 gram!')
|
||||
.typeError('Berat telur harus berupa angka!'),
|
||||
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
|
||||
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
|
||||
});
|
||||
|
||||
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
|
||||
Yup.object({
|
||||
record_date: Yup.string()
|
||||
.required('Tanggal recording wajib diisi!')
|
||||
.min(1, 'Tanggal recording wajib diisi!')
|
||||
.typeError('Tanggal recording wajib diisi!'),
|
||||
location: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
location_id: Yup.number()
|
||||
.min(1, 'Lokasi wajib diisi!')
|
||||
.required('Lokasi wajib diisi!')
|
||||
.typeError('Lokasi wajib diisi!'),
|
||||
project_flock: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
project_flock_id: Yup.number()
|
||||
.min(1, 'Project flock wajib diisi!')
|
||||
.required('Project flock wajib diisi!')
|
||||
.typeError('Project flock wajib diisi!'),
|
||||
kandang: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
kandang_id: Yup.number()
|
||||
.min(1, 'Kandang wajib diisi!')
|
||||
.required('Kandang wajib diisi!')
|
||||
.typeError('Kandang wajib diisi!'),
|
||||
project_flock_kandang: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
@@ -100,7 +135,7 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
|
||||
.required('Project Flock Kandang wajib diisi!')
|
||||
.test(
|
||||
'not-already-recorded',
|
||||
'Project Flock ini sudah direcord hari ini!',
|
||||
'Project Flock ini sudah direcord pada tanggal tersebut!',
|
||||
function (value) {
|
||||
const recordedProjectFlockIds = this.options.context
|
||||
?.recordedProjectFlockIds as Set<number>;
|
||||
@@ -119,18 +154,12 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
|
||||
.of(StockObjectSchema)
|
||||
.min(1, 'Minimal harus ada 1 data stok!')
|
||||
.required('Data stok wajib diisi!'),
|
||||
depletions: Yup.array()
|
||||
.of(DepletionObjectSchema)
|
||||
.min(1, 'Minimal harus ada 1 data depletions!')
|
||||
.required('Data depletions wajib diisi!'),
|
||||
depletions: Yup.array().of(DepletionObjectSchema).default([]),
|
||||
});
|
||||
|
||||
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
|
||||
RecordingGrowingFormSchema.shape({
|
||||
eggs: Yup.array()
|
||||
.of(EggObjectSchema)
|
||||
.min(1, 'Minimal harus ada 1 data telur!')
|
||||
.required('Data telur wajib diisi!'),
|
||||
eggs: Yup.array().of(EggObjectSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateRecordingGrowingFormSchema =
|
||||
@@ -179,6 +208,15 @@ type RecordingFormData = Partial<Recording> & {
|
||||
export const getRecordingGrowingFormInitialValues = (
|
||||
initialValues?: RecordingFormData
|
||||
): RecordingGrowingFormValues => ({
|
||||
record_date: initialValues?.record_datetime
|
||||
? new Date(initialValues.record_datetime).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
location: null,
|
||||
location_id: 0,
|
||||
project_flock: null,
|
||||
project_flock_id: 0,
|
||||
kandang: null,
|
||||
kandang_id: 0,
|
||||
project_flock_kandang: initialValues?.project_flock_kandang_id
|
||||
? {
|
||||
value: initialValues.project_flock_kandang_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -179,12 +179,16 @@ const TransferToLayingsTable = () => {
|
||||
setInputValue: setFlockSourceInputValue,
|
||||
options: flockSourceOptions,
|
||||
isLoadingOptions: isLoadingFlockSourceOptions,
|
||||
loadMore: loadMoreFlockSource,
|
||||
hasMore: hasMoreFlockSource,
|
||||
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setFlockDestinationInputValue,
|
||||
options: flockDestinationOptions,
|
||||
isLoadingOptions: isLoadingFlockDestinationOptions,
|
||||
loadMore: loadMoreFlockDestination,
|
||||
hasMore: hasMoreFlockDestination,
|
||||
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
|
||||
|
||||
// Flocks value
|
||||
@@ -595,6 +599,7 @@ const TransferToLayingsTable = () => {
|
||||
value={selectedFlockSource}
|
||||
onChange={flockSourceChangeHandler}
|
||||
onInputChange={setFlockSourceInputValue}
|
||||
onMenuScrollToBottom={loadMoreFlockSource}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-3',
|
||||
@@ -608,6 +613,7 @@ const TransferToLayingsTable = () => {
|
||||
value={selectedFlockDestination}
|
||||
onChange={flockDestinationChangeHandler}
|
||||
onInputChange={setFlockDestinationInputValue}
|
||||
onMenuScrollToBottom={loadMoreFlockDestination}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-3',
|
||||
|
||||
@@ -270,6 +270,8 @@ const TransferToLayingForm = ({
|
||||
options: flockSourceOptions,
|
||||
isLoadingOptions: isLoadingFlockSourceOptions,
|
||||
rawData: flockSources,
|
||||
loadMore: loadMoreFlockSource,
|
||||
hasMore: hasMoreFlockSource,
|
||||
} = useSelect<ProjectFlock>(
|
||||
'/production/project-flocks',
|
||||
'id',
|
||||
@@ -360,6 +362,8 @@ const TransferToLayingForm = ({
|
||||
options: flockDestinationOptions,
|
||||
isLoadingOptions: isLoadingFlockDestinationOptions,
|
||||
rawData: flockDestinations,
|
||||
loadMore: loadMoreFlockDestination,
|
||||
hasMore: hasMoreFlockDestination,
|
||||
} = useSelect<ProjectFlock>(
|
||||
'/production/project-flocks',
|
||||
'id',
|
||||
@@ -573,6 +577,7 @@ const TransferToLayingForm = ({
|
||||
onChange={flockSourceChangeHandler}
|
||||
isLoading={isLoadingFlockSourceOptions}
|
||||
onInputChange={setFlockSourceInputValue}
|
||||
onMenuScrollToBottom={loadMoreFlockSource}
|
||||
isError={
|
||||
formik.touched.flockSource &&
|
||||
Boolean(typeof formik.errors.flockSource === 'string')
|
||||
@@ -591,6 +596,7 @@ const TransferToLayingForm = ({
|
||||
onChange={flockDestinationChangeHandler}
|
||||
isLoading={isLoadingFlockDestinationOptions}
|
||||
onInputChange={setFlockDestinationInputValue}
|
||||
onMenuScrollToBottom={loadMoreFlockDestination}
|
||||
isError={
|
||||
formik.touched.flockDestination &&
|
||||
Boolean(typeof formik.errors.flockDestination === 'string')
|
||||
|
||||
@@ -37,7 +37,10 @@ import DateInput from '@/components/input/DateInput';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
|
||||
import {
|
||||
ProjectFlockKandangLookup,
|
||||
ProjectFlock,
|
||||
} from '@/types/api/production/project-flock';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIndicatorColor,
|
||||
@@ -229,63 +232,37 @@ const UniformityTable = () => {
|
||||
useState<number | undefined>(undefined);
|
||||
const [filterStartDate, setFilterStartDate] = useState('');
|
||||
const [filterEndDate, setFilterEndDate] = useState('');
|
||||
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
|
||||
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
|
||||
useState<string>('');
|
||||
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
setInputValue: setFilterLocationInputValue,
|
||||
options: filterLocationOptions,
|
||||
isLoadingOptions: isLoadingFilterLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
limit: '100',
|
||||
});
|
||||
loadMore: loadMoreFilterLocations,
|
||||
hasMore: hasMoreFilterLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
// ===== FETCH PROJECT FLOCKS DATA FOR FILTER =====
|
||||
const filterProjectFlocksUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
search: projectFlockSearchValue || '',
|
||||
limit: '100',
|
||||
});
|
||||
if (filterLocation) {
|
||||
params.append('location_id', filterLocation.value.toString());
|
||||
}
|
||||
return `${ProjectFlockApi.basePath}?${params.toString()}`;
|
||||
}, [projectFlockSearchValue, filterLocation]);
|
||||
|
||||
const {
|
||||
data: filterProjectFlocksData,
|
||||
isLoading: isLoadingFilterProjectFlocks,
|
||||
} = useSWR(filterProjectFlocksUrl, ProjectFlockApi.getAllFetcher);
|
||||
|
||||
const filterProjectFlocksDataList = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(filterProjectFlocksData)
|
||||
? filterProjectFlocksData.data
|
||||
: undefined,
|
||||
[filterProjectFlocksData]
|
||||
);
|
||||
|
||||
const filterProjectFlockOptions = useMemo(() => {
|
||||
let options: OptionType[] = [];
|
||||
|
||||
if (isResponseSuccess(filterProjectFlocksData)) {
|
||||
const flockOptions =
|
||||
filterProjectFlocksData?.data.map((projectFlock) => ({
|
||||
value: projectFlock.id,
|
||||
label: projectFlock.flock_name || '',
|
||||
})) || [];
|
||||
options = options.concat(flockOptions);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [filterProjectFlocksData]);
|
||||
setInputValue: setFilterProjectFlockSearchValue,
|
||||
options: filterProjectFlockOptions,
|
||||
rawData: filterProjectFlocksRawData,
|
||||
isLoadingOptions: isLoadingFilterProjectFlocks,
|
||||
loadMore: loadMoreFilterProjectFlocks,
|
||||
hasMore: hasMoreFilterProjectFlocks,
|
||||
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
|
||||
location_id: filterProjectFlockLocationId,
|
||||
});
|
||||
|
||||
// ===== KANDANG OPTIONS FOR FILTER =====
|
||||
const filterKandangOptions = useMemo(() => {
|
||||
let options: OptionType[] = [];
|
||||
|
||||
if (filterProjectFlock && filterProjectFlocksDataList) {
|
||||
const selectedProjectFlockData = filterProjectFlocksDataList.find(
|
||||
if (filterProjectFlock && isResponseSuccess(filterProjectFlocksRawData)) {
|
||||
const data = filterProjectFlocksRawData.data as unknown as ProjectFlock[];
|
||||
const selectedProjectFlockData = data.find(
|
||||
(pf) => pf.id === filterProjectFlock.value
|
||||
);
|
||||
|
||||
@@ -301,7 +278,7 @@ const UniformityTable = () => {
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [filterProjectFlock, filterProjectFlocksDataList]);
|
||||
}, [filterProjectFlock, filterProjectFlocksRawData]);
|
||||
|
||||
// ===== PROJECT FLOCK KANDANG LOOKUP =====
|
||||
const projectFlockKandangLookupUrl = useMemo(() => {
|
||||
@@ -394,9 +371,13 @@ const UniformityTable = () => {
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
setFilterLocation(val as OptionType | null);
|
||||
const location = val as OptionType | null;
|
||||
setFilterLocation(location);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterProjectFlockLocationId(
|
||||
location ? location.value.toString() : ''
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -1206,6 +1187,7 @@ const UniformityTable = () => {
|
||||
options={filterLocationOptions}
|
||||
onInputChange={setFilterLocationInputValue}
|
||||
isLoading={isLoadingFilterLocations}
|
||||
onMenuScrollToBottom={loadMoreFilterLocations}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
{filterErrors.location && (
|
||||
@@ -1225,8 +1207,9 @@ const UniformityTable = () => {
|
||||
setFilterErrors((prev) => ({ ...prev, project_flock: '' }));
|
||||
}}
|
||||
options={filterProjectFlockOptions}
|
||||
onInputChange={setProjectFlockSearchValue}
|
||||
onInputChange={setFilterProjectFlockSearchValue}
|
||||
isLoading={isLoadingFilterProjectFlocks}
|
||||
onMenuScrollToBottom={loadMoreFilterProjectFlocks}
|
||||
isDisabled={!filterLocation}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Badge from '../../../../Badge';
|
||||
import Badge from '@/components/Badge';
|
||||
import Card from '@/components/Card';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
VerifyUniformityPayload,
|
||||
} from '@/types/api/production/uniformity';
|
||||
import { type BaseApiResponse } from '@/types/api/api-general';
|
||||
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
|
||||
import {
|
||||
ProjectFlockKandangLookup,
|
||||
ProjectFlock,
|
||||
} from '@/types/api/production/project-flock';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
|
||||
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
|
||||
@@ -88,7 +91,9 @@ const UniformityForm = ({
|
||||
null
|
||||
);
|
||||
|
||||
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState('');
|
||||
const [selectedProjectFlockLocationId, setSelectedProjectFlockLocationId] =
|
||||
useState<string>('');
|
||||
|
||||
const [selectedProjectFlock, setSelectedProjectFlock] =
|
||||
useState<OptionType | null>(null);
|
||||
|
||||
@@ -100,50 +105,21 @@ const UniformityForm = ({
|
||||
setInputValue: setLocationSelectInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
page: '1',
|
||||
limit: '100',
|
||||
loadMore: loadMoreLocations,
|
||||
hasMore: hasMoreLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockSearchValue,
|
||||
options: projectFlockOptions,
|
||||
rawData: projectFlocksRawData,
|
||||
isLoadingOptions: isLoadingProjectFlocks,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
hasMore: hasMoreProjectFlocks,
|
||||
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
|
||||
location_id: selectedProjectFlockLocationId,
|
||||
});
|
||||
|
||||
// ===== FETCH PROJECT FLOCKS DATA =====
|
||||
const projectFlocksUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
search: projectFlockSearchValue || '',
|
||||
page: '1',
|
||||
limit: '100',
|
||||
});
|
||||
if (selectedLocation) {
|
||||
params.append('location_id', selectedLocation.value.toString());
|
||||
}
|
||||
return `${ProjectFlockApi.basePath}?${params.toString()}`;
|
||||
}, [projectFlockSearchValue, selectedLocation]);
|
||||
|
||||
const { data: projectFlocksData, isLoading: isLoadingProjectFlocks } = useSWR(
|
||||
projectFlocksUrl,
|
||||
ProjectFlockApi.getAllFetcher
|
||||
);
|
||||
|
||||
const projectFlocksDataList =
|
||||
projectFlocksData?.status === 'success'
|
||||
? projectFlocksData.data
|
||||
: undefined;
|
||||
|
||||
// ===== PROJECT FLOCK OPTIONS =====
|
||||
const projectFlockOptions = useMemo(() => {
|
||||
let options: OptionType[] = [];
|
||||
|
||||
if (isResponseSuccess(projectFlocksData)) {
|
||||
const flockOptions =
|
||||
projectFlocksData?.data.map((projectFlock) => ({
|
||||
value: projectFlock.id,
|
||||
label: projectFlock.flock_name || '',
|
||||
})) || [];
|
||||
options = options.concat(flockOptions);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [projectFlocksData]);
|
||||
|
||||
// ===== APPROVED PROJECT FLOCK KANDANGS =====
|
||||
const approvedProjectFlockKandangsUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
@@ -168,8 +144,9 @@ const UniformityForm = ({
|
||||
const kandangOptions = useMemo(() => {
|
||||
let options: OptionType[] = [];
|
||||
|
||||
if (selectedProjectFlock && projectFlocksDataList) {
|
||||
const selectedProjectFlockData = projectFlocksDataList.find(
|
||||
if (selectedProjectFlock && isResponseSuccess(projectFlocksRawData)) {
|
||||
const data = projectFlocksRawData.data as unknown as ProjectFlock[];
|
||||
const selectedProjectFlockData = data.find(
|
||||
(pf) => pf.id === selectedProjectFlock.value
|
||||
);
|
||||
|
||||
@@ -196,7 +173,7 @@ const UniformityForm = ({
|
||||
return options;
|
||||
}, [
|
||||
selectedProjectFlock,
|
||||
projectFlocksDataList,
|
||||
projectFlocksRawData,
|
||||
approvedProjectFlockKandangs,
|
||||
formType,
|
||||
]);
|
||||
@@ -313,6 +290,10 @@ const UniformityForm = ({
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
|
||||
setSelectedLocation(location);
|
||||
setSelectedProjectFlock(null);
|
||||
setSelectedProjectFlockLocationId(
|
||||
location ? location.value.toString() : ''
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -513,6 +494,7 @@ const UniformityForm = ({
|
||||
options={locationOptions}
|
||||
onInputChange={setLocationSelectInputValue}
|
||||
isLoading={isLoadingLocations}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isError={
|
||||
formik.touched.location_id && Boolean(formik.errors.location_id)
|
||||
}
|
||||
@@ -530,6 +512,7 @@ const UniformityForm = ({
|
||||
options={projectFlockOptions}
|
||||
onInputChange={setProjectFlockSearchValue}
|
||||
isLoading={isLoadingProjectFlocks}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isDisabled={!formik.values.location_id}
|
||||
isError={
|
||||
formik.touched.project_flock_id &&
|
||||
|
||||
@@ -156,8 +156,11 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
setInputValue: setExpeditionsSelectInputValue,
|
||||
options: expeditionVendors,
|
||||
isLoadingOptions: isLoadingExpeditions,
|
||||
loadMore: loadMoreExpeditions,
|
||||
hasMore: hasMoreExpeditions,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
|
||||
category: 'BOP',
|
||||
flag: 'EKSPEDISI',
|
||||
});
|
||||
|
||||
// ===== FORM CONFIGURATION =====
|
||||
@@ -183,8 +186,8 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
purchase_item_id: formItem.purchase_item_id || 0,
|
||||
received_date: formItem.received_date || '',
|
||||
travel_number: formItem.travel_number || '',
|
||||
vehicle_number: formItem.vehicle_number || '',
|
||||
expedition_vendor_id: formItem.expedition_vendor_id || 0,
|
||||
vehicle_number: formItem.vehicle_number || null,
|
||||
expedition_vendor_id: formItem.expedition_vendor_id || null,
|
||||
received_qty:
|
||||
typeof formItem.received_qty === 'string'
|
||||
? parseFloat(formItem.received_qty) || 0
|
||||
@@ -192,10 +195,13 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
transport_per_item:
|
||||
typeof formItem.transport_per_item === 'string'
|
||||
? parseFloat(formItem.transport_per_item) || 0
|
||||
: formItem.transport_per_item || 0,
|
||||
: formItem.transport_per_item || null,
|
||||
};
|
||||
}) || [],
|
||||
travel_documents: values.travel_documents || [],
|
||||
travel_documents:
|
||||
values.travel_documents
|
||||
?.filter((file): file is File => file instanceof File)
|
||||
.filter(Boolean) || undefined,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@@ -403,22 +409,13 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
Dokumen Surat Jalan
|
||||
<span className='text-error'>*</span>
|
||||
</th>
|
||||
<th>
|
||||
Nomor Kendaraan
|
||||
<span className='text-error'>*</span>
|
||||
</th>
|
||||
<th>
|
||||
Vendor Ekspedisi
|
||||
<span className='text-error'>*</span>
|
||||
</th>
|
||||
<th>Nomor Kendaraan</th>
|
||||
<th>Vendor Ekspedisi</th>
|
||||
<th>
|
||||
Jumlah Diterima
|
||||
<span className='text-error'>*</span>
|
||||
</th>
|
||||
<th>
|
||||
Transport/Item
|
||||
<span className='text-error'>*</span>
|
||||
</th>
|
||||
<th>Transport/Item</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -536,7 +533,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
required
|
||||
name={`items.${idx}.vehicle_number`}
|
||||
type='text'
|
||||
value={formItem?.vehicle_number || ''}
|
||||
@@ -562,7 +558,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
</td>
|
||||
<td>
|
||||
<SelectInput
|
||||
required
|
||||
isClearable={true}
|
||||
value={formItem?.expedition_vendor}
|
||||
key={`expedition-vendor-${idx}`}
|
||||
@@ -570,6 +565,8 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
expeditionVendorChangeHandler(idx, val)
|
||||
}
|
||||
options={getExpeditionVendorOptions()}
|
||||
isLoading={isLoadingExpeditions}
|
||||
onMenuScrollToBottom={loadMoreExpeditions}
|
||||
isError={
|
||||
isRepeaterInputError(idx, 'expedition_vendor_id')
|
||||
.isError
|
||||
@@ -629,7 +626,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
required
|
||||
name={`items.${idx}.transport_per_item`}
|
||||
value={formItem?.transport_per_item || ''}
|
||||
onChange={(e) =>
|
||||
@@ -680,7 +676,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
|
||||
<div className={'col-span-2 my-2'}>
|
||||
<FileInput
|
||||
required
|
||||
name='travel_documents'
|
||||
label='Dokumen Surat Jalan'
|
||||
accept='.pdf,.jpg,.jpeg,.png'
|
||||
|
||||
@@ -38,16 +38,16 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
vehicle_number: string;
|
||||
vehicle_number?: string | null;
|
||||
expedition_vendor?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
expedition_vendor_id: number;
|
||||
expedition_vendor_id?: number | null;
|
||||
received_qty: number | string;
|
||||
transport_per_item: number | string;
|
||||
transport_per_item?: number | string | null;
|
||||
}[];
|
||||
travel_documents: File[];
|
||||
travel_documents?: (File | null | undefined)[] | null;
|
||||
};
|
||||
|
||||
export type PurchaseStaffApprovalItemSchema = {
|
||||
@@ -75,14 +75,14 @@ export type PurchaseAcceptApprovalItemSchema = {
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
vehicle_number: string;
|
||||
vehicle_number?: string | null;
|
||||
expedition_vendor?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
expedition_vendor_id: number;
|
||||
expedition_vendor_id?: number | null;
|
||||
received_qty: number | string;
|
||||
transport_per_item: number | string;
|
||||
transport_per_item?: number | string | null;
|
||||
};
|
||||
|
||||
export type PurchaseDeleteItemsSchema = {
|
||||
@@ -184,24 +184,19 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
|
||||
.required('No. Surat jalan wajib diisi!')
|
||||
.typeError('No. Surat jalan wajib diisi!'),
|
||||
vehicle_number: Yup.string()
|
||||
.required('Nomor kendaraan wajib diisi!')
|
||||
.typeError('Nomor kendaraan wajib diisi!'),
|
||||
.nullable()
|
||||
.optional()
|
||||
.typeError('Nomor kendaraan harus berupa plat nomor!'),
|
||||
expedition_vendor: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
expedition_vendor_id: Yup.number()
|
||||
.min(1, 'Vendor ekspedisi wajib diisi!')
|
||||
.required('Vendor ekspedisi wajib diisi!')
|
||||
.test(
|
||||
'is-valid-expedition-vendor',
|
||||
'Vendor ekspedisi harus dipilih!',
|
||||
function (value) {
|
||||
if (!this.parent.expedition_vendor) return true;
|
||||
return Boolean(value && value > 0);
|
||||
}
|
||||
)
|
||||
.typeError('Vendor ekspedisi harus dipilih!'),
|
||||
.nullable()
|
||||
.optional()
|
||||
.typeError('Vendor ekspedisi harus berupa angka!'),
|
||||
received_qty: Yup.mixed<string | number>()
|
||||
.required('Jumlah diterima wajib diisi!')
|
||||
.test(
|
||||
@@ -217,13 +212,14 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
|
||||
)
|
||||
.typeError('Jumlah diterima harus berupa angka!'),
|
||||
transport_per_item: Yup.mixed<string | number>()
|
||||
.required('Biaya transport per item wajib diisi!')
|
||||
.nullable()
|
||||
.optional()
|
||||
.test(
|
||||
'is-valid-transport-per-item',
|
||||
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
|
||||
function (value) {
|
||||
if (value === '' || value === null || value === undefined)
|
||||
return false;
|
||||
return true;
|
||||
const numValue =
|
||||
typeof value === 'string' ? parseFloat(value) : value;
|
||||
return !isNaN(numValue) && numValue >= 0;
|
||||
@@ -389,16 +385,17 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
|
||||
travel_documents: Yup.array()
|
||||
.of(
|
||||
Yup.mixed<File>()
|
||||
.required('Dokumen surat jalan wajib diupload!')
|
||||
.nullable()
|
||||
.optional()
|
||||
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
|
||||
if (!value) return true;
|
||||
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
.required('Dokumen surat jalan wajib diupload!')
|
||||
.min(1, 'Minimal upload 1 dokumen surat jalan!')
|
||||
.typeError('Dokumen surat jalan wajib diupload!'),
|
||||
.nullable()
|
||||
.optional()
|
||||
.typeError('Dokumen surat jalan harus berupa array!'),
|
||||
});
|
||||
|
||||
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
|
||||
|
||||
@@ -633,8 +633,18 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
|
||||
formik.setFieldValue(`items.${idx}.qty`, numValue);
|
||||
|
||||
formik.setFieldValue(`items.${idx}.price`, '');
|
||||
formik.setFieldValue(`items.${idx}.total_price`, '');
|
||||
if (
|
||||
formItem.price !== '' &&
|
||||
formItem.price !== undefined &&
|
||||
formItem.price !== null &&
|
||||
numValue !== '' &&
|
||||
numValue > 0
|
||||
) {
|
||||
const calculatedTotal = Number(formItem.price) * Number(numValue);
|
||||
formik.setFieldValue(`items.${idx}.total_price`, calculatedTotal);
|
||||
} else if (numValue === '') {
|
||||
formik.setFieldValue(`items.${idx}.total_price`, '');
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'price' || field === 'total_price') {
|
||||
@@ -1184,8 +1194,10 @@ const PurchaseOrderStaffApprovalForm = ({
|
||||
color='warning'
|
||||
className='px-4'
|
||||
onClick={() => {
|
||||
formik.setValues(formikInitialValues);
|
||||
formik.resetForm();
|
||||
if (type === 'add') {
|
||||
formik.setValues(formikInitialValues);
|
||||
formik.resetForm();
|
||||
}
|
||||
setPurchaseOrderFormErrorMessage('');
|
||||
onCancel?.();
|
||||
onModalClose?.();
|
||||
|
||||
@@ -63,11 +63,9 @@ const PurchaseRequestForm = ({
|
||||
useState('');
|
||||
const [formErrorList, setFormErrorList] = useState<string[]>([]);
|
||||
|
||||
// ===== TYPE DEFINITIONS =====
|
||||
interface ProductOptionType {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
const [selectedArea, setSelectedArea] = useState('');
|
||||
const [selectedLocation, setSelectedLocation] = useState('');
|
||||
const [disabledLocation, setDisabledLocation] = useState(true);
|
||||
|
||||
// ===== UTILITY FUNCTIONS =====
|
||||
const isRepeaterInputError = (
|
||||
@@ -160,11 +158,35 @@ const PurchaseRequestForm = ({
|
||||
isLoadingOptions: isLoadingAreas,
|
||||
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
loadMore: loadMoreLocations,
|
||||
hasMore: hasMoreLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
: ((initialValues?.area?.id ?? '') as string),
|
||||
});
|
||||
|
||||
const {
|
||||
inputValue: warehouseSelectInputValue,
|
||||
setInputValue: setWarehouseSelectInputValue,
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouses,
|
||||
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||
loadMore: loadMoreWarehouses,
|
||||
hasMore: hasMoreWarehouses,
|
||||
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
: ((initialValues?.area?.id ?? '') as string),
|
||||
location_id:
|
||||
selectedLocation != ''
|
||||
? selectedLocation
|
||||
: ((initialValues?.location?.id ?? '') as string),
|
||||
});
|
||||
|
||||
// ===== FORM CONFIGURATION =====
|
||||
const formikInitialValues = useMemo<PurchaseRequestFormValues>(
|
||||
@@ -267,70 +289,6 @@ const PurchaseRequestForm = ({
|
||||
return data;
|
||||
}, [supplierData]);
|
||||
|
||||
const locationsUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
search: locationSelectInputValue,
|
||||
...(formik.values.area_id && formik.values.area_id > 0
|
||||
? { area_id: formik.values.area_id.toString() }
|
||||
: {}),
|
||||
});
|
||||
return `${LocationApi.basePath}?${params.toString()}`;
|
||||
}, [locationSelectInputValue, formik.values.area_id]);
|
||||
|
||||
const { data: locations, isLoading: isLoadingLocations } = useSWR(
|
||||
locationsUrl,
|
||||
LocationApi.getAllFetcher
|
||||
);
|
||||
|
||||
const locationOptions = useMemo(() => {
|
||||
if (!isResponseSuccess(locations)) return [];
|
||||
return (
|
||||
locations?.data.map((location) => ({
|
||||
value: location.id,
|
||||
label: location.name,
|
||||
})) || []
|
||||
);
|
||||
}, [locations]);
|
||||
|
||||
const warehousesUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({ search: warehouseSelectInputValue });
|
||||
|
||||
if (formik.values.area_id && formik.values.area_id > 0) {
|
||||
params.append('area_id', formik.values.area_id.toString());
|
||||
}
|
||||
|
||||
if (formik.values.location_id && formik.values.location_id > 0) {
|
||||
params.append('location_id', formik.values.location_id.toString());
|
||||
}
|
||||
|
||||
return `${WarehouseApi.basePath}?${params.toString()}`;
|
||||
}, [
|
||||
warehouseSelectInputValue,
|
||||
formik.values.area_id,
|
||||
formik.values.location_id,
|
||||
]);
|
||||
|
||||
const { data: warehouses } = useSWR(
|
||||
warehousesUrl,
|
||||
WarehouseApi.getAllFetcher
|
||||
);
|
||||
|
||||
const warehouseOptions = useMemo(() => {
|
||||
if (!isResponseSuccess(warehouses)) return [];
|
||||
|
||||
return (
|
||||
warehouses?.data.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.name,
|
||||
area: w.area?.name,
|
||||
location:
|
||||
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
|
||||
? w.location?.name
|
||||
: undefined,
|
||||
})) || []
|
||||
);
|
||||
}, [warehouses]);
|
||||
|
||||
const addPurchaseItem = () => {
|
||||
const newItems = [
|
||||
...(formik.values.items || []),
|
||||
@@ -407,6 +365,18 @@ const PurchaseRequestForm = ({
|
||||
}
|
||||
}, [formik.values.supplier_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== 'add' && initialValues) {
|
||||
if (initialValues.area?.id) {
|
||||
setSelectedArea(initialValues.area.id.toString());
|
||||
setDisabledLocation(false);
|
||||
}
|
||||
if (initialValues.location?.id) {
|
||||
setSelectedLocation(initialValues.location.id.toString());
|
||||
}
|
||||
}
|
||||
}, [type, initialValues]);
|
||||
|
||||
// ===== FORM HANDLERS =====
|
||||
const handleSupplierChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
@@ -445,6 +415,16 @@ const PurchaseRequestForm = ({
|
||||
formik.setFieldValue('area_id', (area as OptionType)?.value || 0);
|
||||
formik.setFieldTouched('area', true);
|
||||
formik.setFieldValue('area', area);
|
||||
|
||||
setSelectedArea((area as OptionType)?.value as string);
|
||||
setSelectedLocation('');
|
||||
const disabled = (area as OptionType)?.value == null;
|
||||
setDisabledLocation(disabled);
|
||||
|
||||
formik.setFieldTouched('location_id', false);
|
||||
formik.setFieldValue('location_id', 0);
|
||||
formik.setFieldTouched('location', false);
|
||||
formik.setFieldValue('location', null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -456,6 +436,8 @@ const PurchaseRequestForm = ({
|
||||
formik.setFieldValue('location_id', (location as OptionType)?.value || 0);
|
||||
formik.setFieldTouched('location', true);
|
||||
formik.setFieldValue('location', location);
|
||||
|
||||
setSelectedLocation((location as OptionType)?.value as string);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -596,10 +578,15 @@ const PurchaseRequestForm = ({
|
||||
placeholder='Pilih Lokasi...'
|
||||
value={formik.values.location}
|
||||
onChange={handleLocationChange}
|
||||
options={locationOptions}
|
||||
options={
|
||||
selectedArea != '' || initialValues?.area?.id
|
||||
? locationOptions
|
||||
: []
|
||||
}
|
||||
onInputChange={setLocationSelectInputValue}
|
||||
isLoading={isLoadingLocations}
|
||||
isDisabled={type === 'detail'}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isDisabled={type === 'detail' || disabledLocation}
|
||||
isClearable={type !== 'detail'}
|
||||
/>
|
||||
|
||||
@@ -713,6 +700,7 @@ const PurchaseRequestForm = ({
|
||||
options={warehouseOptions}
|
||||
onInputChange={setWarehouseSelectInputValue}
|
||||
isLoading={isLoadingWarehouses}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isError={
|
||||
isRepeaterInputError(idx, 'warehouse_id').isError
|
||||
}
|
||||
@@ -732,9 +720,9 @@ const PurchaseRequestForm = ({
|
||||
required
|
||||
value={item.product ?? undefined}
|
||||
onChange={(val) => {
|
||||
const product = val as ProductOptionType | null;
|
||||
const product = val as OptionType | null;
|
||||
const productId =
|
||||
(product as ProductOptionType)?.value || 0;
|
||||
(product as OptionType)?.value || 0;
|
||||
|
||||
formik.setFieldTouched(
|
||||
`items.${idx}.product`,
|
||||
|
||||
@@ -540,31 +540,6 @@ const PurchaseOrderDetail = ({
|
||||
accessorKey: 'travel_number',
|
||||
cell: (props) => props.row.original.travel_number || '-',
|
||||
},
|
||||
{
|
||||
header: 'Dokumen Surat Jalan',
|
||||
accessorKey: 'travel_document_path',
|
||||
cell: (props) => {
|
||||
const documentPath = props.row.original.travel_document_path;
|
||||
return documentPath ? (
|
||||
<Button
|
||||
color='primary'
|
||||
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm'
|
||||
href={documentPath}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:file-open-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Lihat Dokumen
|
||||
</Button>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'No. Armada Pengangkut',
|
||||
accessorKey: 'vehicle_number',
|
||||
@@ -588,7 +563,10 @@ const PurchaseOrderDetail = ({
|
||||
{
|
||||
header: 'Transport /Item',
|
||||
accessorKey: 'transport_per_item',
|
||||
cell: (props) => formatCurrency(props.getValue() as number),
|
||||
cell: (props) => {
|
||||
const value = props.row.original.transport_per_item;
|
||||
return value ? formatCurrency(value) : formatCurrency(0);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -723,8 +701,8 @@ const PurchaseOrderDetail = ({
|
||||
</span>
|
||||
<span className='text-gray-900 ml-3 break-all'>
|
||||
:{' '}
|
||||
{purchaseData.items?.[0]?.warehouse?.type === 'LOKASI' &&
|
||||
purchaseData.items?.[0]?.warehouse?.location?.name
|
||||
{purchaseData.items?.[0]?.warehouse &&
|
||||
'location' in purchaseData.items[0].warehouse
|
||||
? purchaseData.items[0].warehouse.location.name
|
||||
: '-'}
|
||||
</span>
|
||||
@@ -905,11 +883,29 @@ const PurchaseOrderDetail = ({
|
||||
Informasi Penerimaan Barang
|
||||
</h3>
|
||||
{canShowPenerimaanBarang && (
|
||||
<RowDropdownOptions isLast2Rows>
|
||||
<PenerimaanBarangDropdown
|
||||
onEdit={penerimaanBarangModal.openModal}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
<div className='flex items-center gap-2'>
|
||||
{goodsReceiptItems[0]?.travel_document_path && (
|
||||
<Button
|
||||
color='primary'
|
||||
className='w-fit min-w-32 flex items-center justify-start gap-1 p-1.5 text-sm'
|
||||
href={goodsReceiptItems[0].travel_document_path}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:file-open-outline'
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Lihat Dokumen
|
||||
</Button>
|
||||
)}
|
||||
<RowDropdownOptions isLast2Rows>
|
||||
<PenerimaanBarangDropdown
|
||||
onEdit={penerimaanBarangModal.openModal}
|
||||
/>
|
||||
</RowDropdownOptions>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
|
||||
@@ -324,12 +324,14 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
|
||||
PT LUMBUNG TELUR INDONESIA
|
||||
</Text>
|
||||
<Text>
|
||||
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
|
||||
{purchaseData?.items?.[0]?.warehouse &&
|
||||
'location' in purchaseData.items[0].warehouse
|
||||
? purchaseData.items[0].warehouse.location.name
|
||||
: '-'}
|
||||
</Text>
|
||||
<Text>
|
||||
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI'
|
||||
{purchaseData?.items?.[0]?.warehouse &&
|
||||
'location' in purchaseData.items[0].warehouse
|
||||
? purchaseData.items[0].warehouse.location.address
|
||||
: '-'}
|
||||
</Text>
|
||||
@@ -434,7 +436,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
|
||||
</View>
|
||||
<View style={pdfStyles.tableCell}>
|
||||
<Text>
|
||||
{item.warehouse?.type === 'LOKASI'
|
||||
{item.warehouse && 'location' in item.warehouse
|
||||
? item.warehouse.location.address
|
||||
: '-'}
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { Customer } from '@/types/api/master-data/customer';
|
||||
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 { BaseApiResponse } from '@/types/api/api-general';
|
||||
import {
|
||||
@@ -84,6 +87,7 @@ const DailyMarketingReportContent = () => {
|
||||
setInputValue: setAreaInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
|
||||
|
||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -98,6 +102,7 @@ const DailyMarketingReportContent = () => {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -115,6 +120,7 @@ const DailyMarketingReportContent = () => {
|
||||
setInputValue: setWarehouseInputValue,
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
|
||||
|
||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -132,6 +138,7 @@ const DailyMarketingReportContent = () => {
|
||||
setInputValue: setCustomerInputValue,
|
||||
options: customerOptions,
|
||||
isLoadingOptions: isLoadingCustomerOptions,
|
||||
loadMore: loadMoreCustomers,
|
||||
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
|
||||
|
||||
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -150,6 +157,15 @@ const DailyMarketingReportContent = () => {
|
||||
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] =
|
||||
useState<OptionType | null>(null);
|
||||
const marketingTypeChangeHandler = (
|
||||
@@ -252,6 +268,23 @@ const DailyMarketingReportContent = () => {
|
||||
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 (
|
||||
<div className='w-full border border-gray-200 p-4'>
|
||||
<div>
|
||||
@@ -269,6 +302,7 @@ const DailyMarketingReportContent = () => {
|
||||
value={selectedArea}
|
||||
onChange={areaChangeHandler}
|
||||
onInputChange={setAreaInputValue}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
||||
@@ -283,6 +317,7 @@ const DailyMarketingReportContent = () => {
|
||||
value={selectedLocation}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
||||
@@ -297,6 +332,7 @@ const DailyMarketingReportContent = () => {
|
||||
value={selectedWarehouse}
|
||||
onChange={warehouseChangeHandler}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
||||
@@ -311,6 +347,7 @@ const DailyMarketingReportContent = () => {
|
||||
value={selectedCustomer}
|
||||
onChange={customerChangeHandler}
|
||||
onInputChange={setCustomerInputValue}
|
||||
onMenuScrollToBottom={loadMoreCustomers}
|
||||
isClearable
|
||||
className={{
|
||||
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
|
||||
@@ -341,6 +378,18 @@ const DailyMarketingReportContent = () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
label='Tipe Marketing'
|
||||
placeholder='Pilih Tipe Marketing'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user