Merge branch 'staging' into 'production'

Staging

See merge request mbugroup/lti-web-client!214
This commit is contained in:
Adnan Zahir
2026-01-20 11:52:20 +07:00
142 changed files with 8316 additions and 3821 deletions
+7
View File
@@ -17,6 +17,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"html-to-image": "^1.11.13",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
@@ -7380,6 +7381,12 @@
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC" "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": { "node_modules/html2canvas": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+1
View File
@@ -20,6 +20,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"html-to-image": "^1.11.13",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
+3 -1
View File
@@ -38,9 +38,11 @@ const ExpenseEditPage = () => {
!isLoadingExpense && !isLoadingExpense &&
isResponseSuccess(expense) && isResponseSuccess(expense) &&
expense.data.latest_approval.step_number !== 5 && expense.data.latest_approval.step_number !== 5 &&
expense.data.latest_approval.step_number !== 6 &&
(expense.data.latest_approval.step_number === 1 || (expense.data.latest_approval.step_number === 1 ||
expense.data.latest_approval.step_number === 2 || expense.data.latest_approval.step_number === 2 ||
expense.data.latest_approval.step_number === 3); expense.data.latest_approval.step_number === 3 ||
expense.data.latest_approval.step_number === 4);
if (!isLoadingExpense && !isExpenseCanBeEdited) { if (!isLoadingExpense && !isExpenseCanBeEdited) {
router.back(); router.back();
-2
View File
@@ -24,8 +24,6 @@ const FinanceDetailPage = () => {
); );
} }
console.log(finance);
// if (!finance || isResponseError(finance)) { // if (!finance || isResponseError(finance)) {
// router.replace('/404'); // router.replace('/404');
// return; // return;
+6 -2
View File
@@ -148,7 +148,11 @@ const Card = ({
const hasContent = children || actions || footer; const hasContent = children || actions || footer;
const titleContent = ( const titleContent = (
<div className='group flex items-center !justify-between w-full'> <div
className={
`group flex items-center justify-between! w-full` + getTitleClasses()
}
>
<div className='flex-1'> <div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>} {title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>} {subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
@@ -156,7 +160,7 @@ const Card = ({
{collapsible && ( {collapsible && (
<button <button
onClick={() => handleCollapsedChange(!isCollapsed)} onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle' className={`btn btn-ghost btn-sm btn-circle` + getTitleClasses()}
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'} aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
> >
<Icon <Icon
+42
View File
@@ -0,0 +1,42 @@
import Button, { ButtonProps } from '@/components/Button';
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react';
import { FormikValues } from 'formik';
export type ButtonFilterProps = ButtonProps & {
values: FormikValues;
onClick: () => void;
};
const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
return (
<Button
{...props}
onClick={onClick}
className={cn(
getFilledFormikValuesCount(values) > 0
? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: '',
props.className
)}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
getFilledFormikValuesCount(values) > 0 ? 'text-blue-600' : ''
}
/>
Filter
{getFilledFormikValuesCount(values) > 0 && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{getFilledFormikValuesCount(values)}
</span>
)}
</Button>
);
};
export default ButtonFilter;
+1 -1
View File
@@ -18,7 +18,7 @@ const AlertErrorList = ({
if (formErrorList.length === 0) return null; if (formErrorList.length === 0) return null;
return ( return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'> <Alert color='error' className='w-full flex flex-col gap-2 px-4'>
<div className='flex justify-between items-center gap-2 w-full'> <div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} /> <Icon icon='material-symbols:error-outline' width={24} height={24} />
+18 -2
View File
@@ -113,7 +113,15 @@ const DateInput = ({
}; };
const handleSelectSingle = (selectedDate?: Date) => { 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) { if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`); setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return; return;
@@ -136,7 +144,15 @@ const DateInput = ({
}; };
const handleSelectRange = (range?: { from?: Date; to?: Date }) => { 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); setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
+134 -27
View File
@@ -9,15 +9,20 @@ import Select, {
SingleValue, SingleValue,
components as ReactSelectComponents, components as ReactSelectComponents,
ControlProps, ControlProps,
MenuListProps,
} from 'react-select'; } from 'react-select';
import CreatableSelect from 'react-select/creatable'; import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated'; import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper'; import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite';
import { httpClientFetcher } from '@/services/http/client'; import { httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import {
import { isResponseSuccess } from '@/lib/api-helper'; BaseApiResponse,
ErrorApiResponse,
SuccessApiResponse,
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType { export interface OptionType {
value: string | number; value: string | number;
@@ -35,6 +40,7 @@ interface SelectInputBaseProps<T = OptionType> {
bottomLabel?: ReactNode; bottomLabel?: ReactNode;
options: T[]; options: T[];
optionComponent?: OptionComponent<T>; optionComponent?: OptionComponent<T>;
components?: Partial<typeof ReactSelectComponents>;
isDisabled?: boolean; isDisabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
isClearable?: boolean; isClearable?: boolean;
@@ -56,9 +62,13 @@ interface SelectInputBaseProps<T = OptionType> {
onInputChange?: (search: string) => void; onInputChange?: (search: string) => void;
startAdornment?: ReactNode; startAdornment?: ReactNode;
menuPortalTarget?: HTMLElement | null; menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
} }
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> { export interface SelectInputProps<T = OptionType>
extends SelectInputBaseProps<T> {
createables?: boolean; createables?: boolean;
value?: T | T[] | null; value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void; onChange?: (val: T | T[] | null) => void;
@@ -93,6 +103,29 @@ const CustomControl = <
); );
}; };
const CustomMenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const { children, selectProps, options } = props;
const { isLoading } = selectProps;
return (
<ReactSelectComponents.MenuList {...props}>
{children}
{options.length > 0 && isLoading && (
<div className='mt-1 px-3 py-2 rounded-md text-center text-gray-400'>
<span className='loading loading-spinner loading-md' />
</div>
)}
</ReactSelectComponents.MenuList>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => { const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const { const {
label, label,
@@ -101,6 +134,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onChange, onChange,
options, options,
optionComponent, optionComponent,
components: customComponents,
isDisabled, isDisabled,
isLoading, isLoading,
isClearable, isClearable,
@@ -119,6 +153,9 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onInputChange, onInputChange,
startAdornment, startAdornment,
menuPortalTarget, menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom,
} = props; } = props;
const [internalInputValue, setInternalInputValue] = useState(''); const [internalInputValue, setInternalInputValue] = useState('');
@@ -128,14 +165,18 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const components = useMemo(() => { const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {}; const base = isAnimated ? animatedComponents : {};
const customComponents = { ...base, IndicatorSeparator: () => null }; const mergedComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) { if (startAdornment) {
customComponents.Control = CustomControl; mergedComponents.Control = CustomControl;
} }
return customComponents; if (customComponents) {
}, [isAnimated, startAdornment]); Object.assign(mergedComponents, customComponents);
}
return mergedComponents;
}, [isAnimated, startAdornment, customComponents]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => { const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val); if (meta.action === 'input-change') setInternalInputValue(val);
@@ -205,6 +246,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
isRtl={isRtl} isRtl={isRtl}
isSearchable={isSearchable} isSearchable={isSearchable}
placeholder={placeholder} placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)} className={cn('w-full', className?.select)}
classNames={{ classNames={{
...(!startAdornment && { ...(!startAdornment && {
@@ -256,6 +299,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
components={{ components={{
...components, ...components,
...(optionComponent ? { Option: optionComponent } : {}), ...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}} }}
{...(startAdornment && { {...(startAdornment && {
shouldShowAdornment, shouldShowAdornment,
@@ -269,6 +313,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}} }}
onMenuScrollToBottom={onMenuScrollToBottom}
/> />
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>} {isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
@@ -280,7 +325,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}; };
const useSelect = <T,>( const useSelect = <T,>(
basePath: string, basePath: string | null,
valueKey: keyof T | string, valueKey: keyof T | string,
labelKey: keyof T | string, labelKey: keyof T | string,
searchKey: string = 'search', searchKey: string = 'search',
@@ -288,34 +333,96 @@ const useSelect = <T,>(
) => { ) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const optionsUrlParams = useMemo(() => { const pageKey = 'page';
return new URLSearchParams({ const limitKey = 'limit';
const limit = params?.['limit'] ?? 10;
const getKey = (
pageIndex: number,
previousPageData?: BaseApiResponse<T[]>
) => {
// stop when backend says no more pages
if (previousPageData && isResponseSuccess(previousPageData)) {
const meta = previousPageData.meta;
if (meta && meta.page >= meta.total_pages) return null;
}
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '', [searchKey]: inputValue ?? '',
...params, [pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString(); }).toString();
}, [inputValue, searchKey, params]);
const optionsUrl = `${basePath}?${optionsUrlParams}`; return basePath ? `${basePath}?${qs}` : null;
};
const { data, isLoading } = useSWR(optionsUrl, async (url) => { const {
return await httpClientFetcher<BaseApiResponse<T[]>>(url); data: pages,
}); isLoading,
isValidating,
size,
setSize,
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
httpClientFetcher<BaseApiResponse<T[]>>(url)
);
const options = isResponseSuccess(data) const options = useMemo(() => {
? data.data.map((item) => { if (!pages) return [];
return {
value: getByPath<T, number>(item, valueKey as string), return pages.flatMap((page) =>
label: getByPath<T, string>(item, labelKey as string), isResponseSuccess(page)
}; ? page.data.map((item) => ({
}) value: getByPath<T, number>(item, valueKey as string),
: []; label: getByPath<T, string>(item, labelKey as string),
}))
: []
);
}, [pages, valueKey, labelKey]);
const lastPage = pages?.[pages.length - 1];
const hasMore =
!!lastPage &&
isResponseSuccess(lastPage) &&
!!lastPage.meta &&
lastPage.meta.page < lastPage.meta.total_pages;
const loadMore = () => {
if (!hasMore) return;
setSize(size + 1);
};
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedSuccessRawData = {
...pages?.[latestPagesIndex],
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return { return {
inputValue, inputValue,
setInputValue, setInputValue,
options, options,
isLoadingOptions: isLoading, rawData: isResponseSuccess(pages?.[latestPagesIndex])
rawData: data, ? formattedSuccessRawData
: formattedErrorRawData,
isLoadingOptions: isLoading || isValidating,
isLoadingMore: isValidating && size > 1,
hasMore,
loadMore,
}; };
}; };
@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import {
OptionProps,
GroupBase,
components as ReactSelectComponents,
} from 'react-select';
import SelectInput, { OptionType, SelectInputProps } from './SelectInput';
import { cn } from '@/lib/helper';
interface SelectInputCheckboxProps<T = OptionType>
extends Omit<
SelectInputProps<T>,
'closeMenuOnSelect' | 'hideSelectedOptions' | 'optionComponent'
> {
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
}
const CheckboxOption = <
T extends OptionType,
IsMulti extends boolean,
Group extends GroupBase<T>,
>(
props: OptionProps<T, IsMulti, Group>
) => {
const { isSelected, label, innerRef, innerProps, className } = props;
return (
<div
ref={innerRef}
{...innerProps}
className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer',
className
)}
>
<input
type='checkbox'
checked={isSelected}
onChange={() => null}
className='checkbox checkbox-sm checkbox-primary pointer-events-none'
/>
<label className='cursor-pointer flex-1 select-none'>{label}</label>
</div>
);
};
const SelectInputCheckbox = <T extends OptionType>(
props: SelectInputCheckboxProps<T>
) => {
const {
closeMenuOnSelect = false,
hideSelectedOptions = false,
isMulti = true,
className,
...restProps
} = props;
const customComponents = useMemo(() => {
return {
Option: CheckboxOption as typeof ReactSelectComponents.Option,
};
}, []);
return (
<SelectInput<T>
{...restProps}
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={{
...className,
select: cn(className?.select, 'select-checkbox'),
}}
components={customComponents}
/>
);
};
export default SelectInputCheckbox;
@@ -3,224 +3,82 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatTitleCase } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { import { HppItem, ProfitLossItem } from '@/types/api/closing';
DataSummarySubTotal, import { useSearchParams } from 'next/navigation';
HppPurchaseData, import { useMemo } from 'react';
ProfitLossDataAmount,
} from '@/types/api/closing';
import useSWR from 'swr'; 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 = ({ const ClosingFinanceTable = ({
projectFlockId, projectFlockId,
}: { }: {
projectFlockId: number; projectFlockId: number;
}) => { }) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR( const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`, `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() => ClosingApi.getFinance(projectFlockId) () =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
); );
const staticHppRows: Array<{ const hppTableData: HppItem[] = useMemo(() => {
group_name: string; if (isResponseSuccess(finance)) {
type: string; const customItems = {
group_index: number; label: 'HPP dan Pengeluaran',
}> = [ code: 'custom_row',
{ } as HppItem;
group_name: 'HPP dan Pengeluaran', const purchases = finance.data.hpp.items.filter(
type: 'Pembelian PAKAN', (item) => item.category === 'purchase'
group_index: 0, );
}, const totalBudgeting = {
{ label: 'HPP dan Bahan Baku',
group_name: 'HPP dan Pengeluaran', code: 'custom_row',
type: 'Pembelian STARTER', } as HppItem;
group_index: 0, const overheads = finance.data.hpp.items.filter(
}, (item) => item.category === 'overhead'
{ );
group_name: 'HPP dan Pengeluaran', return [customItems, ...purchases, totalBudgeting, ...overheads];
type: 'Pembelian DOC', }
group_index: 0, return [];
}, }, [finance]);
{
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: HppTableRow[] = [ const profitLossTableData: ProfitLossItem[] = useMemo(() => {
{ if (isResponseSuccess(finance)) {
group_name: 'HPP dan Pengeluaran', const incomes = finance.data.profit_loss.items.filter(
group_index: 0, (item) => item.type === 'income'
isGroupHeader: true as const, );
}, const purchases = finance.data.profit_loss.items.filter(
...staticHppRows (item) => item.type === 'purchase'
.filter((row) => row.group_index === 0) );
.map((staticRow) => { const overheads = finance.data.profit_loss.items.filter(
const apiData = isResponseSuccess(finance) (item) => item.type === 'overhead'
? finance.data.hpp_purchases.hpp );
.find((g) => g.group_name === staticRow.group_name) const grossProfit = {
?.data.find((d) => d.type === staticRow.type) label: 'LABA RUGI BRUTO',
: null; code: 'custom_row',
type: 'gross_profit',
return { rp_per_bird:
group_name: staticRow.group_name, finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
group_index: staticRow.group_index, rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
type: staticRow.type, amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
budgeting: apiData?.budgeting || { } as ProfitLossItem;
rp_per_bird: 0, const subtotal = {
rp_per_kg: 0, label: 'Subtotal',
amount: 0, code: 'custom_row',
}, type: 'subtotal',
realization: apiData?.realization || { rp_per_bird:
rp_per_bird: 0, finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
rp_per_kg: 0, rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
amount: 0, amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
}, } as ProfitLossItem;
isGroupHeader: false as const, return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
}; }
}), return [];
{ }, [finance]);
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,
},
]
: [];
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
@@ -233,35 +91,21 @@ const ClosingFinanceTable = ({
> >
<div className='grid grid-cols-2 gap-6'> <div className='grid grid-cols-2 gap-6'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div> <div>Laba Rugi Brutto</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.gross_profit
.label || '-'
)
: 'Laba Rugi Brutto'}
</div>
<div className='text-lg font-bold'> <div className='text-lg font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.gross_profit.amount finance.data.profit_loss.summary.gross_profit.amount
) )
: '-'} : '-'}
</div> </div>
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div> <div>Laba Rugi Netto</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit.label ||
'-'
)
: 'Laba Rugi Netto'}
</div>
<div className='text-lg font-bold'> <div className='text-lg font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit.amount finance.data.profit_loss.summary.net_profit.amount
) )
: '-'} : '-'}
</div> </div>
@@ -269,11 +113,7 @@ const ClosingFinanceTable = ({
</div> </div>
</Card> </Card>
<Card <Card
title={ title='HPP Purchases'
isResponseSuccess(finance)
? finance.data.hpp_purchases.title
: 'HPP Purchases'
}
variant='bordered' variant='bordered'
collapsible collapsible
className={{ className={{
@@ -281,17 +121,18 @@ const ClosingFinanceTable = ({
}} }}
> >
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<HppTableRow> <Table<HppItem>
data={hppTableData} data={hppTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'No.', header: 'No.',
enableSorting: false, enableSorting: false,
accessorFn: (item, index) => { accessorFn: (item, index) => {
if (item.isGroupHeader) return '-'; if (item.code === 'custom_row') return '-';
const dataRowsBefore = hppTableData const dataRowsBefore = hppTableData
.slice(0, index) .slice(0, index)
.filter((row) => !row.isGroupHeader).length; .filter((row) => row.code !== 'custom_row').length;
return dataRowsBefore + 1; return dataRowsBefore + 1;
}, },
footer: (props) => { footer: (props) => {
@@ -299,9 +140,9 @@ const ClosingFinanceTable = ({
}, },
}, },
{ {
header: 'Type', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'), accessorFn: (item) => formatTitleCase(item.label || '-'),
}, },
{ {
header: 'Budgeting', header: 'Budgeting',
@@ -317,7 +158,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_bird' && return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting
?.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
@@ -333,8 +174,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_kg' && return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting?.rp_per_kg ||
?.rp_per_kg || 0 0
) )
: '-'; : '-';
}, },
@@ -349,8 +190,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_amount' && return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting?.amount || 0
?.amount || 0
) )
: '-'; : '-';
}, },
@@ -371,8 +211,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_bird' && return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization
?.realization?.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -387,8 +227,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_kg' && return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization
?.realization?.rp_per_kg || 0 ?.rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -403,8 +243,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_amount' && return props.column.id === 'realization_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization?.amount || 0
?.realization?.amount || 0
) )
: '-'; : '-';
}, },
@@ -414,7 +253,7 @@ const ClosingFinanceTable = ({
]} ]}
renderCustomRow={(row) => { renderCustomRow={(row) => {
const rowData = row.original; const rowData = row.original;
if (rowData.isGroupHeader) { if (rowData.code === 'custom_row') {
return ( return (
<tr <tr
key={row.id} key={row.id}
@@ -428,7 +267,7 @@ const ClosingFinanceTable = ({
className={TABLE_DEFAULT_STYLING.bodyColumnClassName} className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
> >
<div className='font-bold'> <div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')} {formatTitleCase(rowData.label ?? '-')}
</div> </div>
</td> </td>
</tr> </tr>
@@ -441,11 +280,7 @@ const ClosingFinanceTable = ({
</div> </div>
</Card> </Card>
<Card <Card
title={ title='Profit/Loss'
isResponseSuccess(finance)
? finance.data.profit_loss.title
: 'Profit/Loss'
}
variant='bordered' variant='bordered'
collapsible collapsible
className={{ className={{
@@ -453,38 +288,32 @@ const ClosingFinanceTable = ({
}} }}
> >
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow> <Table<ProfitLossItem>
data={profitLossTableData} data={profitLossTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'Jenis', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => item.type, accessorFn: (item) => item.label,
cell: (item) => ( cell: (item) => (
<div className=''> <div className=''>
{formatTitleCase(item.row.original.type || '-')} {formatTitleCase(item.row.original.label || '-')}
</div> </div>
), ),
footer: (item) => ( footer: () => (
<div className='font-bold uppercase'> <div className='font-bold uppercase'>LABA RUGI NETTO</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit
.label || '-'
)
: '-'}
</div>
), ),
}, },
{ {
header: 'Rp/Ekor', header: 'Rp/Ekor',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.rp_per_bird || 0 .rp_per_bird || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -495,11 +324,11 @@ const ClosingFinanceTable = ({
header: 'Rp/Kg', header: 'Rp/Kg',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.rp_per_kg || 0 .rp_per_kg || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -510,11 +339,11 @@ const ClosingFinanceTable = ({
header: 'Jumlah (Rp)', header: 'Jumlah (Rp)',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0), accessorFn: (item) => formatCurrency(item.amount || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.amount || 0 .amount || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -524,55 +353,30 @@ const ClosingFinanceTable = ({
]} ]}
renderCustomRow={(row) => { renderCustomRow={(row) => {
const rowData = row.original; const rowData = row.original;
if (rowData.isGroupHeader) { if (rowData.code === 'custom_row') {
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>
);
}
return ( return (
<tr <tr
key={row.id} key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName} className={TABLE_DEFAULT_STYLING.footerRowClassName}
> >
<td <td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
colSpan={4} <div className='font-bold ps-6 uppercase'>
className={TABLE_DEFAULT_STYLING.bodyColumnClassName} {formatTitleCase(rowData.label ?? '-')}
> </div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'> <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> </div>
</td> </td>
</tr> </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'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table'; import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingIncomingSapronaksTableProps {
const ClosingIncomingSapronaksTable = ({ const ClosingIncomingSapronaksTable = ({
projectFlockId, projectFlockId,
}: ClosingIncomingSapronaksTableProps) => { }: ClosingIncomingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -43,7 +47,7 @@ const ClosingIncomingSapronaksTable = ({
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } = const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR( useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`, `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakFetcher, ClosingApi.getAllIncomingSapronakFetcher,
{ {
keepPreviousData: true, 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'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table'; import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingOutgoingSapronaksTableProps {
const ClosingOutgoingSapronaksTable = ({ const ClosingOutgoingSapronaksTable = ({
projectFlockId, projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => { }: ClosingOutgoingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -43,7 +47,7 @@ const ClosingOutgoingSapronaksTable = ({
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } = const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR( useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`, `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllOutgoingSapronakFetcher, ClosingApi.getAllOutgoingSapronakFetcher,
{ {
keepPreviousData: true, keepPreviousData: true,
@@ -5,122 +5,187 @@ import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { Overhead, OverheadTotal } from '@/types/api/closing'; import { Overhead, OverheadTotal } from '@/types/api/closing';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
interface ClosingOverheadTableProps { interface ClosingOverheadTableProps {
type?: 'detail';
projectFlockId: number; projectFlockId: number;
} }
const ClosingOverheadTable = ({ const ClosingOverheadTable = ({
type,
projectFlockId, projectFlockId,
}: ClosingOverheadTableProps) => { }: ClosingOverheadTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: overhead, isLoading: isLoadingOverhead } = useSWR( const { data: overhead, isLoading: isLoadingOverhead } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/overhead`, `${ClosingApi.basePath}/${projectFlockId}${kandangId ? `/${kandangId}` : ''}/overhead`,
() => ClosingApi.getOverhead(projectFlockId), () =>
ClosingApi.getOverhead(
projectFlockId,
kandangId ? Number(kandangId) : undefined
),
{ {
keepPreviousData: true, keepPreviousData: true,
} }
); );
// Helper function to create columns with footer support // Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [ const createColumns = (
// Group untuk kolom tanpa footer total?: OverheadTotal,
{ kandangId?: number
header: 'Nama Item', ): ColumnDef<Overhead>[] => {
accessorFn: (props) => props.item_name, const flockColumn: ColumnDef<Overhead>[] = [
footer: 'Total Pengeluaran Overhead', {
}, header: 'Budget Pengajuan',
{ footer: '',
header: 'Satuan', columns: [
accessorFn: (props) => props.uom_name, {
}, id: 'budget_quantity',
{ header: 'Jumlah',
header: 'Budget Pengajuan', accessorFn: (props) =>
footer: '', props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
columns: [ footer: total ? () => formatNumber(total.budget_quantity) : '',
{ },
id: 'budget_quantity', {
header: 'Jumlah', id: 'budget_unit_price',
accessorFn: (props) => header: 'Harga Satuan',
props.budget_quantity ? formatNumber(props.budget_quantity) : '-', accessorFn: (props) =>
footer: total ? () => formatNumber(total.budget_quantity) : '', props.budget_unit_price
}, ? formatCurrency(props.budget_unit_price)
{ : '-',
id: 'budget_unit_price', footer: '',
header: 'Harga Satuan', },
accessorFn: (props) => {
props.budget_unit_price id: 'budget_total_amount',
? formatCurrency(props.budget_unit_price) header: 'Total',
: '-', accessorFn: (props) =>
footer: '', props.budget_total_amount
}, ? formatCurrency(props.budget_total_amount)
{ : '-',
id: 'budget_total_amount', footer: total
header: 'Total', ? () => formatCurrency(total.budget_total_amount)
accessorFn: (props) => : '',
props.budget_total_amount },
? formatCurrency(props.budget_total_amount) ],
: '-', },
footer: total ? () => formatCurrency(total.budget_total_amount) : '', {
}, header: 'Realisasi',
], footer: '',
}, columns: [
{ {
header: 'Realisasi', id: 'actual_date',
footer: '', header: 'Tanggal',
columns: [ accessorFn: (props) =>
{ props.actual_date
id: 'actual_date', ? formatDate(props.actual_date, 'DD MMM, YYYY')
header: 'Tanggal', : '-',
accessorFn: (props) => footer: '',
props.actual_date },
? formatDate(props.actual_date, 'DD MMM, YYYY') {
: '-', id: 'actual_quantity',
footer: '', header: 'Jumlah',
}, accessorFn: (props) =>
{ props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
id: 'actual_quantity', footer: total ? () => formatNumber(total.actual_quantity) : '',
header: 'Jumlah', },
accessorFn: (props) => {
props.actual_quantity ? formatNumber(props.actual_quantity) : '-', id: 'actual_unit_price',
footer: total ? () => formatNumber(total.actual_quantity) : '', header: 'Harga Satuan',
}, accessorFn: (props) =>
{ props.actual_unit_price
id: 'actual_unit_price', ? formatCurrency(props.actual_unit_price)
header: 'Harga Satuan', : '-',
accessorFn: (props) => footer: '',
props.actual_unit_price },
? formatCurrency(props.actual_unit_price) {
: '-', id: 'actual_total_amount',
footer: '', header: 'Total',
}, accessorFn: (props) =>
{ props.actual_total_amount
id: 'actual_total_amount', ? formatCurrency(props.actual_total_amount)
header: 'Total', : '-',
accessorFn: (props) => footer: total
props.actual_total_amount ? () => formatCurrency(total.actual_total_amount)
? formatCurrency(props.actual_total_amount) : '',
: '-', },
footer: total ? () => formatCurrency(total.actual_total_amount) : '', ],
}, },
], ];
},
{ const kandangColumn: ColumnDef<Overhead>[] = [
id: 'cost_per_bird', {
header: 'Rp/Ekor', id: 'actual_date',
accessorFn: (props) => header: 'Tanggal',
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-', accessorFn: (props) =>
footer: total ? () => formatCurrency(total.cost_per_bird) : '', 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( const columns = useMemo(
() => () =>
isResponseSuccess(overhead) isResponseSuccess(overhead)
? createColumns(overhead.data?.total) ? createColumns(
overhead.data?.total,
kandangId ? Number(kandangId) : undefined
)
: createColumns(), : createColumns(),
[overhead] [overhead]
); );
@@ -148,6 +213,7 @@ const ClosingOverheadTable = ({
'whitespace-nowrap' 'whitespace-nowrap'
), ),
}} }}
isLoading={isLoadingOverhead}
renderFooter={ renderFooter={
isResponseSuccess(overhead) isResponseSuccess(overhead)
? overhead.data?.overheads.length > 0 ? overhead.data?.overheads.length > 0
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -12,9 +13,12 @@ interface ClosingProductionDataTabContentProps {
const ClosingProductionDataTabContent = ({ const ClosingProductionDataTabContent = ({
projectFlockId, projectFlockId,
}: ClosingProductionDataTabContentProps) => { }: ClosingProductionDataTabContentProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: productionData, isLoading } = useSWR( const { data: productionData, isLoading } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/production-data`, `${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
() => ClosingApi.getProductionData(projectFlockId) () => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
); );
if (isLoading) { if (isLoading) {
@@ -197,7 +201,7 @@ const ClosingProductionDataTabContent = ({
value={formatNumber(performance.mor_diff)} value={formatNumber(performance.mor_diff)}
unitClassName='hidden' unitClassName='hidden'
/> />
<DataRow {/* <DataRow
label='AWG Std' label='AWG Std'
value={formatNumber(performance.awg_std)} value={formatNumber(performance.awg_std)}
unit='Gr/Hari' unit='Gr/Hari'
@@ -206,7 +210,7 @@ const ClosingProductionDataTabContent = ({
label='AWG Act' label='AWG Act'
value={formatNumber(performance.awg_act)} value={formatNumber(performance.awg_act)}
unit='Gr/Hari' unit='Gr/Hari'
/> /> */}
<DataRow <DataRow
label='Feed Intake Std' label='Feed Intake Std'
value={formatNumber(performance.feed_intake_std)} value={formatNumber(performance.feed_intake_std)}
@@ -2,6 +2,8 @@
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable';
import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable';
interface ClosingSapronakTableProps { interface ClosingSapronakTableProps {
projectFlockId?: number; projectFlockId?: number;
@@ -16,7 +18,15 @@ const ClosingSapronakTabContent = ({
<> <>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} /> <ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingIncomingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} /> <ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
</> </>
)} )}
</div> </div>
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6', wrapper: 'col-span-12 sm:col-span-6',
@@ -82,12 +82,12 @@ const SalesReportTable = ({
<div className='font-semibold text-gray-900'>Total Penjualan</div> <div className='font-semibold text-gray-900'>Total Penjualan</div>
), ),
}, },
{ // {
id: 'age', // id: 'age',
accessorKey: 'age', // accessorKey: 'age',
header: 'Umur', // header: 'Umur',
cell: (props) => props.getValue() || '-', // cell: (props) => props.getValue() || '-',
}, // },
{ {
id: 'do_number', id: 'do_number',
accessorKey: 'do_number', accessorKey: 'do_number',
@@ -8,19 +8,22 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data'; import { KandangApi, LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import { import {
DashboardFilterType, DashboardFilterType,
getDashboardFilterSchema, getDashboardFilterSchema,
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart'; import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton'; 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 { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { import {
DashboardFilter, DashboardFilter,
@@ -30,6 +33,11 @@ import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import ButtonFilter from '@/components/helper/ButtonFilter';
import 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 // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -44,11 +52,22 @@ const normalizeToArray = (
const DashboardProduction = () => { const DashboardProduction = () => {
const filterModal = useModal(); const filterModal = useModal();
// ===== DASHBOARD STORE =====
const { filterValues, setFilterValues, resetFilterValues } =
useDashboardStore();
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
'OVERVIEW' (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
); );
const [endpointUrl, setEndpointUrl] = useState('/dashboards'); 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 ===== // ===== FETCH DATA =====
const { const {
@@ -64,22 +83,32 @@ const DashboardProduction = () => {
: undefined; : undefined;
// ===== SELECT ===== // ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const { const {
setInputValue: setInputValueFlock,
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueLocation,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', { } = useSelect(LocationApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } = const {
useSelect(KandangApi.basePath, 'id', 'name', '', { setInputValue: setInputValueKandang,
limit: 'limit', options: kandangOptions,
location_id: selectedLocationIds ? selectedLocationIds.toString() : '', isLoadingOptions: isLoadingKandangOptions,
}); loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [ const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' }, { value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' }, { value: 'FLOCK', label: 'Flock' },
@@ -89,20 +118,21 @@ const DashboardProduction = () => {
// ===== FORMIK ===== // ===== FORMIK =====
const formik = useFormik({ const formik = useFormik({
initialValues: { initialValues: {
startDate: '', startDate: filterValues.startDate || '',
endDate: '', endDate: filterValues.endDate || '',
flock: [] as OptionType[], flock: filterValues.flock || ([] as OptionType[]),
location: [] as OptionType[], location: filterValues.location || ([] as OptionType[]),
kandang: [] as OptionType[], kandang: filterValues.kandang || ([] as OptionType[]),
analysisMode: analysisMode, analysisMode: filterValues.analysisMode || analysisMode,
comparisonType: '', comparisonType: filterValues.comparisonType || '',
lokasiIds: [], locationIds: filterValues.locationIds || [],
flockIds: [], flockIds: filterValues.flockIds || [],
kandangIds: [], kandangIds: filterValues.kandangIds || [],
} as DashboardFilterType, } as DashboardFilterType,
validationSchema: getDashboardFilterSchema(analysisMode), validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => { onSubmit: (values) => {
console.log(values); // Save filter values to store
setFilterValues(values);
handleApplyFilter({ handleApplyFilter({
start_date: values.startDate || '', start_date: values.startDate || '',
@@ -118,13 +148,13 @@ const DashboardProduction = () => {
const handleResetFilter = () => { const handleResetFilter = () => {
formik.resetForm(); formik.resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW'); setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards'); setEndpointUrl('/dashboards');
setSelectedLocationIds([]);
}; };
const handleApplyFilter = (values: DashboardFilter) => { const handleApplyFilter = (values: DashboardFilter) => {
console.log(values);
// Build query params object, only include non-empty values // Build query params object, only include non-empty values
const params: Record<string, string> = {}; const params: Record<string, string> = {};
@@ -140,15 +170,37 @@ const DashboardProduction = () => {
if (values.comparison_type) params.comparison_type = values.comparison_type; if (values.comparison_type) params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`); setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
console.log(endpointUrl);
filterModal.closeModal(); filterModal.closeModal();
refreshDashboardProductionData(); 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 ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Export PDF =====
const handleExportPDF = async () => {
await generateDashboardPDF({
filterValues: formik.values,
statsRef,
allChartsRef,
setExporting,
});
};
if (isLoadingDashboardProductionData) { if (isLoadingDashboardProductionData) {
return ( return (
<div className='w-full min-h-screen flex items-center justify-center'> <div className='w-full min-h-screen flex items-center justify-center'>
@@ -156,103 +208,108 @@ const DashboardProduction = () => {
</div> </div>
); );
} }
return ( return (
<> <>
<section className='w-full p-4 space-y-6'> <section className='w-full p-4 space-y-6'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'> <div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
<div></div> <div></div>
<div className='flex flex-row justify-end gap-2'> <div className='flex flex-row justify-end gap-2'>
<Button <ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline' variant='outline'
className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg' className='min-w-28 rounded-lg'
onClick={() => filterModal.openModal()}
/>
<Dropdown
trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'>
<Icon icon='heroicons:arrow-down-tray' />
Export
<Icon icon='heroicons:chevron-down' />
</Button>
}
className={{
content: 'w-full',
}}
> >
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} /> <Menu className={exporting ? 'hidden' : ''}>
Export <MenuItem title='PDF' onClick={handleExportPDF} />
<Icon icon='heroicons:chevron-down' width={20} height={20} /> </Menu>
</Button> </Dropdown>
</div> </div>
</div> </div>
{/* Dashboard Stats */} {/* Dashboard Stats */}
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} /> <div ref={statsRef}>
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
</div>
{/* Use DashboardLineChart component or skeleton */} {/* Use DashboardLineChart component or skeleton */}
{isLoadingDashboardProductionData ? ( <div ref={chartRef}>
<DashboardLineChartSkeleton /> {isLoadingDashboardProductionData ? (
) : dashboardProductionData && <DashboardLineChartSkeleton />
dashboardProductionData.charts && ) : dashboardProductionData &&
Object.keys(dashboardProductionData.charts).length > 0 ? ( dashboardProductionData.charts &&
<DashboardLineChart Object.keys(dashboardProductionData.charts).length > 0 ? (
analysisMode={ <DashboardLineChart
isResponseSuccess(dashboardProductionResponse) analysisMode={
? dashboardProductionResponse.meta isResponseSuccess(dashboardProductionResponse)
? ( ? dashboardProductionResponse.meta
dashboardProductionResponse.meta as unknown as DashboardMeta ? (
).filters?.analysis_mode dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode : analysisMode
: analysisMode }
} data={dashboardProductionData}
data={dashboardProductionData} selectedKandang={
/> analysisMode === 'OVERVIEW'
) : ( ? (formik.values.kandang as OptionType)
<DashboardLineChartSkeleton : undefined
meta={ }
isResponseSuccess(dashboardProductionResponse) />
? (dashboardProductionResponse.meta as unknown as DashboardMeta) ) : (
: 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> </section>
@@ -287,7 +344,7 @@ const DashboardProduction = () => {
{/* Rentang Waktu */} {/* Rentang Waktu */}
<div className='px-4'> <div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label> <label className='flex items-center gap-2 mb-3'>Tanggal</label>
<div className='flex items-center gap-2'> <div className='flex items-start gap-2'>
<DateInput <DateInput
name='startDate' name='startDate'
placeholder='Tanggal Mulai' placeholder='Tanggal Mulai'
@@ -302,7 +359,7 @@ const DashboardProduction = () => {
Boolean(formik.touched.startDate) Boolean(formik.touched.startDate)
} }
/> />
<span className='hidden md:block text-center'></span> <div className='hidden md:block mt-3 text-center'></div>
<DateInput <DateInput
name='endDate' name='endDate'
placeholder='Tanggal Akhir' placeholder='Tanggal Akhir'
@@ -383,6 +440,8 @@ const DashboardProduction = () => {
<SelectInput <SelectInput
label='Farm' label='Farm'
value={formik.values.location} value={formik.values.location}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
onChange={(selected) => { onChange={(selected) => {
formik.setFieldValue('location', selected); formik.setFieldValue('location', selected);
// Update selectedLocationIds for kandang filter // Update selectedLocationIds for kandang filter
@@ -422,6 +481,8 @@ const DashboardProduction = () => {
formik.setFieldValue('flock', selected) formik.setFieldValue('flock', selected)
} }
errorMessage={formik.errors.flock as string} errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
options={flockOptions} options={flockOptions}
isLoading={isLoadingFlockOptions} isLoading={isLoadingFlockOptions}
isMulti={ isMulti={
@@ -450,6 +511,8 @@ const DashboardProduction = () => {
formik.setFieldValue('kandang', selected) formik.setFieldValue('kandang', selected)
} }
errorMessage={formik.errors.kandang as string} errorMessage={formik.errors.kandang as string}
onInputChange={setInputValueKandang}
onMenuScrollToBottom={loadMoreKandang}
options={kandangOptions} options={kandangOptions}
isLoading={isLoadingKandangOptions} isLoading={isLoadingKandangOptions}
isMulti={ isMulti={
@@ -465,7 +528,9 @@ const DashboardProduction = () => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <div className='w-full p-4'>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
@@ -473,7 +538,6 @@ const DashboardProduction = () => {
type='reset' type='reset'
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilter}
> >
Reset Filter Reset Filter
</Button> </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 Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import { formatNumber } from '@/lib/helper';
import { import {
Dashboard, Dashboard,
DashboardOverviewCharts, DashboardOverviewCharts,
@@ -25,20 +27,29 @@ import {
type DashboardLineChartProps = { type DashboardLineChartProps = {
analysisMode: 'OVERVIEW' | 'COMPARISON'; analysisMode: 'OVERVIEW' | 'COMPARISON';
data: Dashboard; data: Dashboard;
selectedKandang?: OptionType;
}; };
// Type guard to check if charts is DashboardOverviewCharts // Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts( function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts { ): 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 // Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts( function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts { ): 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> = { const lineColors: Record<string, string> = {
@@ -94,6 +105,7 @@ const getLineColor = (
const DashboardLineChart = ({ const DashboardLineChart = ({
analysisMode, analysisMode,
data, data,
selectedKandang,
}: DashboardLineChartProps) => { }: DashboardLineChartProps) => {
const [chartData, setChartData] = const [chartData, setChartData] =
useState<keyof DashboardOverviewCharts>('body_weight'); useState<keyof DashboardOverviewCharts>('body_weight');
@@ -123,7 +135,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts) isComparisonCharts(data.charts)
) { ) {
const comparisonChart = const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang; data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || []; seriesData = comparisonChart?.series || [];
} }
@@ -224,7 +236,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts) isComparisonCharts(data.charts)
) { ) {
const comparisonChart = const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang; data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || []; seriesData = comparisonChart?.series || [];
} }
@@ -283,261 +295,382 @@ const DashboardLineChart = ({
})()} })()}
</div> </div>
{/* Chart */} {/* Chart Container with Empty State Overlay */}
<ResponsiveContainer width='100%' height={350}> <div className='relative'>
<LineChart {/* Chart */}
data={(() => { <ResponsiveContainer width='100%' height={350}>
// Transform data based on analysisMode <LineChart
if (analysisMode === 'OVERVIEW') { data={(() => {
// For OVERVIEW mode, use the selected chart data // Transform data based on analysisMode
if (isOverviewCharts(data.charts)) { if (analysisMode === 'OVERVIEW') {
const selectedChartData = data.charts[chartData]; // For OVERVIEW mode, use the selected chart data
if (!selectedChartData || !selectedChartData.dataset) return []; if (isOverviewCharts(data.charts)) {
return selectedChartData.dataset; const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.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={(() => { margin={{
// Calculate dynamic ticks based on domain top: 5,
let seriesData: DashboardChartsSeries[] = []; right: 10,
let dataset: DashboardChartsDataset[] = []; left: 0,
bottom: 5,
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} >
itemStyle={{ color: 'white', fontSize: '12px' }} <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
labelFormatter={(value) => `Week ${value}`} <XAxis
formatter={( dataKey='week'
value: number | undefined, tick={{ fontSize: 11, fill: '#9ca3af' }}
name: string | undefined tickLine={false}
) => { axisLine={{ stroke: '#e5e7eb' }}
if (value === undefined || name === undefined) return ['', '']; label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
// Get series data to find the unit if (
let seriesData: DashboardChartsSeries[] = []; analysisMode === 'OVERVIEW' &&
if ( isOverviewCharts(data.charts)
analysisMode === 'OVERVIEW' && ) {
isOverviewCharts(data.charts) seriesData = data.charts[chartData]?.series || [];
) { dataset = data.charts[chartData]?.dataset || [];
seriesData = data.charts[chartData]?.series || []; } else if (
} else if ( analysisMode === 'COMPARISON' &&
analysisMode === 'COMPARISON' && isComparisonCharts(data.charts)
isComparisonCharts(data.charts) ) {
) { const comparisonChart =
const comparisonChart = data.charts.farm ||
data.charts.location || data.charts.flock ||
data.charts.flock || data.charts.kandang;
data.charts.kandang; seriesData = comparisonChart?.series || [];
seriesData = comparisonChart?.series || []; dataset = comparisonChart?.dataset || [];
} }
// Find the series that matches this line's name // Get all values from visible series
const series = seriesData.find((s) => s.label === name); const visibleSeriesIds = Array.from(visibleSeries);
const unit = series?.unit || ''; const allValues: number[] = [];
return [`${value} ${unit}`, name]; dataset.forEach((item: DashboardChartsDataset) => {
}} visibleSeriesIds.forEach((seriesId) => {
/> const value = item[seriesId];
{/* Dynamic Line rendering based on visible series */} if (typeof value === 'number') {
{(() => { allValues.push(value);
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
} }
activeDot={isStandard ? undefined : { r: 5 }} });
/> });
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.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>
); );
}); }}
})()} formatter={(
</LineChart> value: number | undefined,
</ResponsiveContainer> 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> </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; analysisMode: string;
comparisonType: string | undefined; comparisonType: string | undefined;
location: OptionType | OptionType[]; location: OptionType | OptionType[];
lokasiIds: number[] | undefined; locationIds: number[] | undefined;
flock: OptionType | OptionType[] | undefined; flock: OptionType | OptionType[] | undefined;
flockIds: number[] | undefined; flockIds: number[] | undefined;
kandang: OptionType | OptionType[] | undefined; kandang: OptionType | OptionType[] | undefined;
@@ -25,7 +25,7 @@ export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType
then: (schema) => schema.required('Compared by is required'), then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(), otherwise: (schema) => schema.optional(),
}), }),
lokasiIds: yup.array().optional(), locationIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup location: yup
@@ -68,7 +68,7 @@ export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterTy
then: (schema) => schema.required('Compared by is required'), then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(), otherwise: (schema) => schema.optional(),
}), }),
lokasiIds: yup.array().optional(), locationIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup location: yup
@@ -43,7 +43,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
return ( 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'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -65,7 +65,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
tabs={expenseDetailTabs} tabs={expenseDetailTabs}
variant='lifted' variant='lifted'
className={{ className={{
wrapper: 'max-w-5xl mx-auto mt-4', wrapper: 'mx-auto mt-4',
}} }}
/> />
</section> </section>
@@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({
return ( return (
<div> <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'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
<RequirePermission permissions='lti.expense.update.realization'> <RequirePermission permissions='lti.expense.update.realization'>
<Button <Button
@@ -84,7 +84,7 @@ const ExpenseRealizationContent = ({
</div> </div>
</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'> <table className='table table-sm table-zebra'>
<tbody> <tbody>
<tr> <tr>
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
</table> </table>
</div> </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'> <div className='flex flex-row gap-4'>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}> <Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'> <div className='w-full flex flex-col gap-2'>
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
</div> </div>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto grid grid-cols-2 gap-4'>
<h2 className='font-bold text-xl text-center'> <div>
Rincian Pengajuan Biaya Operasional <h2 className='font-bold text-xl text-center'>
</h2> Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'> <div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { {initialValues?.kandangs.map(
let expenseGrandTotal = 0; (kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
<div <div
key={kandangExpenseIdx} key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto' className='overflow-x-auto w-full mx-auto'
> >
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<thead> <thead>
<tr> <tr>
<th <th
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
Biaya {kandangExpense.name} Biaya {kandangExpense.name}
</th> </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>
</tr> </tr>
) <tr>
)} <th>Nonstock</th>
</tbody> <th>Total Kuantitas</th>
<tfoot> <th>Total Biaya</th>
<tr className='border-y'> <th>Catatan</th>
<th colSpan={2} className='text-right'> </tr>
Total Biaya Keseluruhan: </thead>
</th> <tbody>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th> {kandangExpense.pengajuans?.map(
</tr> (pengajuanItem, pengajuanIdx) => (
</tfoot> <tr key={pengajuanIdx}>
</table> <td>{pengajuanItem.nonstock.name}</td>
</div> <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>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div>
<h2 className='font-bold text-xl text-center'> <h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional Rincian Realisasi Biaya Operasional
</h2> </h2>
<div className='w-full mt-2 flex flex-col gap-4'> <div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { {initialValues?.kandangs.map(
let expenseGrandTotal = 0; (kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
<div <div
key={kandangExpenseIdx} key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto' className='overflow-x-auto w-full mx-auto'
> >
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<thead> <thead>
<tr> <tr>
<th <th
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
Biaya {kandangExpense.name} Biaya {kandangExpense.name}
</th> </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>
</tr> </tr>
) <tr>
)} <th>Nonstock</th>
</tbody> <th>Total Kuantitas</th>
<tfoot> <th>Total Biaya</th>
<tr className='border-y'> <th>Catatan</th>
<th colSpan={2} className='text-right'> </tr>
Total Biaya Keseluruhan: </thead>
</th> <tbody>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th> {kandangExpense.realisasi?.map(
</tr> (realisasiItem, realisasiIdx) => (
</tfoot> <tr key={realisasiIdx}>
</table> <td>{realisasiItem.nonstock.name}</td>
</div> <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> </div>
</div> </div>
@@ -273,7 +273,7 @@ const ExpenseRequestContent = ({
<> <>
<div> <div>
{initialValues && !isLoadingApprovalHistory && approvalHistory && ( {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} /> <ApprovalSteps approvals={approvalHistory} />
</div> </div>
)} )}
@@ -281,7 +281,7 @@ const ExpenseRequestContent = ({
<div className='w-full mt-4 flex flex-col gap-4'> <div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */} {/* 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 && ( {isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.head_area'>
<Button <Button
@@ -414,7 +414,7 @@ const ExpenseRequestContent = ({
</div> </div>
</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'> <table className='table table-sm table-zebra'>
<tbody> <tbody>
<tr> <tr>
@@ -608,7 +608,7 @@ const ExpenseRequestContent = ({
</table> </table>
</div> </div>
</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'> <h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional Rincian Pengajuan Biaya Operasional
</h2> </h2>
@@ -654,7 +654,7 @@ const ExpenseRequestContent = ({
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td> <td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'> <td className='w-xs'>
{pengajuanItem.note ?? '-'} {pengajuanItem.notes ?? '-'}
</td> </td>
</tr> </tr>
) )
+20 -28
View File
@@ -54,17 +54,19 @@ const RowOptionsMenu = ({
rejectClickHandler: () => void; rejectClickHandler: () => void;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = const showEditButton = props.row.original.latest_approval
props.row.original.latest_approval.step_number !== 6 && ? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 || (props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 || props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 || props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4); props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC // TODO: apply RBAC
const showRealizationButton = const showRealizationButton = props.row.original.latest_approval
props.row.original.latest_approval.action !== 'REJECTED' && ? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4; props.row.original.latest_approval.step_number === 4
: false;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
@@ -278,6 +280,7 @@ const ExpensesTable = () => {
cell: ({ row }) => { cell: ({ row }) => {
const isCheckboxDisabled = const isCheckboxDisabled =
!row.getCanSelect() || !row.getCanSelect() ||
!row.original.latest_approval ||
row.original.latest_approval.action === 'REJECTED'; row.original.latest_approval.action === 'REJECTED';
return ( return (
@@ -413,6 +416,8 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = ( const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row row
) => { ) => {
if (!row.original.latest_approval) return false;
return ( return (
row.original.latest_approval.action !== 'REJECTED' && row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6 row.original.latest_approval.step_number !== 6
@@ -692,14 +697,6 @@ const ExpensesTable = () => {
</> </>
)} )}
</div> </div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div> </div>
<div className='grid grid-cols-12 justify-end gap-2'> <div className='grid grid-cols-12 justify-end gap-2'>
@@ -753,17 +750,12 @@ const ExpensesTable = () => {
}} }}
/> />
<SelectInput <DebouncedTextInput
label='Baris' name='search'
options={ROWS_OPTIONS} placeholder='Cari Biaya Operasional'
value={{ value={tableFilterState.search}
label: String(tableFilterState.pageSize), onChange={searchChangeHandler}
value: tableFilterState.pageSize, className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
/> />
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper';
interface ExpenseKandangsTableProps { interface ExpenseKandangsTableProps {
locationId?: number; locationId?: number;
type: 'add' | 'edit' | 'detail'; type: 'add' | 'edit' | 'detail';
formType?: 'request' | 'realization';
selectedKandangs: { selectedKandangs: {
id?: number; id?: number;
name?: string; name?: string;
@@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps {
const ExpenseKandangsTable = ({ const ExpenseKandangsTable = ({
type, type,
formType = 'request',
locationId, locationId,
selectedKandangs, selectedKandangs,
onChange, onChange,
@@ -172,69 +174,84 @@ const ExpenseKandangsTable = ({
updateSortingFilter('picSort', picSortFilter); updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]); }, [sorting, updateSortingFilter]);
return ( // Tampilkan tabel jika:
<Card // 1. Mode request pertama kali (type='add' dan formType='request')
className={{ // 2. Atau sudah ada kandang yang dipilih
wrapper: className?.wrapper, const shouldShowTable =
body: 'p-4 shadow', (type === 'add' && formType === 'request') ||
}} (selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id));
>
<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>
<Icon return (
icon='material-symbols:keyboard-arrow-down' <>
width={24} {shouldShowTable && (
height={24} <Card
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={{ className={{
containerClassName: cn({ wrapper: className?.wrapper,
'mb-20': body: 'p-4 shadow',
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> <Collapse
</Card> 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') ? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
: undefined, : undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({ kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id, id: kandang.id,
name: kandang.name, name: kandang.name,
})), })),
supplier: initialValues?.supplier supplier: initialValues?.supplier
@@ -159,7 +159,7 @@ export const getExpenseRealizationFormInitialValues = (
}, },
quantity: realisasiItem.qty, quantity: realisasiItem.qty,
price: realisasiItem.price, price: realisasiItem.price,
notes: realisasiItem.note, notes: realisasiItem.notes,
}; };
}) })
: kandangExpense.pengajuans : kandangExpense.pengajuans
@@ -170,7 +170,7 @@ export const getExpenseRealizationFormInitialValues = (
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.notes,
})) }))
: []; : [];
@@ -249,7 +249,7 @@ const ExpenseRealizationForm = ({
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]); }, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
return ( return (
<section className='w-full max-w-5xl'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -297,6 +297,7 @@ const ExpenseRealizationForm = ({
<ExpenseKandangsTable <ExpenseKandangsTable
type='detail' type='detail'
formType='realization'
locationId={formik.values.location?.value} locationId={formik.values.location?.value}
selectedKandangs={formik.values.kandangs ?? []} selectedKandangs={formik.values.kandangs ?? []}
onChange={kandangsChangeHandler} onChange={kandangsChangeHandler}
@@ -41,22 +41,25 @@ type ExpenseFormSchemaType = {
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> = export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({ Yup.object({
category: Yup.object({ category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), value: Yup.string()
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), .oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
label: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
}) })
.nullable() .nullable()
.optional(), .required('Kategori wajib diisi!')
.typeError('Kategori wajib diisi!'),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).nullable(),
.nullable()
.optional(),
location_id: Yup.number() location_id: Yup.number()
.required('Lokasi wajib diisi!')
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'), .typeError('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'), transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
@@ -73,9 +76,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).nullable(),
.nullable()
.optional(),
supplier_id: Yup.number() supplier_id: Yup.number()
.required('Vendor wajib diisi!') .required('Vendor wajib diisi!')
@@ -104,9 +105,12 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
.of( .of(
Yup.object({ Yup.object({
nonstock: Yup.object({ nonstock: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required('Nonstock wajib diisi!'),
label: Yup.string().required(), label: Yup.string().required('Nonstock wajib diisi!'),
}).nullable(), })
.nullable()
.required('Nonstock wajib diisi!')
.typeError('Nonstock wajib diisi!'),
nonstock_id: Yup.number() nonstock_id: Yup.number()
.required('Nonstock wajib diisi!') .required('Nonstock wajib diisi!')
.min(1, 'Nonstock wajib diisi!') .min(1, 'Nonstock wajib diisi!')
@@ -204,7 +208,7 @@ export const getExpenseFormInitialValues = (
nonstock_id: expenseItem.nonstock.id, nonstock_id: expenseItem.nonstock.id,
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.notes,
})) }))
: [], : [],
})) }))
@@ -190,30 +190,18 @@ const ExpenseRequestForm = ({
formik.setFieldValue('category', val); formik.setFieldValue('category', val);
}; };
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = useCallback(
formik.setFieldTouched('location', true); (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('location', val); const location = val as OptionType | null;
const locationId = location ? Number(location.value) : 0;
const locationId = Array.isArray(val) ? val[0]?.value : val?.value; formik.setFieldTouched('location', true);
formik.setFieldValue('location_id', locationId); formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('kandangs', []); formik.setFieldValue('location_id', locationId);
},
// Auto-create expense item for location (without kandang) []
formik.setFieldValue('expense_nonstocks', [ );
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
};
const kandangsChangeHandler = ( const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[] kandangs: { id?: number; name?: string }[]
@@ -268,6 +256,7 @@ const ExpenseRequestForm = ({
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('supplier', true); formik.setFieldTouched('supplier', true);
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier', val); formik.setFieldValue('supplier', val);
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value; const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
@@ -360,7 +349,7 @@ const ExpenseRequestForm = ({
return ( return (
<> <>
<section className='w-full max-w-5xl'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -407,6 +396,16 @@ const ExpenseRequestForm = ({
placeholder='Pilih Kategori' placeholder='Pilih Kategori'
value={formik.values.category} value={formik.values.category}
onChange={categoryChangeHandler} 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={[ options={[
{ {
value: 'BOP', value: 'BOP',
@@ -427,8 +426,13 @@ const ExpenseRequestForm = ({
value={formik.values.location} value={formik.values.location}
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue} 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' }} className={{ wrapper: 'col-span-12 sm:col-span-4' }}
/> />
@@ -438,6 +442,12 @@ const ExpenseRequestForm = ({
required required
value={formik.values.transaction_date} value={formik.values.transaction_date}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
Boolean(formik.errors.transaction_date)
}
errorMessage={formik.errors.transaction_date as string}
className={{ className={{
wrapper: 'col-span-12 sm:col-span-4', wrapper: 'col-span-12 sm:col-span-4',
}} }}
@@ -460,8 +470,12 @@ const ExpenseRequestForm = ({
value={formik.values.supplier} value={formik.values.supplier}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue} 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' }} className={{ wrapper: 'col-span-12' }}
/> />
@@ -55,6 +55,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true true
); );
formik.setFieldTouched(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
true
);
formik.setFieldValue( formik.setFieldValue(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val val
@@ -96,7 +100,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'price' | 'notes', column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
@@ -105,11 +109,14 @@ const ExpenseRequestKandangDetailExpense: React.FC<
expenseIdx expenseIdx
]?.[column] && ]?.[column] &&
Boolean( Boolean(
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof formik.errors.expense_nonstocks?.[kandangExpenseIdx] &&
Object && typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx] ===
'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
] instanceof Object && ] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx]
.cost_items?.[expenseIdx] === 'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
]?.[column] ]?.[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 ( return (
<Card <Card
className={{ className={{
@@ -202,10 +235,21 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val val
); );
}} }}
isError={isExpenseRepeaterInputError(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
options={nonstockOptions} options={nonstockOptions}
isLoading={isLoadingNonstockOptions} isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue} onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }} className={{ wrapper: 'min-w-48' }}
isClearable={true}
/> />
</td> </td>
@@ -226,6 +270,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }} className={{ wrapper: 'min-w-24' }}
/> />
</td> </td>
@@ -246,6 +295,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={ inputPrefix={
<span className='text-gray-600 font-medium'> <span className='text-gray-600 font-medium'>
Rp Rp
@@ -271,6 +325,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }} className={{ wrapper: 'min-w-24' }}
/> />
</td> </td>
@@ -447,7 +447,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.note} {pengajuan.notes}
</Text> </Text>
</View> </View>
</View> </View>
@@ -607,7 +607,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.note} {realisasi.notes}
</Text> </Text>
</View> </View>
</View> </View>
+19 -22
View File
@@ -34,7 +34,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Pihak', label: 'Pihak',
value: finance.party.id ? finance.party.name : '-', value: finance.party?.id ? finance.party?.name : '-',
}, },
{ {
label: 'Tanggal', label: 'Tanggal',
@@ -56,25 +56,21 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Nomor Rekening', 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)}`, label: `Rekening ${formatTitleCase(finance.party?.type)}`,
value: finance.party.account_number, value: finance.party?.account_number,
}, },
{ {
label: 'Nominal', label: 'Nominal',
value: formatCurrency(finance.expense_amount), value: formatCurrency(finance.nominal),
},
{
label: 'Sisa',
value: formatCurrency(finance.income_amount),
}, },
].filter((item) => { ].filter((item) => {
// Hide party account number row if transaction type is INJECTION // Hide party account number row if transaction type is INJECTION
if ( if (
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
item.label === `Rekening ${formatTitleCase(finance.party.type)}` item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
) { ) {
return false; return false;
} }
@@ -148,18 +144,19 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Card> </Card>
<div className='flex flex-row gap-2 justify-end'> <div className='flex flex-row gap-2 justify-end'>
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && ( {FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
<RequirePermission permissions='lti.finance.payments.update'> finance.party?.type !== 'SUPPLIER' && (
<Button <RequirePermission permissions='lti.finance.payments.update'>
color='warning' <Button
className='min-w-24' color='warning'
href={`/finance/detail/edit?financeId=${finance.id}`} className='min-w-24'
> href={`/finance/detail/edit?financeId=${finance.id}`}
<Icon icon='mdi:pencil-outline' /> >
Edit <Icon icon='mdi:pencil-outline' />
</Button> Edit
</RequirePermission> </Button>
)} </RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && ( {FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
<RequirePermission permissions='lti.finance.initial_balances.update'> <RequirePermission permissions='lti.finance.initial_balances.update'>
<Button <Button
+67 -54
View File
@@ -1,21 +1,17 @@
import { ChangeEventHandler, useMemo, useState } from 'react'; 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 { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/dropdown/Dropdown';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Tooltip from '@/components/Tooltip';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance'; import { Finance } from '@/types/api/finance/finance';
@@ -23,7 +19,6 @@ import {
FINANCE_INITIAL_BALANCE_STATUS, FINANCE_INITIAL_BALANCE_STATUS,
FINANCE_INJECTION_STATUS, FINANCE_INJECTION_STATUS,
FINANCE_TRANSACTION_STATUS, FINANCE_TRANSACTION_STATUS,
ROWS_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -70,19 +65,24 @@ const RowOptionsMenu = ({
{FINANCE_TRANSACTION_STATUS.includes( {FINANCE_TRANSACTION_STATUS.includes(
props.row.original.transaction_type props.row.original.transaction_type
) && ( ) &&
<RequirePermission permissions='lti.finance.payments.update'> props.row.original.party?.type !== 'SUPPLIER' && (
<Button <RequirePermission permissions='lti.finance.payments.update'>
href={`/finance/detail/edit?financeId=${props.row.original.id}`} <Button
variant='ghost' href={`/finance/detail/edit?financeId=${props.row.original.id}`}
color='warning' variant='ghost'
className='justify-start text-sm' color='warning'
> className='justify-start text-sm'
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> >
Edit <Icon
</Button> icon='material-symbols:edit-outline'
</RequirePermission> width={16}
)} height={16}
/>
Edit
</Button>
</RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes( {FINANCE_INITIAL_BALANCE_STATUS.includes(
props.row.original.transaction_type props.row.original.transaction_type
@@ -199,35 +199,37 @@ const FinanceTable = () => {
// ===== Options ===== // ===== Options =====
const transactionTypeOptions = useMemo(() => { 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 [ return [
{ label: 'Customer', value: 'CUSTOMER' }, { label: 'Customer', value: 'CUSTOMER' },
{ label: 'Supplier', value: 'SUPPLIER' }, { 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(() => { const sortByOptions = useMemo(() => {
return [ return [
{ label: 'Tanggal Pembayaran', value: 'payment_date' }, { label: 'Tanggal Pembayaran', value: 'payment_date' },
{ label: 'Tanggal Dibuat', value: 'created_at' }, { label: 'Tanggal Dibuat', value: 'created_at' },
]; ];
}, []); }, []);
const { options: bankOptions, rawData: bankRawData } = useSelect<Bank>( const {
BankApi.basePath, options: bankOptions,
'id', rawData: bankRawData,
'alias', setInputValue: bankInputValue,
'', loadMore: bankLoadMore,
{ } = useSelect<Bank>(BankApi.basePath, 'id', 'alias');
limit: 'limit',
}
);
// ===== Handler ===== // ===== Handler =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
@@ -344,10 +346,10 @@ const FinanceTable = () => {
}, },
{ {
header: 'Pihak', header: 'Pihak',
accessorFn: (finance: Finance) => finance.party.name, accessorFn: (finance: Finance) => finance.party?.name,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party.id) { if (props.row.original.party?.id) {
return <span>{props.row.original.party.name}</span>; return <span>{props.row.original.party?.name}</span>;
} }
return <span>{'-'}</span>; return <span>{'-'}</span>;
}, },
@@ -368,12 +370,12 @@ const FinanceTable = () => {
{ {
header: 'Bank', header: 'Bank',
accessorFn: (finance: Finance) => 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)', header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) => accessorFn: (finance: Finance) =>
formatCurrency(finance.expense_amount), formatCurrency(Math.abs(finance.expense_amount)),
}, },
{ {
header: 'Pemasukan (Rp)', header: 'Pemasukan (Rp)',
@@ -476,38 +478,49 @@ const FinanceTable = () => {
<div className='grid grid-cols-4 gap-6'> <div className='grid grid-cols-4 gap-6'>
<SelectInput <SelectInput
options={transactionTypeOptions} options={transactionTypeOptions}
label='Jenis Transaksi' label='Tipe Transaksi'
value={selectedTransactionType} value={selectedTransactionType}
onChange={transactionTypeChangeHandler} onChange={transactionTypeChangeHandler}
isClearable isClearable
/> />
<SelectInput
options={partyTypeOptions}
label={
selectedTransactionType
? selectedTransactionType.value === 'CUSTOMER'
? 'Pelanggan'
: 'Supplier'
: 'Pihak'
}
value={selectedPartyType}
onChange={partyTypeChangeHandler}
onInputChange={partyTypeInputValue}
onMenuScrollToBottom={partyTypeLoadMore}
isLoading={partyTypeIsLoadingOptions}
isClearable
/>
<SelectInput <SelectInput
options={ options={
isResponseSuccess(bankRawData) isResponseSuccess(bankRawData)
? bankOptions.map((bank) => ({ ? bankOptions.map((bank) => ({
label: label:
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.alias + ?.alias +
' - ' + ' - ' +
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.account_number + ?.account_number +
' - ' + ' - ' +
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.owner, ?.owner,
value: bank.value, value: bank?.value,
})) }))
: [] : []
} }
label='Bank' label='Bank'
value={selectedBank} value={selectedBank}
onChange={bankChangeHandler} onChange={bankChangeHandler}
isClearable onInputChange={bankInputValue}
/> onMenuScrollToBottom={bankLoadMore}
<SelectInput
options={partyTypeOptions}
label='Pihak'
value={selectedPartyType}
onChange={partyTypeChangeHandler}
isClearable isClearable
/> />
<DebouncedTextInput <DebouncedTextInput
@@ -32,8 +32,10 @@ import {
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceAddProps { interface FormFinanceAddProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -51,18 +53,22 @@ const FormFinanceAdd = ({
initialValues, initialValues,
}: FormFinanceAddProps) => { }: FormFinanceAddProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
const [isSupplier, setIsSupplier] = useState(
initialValues?.party?.type === 'SUPPLIER'
);
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): FinanceFormValues => { const formikInitialValues = useMemo((): FinanceFormValues => {
return { return {
party_type_option: party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find( FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type (option) => option.value === initialValues?.party?.type
) || null, ) || null,
party_id_option: initialValues?.party party_id_option: initialValues?.party
? { ? {
label: initialValues?.party.name || '', label: initialValues?.party?.name || '',
value: initialValues?.party.id || 0, value: initialValues?.party?.id || 0,
} }
: null, : null,
payment_date: initialValues?.payment_date || '', payment_date: initialValues?.payment_date || '',
@@ -72,11 +78,11 @@ const FormFinanceAdd = ({
) || null, ) || null,
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues?.bank?.name,
value: initialValues.bank.id, value: initialValues?.bank?.id,
} }
: null, : null,
party_account_number: initialValues?.party.account_number || '', party_account_number: initialValues?.party?.account_number || '',
reference_number: initialValues?.reference_number || '', reference_number: initialValues?.reference_number || '',
nominal: initialValues?.nominal.toString() || '', nominal: initialValues?.nominal.toString() || '',
notes: initialValues?.notes || '', notes: initialValues?.notes || '',
@@ -113,20 +119,22 @@ const FormFinanceAdd = ({
options: partyOptions, options: partyOptions,
isLoadingOptions: isLoadingPartyOptions, isLoadingOptions: isLoadingPartyOptions,
rawData: partyRawData, rawData: partyRawData,
setInputValue: setPartyInputValue,
loadMore: loadMorePartyOptions,
} = useSelect<PartyCommonProps>( } = useSelect<PartyCommonProps>(
formik.values.party_type_option?.value === 'CUSTOMER' formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath ? CustomerApi.basePath
: SupplierApi.basePath, : SupplierApi.basePath,
'id', 'id',
'name', 'name'
'',
{ limit: 'limit' }
); );
const { const {
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -151,6 +159,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -166,6 +175,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -205,6 +215,7 @@ const FormFinanceAdd = ({
? formik.errors.party_type_option ? formik.errors.party_type_option
: '' : ''
} }
isDisabled={type === 'edit' || isSupplier}
required required
isClearable 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'}`} placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`}
options={partyOptions} options={partyOptions}
value={formik.values.party_id_option} value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_id_option', value); formik.setFieldValue('party_id_option', value);
if (isResponseSuccess(partyRawData) && value) { if (isResponseSuccess(partyRawData) && value) {
@@ -241,7 +254,7 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={!formik.values.party_type_option?.value} isDisabled={!formik.values.party_type_option?.value || isSupplier}
/> />
<DateInput <DateInput
label='Tanggal' label='Tanggal'
@@ -259,6 +272,7 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<SelectInput <SelectInput
label='Metode Pembayaran' label='Metode Pembayaran'
@@ -280,6 +294,7 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={isSupplier}
/> />
<SelectInput <SelectInput
label='Bank' label='Bank'
@@ -304,6 +319,8 @@ const FormFinanceAdd = ({
: [] : []
} }
value={formik.values.bank_id_option} value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('bank_id_option', value); formik.setFieldValue('bank_id_option', value);
}} }}
@@ -318,6 +335,7 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={isSupplier}
/> />
<TextInput <TextInput
label={`Nomor Rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'Pihak'}`} 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 required
readOnly readOnly
disabled={isSupplier}
/> />
<TextInput <TextInput
label='Nomor Referensi' label='Nomor Referensi'
@@ -357,6 +376,7 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<NumberInput <NumberInput
label='Nominal' label='Nominal'
@@ -372,6 +392,7 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<TextArea <TextArea
label='Catatan' label='Catatan'
@@ -387,8 +408,18 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <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'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -27,13 +27,7 @@ export const InitialBalanceFormSchema = Yup.object().shape({
'Pihak wajib diisi', 'Pihak wajib diisi',
(value) => value !== null && value !== undefined (value) => value !== null && value !== undefined
), ),
bank_id_option: Yup.mixed() bank_id_option: Yup.mixed().nullable(),
.nullable()
.test(
'is-valid-option',
'Bank wajib diisi',
(value) => value !== null && value !== undefined
),
reference_number: Yup.string().required('Nomor referensi wajib diisi'), reference_number: Yup.string().required('Nomor referensi wajib diisi'),
initial_balance_type_option: Yup.mixed() initial_balance_type_option: Yup.mixed()
.nullable() .nullable()
@@ -29,8 +29,9 @@ import { Bank } from '@/types/api/master-data/bank';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FormFinanceAddInitialBalanceProps { interface FormFinanceAddInitialBalanceProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -42,6 +43,7 @@ const FormFinanceAddInitialBalance = ({
initialValues, initialValues,
}: FormFinanceAddInitialBalanceProps) => { }: FormFinanceAddInitialBalanceProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): InitialBalanceFormValues => { const formikInitialValues = useMemo((): InitialBalanceFormValues => {
@@ -53,18 +55,18 @@ const FormFinanceAddInitialBalance = ({
return { return {
party_type_option: party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find( FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type (option) => option.value === initialValues?.party?.type
) || null, ) || null,
party_id_option: initialValues?.party party_id_option: initialValues?.party
? { ? {
label: initialValues.party.name, label: initialValues.party?.name,
value: initialValues.party.id, value: initialValues.party?.id,
} }
: null, : null,
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues.bank?.name,
value: initialValues.bank.id, value: initialValues.bank?.id,
} }
: null, : null,
reference_number: initialValues?.reference_number || '', reference_number: initialValues?.reference_number || '',
@@ -104,21 +106,25 @@ const FormFinanceAddInitialBalance = ({
}); });
// ===== Options ===== // ===== Options =====
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } = const {
useSelect( options: partyOptions,
formik.values.party_type_option?.value === 'CUSTOMER' isLoadingOptions: isLoadingPartyOptions,
? CustomerApi.basePath setInputValue: setPartyInputValue,
: SupplierApi.basePath, loadMore: loadMorePartyOptions,
'id', } = useSelect(
'name', formik.values.party_type_option?.value === 'CUSTOMER'
'', ? CustomerApi.basePath
{ limit: 'limit' } : SupplierApi.basePath,
); 'id',
'name'
);
const { const {
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -143,6 +149,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -162,6 +169,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -189,6 +197,8 @@ const FormFinanceAddInitialBalance = ({
placeholder='Pilih jenis pihak' placeholder='Pilih jenis pihak'
options={FINANCE_PARTY_TYPE_OPTIONS} options={FINANCE_PARTY_TYPE_OPTIONS}
value={formik.values.party_type_option} value={formik.values.party_type_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_type_option', value); formik.setFieldValue('party_type_option', value);
formik.setFieldValue('party_id_option', null); formik.setFieldValue('party_id_option', null);
@@ -205,6 +215,7 @@ const FormFinanceAddInitialBalance = ({
: '' : ''
} }
required required
isDisabled={type === 'edit'}
isClearable isClearable
/> />
<SelectInput <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'}`} placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`}
options={partyOptions} options={partyOptions}
value={formik.values.party_id_option} value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_id_option', value); formik.setFieldValue('party_id_option', value);
}} }}
@@ -269,7 +282,6 @@ const FormFinanceAddInitialBalance = ({
? formik.errors.bank_id_option ? formik.errors.bank_id_option
: '' : ''
} }
required
isClearable isClearable
/> />
<TextInput <TextInput
@@ -354,7 +366,18 @@ const FormFinanceAddInitialBalance = ({
} }
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <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'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -24,8 +24,10 @@ import {
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceInjectionProps { interface FormFinanceInjectionProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -37,14 +39,15 @@ const FormFinanceInjection = ({
initialValues, initialValues,
}: FormFinanceInjectionProps) => { }: FormFinanceInjectionProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): InjectionFormValues => { const formikInitialValues = useMemo((): InjectionFormValues => {
return { return {
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues.bank?.name,
value: initialValues.bank.id, value: initialValues.bank?.id,
} }
: null, : null,
adjustment_date: initialValues?.payment_date || '', adjustment_date: initialValues?.payment_date || '',
@@ -80,7 +83,9 @@ const FormFinanceInjection = ({
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -101,6 +106,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -117,6 +123,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -162,6 +169,8 @@ const FormFinanceInjection = ({
: [] : []
} }
value={formik.values.bank_id_option} value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('bank_id_option', value); formik.setFieldValue('bank_id_option', value);
}} }}
@@ -226,6 +235,15 @@ const FormFinanceInjection = ({
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <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'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage, InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage, setInventoryAdjustmentFormErrorMessage,
] = useState(''); ] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true); const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok'); const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler // Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
}); });
// Fetch Data // Fetch Data
const productCategoriesUrl = `${ const {
ProductCategoryApi.basePath setInputValue: setProductCategoryInputValue,
}?${new URLSearchParams({ options: productCategoryOptions,
search: '', isLoadingOptions: isLoadingProductCategoryOptions,
}).toString()}`; loadMore: loadMoreProductCategories,
const { data: productCategories, isLoading: isLoadingProductCategories } = } = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setProductInputValue,
product_category_id: selectedProductCategories, options: productOptions,
}).toString()}`; isLoadingOptions: isLoadingProductOptions,
const { data: products, isLoading: isLoadingProducts } = useSWR( loadMore: loadMoreProducts,
productUrl, } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
ProductApi.getAllFetcher product_category_id: formik.values.product_category_id
); ? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setWarehouseInputValue,
limit: '100', options: warehouseOptions,
}).toString()}`; isLoadingOptions: isLoadingWarehouseOptions,
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( loadMore: loadMoreWarehouses,
warehouseUrl, } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
// Options Handler // Options Handler
const productCategoryChangeHandler = ( const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null; const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled); setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0); formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect // Effect
useEffect(() => { useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) { if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false); setDisabledProduct(false);
formik.setFieldValue( formik.setFieldValue(
'product_id', 'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
); );
formik.setFieldValue('note', initialValues.note); formik.setFieldValue('note', initialValues.note);
} }
}, [ }, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function // Utils Function
const formatNumber = (value: string) => { const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk' label='Kategori Produk'
value={formik.values.product_category as OptionType} value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler} onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories} onInputChange={setProductCategoryInputValue}
options={optionsProductCategory} options={productCategoryOptions}
isLoading={isLoadingProductCategories} onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={ isError={
formik.touched.product_category && formik.touched.product_category &&
Boolean(formik.errors.product_category) Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk' label='Produk'
value={formik.values.product as OptionType} value={formik.values.product as OptionType}
onChange={productChangeHandler} onChange={productChangeHandler}
options={optionsProduct} onInputChange={setProductInputValue}
isLoading={isLoadingProducts} options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)} isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string} errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct} isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse' label='Warehouse'
value={formik.values.warehouse as OptionType} value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
options={optionsWarehouse} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouses} options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={ isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse) formik.touched.warehouse && Boolean(formik.errors.warehouse)
} }
@@ -110,6 +110,14 @@ const DeliveryProductObjectSchema = Yup.object({
.typeError('Qty harus berupa angka!'), .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({ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
delivery_cost: Yup.number() delivery_cost: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .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_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>() document: DeliveryDocumentSchema,
.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;
}),
driver_name: Yup.string().required('Nama sopir wajib diisi!'), driver_name: Yup.string().required('Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
supplier: Yup.object({ supplier: Yup.object({
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant'; import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper'; import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]); const [formErrorList, setFormErrorList] = useState<string[]>([]);
@@ -93,10 +91,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
const { const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses, isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
flag: 'EKSPEDISI',
});
// ===== SELECT INPUT DATA ===== // ===== SELECT INPUT DATA =====
const { const {
@@ -107,12 +108,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
category: 'BOP', category: 'BOP',
}); });
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING ===== // ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => { const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map(); if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -268,26 +263,64 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, },
}); });
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) ===== const prevSourceWarehouseIdRef = useRef<number | null>(
const getProductWarehousesUrl = useCallback(() => { formik.values.source_warehouse_id
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 productWarehousesUrl = getProductWarehousesUrl(); // ===== RESET PRODUCTS WHEN SOURCE WAREHOUSE CHANGES =====
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = useEffect(() => {
useSWR( const prevSourceWarehouseId = prevSourceWarehouseIdRef.current;
formik.values.source_warehouse_id ? productWarehousesUrl : null, const currentSourceWarehouseId = formik.values.source_warehouse_id;
ProductWarehouseApi.getAllFetcher
); 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) const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({ ? 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 ===== // ===== EVENT HANDLERS =====
// Product Handlers const handleTransferDateChange = useCallback(
const addProduct = () => { (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 = [ const newProducts = [
...(formik.values.products || []), ...(formik.values.products || []),
{ {
@@ -373,22 +464,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, },
]; ];
formik.setFieldValue('products', newProducts); formik.setFieldValue('products', newProducts);
}; }, []);
const removeProduct = useCallback( const removeProduct = useCallback((i: number) => {
(i: number) => { const updatedProducts =
const updatedProducts = formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
formik.values.products?.reduce((acc: ProductSchema[], item, index) => { if (index !== i) {
if (index !== i) { acc.push(item);
acc.push(item); }
} return acc;
return acc; }, []) ?? [];
}, []) ?? [];
formik.setFieldValue('products', updatedProducts); formik.setFieldValue('products', updatedProducts);
}, }, []);
[formik]
);
const bulkRemoveProduct = useCallback(() => { const bulkRemoveProduct = useCallback(() => {
const updatedProducts = const updatedProducts =
@@ -397,10 +485,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? []; ) ?? [];
formik.setFieldValue('products', updatedProducts); formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]); setSelectedProducts([]);
}, [formik, selectedProducts]); }, [formik, selectedProducts, setSelectedProducts]);
// Delivery Handlers const handleProductChange = useCallback(
const addDelivery = () => { (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.setFieldValue('deliveries', [
...(formik.values.deliveries || []), ...(formik.values.deliveries || []),
{ {
@@ -420,25 +543,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
], ],
}, },
]); ]);
}; }, []);
const removeDelivery = useCallback( const removeDelivery = useCallback((i: number) => {
(i: number) => { const updatedDeliveries =
const updatedDeliveries = formik.values.deliveries?.reduce((acc: DeliverySchema[], item, index) => {
formik.values.deliveries?.reduce( if (index !== i) {
(acc: DeliverySchema[], item, index) => { acc.push(item);
if (index !== i) { }
acc.push(item); return acc;
} }, []) ?? [];
return acc;
},
[]
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries); formik.setFieldValue('deliveries', updatedDeliveries);
}, }, []);
[formik]
);
const bulkRemoveDelivery = useCallback(() => { const bulkRemoveDelivery = useCallback(() => {
const updatedDeliveries = const updatedDeliveries =
@@ -447,33 +564,101 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? []; ) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries); formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]); setSelectedDeliveries([]);
}, [formik, selectedDeliveries]); }, [formik, selectedDeliveries, setSelectedDeliveries]);
// Cost Calculation Handlers const handleDeliverySelectAllChange = useCallback(
const handleDeliveryCostChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => {
(idx: number, value: number) => { if (e.target.checked) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value); setSelectedDeliveries(
formik.values.deliveries?.map((_, idx) => idx) ?? []
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) { } else {
const perItem = value / productQty; setSelectedDeliveries([]);
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
} }
}, },
[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( const handleDeliveryCostPerItemChange = useCallback(
(idx: number, value: number) => { (idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value); formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
@@ -492,7 +677,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
} }
}, },
[formik] []
); );
const handleDeliveryCostChangeWrapper = useCallback( const handleDeliveryCostChangeWrapper = useCallback(
@@ -967,45 +1152,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang' label='Gudang'
placeholder='Pilih gudang asal...' placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse} value={formik.values.source_warehouse}
onChange={(val) => { onChange={handleSourceWarehouseChange}
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);
}
}}
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
isError={ isError={
formik.touched.source_warehouse_id && formik.touched.source_warehouse_id &&
@@ -1066,44 +1216,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang' label='Gudang'
placeholder='Pilih gudang tujuan...' placeholder='Pilih gudang tujuan...'
value={formik.values.destination_warehouse} value={formik.values.destination_warehouse}
onChange={(val) => { onChange={handleDestinationWarehouseChange}
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);
}
}}
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
formik.touched.destination_warehouse_id && formik.touched.destination_warehouse_id &&
Boolean(formik.errors.destination_warehouse_id) Boolean(formik.errors.destination_warehouse_id)
@@ -1173,18 +1290,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedProducts.length && selectedProducts.length &&
formik.values.products?.length > 0 formik.values.products?.length > 0
} }
onChange={( onChange={handleProductSelectAllChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map((_, idx) => idx) ??
[]
);
} else {
setSelectedProducts([]);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1221,17 +1327,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput <CheckboxInput
name={`product-${idx}`} name={`product-${idx}`}
checked={selectedProducts.includes(idx)} checked={selectedProducts.includes(idx)}
onChange={( onChange={handleProductCheckboxChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([...selectedProducts, idx]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1243,26 +1339,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput <SelectInput
required required
value={product.product ?? undefined} value={product.product ?? undefined}
onChange={(val) => { onChange={(val) => handleProductChange(idx, 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
);
}}
options={productWarehouseOptions} options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue} onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
isLoading={isLoadingProductWarehouses} isLoading={isLoadingProductWarehouses}
isDisabled={ isDisabled={
type === 'detail' || type === 'detail' ||
@@ -1386,19 +1466,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedDeliveries.length && selectedDeliveries.length &&
formik.values.deliveries?.length > 0 formik.values.deliveries?.length > 0
} }
onChange={( onChange={handleDeliverySelectAllChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedDeliveries([]);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1481,20 +1549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput <CheckboxInput
name={`delivery-${idx}`} name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)} checked={selectedDeliveries.includes(idx)}
onChange={( onChange={handleDeliveryCheckboxChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter((i) => i !== idx)
);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1507,24 +1562,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required required
placeholder='Pilih produk...' placeholder='Pilih produk...'
value={delivery.products[0]?.product ?? undefined} value={delivery.products[0]?.product ?? undefined}
onChange={(val) => { onChange={(val) =>
formik.setFieldTouched( handleDeliveryProductChange(idx, val)
`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
);
}}
options={getFilteredProductWarehouseOptions()} options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
@@ -1575,24 +1615,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required required
placeholder='Pilih supplier...' placeholder='Pilih supplier...'
value={delivery.supplier} value={delivery.supplier}
onChange={(val) => { onChange={(val) =>
formik.setFieldTouched( handleDeliverySupplierChange(idx, val)
`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
);
}}
options={supplierOptions} options={supplierOptions}
onInputChange={setSupplierSelectInputValue} onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers} isLoading={isLoadingSuppliers}
@@ -1684,20 +1709,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<FileInput <FileInput
accept='.pdf,.jpg,.jpeg,.png' accept='.pdf,.jpg,.jpeg,.png'
name={`deliveries.${idx}.document`} name={`deliveries.${idx}.document`}
onChange={(e) => { onChange={(e) =>
const file = e.target.files?.[0]; handleDeliveryDocumentChange(idx, e)
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
);
}
}}
{...isRepeaterInputError( {...isRepeaterInputError(
'deliveries', 'deliveries',
'document', 'document',
@@ -91,7 +91,7 @@ const InventoryProductDetail = ({
<td>:</td> <td>:</td>
<td> <td>
{inventoryProduct?.tax {inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax) ? formatNumber(inventoryProduct?.tax) + '%'
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -16,7 +16,7 @@ import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar'; import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { import {
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
@@ -33,6 +33,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { CustomerApi, ProductApi } from '@/services/api/master-data'; import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line'; import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import Badge from '@/components/Badge';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -184,12 +185,16 @@ const MarketingTable = () => {
const { const {
options: productsOptions, options: productsOptions,
isLoadingOptions: isLoadingProductsOptions, isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', { } = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
const { const {
options: customersOptions, options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions, isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', { } = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
@@ -400,6 +405,8 @@ const MarketingTable = () => {
.join(',') || '' .join(',') || ''
) )
} }
onInputChange={setProductsInputValue}
onMenuScrollToBottom={loadMoreProducts}
isMulti isMulti
/> />
{/* select status */} {/* select status */}
@@ -444,6 +451,8 @@ const MarketingTable = () => {
(value as OptionType)?.value.toString() || '' (value as OptionType)?.value.toString() || ''
) )
} }
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/> />
</TableRowSizeSelector> </TableRowSizeSelector>
</div> </div>
@@ -512,8 +521,53 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'latest_approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', 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', accessorKey: 'customer.name',
@@ -16,6 +16,7 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
formatNumber, formatNumber,
formatTitleCase,
formatVechicleNumber, formatVechicleNumber,
} from '@/lib/helper'; } from '@/lib/helper';
import { import {
@@ -34,6 +35,7 @@ import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport'; import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport'; import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import Badge from '@/components/Badge';
const MarketingDetail = ({ const MarketingDetail = ({
initialValues, initialValues,
@@ -121,6 +123,10 @@ const MarketingDetail = ({
); );
}; };
const approval = initialValues?.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return ( return (
<> <>
<div className='flex flex-col w-full gap-4'> <div className='flex flex-col w-full gap-4'>
@@ -230,7 +236,46 @@ const MarketingDetail = ({
<tr> <tr>
<td className='font-semibold'>Status</td> <td className='font-semibold'>Status</td>
<td>:</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>
<tr> <tr>
<td className='font-semibold'>Tanggal Penjualan</td> <td className='font-semibold'>Tanggal Penjualan</td>
@@ -11,6 +11,13 @@ import {
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
sales_person_id: number | undefined; sales_person_id: number | undefined;
sales_person:
| {
value: number;
label: string;
}
| undefined
| null;
customer: customer:
| { | {
value: number; value: number;
@@ -33,7 +40,11 @@ type DeliveryOrderSchemaType = {
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> = export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({ Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'), 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({ customer: Yup.object({
value: Yup.number().required(), value: Yup.number().required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -50,6 +50,8 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { CreatedUser } from '@/types/api/api-general';
import { UserApi } from '@/services/api/user';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -244,7 +246,15 @@ const MarketingForm = ({
const { const {
options: customerOptions, options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions, isLoadingOptions: isLoadingCustomerOptions,
setInputValue: setInputCustomerValue,
loadMore: loadMoreCustomer,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name'); } = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const {
options: salesOptions,
isLoadingOptions: isLoadingSalesOptions,
setInputValue: setInputSalesValue,
loadMore: loadMoreSales,
} = useSelect<CreatedUser>(UserApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ================== // ================== SETUP FORMIK ==================
const formikInitialValues = useMemo< const formikInitialValues = useMemo<
@@ -255,6 +265,12 @@ const MarketingForm = ({
notes: initialValues?.notes || undefined, notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined, customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1, 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 customer: initialValues?.customer
? { ? {
value: initialValues.customer.id, value: initialValues.customer.id,
@@ -345,6 +361,8 @@ const MarketingForm = ({
}, },
}); });
const memoSalesOrder = formik.values.sales_order;
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
setIsLoading(true); 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(() => { const handleDelete = useCallback(() => {
deleteModal.openModal(); deleteModal.openModal();
}, [deleteModal]); }, [deleteModal]);
// ================== SALES ORDER HANDLER ================== // ================== SALES ORDER HANDLER ==================
const handleDeleteSO = useCallback((id: number) => { const handleDeleteSO = useCallback(
const currentProducts = formik.values.sales_order; (id: number) => {
formik.setFieldValue( const currentProducts = formik.values.sales_order;
'sales_order', formik.setFieldValue(
currentProducts.filter((p) => p.id != id) '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 handleBulkDeleteSO = useCallback(() => {
const currentProducts = formik.values.sales_order; const currentProducts = formik.values.sales_order;
formik.setFieldValue( formik.setFieldValue(
@@ -464,7 +501,7 @@ const MarketingForm = ({
) )
); );
setRowSOSelection({}); setRowSOSelection({});
}, [selectedRowSOIds]); }, [selectedRowSOIds, memoSalesOrder]);
const handleAddSOClick = useCallback(() => { const handleAddSOClick = useCallback(() => {
setSelectedMarketingProduct(null); setSelectedMarketingProduct(null);
addSOModal.openModal(); addSOModal.openModal();
@@ -500,7 +537,7 @@ const MarketingForm = ({
addSOModal.closeModal(); addSOModal.closeModal();
}, },
[addSOModal] [addSOModal, memoSalesOrder]
); );
// ================== DELIVERY ORDER HANDLER ================== // ================== DELIVERY ORDER HANDLER ==================
@@ -545,8 +582,30 @@ const MarketingForm = ({
}, },
[addDOModal] [addDOModal]
); );
const handleDeleteDO = useCallback(
const memoSalesOrder = formik.values.sales_order; 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(() => { useEffect(() => {
formik.setFieldValue('delivery_order', deliveryOrderValues); formik.setFieldValue('delivery_order', deliveryOrderValues);
@@ -580,6 +639,7 @@ const MarketingForm = ({
className={{ className={{
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
variant='bordered'
> >
<div className='grid sm:grid-cols-2 gap-3 mt-3'> <div className='grid sm:grid-cols-2 gap-3 mt-3'>
<SelectInput <SelectInput
@@ -588,6 +648,8 @@ const MarketingForm = ({
isLoading={isLoadingCustomerOptions} isLoading={isLoadingCustomerOptions}
value={formik.values.customer} value={formik.values.customer}
onChange={handleChangeCustomer} onChange={handleChangeCustomer}
onInputChange={setInputCustomerValue}
onMenuScrollToBottom={loadMoreCustomer}
isError={ isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id) formik.touched.customer_id && Boolean(formik.errors.customer_id)
} }
@@ -595,7 +657,9 @@ const MarketingForm = ({
isClearable isClearable
placeholder='Pilih Pelanggan' placeholder='Pilih Pelanggan'
isDisabled={ isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver' formType === 'add_deliver' ||
formType === 'edit_deliver' ||
formType === 'edit'
} }
/> />
<DateInput <DateInput
@@ -617,6 +681,7 @@ const MarketingForm = ({
className={{ className={{
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
variant='bordered'
> >
<MemoizedSalesOrderProductTable <MemoizedSalesOrderProductTable
formType={formType} formType={formType}
@@ -625,6 +690,7 @@ const MarketingForm = ({
setRowSelection={setRowSOSelection} setRowSelection={setRowSOSelection}
selectedRowIds={selectedRowSOIds} selectedRowIds={selectedRowSOIds}
onDelete={handleDeleteSO} onDelete={handleDeleteSO}
onEdit={handleEditSO}
onBulkDelete={handleBulkDeleteSO} onBulkDelete={handleBulkDeleteSO}
onAddProductClick={handleAddSOClick} onAddProductClick={handleAddSOClick}
/> />
@@ -644,6 +710,7 @@ const MarketingForm = ({
formType={formType} formType={formType}
data={deliveryOrderValues} data={deliveryOrderValues}
onEdit={handleEditDO} onEdit={handleEditDO}
onDelete={handleDeleteDO}
onAddProductClick={handleAddDOClick} onAddProductClick={handleAddDOClick}
/> />
</Card> </Card>
@@ -651,19 +718,42 @@ const MarketingForm = ({
{/* Input Notes */} {/* Input Notes */}
<div className='grid sm:grid-cols-2 gap-3'> <div className='grid sm:grid-cols-2 gap-3'>
<DebouncedTextArea <div className='flex flex-col h-full items-end gap-3'>
required <SelectInput
name='notes' label='Sales'
label='Catatan' options={salesOptions}
rows={3} isLoading={isLoadingSalesOptions}
placeholder='Masukan catatan penjualan' value={formik.values.sales_person}
value={formik.values.notes} onChange={handleChangeSalesPerson}
onChange={formik.handleChange} onInputChange={setInputSalesValue}
isError={formik.touched.notes && Boolean(formik.errors.notes)} onMenuScrollToBottom={loadMoreSales}
errorMessage={formik.errors.notes} isError={
disabled={formType === 'add_deliver' || formType === 'edit_deliver'} formik.touched.sales_person_id &&
/> Boolean(formik.errors.sales_person_id)
<div className='flex flex-col h-full justify-between items-end py-6'> }
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>Total Penjualan</span>
<span className='text-lg font-semibold'> <span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '} {formatCurrency(grandTotal)}{' '}
@@ -18,6 +18,11 @@ import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import 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 = ({ const DeliveryOrderProductForm = ({
formState, formState,
@@ -43,6 +48,17 @@ const DeliveryOrderProductForm = ({
); );
const [currentInput, setCurrentInput] = useState<string>(''); 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( const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
@@ -90,6 +106,7 @@ const DeliveryOrderProductForm = ({
await onUpdateForm?.(values.marketing_product_id as number, values); await onUpdateForm?.(values.marketing_product_id as number, values);
} }
handleResetForm(); handleResetForm();
setSelectedProduct(null);
}, },
}); });
@@ -108,27 +125,65 @@ const DeliveryOrderProductForm = ({
marketing_product: undefined, marketing_product: undefined,
}, },
}); });
setSelectedProduct(null); // setSelectedProduct(null);
}; };
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field); setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { const qty = Number(formik.values.qty || 0);
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) { const avgWeight = Number(formik.values.avg_weight || 0);
formik.setFieldValue('total_price', Number(qty) * Number(unit_price)); const totalWeight = Number(formik.values.total_weight || 0);
} else if (qty && total_price && field === 'total_price') { const unitPrice = Number(formik.values.unit_price || 0);
formik.setFieldValue('unit_price', Number(total_price) / Number(qty)); 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') { case 'avg_weight': {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) { if (avgWeight > 0) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight)); const tw = roundWeight(qty * avgWeight);
} else if (qty && total_weight && field === 'total_weight') { formik.setFieldValue('total_weight', tw);
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
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>
)} )}
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-3 gap-4'>
<SelectInput <SelectInput
options={options} options={options}
label='Produk' label='Produk'
@@ -287,7 +342,9 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.vehicle_number)} isError={Boolean(formik.errors.vehicle_number)}
errorMessage={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 <NumberInput
required required
label='Kuantitas' label='Kuantitas'
@@ -301,33 +358,28 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)} isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' 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={ bottomLabel={
formik.values.marketing_product_id formik.values.marketing_product_id
? 'Stok dijual: ' + ? 'Stok dijual: ' +
salesOrders?.find( salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id (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 <NumberInput
required required
label='Harga Satuan (Rp)' label='Harga Satuan (Rp)'
@@ -342,7 +394,20 @@ const DeliveryOrderProductForm = ({
errorMessage={formik.errors.unit_price} errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan' 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 <NumberInput
required required
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
@@ -18,6 +18,7 @@ type SalesOrderProductSchemaType = {
avg_weight: string | number | undefined; avg_weight: string | number | undefined;
total_price: string | number | undefined; total_price: string | number | undefined;
vehicle_number?: string | undefined; vehicle_number?: string | undefined;
uom?: string | null | undefined;
}; };
export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> = export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaType> =
@@ -57,6 +58,7 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
total_price: Yup.number() total_price: Yup.number()
.min(1, 'Total Penjualan wajib diisi!') .min(1, 'Total Penjualan wajib diisi!')
.required('Total Penjualan wajib diisi!'), .required('Total Penjualan wajib diisi!'),
uom: Yup.string().nullable().optional().notRequired(),
}); });
export type SalesOrderProductFormValues = Yup.InferType< export type SalesOrderProductFormValues = Yup.InferType<
@@ -11,7 +11,7 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang'; 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 { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
@@ -26,6 +26,10 @@ import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -39,21 +43,35 @@ const SalesOrderProductForm = ({
}) => { }) => {
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>(''); 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 ============ // ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ const formik = useFormik<SalesOrderProductFormValues>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
vehicle_number: initialValues?.vehicle_number || undefined, vehicle_number: initialValues?.vehicle_number || '',
kandang_id: initialValues?.kandang_id || undefined, kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || undefined, kandang: initialValues?.kandang || null,
product_warehouse: initialValues?.product_warehouse || undefined, product_warehouse: initialValues?.product_warehouse || null,
product_warehouse_id: initialValues?.product_warehouse_id || undefined, product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialValues?.unit_price || undefined, unit_price: initialValues?.unit_price || '',
total_weight: initialValues?.total_weight || undefined, total_weight: initialValues?.total_weight || '',
qty: initialValues?.qty || undefined, qty: initialValues?.qty || '',
avg_weight: initialValues?.avg_weight || undefined, avg_weight: initialValues?.avg_weight || '',
total_price: initialValues?.total_price || undefined, total_price: initialValues?.total_price || '',
uom: initialValues?.uom || '',
}, },
validationSchema: SalesOrderProductSchema, validationSchema: SalesOrderProductSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
@@ -69,17 +87,21 @@ const SalesOrderProductForm = ({
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
const { const {
options: warehouseSourceOptions, options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions, isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouse,
} = useSelect<ProductWarehouse>( } = useSelect<ProductWarehouse>(
ProductWarehouseApi.basePath, ProductWarehouseApi.basePath,
'id', 'id',
'product.name', 'product.name',
'search', '',
{ {
warehouse_id: formik.values.kandang_id?.toString() ?? '', warehouse_id: formik.values.kandang_id?.toString() ?? '',
} }
@@ -112,6 +134,7 @@ const SalesOrderProductForm = ({
const productWarehouse = warehouseSourceRawData?.data.find( const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === newId (item: ProductWarehouse) => item.id === newId
); );
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
@@ -139,40 +162,78 @@ const SalesOrderProductForm = ({
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field); setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { const qty = Number(formik.values.qty || 0);
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) { const avgWeight = Number(formik.values.avg_weight || 0);
formik.setFieldValue( const totalWeight = Number(formik.values.total_weight || 0);
'total_price', const unitPrice = Number(formik.values.unit_price || 0);
(qty as number) * (unit_price as number) const totalPrice = Number(formik.values.total_price || 0);
);
} else if (qty && total_price && field === 'total_price') { if (qty <= 0) return;
formik.setFieldValue(
'unit_price', switch (field) {
(total_price as number) / (qty as number) // ===== 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') { case 'avg_weight': {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) { if (avgWeight > 0) {
formik.setFieldValue( const tw = roundWeight(qty * avgWeight);
'total_weight', formik.setFieldValue('total_weight', tw);
(qty as number) * (avg_weight as number)
); if (unitPrice > 0) {
} else if (qty && total_weight && field === 'total_weight') { formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
formik.setFieldValue( }
'avg_weight', }
(total_weight as number) / (qty as number) 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 ===== // ===== 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 ( return (
<> <>
@@ -188,7 +249,7 @@ const SalesOrderProductForm = ({
</Alert> </Alert>
</div> </div>
)} )}
<div className='grid sm:grid-cols-2 gap-4 z-200'> <div className='grid sm:grid-cols-3 gap-4 z-200'>
<PatternInput <PatternInput
name='vehicle_number' name='vehicle_number'
label='No. Polisi' label='No. Polisi'
@@ -215,6 +276,8 @@ const SalesOrderProductForm = ({
value={formik.values.kandang} value={formik.values.kandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
isClearable isClearable
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -228,6 +291,8 @@ const SalesOrderProductForm = ({
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse} value={formik.values.product_warehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable isClearable
placeholder={ placeholder={
formik.values.kandang_id formik.values.kandang_id
@@ -243,6 +308,9 @@ const SalesOrderProductForm = ({
} }
errorMessage={formik.errors.product_warehouse_id} 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 <NumberInput
required required
label='Kuantitas' label='Kuantitas'
@@ -256,6 +324,15 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)} isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' 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={ bottomLabel={
isResponseSuccess(warehouseSourceRawData) && isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id formik.values.product_warehouse_id
@@ -264,32 +341,13 @@ const SalesOrderProductForm = ({
(item) => item.id === formik.values.product_warehouse_id (item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0 )?.quantity ?? 0
)} ${ )} ${
warehouseSourceRawData?.data?.find( isResponseSuccess(productData)
(item) => item.id === formik.values.product_warehouse_id ? productData?.data?.uom.name
)?.product?.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 <NumberInput
required required
label='Harga Satuan (Rp)' label='Harga Satuan (Rp)'
@@ -306,6 +364,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.unit_price} errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan' 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 <NumberInput
required required
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
@@ -16,6 +16,7 @@ type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[]; data: DeliveryOrderProductFormValues[];
formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver'; formType?: 'add' | 'edit' | 'add_deliver' | 'edit_deliver';
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (id: number) => void;
onAddProductClick: () => void; onAddProductClick: () => void;
}; };
@@ -23,10 +24,13 @@ const DeliveryOrderProductTable = ({
data, data,
formType, formType,
onEdit, onEdit,
onDelete,
onAddProductClick, onAddProductClick,
}: DeliveryOrderProductTableProps) => { }: DeliveryOrderProductTableProps) => {
const onEditRef = useRef(onEdit); const onEditRef = useRef(onEdit);
onEditRef.current = onEdit; onEditRef.current = onEdit;
const onDeleteRef = useRef(onDelete);
onDeleteRef.current = onDelete;
const canAddData = data.filter((item) => !Boolean(item.qty)); 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'> <div className='flex flex-row gap-1 items-center justify-end h-full mt-2'>
<> <>
{props.row.original.qty && ( {props.row.original.qty && (
<Button <>
color='warning' <Button
className='px-2 py-1 text-sm' color='warning'
onClick={() => className='px-2 py-1 text-sm'
onEditRef.current(props.row.original.id as number) onClick={() =>
} onEditRef.current(props.row.original.id as number)
type='button' }
> type='button'
<Icon icon='mdi:edit' width={16} height={16} /> Edit >
</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 && '-'} {!props.row.original.qty && '-'}
</> </>
@@ -23,6 +23,7 @@ type SalesOrderProductTableProps = {
>; >;
selectedRowIds: number[]; selectedRowIds: number[];
onDelete: (id: number) => void; onDelete: (id: number) => void;
onEdit: (id: number) => void;
onBulkDelete: () => void; onBulkDelete: () => void;
onAddProductClick: () => void; onAddProductClick: () => void;
}; };
@@ -34,11 +35,14 @@ const SalesOrderProductTable = ({
setRowSelection, setRowSelection,
selectedRowIds, selectedRowIds,
onDelete, onDelete,
onEdit,
onBulkDelete, onBulkDelete,
onAddProductClick, onAddProductClick,
}: SalesOrderProductTableProps) => { }: SalesOrderProductTableProps) => {
const onDeleteRef = useRef(onDelete); const onDeleteRef = useRef(onDelete);
onDeleteRef.current = onDelete; onDeleteRef.current = onDelete;
const onEditRef = useRef(onEdit);
onEditRef.current = onEdit;
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -92,17 +96,26 @@ const SalesOrderProductTable = ({
}, },
{ {
accessorFn: (row: SalesOrderProductFormValues) => accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.total_weight as string)), formatNumber(parseFloat(row.total_weight as string), undefined, 0, 5),
header: 'Total Bobot (Kg)', header: 'Total Bobot (Kg)',
}, },
{ {
accessorFn: (row: SalesOrderProductFormValues) => accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.qty as string)), formatNumber(parseFloat(row.qty as string)),
header: 'Kuantitas', header: 'Kuantitas',
cell: ({ row }: { row: TanStack.Row<SalesOrderProductFormValues> }) =>
formatNumber(
parseFloat(row.original.qty as string),
undefined,
0,
5
) +
' ' +
(row.original.uom ?? ''),
}, },
{ {
accessorFn: (row: SalesOrderProductFormValues) => accessorFn: (row: SalesOrderProductFormValues) =>
formatNumber(parseFloat(row.avg_weight as string)), formatNumber(parseFloat(row.avg_weight as string), undefined, 0, 5),
header: 'Avg. Bobot (Kg)', header: 'Avg. Bobot (Kg)',
}, },
{ {
@@ -116,6 +129,14 @@ const SalesOrderProductTable = ({
props: TanStack.CellContext<SalesOrderProductFormValues, unknown> props: TanStack.CellContext<SalesOrderProductFormValues, unknown>
) => ( ) => (
<div className='flex flex-row gap-1 items-center justify-end h-full mt-2'> <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 <Button
color='error' color='error'
className='p-1' className='p-1'
@@ -124,7 +145,7 @@ const SalesOrderProductTable = ({
} }
type='button' type='button'
> >
<Icon icon='mdi:trash' width={16} height={16} /> <Icon icon='mdi:trash' width={16} height={16} /> Hapus
</Button> </Button>
</div> </div>
), ),
@@ -7,6 +7,7 @@ import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path'; import { format } from 'path';
import { date } from 'yup'; import { date } from 'yup';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
interface DeliveryOrderExportProps { interface DeliveryOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -23,7 +24,7 @@ const DeliveryOrderExport = ({
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!salesData) { if (!salesData) {
alert('No sales order data available'); toast.error('No sales order data available');
return; return;
} }
setIsGeneratingPDF(true); setIsGeneratingPDF(true);
@@ -40,8 +41,7 @@ const DeliveryOrderExport = ({
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error('Error generating PDF:', error); toast.error('Failed to generate PDF. Please try again.');
alert('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
} }
@@ -5,6 +5,7 @@ import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
interface SalesOrderExportProps { interface SalesOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -17,7 +18,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!salesData) { if (!salesData) {
alert('No sales order data available'); toast.error('No sales order data available');
return; return;
} }
setIsGeneratingPDF(true); setIsGeneratingPDF(true);
@@ -32,8 +33,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error('Error generating PDF:', error); toast.error('Failed to generate PDF. Please try again.');
alert('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
} }
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Area } from '@/types/api/master-data/area'; import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const AreasTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshAreas();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data'; import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -177,7 +177,14 @@ const BanksTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshBanks();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant'; 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 { cn } from '@/lib/helper';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -186,7 +186,16 @@ const CustomersTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshCustomers();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -23,13 +23,17 @@ import TextInput from '@/components/input/TextInput';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import useSWR from 'swr'; import useSWR from 'swr';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
import { TYPE_OPTIONS } from '@/config/constant'; import { TYPE_OPTIONS } from '@/config/constant';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
interface CustomerFormProps { interface CustomerFormProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -47,25 +51,15 @@ const CustomerForm = ({
// Setup State // Setup State
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState(''); const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [picSelectInputValue, setPicSelectInputValue] = useState('');
// Fetch Data const {
const picUrl = `${UserApi.basePath}?${new URLSearchParams({ setInputValue: setPicSelectInputValue,
search: picSelectInputValue ?? '', options: picOptions,
})}`; isLoadingOptions: isLoadingPicOptions,
loadMore: loadMorePic,
const { data: pic, isLoading: isLoadingPic } = useSWR( } = useSelect<User>(UserApi.basePath, 'id', 'name');
picUrl,
UserApi.getAllFetcher
);
// -- Options data mapping // -- Options data mapping
const picOptions = isResponseSuccess(pic)
? pic?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const typeOptions = TYPE_OPTIONS; const typeOptions = TYPE_OPTIONS;
// Handler Event // Handler Event
@@ -240,11 +234,12 @@ const CustomerForm = ({
required required
placeholder='Pilih PIC' placeholder='Pilih PIC'
label='PIC' label='PIC'
value={formik.values.pic ?? undefined} value={formik.values.pic?.value ? formik.values.pic : undefined}
onChange={picChangeHandler} onChange={picChangeHandler}
options={picOptions} options={picOptions}
onInputChange={setPicSelectInputValue} onInputChange={setPicSelectInputValue}
isLoading={isLoadingPic} onMenuScrollToBottom={loadMorePic}
isLoading={isLoadingPicOptions}
isError={formik.touched.picId && Boolean(formik.errors.picId)} isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string} errorMessage={formik.errors.picId as string}
isDisabled={formType === 'detail'} isDisabled={formType === 'detail'}
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Fcr } from '@/types/api/master-data/fcr'; import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data'; import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const FcrsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshFcrs();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -19,7 +19,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
const RowsOptions = ({ const RowsOptions = ({
@@ -33,22 +33,6 @@ const RowsOptions = ({
}) => { }) => {
return ( return (
<RowOptionsMenuWrapper type={type}> <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'> <RequirePermission permissions='lti.master.flocks.detail'>
<Button <Button
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`} href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
@@ -65,6 +49,22 @@ const RowsOptions = ({
Detail Detail
</Button> </Button>
</RequirePermission> </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'> <RequirePermission permissions='lti.master.flocks.delete'>
<Button <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
@@ -182,7 +182,14 @@ const FlockTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshFlocks();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -19,6 +19,8 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { toast } from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FlockCustomProps { interface FlockCustomProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -37,7 +39,13 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); deleteModal.closeModal();
setIsDeleteLoading(false); setIsDeleteLoading(false);
@@ -68,12 +76,29 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
// cek type form yang disubmit // cek type form yang disubmit
switch (formType) { switch (formType) {
case 'add': case 'add': {
await FlockApi.create(payload); const createFlockRes = await FlockApi.create(payload);
if (createFlockRes?.status === 'error') {
setFlockFormErrorMessage(createFlockRes.message);
return;
}
toast.success(createFlockRes?.message as string);
break; 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; break;
}
default: default:
break; break;
} }
@@ -174,6 +199,24 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <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' && ( {formType !== 'detail' && (
<div <div
@@ -197,17 +240,6 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
</div> </div>
)} )}
</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> </form>
</section> </section>
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data'; import { KandangApi } from '@/services/api/master-data';
import { cn, formatNumber } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -199,7 +199,16 @@ const KandangsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshKandangs();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -31,6 +34,7 @@ import { UserApi } from '@/services/api/user';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
interface KandangFormProps { interface KandangFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -128,23 +132,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// location // location
const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); const {
setInputValue: setLocationSelectInputValue,
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ options: locationOptions,
search: locationSelectInputValue ?? '', isLoadingOptions: isLoadingLocationOptions,
}).toString()}`; loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
@@ -155,23 +148,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
}; };
// PIC // PIC
const [picSelectInputValue, setPicSelectInputValue] = useState(''); const {
setInputValue: setPicSelectInputValue,
const picsUrl = `${UserApi.basePath}?${new URLSearchParams({ options: picOptions,
search: picSelectInputValue ?? '', isLoadingOptions: isLoadingPicOptions,
}).toString()}`; loadMore: loadMorePics,
} = useSelect<User>(UserApi.basePath, 'id', 'name');
const { data: pics, isLoading: isLoadingPics } = useSWR(
picsUrl,
LocationApi.getAllFetcher
);
const picOptions = isResponseSuccess(pics)
? pics?.data.map((pic) => ({
value: pic.id,
label: pic.name,
}))
: [];
const picChangeHandler = (val: OptionType | OptionType[] | null) => { const picChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('pic', true); formik.setFieldTouched('pic', true);
@@ -233,7 +215,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama kandang'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -249,7 +231,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isError={ isError={
formik.touched.locationId && Boolean(formik.errors.locationId) formik.touched.locationId && Boolean(formik.errors.locationId)
} }
@@ -280,7 +263,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
onChange={picChangeHandler} onChange={picChangeHandler}
options={picOptions} options={picOptions}
onInputChange={setPicSelectInputValue} onInputChange={setPicSelectInputValue}
isLoading={isLoadingPics} onMenuScrollToBottom={loadMorePics}
isLoading={isLoadingPicOptions}
isError={formik.touched.picId && Boolean(formik.errors.picId)} isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string} errorMessage={formik.errors.picId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -186,7 +186,16 @@ const LocationsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshLocations();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -29,6 +32,7 @@ import { AreaApi, LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Area } from '@/types/api/master-data/area';
interface LocationFormProps { interface LocationFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -117,23 +121,12 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); const {
setInputValue: setAreaSelectInputValue,
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({ options: areaOptions,
search: areaSelectInputValue ?? '', isLoadingOptions: isLoadingAreaOptions,
}).toString()}`; loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('area', true); formik.setFieldTouched('area', true);
@@ -224,7 +217,8 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
onChange={areaChangeHandler} onChange={areaChangeHandler}
options={areaOptions} options={areaOptions}
onInputChange={setAreaSelectInputValue} onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas} onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)} isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string} errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Nonstock } from '@/types/api/master-data/nonstock'; import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data'; import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -198,7 +198,16 @@ const NonstocksTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshNonstocks();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -31,6 +34,8 @@ import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant'; import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Supplier } from '@/types/api/master-data/supplier';
import { Uom } from '@/types/api/master-data/uom';
interface NonstockFormProps { interface NonstockFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -78,7 +83,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const formikInitialValues = useMemo<NonstockFormValues>(() => { const formikInitialValues = useMemo<NonstockFormValues>(() => {
return { return {
name: initialValues?.name ?? '', name: initialValues?.name ?? '',
uomId: initialValues?.uom_id ?? 0, uomId: initialValues?.uom?.id ?? 0,
uom: initialValues?.uom uom: initialValues?.uom
? { ? {
value: initialValues?.uom?.id, value: initialValues?.uom?.id,
@@ -129,23 +134,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// UOM // UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState(''); const {
setInputValue: setUomSelectInputValue,
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({ options: uomOptions,
search: uomSelectInputValue ?? '', isLoadingOptions: isLoadingUomOptions,
}).toString()}`; loadMore: loadMoreUoms,
} = useSelect<Uom>(UomApi.basePath, 'id', 'name');
const { data: uoms, isLoading: isLoadingUoms } = useSWR(
uomsUrl,
UomApi.getAllFetcher
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({
value: uom.id,
label: uom.name,
}))
: [];
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
@@ -156,25 +150,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
}; };
// supplier // supplier
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const {
setInputValue: setSupplierSelectInputValue,
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ options: supplierOptions,
search: supplierSelectInputValue ?? '', isLoadingOptions: isLoadingSupplierOptions,
}).toString()}`; loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'BOP')
.map((supplier) => ({
value: supplier.id,
label: supplier.name,
}))
: [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('suppliers', true); formik.setFieldTouched('suppliers', true);
@@ -248,7 +229,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama nonstock'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -264,7 +245,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
onInputChange={setUomSelectInputValue} onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms} isLoading={isLoadingUomOptions}
onMenuScrollToBottom={loadMoreUoms}
isError={formik.touched.uomId && Boolean(formik.errors.uomId)} isError={formik.touched.uomId && Boolean(formik.errors.uomId)}
errorMessage={formik.errors.uomId as string} errorMessage={formik.errors.uomId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -278,7 +260,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions ?? []} options={supplierOptions ?? []}
onInputChange={setSupplierSelectInputValue} onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers} onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isError={ isError={
formik.touched.suppliers && Boolean(formik.errors.suppliers) formik.touched.suppliers && Boolean(formik.errors.suppliers)
} }
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -170,7 +170,16 @@ const ProductCategoryTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshProductCategories();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { ProductApi } from '@/services/api/master-data'; import { ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -230,8 +230,19 @@ const ProductsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshProducts();
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Product!'); toast.success('Successfully delete Product!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = { type ProductFormSchemaType = {
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
uom?: { uom?: {
value: number; value: number;
label: string; label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null; } | null;
product_category_id: number; product_category_id: number;
product_price: number | string; product_price: number | string;
selling_price: number | string; selling_price?: number | string;
tax: number | string; tax?: number | string;
expiry_period: number | string; expiry_period?: number | string;
supplier_ids: number[]; suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[]; flags: string[];
}; };
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({ Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'), brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string(),
uom: Yup.object({ uom: Yup.object({
value: Yup.number() value: Yup.number()
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'), .min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .typeError('Harga hanya boleh angka!')
.typeError('Harga jual wajib diisi!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'), .min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .typeError('Pajak hanya boleh angka!')
.typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa hanya boleh angka!')
.typeError('Periode kadaluarsa wajib diisi!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'), .min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array() suppliers: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(
.min(1, 'Minimal harus ada 1 supplier!') Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flags: Yup.array()
@@ -40,6 +40,9 @@ import {
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ProductFormProps { interface ProductFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -100,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
selling_price: initialValues?.selling_price ?? '', selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? '', tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? '', expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], suppliers: initialValues?.suppliers
? initialValues.suppliers.map((supplier) => ({
supplier: {
value: supplier.id,
label: supplier.name,
},
price: supplier.price,
}))
: [],
flags: initialValues?.flags ?? [], flags: initialValues?.flags ?? [],
}), }),
[initialValues] [initialValues]
@@ -119,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: parseInt(values.selling_price.toString()) || 0, selling_price: values.selling_price
tax: parseInt(values.tax.toString()) || 0, ? parseInt(values.selling_price.toString()) || 0
expiry_period: parseInt(values.expiry_period.toString()) || 0, : undefined,
supplier_ids: values.supplier_ids.filter( tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
(id): id is number => typeof id === 'number' expiry_period: values.expiry_period
), ? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
})),
flags: values.flags.filter((f): f is string => typeof f === 'string'), flags: values.flags.filter((f): f is string => typeof f === 'string'),
}; };
switch (type) { switch (type) {
@@ -145,6 +161,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
setInputValue: setUomSelectInputValue, setInputValue: setUomSelectInputValue,
options: uomOptions, options: uomOptions,
isLoadingOptions: isLoadingUoms, isLoadingOptions: isLoadingUoms,
loadMore: loadMoreUoms,
} = useSelect(UomApi.basePath, 'id', 'name'); } = useSelect(UomApi.basePath, 'id', 'name');
const uomChangeHandler = (val: OptionType | OptionType[] | null) => { const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true); formik.setFieldTouched('uom', true);
@@ -158,6 +175,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
setInputValue: setCategorySelectInputValue, setInputValue: setCategorySelectInputValue,
options: categoryOptions, options: categoryOptions,
isLoadingOptions: isLoadingCategories, isLoadingOptions: isLoadingCategories,
loadMore: loadMoreCategories,
} = useSelect(ProductCategoryApi.basePath, 'id', 'name'); } = useSelect(ProductCategoryApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true); formik.setFieldTouched('product_category', true);
@@ -167,24 +185,38 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
}; };
// Supplier (multi select) - using SWR to filter by category // Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState(''); const {
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`; setInputValue: setSupplierSelectInputValue,
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR( options: supplierOptions,
suppliersUrl, isLoadingOptions: isLoadingSuppliers,
SupplierApi.getAllFetcher loadMore: loadMoreSuppliers,
); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
const supplierOptions = isResponseSuccess(suppliers) category: 'SAPRONAK',
? suppliers?.data });
.filter((sup) => sup.category === 'SAPRONAK')
.map((sup) => ({ value: sup.id, label: sup.name })) const filteredSupplierOptions = useMemo(() => {
: []; return supplierOptions.filter((opt) => {
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { return !formik.values.suppliers.some(
const arr = Array.isArray(val) ? val : val ? [val] : []; (s) => s.supplier?.value === opt.value
formik.setFieldTouched('supplier_ids', true); );
formik.setFieldValue( });
'supplier_ids', }, [supplierOptions, formik.values.suppliers]);
arr.map((v) => (v as OptionType).value)
); const addSupplierHandler = () => {
formik.setFieldValue('suppliers', [
...formik.values.suppliers,
{
supplier_id: '',
price: formik.values.product_price,
},
]);
};
const deleteSupplierItemHandler = (idx: number) => {
const path = 'suppliers';
// trims values, errors, and touched at idx
removeArrayItemAndSync(formik, path, idx);
}; };
const deleteProductClickHandler = () => { const deleteProductClickHandler = () => {
@@ -200,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
router.push('/master-data/product'); router.push('/master-data/product');
}; };
const isSupplierRepeaterError = (
column: 'supplier' | 'price',
supplierIdx: number
) => {
return (
formik.touched.suppliers?.[supplierIdx]?.[column] &&
Boolean(
formik.errors.suppliers?.[supplierIdx] instanceof Object &&
formik.errors.suppliers?.[supplierIdx]?.[column]
)
);
};
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
@@ -270,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <TextInput
required
label='SKU' label='SKU'
name='sku' name='sku'
placeholder='Masukkan SKU...' placeholder='Masukkan SKU...'
@@ -291,6 +335,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onChange={uomChangeHandler} onChange={uomChangeHandler}
options={uomOptions} options={uomOptions}
onInputChange={setUomSelectInputValue} onInputChange={setUomSelectInputValue}
onMenuScrollToBottom={loadMoreUoms}
isLoading={isLoadingUoms} isLoading={isLoadingUoms}
isError={ isError={
(formik.touched.uom || formik.touched.uom_id) && (formik.touched.uom || formik.touched.uom_id) &&
@@ -308,6 +353,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
options={categoryOptions} options={categoryOptions}
onInputChange={setCategorySelectInputValue} onInputChange={setCategorySelectInputValue}
onMenuScrollToBottom={loadMoreCategories}
isLoading={isLoadingCategories} isLoading={isLoadingCategories}
isError={ isError={
(formik.touched.product_category || (formik.touched.product_category ||
@@ -341,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<NumberInput <NumberInput
required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
placeholder='Masukkan harga jual...' placeholder='Masukkan harga jual...'
@@ -363,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div> </div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
placeholder='Masukkan pajak...' placeholder='Masukkan pajak...'
@@ -380,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<NumberInput <NumberInput
required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
placeholder='Masukkan periode kadaluarsa...' placeholder='Masukkan periode kadaluarsa...'
@@ -400,27 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div> </div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-1 gap-4'>
<SelectInput
required
label='Supplier'
placeholder='Pilih supplier...'
isMulti
value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
@@ -443,6 +466,129 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable isClearable
/> />
</div> </div>
<div className='grid sm:grid-cols-1 gap-4'>
{type !== 'detail' && formik.values.suppliers.length === 0 && (
<Button
type='button'
color='success'
onClick={addSupplierHandler}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah
Supplier
</Button>
)}
{formik.values.suppliers.length > 0 && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>Supplier</h4>
</div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Supplier
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga
</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.suppliers.map((supplier, idx) => (
<tr key={idx}>
<td className='p-2 w-full max-w-1/2'>
<SelectInput
placeholder='Pilih Supplier'
options={filteredSupplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
value={formik.values.suppliers[idx].supplier}
onChange={(val) => {
formik.setFieldValue(
`suppliers.${idx}.supplier`,
val
);
}}
isError={isSupplierRepeaterError(
'supplier',
idx
)}
isClearable
isDisabled={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
<td className='p-2 w-full max-w-1/2'>
<NumberInput
required
name={`suppliers.${idx}.price`}
placeholder='Masukkan harga...'
value={formik.values.suppliers[idx].price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={isSupplierRepeaterError('price', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => deleteSupplierItemHandler(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='w-full flex flex-row justify-center'>
<Button
type='button'
color='success'
onClick={addSupplierHandler}
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah Supplier
</Button>
</div>
)}
</Card>
)}
</div>
</div> </div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
@@ -7,7 +7,7 @@ import { ProductionStandard } from '@/types/api/master-data/production-standard'
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import useSWR from 'swr'; import useSWR from 'swr';
import { ProductionStandardApi } from '@/services/api/master-data'; 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 RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { CellContext } from '@tanstack/react-table'; import { CellContext } from '@tanstack/react-table';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
@@ -94,9 +94,16 @@ const ProductionStandardTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ProductionStandardApi.delete( const deleteResponse = await ProductionStandardApi.delete(
selectedProductionStandard?.id as number selectedProductionStandard?.id as number
); );
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductionStandards(); refreshProductionStandards();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -2,34 +2,30 @@ import * as Yup from 'yup';
// Schema for LAYING category (production_standard_details is required) // Schema for LAYING category (production_standard_details is required)
const LayingRepeaterFormSchema = Yup.object({ const LayingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'), week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({ production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'), target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'), max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'), min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'), feed_intake: Yup.number().required('Wajib diisi!'),
}), }),
production_standard_details: Yup.object({ production_standard_details: Yup.object({
target_hen_day_production: Yup.number().required( target_hen_day_production: Yup.number().required('Wajib diisi!'),
'Produksi telur per hari wajib diisi!' target_hen_house_production: Yup.number().required('Wajib diisi!'),
), target_egg_weight: Yup.number().required('Wajib diisi!'),
target_hen_house_production: Yup.number().required( target_egg_mass: Yup.number().required('Wajib diisi!'),
'Produksi telur per kandang wajib diisi!' standard_fcr: Yup.number().required('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!'),
}).required(), }).required(),
}); });
// Schema for GROWING category (production_standard_details is optional) // Schema for GROWING category (production_standard_details is optional)
const GrowingRepeaterFormSchema = Yup.object({ const GrowingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'), week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({ production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'), target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'), max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'), min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'), feed_intake: Yup.number().required('Wajib diisi!'),
}), }),
production_standard_details: Yup.object({ production_standard_details: Yup.object({
target_hen_day_production: Yup.number().optional(), target_hen_day_production: Yup.number().optional(),
@@ -344,7 +344,7 @@ const ProductionStandardForm = ({
const columns = useMemo<ColumnDef<TableRowsType>[]>(() => { const columns = useMemo<ColumnDef<TableRowsType>[]>(() => {
const baseColumns: ColumnDef<TableRowsType>[] = [ const baseColumns: ColumnDef<TableRowsType>[] = [
{ {
header: 'Minggu', header: 'Week',
accessorKey: 'week', accessorKey: 'week',
enableSorting: false, enableSorting: false,
}, },
@@ -358,30 +358,40 @@ const ProductionStandardForm = ({
header: 'Hen Day', header: 'Hen Day',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_hen_day_production, row.production_standard_details?.target_hen_day_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_day_production}%`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Hen House', header: 'Hen House',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_hen_house_production, row.production_standard_details?.target_hen_house_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_house_production} pc`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Egg Weight', header: 'Egg Weight',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_egg_weight, row.production_standard_details?.target_egg_weight,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_weight} g`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Egg Mass', header: 'Egg Mass',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_egg_mass, row.production_standard_details?.target_egg_mass,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_mass} g`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'FCR', header: 'FCR',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.standard_fcr, row.production_standard_details?.standard_fcr,
cell: ({ row }) =>
`${row.original.production_standard_details?.standard_fcr} g`,
enableSorting: false, enableSorting: false,
}, },
] ]
@@ -393,24 +403,32 @@ const ProductionStandardForm = ({
header: 'Mean BW', header: 'Mean BW',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.target_mean_bw, row.production_standard_uniformity_details?.target_mean_bw,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.target_mean_bw} g`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Max Depletion', header: 'Max Depletion',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.max_depletion, row.production_standard_uniformity_details?.max_depletion,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.max_depletion}%`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Min Uniformity', header: 'Min Uniformity',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.min_uniformity, row.production_standard_uniformity_details?.min_uniformity,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.min_uniformity}%`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Feed Intake', header: 'Feed Intake',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.feed_intake, row.production_standard_uniformity_details?.feed_intake,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.feed_intake} g`,
enableSorting: false, enableSorting: false,
}, },
]; ];
@@ -728,7 +746,52 @@ const ProductionStandardForm = ({
}; };
// ===== Formik Error List ===== // ===== 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 ( return (
<> <>
@@ -821,19 +884,20 @@ const ProductionStandardForm = ({
key={`row-${row.index}`} key={`row-${row.index}`}
className='sticky bottom-0 bg-base-100 shadow-lg' className='sticky bottom-0 bg-base-100 shadow-lg'
> >
<td colSpan={colSpan} className='p-6'> <td colSpan={colSpan} className='p-2'>
<form <form
className='h-full w-full flex flex-col justify-end' className='h-full w-full flex flex-col justify-end'
onSubmit={repeaterFormik.handleSubmit} onSubmit={repeaterFormik.handleSubmit}
onReset={repeaterFormik.handleReset} onReset={repeaterFormik.handleReset}
> >
<div <div
className={cn( className='grid gap-2 items-start w-full'
'grid gap-4 items-start', style={{
formik.values.project_category === 'LAYING' gridTemplateColumns:
? 'grid-cols-10' formik.values.project_category === 'LAYING'
: 'grid-cols-5' ? 'repeat(10, minmax(auto, 1fr)) minmax(auto, auto)'
)} : 'repeat(4, minmax(auto, 1fr)) minmax(auto, auto)',
}}
> >
<NumberInput <NumberInput
name='week' name='week'
@@ -862,7 +926,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />} bottomLabel='Persen (%)'
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -894,11 +958,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Butir (pc)'
<div className='w-full h-full flex items-center justify-center'>
Butir
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -930,11 +990,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram (g)'
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -959,17 +1015,13 @@ const ProductionStandardForm = ({
name='production_standard_details.target_egg_mass' name='production_standard_details.target_egg_mass'
label='Egg Mass' label='Egg Mass'
placeholder='1' placeholder='1'
bottomLabel='Gram (g)'
value={ value={
repeaterFormik.values repeaterFormik.values
.production_standard_details?.target_egg_mass .production_standard_details?.target_egg_mass
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -1000,11 +1052,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram (g)'
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -1038,11 +1086,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram (g)'
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1072,7 +1116,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />} bottomLabel='Persen (%)'
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1102,7 +1146,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />} bottomLabel='Persen (%)'
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1132,11 +1176,8 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram/Ekor (g)'
<div className='w-full h-full flex items-center justify-center'> endAdornment
gr/ekor
</div>
}
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1162,7 +1203,7 @@ const ProductionStandardForm = ({
type='button' type='button'
color='error' color='error'
variant='outline' variant='outline'
className='min-w-24' className='min-w-xs'
onClick={handleCancelEdit} onClick={handleCancelEdit}
> >
<Icon icon='mdi:close' /> Batal <Icon icon='mdi:close' /> Batal
@@ -1178,7 +1219,7 @@ const ProductionStandardForm = ({
<Button <Button
type='submit' type='submit'
color={editMode ? 'warning' : 'success'} color={editMode ? 'warning' : 'success'}
className='min-w-24' className='min-w-xs'
disabled={ disabled={
isAddingRow || isAddingRow ||
formik.values.project_category === '' formik.values.project_category === ''
@@ -1195,7 +1236,7 @@ const ProductionStandardForm = ({
variant='outline' variant='outline'
color='primary' color='primary'
onClick={toggleTableHeight} onClick={toggleTableHeight}
className='absolute bottom-6 right-6' className='absolute bottom-2 right-2'
> >
<Icon <Icon
icon={ icon={
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant'; 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 { cn } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -205,7 +205,16 @@ const SuppliersTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshSuppliers();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Uom } from '@/types/api/master-data/uom'; import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data'; import { UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const UomsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshUoms();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data'; import { WarehouseApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; 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 { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -220,7 +220,16 @@ const WarehousesTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); 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(); refreshWarehouses();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -35,6 +38,8 @@ import { cn } from '@/lib/helper';
import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant'; import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Area } from '@/types/api/master-data/area';
import { Kandang } from '@/types/api/master-data/kandang';
interface WarehouseFormProps { interface WarehouseFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -221,61 +226,28 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
const { setValues: formikSetValues } = formik; const { setValues: formikSetValues } = formik;
// Area // Area
const [areaSelectInputValue, setAreaSelectInputValue] = useState(''); const {
setInputValue: setAreaSelectInputValue,
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({ options: areaOptions,
search: areaSelectInputValue ?? '', isLoadingOptions: isLoadingAreaOptions,
}).toString()}`; loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
// Location // Location
const [locationSelectInputValue, setLocationSelectInputValue] = useState(''); const {
setInputValue: setLocationSelectInputValue,
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({ options: locationOptions,
search: locationSelectInputValue ?? '', isLoadingOptions: isLoadingLocationOptions,
}).toString()}`; loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
// Kandang // Kandang
const [kandangSelectInputValue, setKandangSelectInputValue] = useState(''); const {
setInputValue: setKandangSelectInputValue,
const kandangsUrl = `${KandangApi.basePath}?${new URLSearchParams({ options: kandangOptions,
search: kandangSelectInputValue ?? '', isLoadingOptions: isLoadingKandangOptions,
}).toString()}`; loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const { data: kandangs, isLoading: isLoadingKandangs } = useSWR(
kandangsUrl,
KandangApi.getAllFetcher
);
const kandangOptions = isResponseSuccess(kandangs)
? kandangs?.data.map((kandang) => ({
value: kandang.id,
label: kandang.name,
}))
: [];
const typeChangeHandler = (val: OptionType | OptionType[] | null) => { const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('type', true); formik.setFieldTouched('type', true);
@@ -358,7 +330,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama warehouse'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -393,7 +365,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={areaChangeHandler} onChange={areaChangeHandler}
options={areaOptions} options={areaOptions}
onInputChange={setAreaSelectInputValue} onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas} onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)} isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string} errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
@@ -409,7 +382,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isError={ isError={
formik.touched.locationId && Boolean(formik.errors.locationId) formik.touched.locationId && Boolean(formik.errors.locationId)
} }
@@ -427,7 +401,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
options={kandangOptions} options={kandangOptions}
onInputChange={setKandangSelectInputValue} onInputChange={setKandangSelectInputValue}
isLoading={isLoadingKandangs} onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangOptions}
isError={ isError={
formik.touched.kandangId && Boolean(formik.errors.kandangId) formik.touched.kandangId && Boolean(formik.errors.kandangId)
} }
@@ -75,12 +75,12 @@ const ChickinFormKandang = ({
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Badge <Badge
variant='soft' variant='soft'
color='success' color='primary'
className={{ className={{
badge: 'rounded-lg px-2', 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 Aktif
</Badge> </Badge>
<div className='divider divider-horizontal p-0 m-0'></div> <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 FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; 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 { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; 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 { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -59,9 +62,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
.map((id) => parseInt(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 [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null null
@@ -90,55 +90,25 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
{ revalidateOnMount: true } { revalidateOnMount: true }
); );
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ // ===== Fetch Data Select =====
search: areaSelectInputValue, const {
limit: '100', options: optionsArea,
}).toString()}`; isLoadingOptions: isLoadingArea,
const { data: areas, isLoading: isLoadingAreas } = useSWR( setInputValue: setAreaSelectInputValue,
areaUrl, loadMore: loadMoreArea,
AreaApi.getAllFetcher } = useSelect(AreaApi.basePath, 'id', 'name');
); const {
options: optionsLocation,
const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({ isLoadingOptions: isLoadingLocation,
search: locationSelectInputValue, setInputValue: setLocationSelectInputValue,
area_id: selectedArea != null ? selectedArea.value.toString() : '', loadMore: loadMoreLocation,
limit: '100', } = useSelect(LocationApi.basePath, 'id', 'name');
}).toString()}`; const {
const { data: locations, isLoading: isLoadingLocations } = useSWR( options: optionsKandang,
locationUrl, isLoadingOptions: isLoadingKandang,
LocationApi.getAllFetcher setInputValue: setKandangSelectInputValue,
); loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name');
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,
}))
: [];
// ====== HANDLER ====== // ====== HANDLER ======
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -284,7 +254,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
header: 'Status', header: 'Status',
cell: (props) => { cell: (props) => {
const approval = props.row.original.approval; const approval = props.row.original.approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return ( return (
<Badge <Badge
variant='soft' variant='soft'
@@ -292,11 +263,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
badge: 'rounded-lg px-2 w-full flex flex-row justify-start', badge: 'rounded-lg px-2 w-full flex flex-row justify-start',
}} }}
color={ color={
approval?.step_number == 1 isRejected
? 'neutral' ? 'error'
: approval?.step_number == 2 : isApproved
? 'success' ? approval?.step_number == 1
: 'error' ? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
} }
> >
<Icon <Icon
@@ -307,11 +284,15 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
approval?.step_number == 1 approval?.step_number == 1
? 'neutral' ? 'neutral'
: approval?.step_number == 2 : approval?.step_number == 2
? 'success' ? 'primary'
: 'error' : approval?.step_number == 3
? 'success'
: 'neutral'
} }
/> />
{approval?.step_name} {isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge> </Badge>
); );
}, },
@@ -385,7 +366,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<SelectInput <SelectInput
label='Area' label='Area'
options={optionsArea} options={optionsArea}
isLoading={isLoadingAreas} isLoading={isLoadingArea}
value={selectedArea} value={selectedArea}
onChange={(val) => { onChange={(val) => {
setSelectedArea(val as OptionType); setSelectedArea(val as OptionType);
@@ -395,12 +376,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}} }}
onInputChange={setAreaSelectInputValue} onInputChange={setAreaSelectInputValue}
onMenuScrollToBottom={loadMoreArea}
isClearable isClearable
/> />
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
options={optionsLocation} options={optionsLocation}
isLoading={isLoadingLocations} isLoading={isLoadingLocation}
value={selectedLocation} value={selectedLocation}
onChange={(val) => { onChange={(val) => {
setSelectedLocation(val as OptionType); setSelectedLocation(val as OptionType);
@@ -410,6 +392,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}} }}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
onMenuScrollToBottom={loadMoreLocation}
isClearable isClearable
/> />
<SelectInput <SelectInput
@@ -425,6 +408,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}} }}
onInputChange={setKandangSelectInputValue} onInputChange={setKandangSelectInputValue}
onMenuScrollToBottom={loadMoreKandang}
isClearable isClearable
/> />
<DebouncedTextInput <DebouncedTextInput
@@ -156,9 +156,9 @@ const ProjectFlockDetail = ({
projectFlock.approval?.step_number == 1 projectFlock.approval?.step_number == 1
? 'neutral' ? 'neutral'
: projectFlock.approval?.step_number == 2 : projectFlock.approval?.step_number == 2
? 'success' ? 'primary'
: projectFlock.approval?.step_number >= 3 : projectFlock.approval?.step_number == 3
? 'error' ? 'success'
: undefined : undefined
} }
className={{ className={{
@@ -173,9 +173,9 @@ const ProjectFlockDetail = ({
projectFlock.approval?.step_number == 1 projectFlock.approval?.step_number == 1
? 'neutral' ? 'neutral'
: projectFlock.approval?.step_number == 2 : projectFlock.approval?.step_number == 2
? 'success' ? 'primary'
: projectFlock.approval?.step_number >= 3 : projectFlock.approval?.step_number == 3
? 'error' ? 'success'
: undefined : undefined
} }
/>{' '} />{' '}
@@ -273,7 +273,7 @@ const ProjectFlockDetail = ({
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Badge <Badge
variant='soft' variant='soft'
color={'success'} color={'primary'}
className={{ className={{
badge: 'rounded-lg px-2', badge: 'rounded-lg px-2',
}} }}
@@ -282,7 +282,7 @@ const ProjectFlockDetail = ({
icon='mdi:circle' icon='mdi:circle'
width={12} width={12}
height={12} height={12}
color={'success'} color={'primary'}
/>{' '} />{' '}
Kandang Aktif ({projectFlock.kandangs?.length}) Kandang Aktif ({projectFlock.kandangs?.length})
</Badge> </Badge>
@@ -102,41 +102,54 @@ const ProjectFlockForm = ({
); );
// Fetch Data // Fetch Data
const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } = const {
useSelect(FlockApi.basePath, 'id', 'name'); setInputValue: setInputValueFlock,
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
});
const { options: optionsArea, isLoadingOptions: isLoadingAreas } = useSelect( const {
AreaApi.basePath, setInputValue: setInputValueArea,
'id', options: optionsArea,
'name' isLoadingOptions: isLoadingAreas,
); loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name');
const { options: optionsLocation, isLoadingOptions: isLoadingLocations } = const {
useSelect(LocationApi.basePath, 'id', 'name', '', { options: optionsLocation,
area_id: isLoadingOptions: isLoadingLocations,
selectedArea != '' setInputValue: setInputValueLocation,
? selectedArea loadMore: loadMoreLocation,
: ((initialValues?.area?.id ?? '') as string), } = useSelect(LocationApi.basePath, 'id', 'name', '', {
}); area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const { options: optionsFcr, isLoadingOptions: isLoadingFcrs } = useSelect( const {
FcrApi.basePath, options: optionsFcr,
'id', isLoadingOptions: isLoadingFcrs,
'name' setInputValue: setInputValueFcr,
); loadMore: loadMoreFcr,
} = useSelect(FcrApi.basePath, 'id', 'name');
const { const {
options: optionsProductionStandards, options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', { } = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory, project_category: selectedCategory,
}); });
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '', search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation, location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit', limit: '500',
}).toString()}`; }).toString()}`;
const { const {
data: kandang, data: kandang,
@@ -153,6 +166,8 @@ const ProjectFlockForm = ({
options: optionsNonstock, options: optionsNonstock,
rawData: nonstocks, rawData: nonstocks,
isLoadingOptions: isLoadingNonstocks, isLoadingOptions: isLoadingNonstocks,
setInputValue: setInputValueNonstock,
loadMore: loadMoreNonstock,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name'); } = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
useEffect(() => { useEffect(() => {
@@ -542,15 +557,12 @@ const ProjectFlockForm = ({
}; };
const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => { const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => {
console.log(`nonstock_id: ${nonstock_id}, index: ${index}`);
if (!nonstock_id) { if (!nonstock_id) {
const updatedBudgets = formik.values.project_budgets const updatedBudgets = formik.values.project_budgets
.map((budget, i) => { .map((budget, i) => {
if (i == index) { if (i == index) {
console.log(`buget: ${null}, index: ${index}, i: ${i}`);
return null; return null;
} else { } else {
console.log(`buget: ${budget}, index: ${index}, i: ${i}`);
return budget; return budget;
} }
}) })
@@ -722,6 +734,8 @@ const ProjectFlockForm = ({
formik.touched.area_id && Boolean(formik.errors.area_id) formik.touched.area_id && Boolean(formik.errors.area_id)
} }
errorMessage={formik.errors.area_id as string} errorMessage={formik.errors.area_id as string}
onInputChange={setInputValueArea}
onMenuScrollToBottom={loadMoreArea}
isClearable isClearable
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
@@ -740,6 +754,8 @@ const ProjectFlockForm = ({
formik.touched.location_id && formik.touched.location_id &&
Boolean(formik.errors.location_id) Boolean(formik.errors.location_id)
} }
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
errorMessage={formik.errors.location_id as string} errorMessage={formik.errors.location_id as string}
isClearable isClearable
isDisabled={formType != 'add' || disabledLocation} isDisabled={formType != 'add' || disabledLocation}
@@ -766,6 +782,8 @@ const ProjectFlockForm = ({
); );
}} }}
options={optionsFlock} options={optionsFlock}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
isLoading={isLoadingFlocks} isLoading={isLoadingFlocks}
isError={ isError={
formik.touched.flock_name && Boolean(formik.errors.flock_name) formik.touched.flock_name && Boolean(formik.errors.flock_name)
@@ -781,6 +799,8 @@ const ProjectFlockForm = ({
onChange={(val) => { onChange={(val) => {
optionChangeHandler(val, 'fcr'); optionChangeHandler(val, 'fcr');
}} }}
onInputChange={setInputValueFcr}
onMenuScrollToBottom={loadMoreFcr}
options={optionsFcr} options={optionsFcr}
isLoading={isLoadingFcrs} isLoading={isLoadingFcrs}
isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)} isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)}
@@ -808,6 +828,8 @@ const ProjectFlockForm = ({
onChange={(val) => { onChange={(val) => {
optionChangeHandler(val, 'production_standard'); optionChangeHandler(val, 'production_standard');
}} }}
onInputChange={setInputValueProductionStandard}
onMenuScrollToBottom={loadMoreProductionStandard}
options={optionsProductionStandards} options={optionsProductionStandards}
isLoading={isLoadingProductionStandards} isLoading={isLoadingProductionStandards}
isError={ isError={
@@ -892,6 +914,8 @@ const ProjectFlockForm = ({
isLoading={isLoadingNonstocks} isLoading={isLoadingNonstocks}
placeholder='Pilih barang non stock' placeholder='Pilih barang non stock'
value={formik.values.project_budgets[index].nonstock} value={formik.values.project_budgets[index].nonstock}
onInputChange={setInputValueNonstock}
onMenuScrollToBottom={loadMoreNonstock}
onChange={(val) => { onChange={(val) => {
const updatedBudgets = [ const updatedBudgets = [
...formik.values.project_budgets, ...formik.values.project_budgets,
@@ -5,7 +5,7 @@ import { RefObject } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { SortingState, CellContext } from '@tanstack/react-table'; 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 RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
@@ -656,34 +656,57 @@ const RecordingTable = () => {
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const recording = row.original;
const isDisabled = isRecordingApproved(recording);
const handleToggleSelection = (e: unknown) => {
if (!isDisabled) {
row.getToggleSelectedHandler()(e);
}
};
return ( return (
<div> <div className={cn({ 'opacity-50': isDisabled })}>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={handleToggleSelection}
disabled={isDisabled}
/> />
</div> </div>
); );
}, },
}, },
{ {
header: '#', header: 'No',
cell: (props) => cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) + tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index + props.row.index +
1, 1,
}, },
{ {
header: 'Nama Project', header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
header: 'Flock',
cell: (props) => 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', header: 'Kategori',
cell: (props) => { cell: (props) => {
const category = props.row.original.project_flock_category; const category =
props.row.original.project_flock?.project_flock_category;
if (!category) return '-'; if (!category) return '-';
const color = category === 'LAYING' ? 'info' : 'warning'; const color = category === 'LAYING' ? 'info' : 'warning';
return ( return (
@@ -695,18 +718,280 @@ const RecordingTable = () => {
}, },
{ {
header: 'Umur (hari)', 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', header: 'Waktu Recording',
cell: (props) => cell: (props) =>
formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'), formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'),
}, },
{ {
header: 'Populasi Awal', header: 'Populasi Akhir',
cell: (props) => 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', header: 'Status Approval',
@@ -728,21 +1013,6 @@ const RecordingTable = () => {
approvalHistoryModal.openModal(); 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 ( return (
<Badge <Badge
variant='soft' variant='soft'
@@ -753,7 +1023,7 @@ const RecordingTable = () => {
}} }}
onClick={openApprovalHistory} onClick={openApprovalHistory}
> >
{getStatusText(approval.action)} {approval.step_name || approval.action}
</Badge> </Badge>
); );
}, },
@@ -872,14 +1142,15 @@ const RecordingTable = () => {
'mb-20': 'mb-20':
isResponseSuccess(recordings) && recordings?.data?.length === 0, isResponseSuccess(recordings) && recordings?.data?.length === 0,
}), }),
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName: headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', '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: 'border-b border-b-gray-200', 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: 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'; } from '@/types/api/production/recording';
type RecordingGrowingFormSchemaType = { 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: { project_flock_kandang: {
value: number; value: number;
label: string; label: string;
@@ -17,16 +33,16 @@ type RecordingGrowingFormSchemaType = {
qty: number | string; qty: number | string;
}[]; }[];
depletions: { depletions: {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
}[]; }[];
}; };
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: { eggs: {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
weight: number | string; weight?: number | string;
}[]; }[];
}; };
@@ -36,14 +52,14 @@ export type StockSchema = {
}; };
export type DepletionSchema = { export type DepletionSchema = {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
}; };
export type EggSchema = { export type EggSchema = {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
weight: number | string; weight?: number | string;
}; };
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({ 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({ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.required('Produk depletions wajib diisi!') .optional()
.min(1, 'Produk depletions wajib diisi!') .typeError('Depletions harus berupa angka!'),
.typeError('Produk depletions harus berupa angka!'),
qty: Yup.number() qty: Yup.number()
.required('Jumlah depletions wajib diisi!') .optional()
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'), .typeError('Jumlah depletions harus berupa angka!'),
}); });
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({ const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.required('Kondisi telur wajib diisi!') .optional()
.min(1, 'Kondisi telur wajib diisi!')
.typeError('Kondisi telur harus berupa angka!'), .typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number() qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
.required('Jumlah telur wajib diisi!') weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
.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!'),
}); });
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> = export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
Yup.object({ 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({ project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -100,7 +135,7 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
.required('Project Flock Kandang wajib diisi!') .required('Project Flock Kandang wajib diisi!')
.test( .test(
'not-already-recorded', 'not-already-recorded',
'Project Flock ini sudah direcord hari ini!', 'Project Flock ini sudah direcord pada tanggal tersebut!',
function (value) { function (value) {
const recordedProjectFlockIds = this.options.context const recordedProjectFlockIds = this.options.context
?.recordedProjectFlockIds as Set<number>; ?.recordedProjectFlockIds as Set<number>;
@@ -119,18 +154,12 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
.of(StockObjectSchema) .of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!') .min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'), .required('Data stok wajib diisi!'),
depletions: Yup.array() depletions: Yup.array().of(DepletionObjectSchema).default([]),
.of(DepletionObjectSchema)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
}); });
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> = export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema.shape({ RecordingGrowingFormSchema.shape({
eggs: Yup.array() eggs: Yup.array().of(EggObjectSchema).default([]),
.of(EggObjectSchema)
.min(1, 'Minimal harus ada 1 data telur!')
.required('Data telur wajib diisi!'),
}); });
export const UpdateRecordingGrowingFormSchema = export const UpdateRecordingGrowingFormSchema =
@@ -179,6 +208,15 @@ type RecordingFormData = Partial<Recording> & {
export const getRecordingGrowingFormInitialValues = ( export const getRecordingGrowingFormInitialValues = (
initialValues?: RecordingFormData initialValues?: RecordingFormData
): RecordingGrowingFormValues => ({ ): 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 project_flock_kandang: initialValues?.project_flock_kandang_id
? { ? {
value: 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, setInputValue: setFlockSourceInputValue,
options: flockSourceOptions, options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions, isLoadingOptions: isLoadingFlockSourceOptions,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name'); } = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
const { const {
setInputValue: setFlockDestinationInputValue, setInputValue: setFlockDestinationInputValue,
options: flockDestinationOptions, options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions, isLoadingOptions: isLoadingFlockDestinationOptions,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name'); } = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
// Flocks value // Flocks value
@@ -595,6 +599,7 @@ const TransferToLayingsTable = () => {
value={selectedFlockSource} value={selectedFlockSource}
onChange={flockSourceChangeHandler} onChange={flockSourceChangeHandler}
onInputChange={setFlockSourceInputValue} onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-3', wrapper: 'col-span-12 sm:col-span-3',
@@ -608,6 +613,7 @@ const TransferToLayingsTable = () => {
value={selectedFlockDestination} value={selectedFlockDestination}
onChange={flockDestinationChangeHandler} onChange={flockDestinationChangeHandler}
onInputChange={setFlockDestinationInputValue} onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-3', wrapper: 'col-span-12 sm:col-span-3',
@@ -270,6 +270,8 @@ const TransferToLayingForm = ({
options: flockSourceOptions, options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions, isLoadingOptions: isLoadingFlockSourceOptions,
rawData: flockSources, rawData: flockSources,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<ProjectFlock>( } = useSelect<ProjectFlock>(
'/production/project-flocks', '/production/project-flocks',
'id', 'id',
@@ -360,6 +362,8 @@ const TransferToLayingForm = ({
options: flockDestinationOptions, options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions, isLoadingOptions: isLoadingFlockDestinationOptions,
rawData: flockDestinations, rawData: flockDestinations,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<ProjectFlock>( } = useSelect<ProjectFlock>(
'/production/project-flocks', '/production/project-flocks',
'id', 'id',
@@ -573,6 +577,7 @@ const TransferToLayingForm = ({
onChange={flockSourceChangeHandler} onChange={flockSourceChangeHandler}
isLoading={isLoadingFlockSourceOptions} isLoading={isLoadingFlockSourceOptions}
onInputChange={setFlockSourceInputValue} onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isError={ isError={
formik.touched.flockSource && formik.touched.flockSource &&
Boolean(typeof formik.errors.flockSource === 'string') Boolean(typeof formik.errors.flockSource === 'string')
@@ -591,6 +596,7 @@ const TransferToLayingForm = ({
onChange={flockDestinationChangeHandler} onChange={flockDestinationChangeHandler}
isLoading={isLoadingFlockDestinationOptions} isLoading={isLoadingFlockDestinationOptions}
onInputChange={setFlockDestinationInputValue} onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isError={ isError={
formik.touched.flockDestination && formik.touched.flockDestination &&
Boolean(typeof formik.errors.flockDestination === 'string') Boolean(typeof formik.errors.flockDestination === 'string')
@@ -37,7 +37,10 @@ import DateInput from '@/components/input/DateInput';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { Kandang } from '@/types/api/master-data/kandang'; 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 { import {
getStatusColor, getStatusColor,
getStatusIndicatorColor, getStatusIndicatorColor,
@@ -229,63 +232,37 @@ const UniformityTable = () => {
useState<number | undefined>(undefined); useState<number | undefined>(undefined);
const [filterStartDate, setFilterStartDate] = useState(''); const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState(''); const [filterEndDate, setFilterEndDate] = useState('');
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({}); const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const { const {
setInputValue: setFilterLocationInputValue, setInputValue: setFilterLocationInputValue,
options: filterLocationOptions, options: filterLocationOptions,
isLoadingOptions: isLoadingFilterLocations, isLoadingOptions: isLoadingFilterLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', { loadMore: loadMoreFilterLocations,
limit: '100', hasMore: hasMoreFilterLocations,
}); } = useSelect(LocationApi.basePath, 'id', 'name', 'search');
// ===== FETCH PROJECT FLOCKS DATA FOR FILTER ===== // ===== 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 { const {
data: filterProjectFlocksData, setInputValue: setFilterProjectFlockSearchValue,
isLoading: isLoadingFilterProjectFlocks, options: filterProjectFlockOptions,
} = useSWR(filterProjectFlocksUrl, ProjectFlockApi.getAllFetcher); rawData: filterProjectFlocksRawData,
isLoadingOptions: isLoadingFilterProjectFlocks,
const filterProjectFlocksDataList = useMemo( loadMore: loadMoreFilterProjectFlocks,
() => hasMore: hasMoreFilterProjectFlocks,
isResponseSuccess(filterProjectFlocksData) } = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
? filterProjectFlocksData.data location_id: filterProjectFlockLocationId,
: 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]);
// ===== KANDANG OPTIONS FOR FILTER ===== // ===== KANDANG OPTIONS FOR FILTER =====
const filterKandangOptions = useMemo(() => { const filterKandangOptions = useMemo(() => {
let options: OptionType[] = []; let options: OptionType[] = [];
if (filterProjectFlock && filterProjectFlocksDataList) { if (filterProjectFlock && isResponseSuccess(filterProjectFlocksRawData)) {
const selectedProjectFlockData = filterProjectFlocksDataList.find( const data = filterProjectFlocksRawData.data as unknown as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === filterProjectFlock.value (pf) => pf.id === filterProjectFlock.value
); );
@@ -301,7 +278,7 @@ const UniformityTable = () => {
} }
return options; return options;
}, [filterProjectFlock, filterProjectFlocksDataList]); }, [filterProjectFlock, filterProjectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP ===== // ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => { const projectFlockKandangLookupUrl = useMemo(() => {
@@ -394,9 +371,13 @@ const UniformityTable = () => {
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback( const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
setFilterLocation(val as OptionType | null); const location = val as OptionType | null;
setFilterLocation(location);
setFilterProjectFlock(null); setFilterProjectFlock(null);
setFilterKandang(null); setFilterKandang(null);
setFilterProjectFlockLocationId(
location ? location.value.toString() : ''
);
}, },
[] []
); );
@@ -1206,6 +1187,7 @@ const UniformityTable = () => {
options={filterLocationOptions} options={filterLocationOptions}
onInputChange={setFilterLocationInputValue} onInputChange={setFilterLocationInputValue}
isLoading={isLoadingFilterLocations} isLoading={isLoadingFilterLocations}
onMenuScrollToBottom={loadMoreFilterLocations}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
{filterErrors.location && ( {filterErrors.location && (
@@ -1225,8 +1207,9 @@ const UniformityTable = () => {
setFilterErrors((prev) => ({ ...prev, project_flock: '' })); setFilterErrors((prev) => ({ ...prev, project_flock: '' }));
}} }}
options={filterProjectFlockOptions} options={filterProjectFlockOptions}
onInputChange={setProjectFlockSearchValue} onInputChange={setFilterProjectFlockSearchValue}
isLoading={isLoadingFilterProjectFlocks} isLoading={isLoadingFilterProjectFlocks}
onMenuScrollToBottom={loadMoreFilterProjectFlocks}
isDisabled={!filterLocation} isDisabled={!filterLocation}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
@@ -1,4 +1,4 @@
import Badge from '../../../../Badge'; import Badge from '@/components/Badge';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
@@ -36,7 +36,10 @@ import {
VerifyUniformityPayload, VerifyUniformityPayload,
} from '@/types/api/production/uniformity'; } from '@/types/api/production/uniformity';
import { type BaseApiResponse } from '@/types/api/api-general'; 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 { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm'; import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm'; import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -88,7 +91,9 @@ const UniformityForm = ({
null null
); );
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); const [selectedProjectFlockLocationId, setSelectedProjectFlockLocationId] =
useState<string>('');
const [selectedProjectFlock, setSelectedProjectFlock] = const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null); useState<OptionType | null>(null);
@@ -100,50 +105,21 @@ const UniformityForm = ({
setInputValue: setLocationSelectInputValue, setInputValue: setLocationSelectInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocations, isLoadingOptions: isLoadingLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', { loadMore: loadMoreLocations,
page: '1', hasMore: hasMoreLocations,
limit: '100', } = 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 ===== // ===== APPROVED PROJECT FLOCK KANDANGS =====
const approvedProjectFlockKandangsUrl = useMemo(() => { const approvedProjectFlockKandangsUrl = useMemo(() => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -168,8 +144,9 @@ const UniformityForm = ({
const kandangOptions = useMemo(() => { const kandangOptions = useMemo(() => {
let options: OptionType[] = []; let options: OptionType[] = [];
if (selectedProjectFlock && projectFlocksDataList) { if (selectedProjectFlock && isResponseSuccess(projectFlocksRawData)) {
const selectedProjectFlockData = projectFlocksDataList.find( const data = projectFlocksRawData.data as unknown as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === selectedProjectFlock.value (pf) => pf.id === selectedProjectFlock.value
); );
@@ -196,7 +173,7 @@ const UniformityForm = ({
return options; return options;
}, [ }, [
selectedProjectFlock, selectedProjectFlock,
projectFlocksDataList, projectFlocksRawData,
approvedProjectFlockKandangs, approvedProjectFlockKandangs,
formType, formType,
]); ]);
@@ -313,6 +290,10 @@ const UniformityForm = ({
formik.setFieldValue('location_id', locationId); formik.setFieldValue('location_id', locationId);
setSelectedLocation(location); setSelectedLocation(location);
setSelectedProjectFlock(null);
setSelectedProjectFlockLocationId(
location ? location.value.toString() : ''
);
}, },
[] []
); );
@@ -513,6 +494,7 @@ const UniformityForm = ({
options={locationOptions} options={locationOptions}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
onMenuScrollToBottom={loadMoreLocations}
isError={ isError={
formik.touched.location_id && Boolean(formik.errors.location_id) formik.touched.location_id && Boolean(formik.errors.location_id)
} }
@@ -530,6 +512,7 @@ const UniformityForm = ({
options={projectFlockOptions} options={projectFlockOptions}
onInputChange={setProjectFlockSearchValue} onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks} isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id} isDisabled={!formik.values.location_id}
isError={ isError={
formik.touched.project_flock_id && formik.touched.project_flock_id &&
@@ -156,8 +156,11 @@ const PurchaseOrderAcceptApprovalForm = ({
setInputValue: setExpeditionsSelectInputValue, setInputValue: setExpeditionsSelectInputValue,
options: expeditionVendors, options: expeditionVendors,
isLoadingOptions: isLoadingExpeditions, isLoadingOptions: isLoadingExpeditions,
loadMore: loadMoreExpeditions,
hasMore: hasMoreExpeditions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', { } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'BOP', category: 'BOP',
flag: 'EKSPEDISI',
}); });
// ===== FORM CONFIGURATION ===== // ===== FORM CONFIGURATION =====
@@ -183,8 +186,8 @@ const PurchaseOrderAcceptApprovalForm = ({
purchase_item_id: formItem.purchase_item_id || 0, purchase_item_id: formItem.purchase_item_id || 0,
received_date: formItem.received_date || '', received_date: formItem.received_date || '',
travel_number: formItem.travel_number || '', travel_number: formItem.travel_number || '',
vehicle_number: formItem.vehicle_number || '', vehicle_number: formItem.vehicle_number || null,
expedition_vendor_id: formItem.expedition_vendor_id || 0, expedition_vendor_id: formItem.expedition_vendor_id || null,
received_qty: received_qty:
typeof formItem.received_qty === 'string' typeof formItem.received_qty === 'string'
? parseFloat(formItem.received_qty) || 0 ? parseFloat(formItem.received_qty) || 0
@@ -192,10 +195,13 @@ const PurchaseOrderAcceptApprovalForm = ({
transport_per_item: transport_per_item:
typeof formItem.transport_per_item === 'string' typeof formItem.transport_per_item === 'string'
? parseFloat(formItem.transport_per_item) || 0 ? 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) { switch (type) {
@@ -403,22 +409,13 @@ const PurchaseOrderAcceptApprovalForm = ({
Dokumen Surat Jalan Dokumen Surat Jalan
<span className='text-error'>*</span> <span className='text-error'>*</span>
</th> </th>
<th> <th>Nomor Kendaraan</th>
Nomor Kendaraan <th>Vendor Ekspedisi</th>
<span className='text-error'>*</span>
</th>
<th>
Vendor Ekspedisi
<span className='text-error'>*</span>
</th>
<th> <th>
Jumlah Diterima Jumlah Diterima
<span className='text-error'>*</span> <span className='text-error'>*</span>
</th> </th>
<th> <th>Transport/Item</th>
Transport/Item
<span className='text-error'>*</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -536,7 +533,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td> </td>
<td> <td>
<TextInput <TextInput
required
name={`items.${idx}.vehicle_number`} name={`items.${idx}.vehicle_number`}
type='text' type='text'
value={formItem?.vehicle_number || ''} value={formItem?.vehicle_number || ''}
@@ -562,7 +558,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td> </td>
<td> <td>
<SelectInput <SelectInput
required
isClearable={true} isClearable={true}
value={formItem?.expedition_vendor} value={formItem?.expedition_vendor}
key={`expedition-vendor-${idx}`} key={`expedition-vendor-${idx}`}
@@ -570,6 +565,8 @@ const PurchaseOrderAcceptApprovalForm = ({
expeditionVendorChangeHandler(idx, val) expeditionVendorChangeHandler(idx, val)
} }
options={getExpeditionVendorOptions()} options={getExpeditionVendorOptions()}
isLoading={isLoadingExpeditions}
onMenuScrollToBottom={loadMoreExpeditions}
isError={ isError={
isRepeaterInputError(idx, 'expedition_vendor_id') isRepeaterInputError(idx, 'expedition_vendor_id')
.isError .isError
@@ -629,7 +626,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td> </td>
<td> <td>
<NumberInput <NumberInput
required
name={`items.${idx}.transport_per_item`} name={`items.${idx}.transport_per_item`}
value={formItem?.transport_per_item || ''} value={formItem?.transport_per_item || ''}
onChange={(e) => onChange={(e) =>
@@ -680,7 +676,6 @@ const PurchaseOrderAcceptApprovalForm = ({
<div className={'col-span-2 my-2'}> <div className={'col-span-2 my-2'}>
<FileInput <FileInput
required
name='travel_documents' name='travel_documents'
label='Dokumen Surat Jalan' label='Dokumen Surat Jalan'
accept='.pdf,.jpg,.jpeg,.png' accept='.pdf,.jpg,.jpeg,.png'
@@ -38,16 +38,16 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
vehicle_number: string; vehicle_number?: string | null;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
expedition_vendor_id: number; expedition_vendor_id?: number | null;
received_qty: number | string; 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 = { export type PurchaseStaffApprovalItemSchema = {
@@ -75,14 +75,14 @@ export type PurchaseAcceptApprovalItemSchema = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
vehicle_number: string; vehicle_number?: string | null;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
expedition_vendor_id: number; expedition_vendor_id?: number | null;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item?: number | string | null;
}; };
export type PurchaseDeleteItemsSchema = { export type PurchaseDeleteItemsSchema = {
@@ -184,24 +184,19 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.required('No. Surat jalan wajib diisi!') .required('No. Surat jalan wajib diisi!')
.typeError('No. Surat jalan wajib diisi!'), .typeError('No. Surat jalan wajib diisi!'),
vehicle_number: Yup.string() vehicle_number: Yup.string()
.required('Nomor kendaraan wajib diisi!') .nullable()
.typeError('Nomor kendaraan wajib diisi!'), .optional()
.typeError('Nomor kendaraan harus berupa plat nomor!'),
expedition_vendor: Yup.object({ expedition_vendor: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), })
.nullable()
.optional(),
expedition_vendor_id: Yup.number() expedition_vendor_id: Yup.number()
.min(1, 'Vendor ekspedisi wajib diisi!') .nullable()
.required('Vendor ekspedisi wajib diisi!') .optional()
.test( .typeError('Vendor ekspedisi harus berupa angka!'),
'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!'),
received_qty: Yup.mixed<string | number>() received_qty: Yup.mixed<string | number>()
.required('Jumlah diterima wajib diisi!') .required('Jumlah diterima wajib diisi!')
.test( .test(
@@ -217,13 +212,14 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
) )
.typeError('Jumlah diterima harus berupa angka!'), .typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>() transport_per_item: Yup.mixed<string | number>()
.required('Biaya transport per item wajib diisi!') .nullable()
.optional()
.test( .test(
'is-valid-transport-per-item', 'is-valid-transport-per-item',
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!', 'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
function (value) { function (value) {
if (value === '' || value === null || value === undefined) if (value === '' || value === null || value === undefined)
return false; return true;
const numValue = const numValue =
typeof value === 'string' ? parseFloat(value) : value; typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0; return !isNaN(numValue) && numValue >= 0;
@@ -389,16 +385,17 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
travel_documents: Yup.array() travel_documents: Yup.array()
.of( .of(
Yup.mixed<File>() Yup.mixed<File>()
.required('Dokumen surat jalan wajib diupload!') .nullable()
.optional()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => { .test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true; if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024; if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true; return true;
}) })
) )
.required('Dokumen surat jalan wajib diupload!') .nullable()
.min(1, 'Minimal upload 1 dokumen surat jalan!') .optional()
.typeError('Dokumen surat jalan wajib diupload!'), .typeError('Dokumen surat jalan harus berupa array!'),
}); });
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType = export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
@@ -633,8 +633,18 @@ const PurchaseOrderStaffApprovalForm = ({
formik.setFieldValue(`items.${idx}.qty`, numValue); formik.setFieldValue(`items.${idx}.qty`, numValue);
formik.setFieldValue(`items.${idx}.price`, ''); if (
formik.setFieldValue(`items.${idx}.total_price`, ''); 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') { if (field === 'price' || field === 'total_price') {
@@ -1184,8 +1194,10 @@ const PurchaseOrderStaffApprovalForm = ({
color='warning' color='warning'
className='px-4' className='px-4'
onClick={() => { onClick={() => {
formik.setValues(formikInitialValues); if (type === 'add') {
formik.resetForm(); formik.setValues(formikInitialValues);
formik.resetForm();
}
setPurchaseOrderFormErrorMessage(''); setPurchaseOrderFormErrorMessage('');
onCancel?.(); onCancel?.();
onModalClose?.(); onModalClose?.();
@@ -63,11 +63,9 @@ const PurchaseRequestForm = ({
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]); const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== TYPE DEFINITIONS ===== const [selectedArea, setSelectedArea] = useState('');
interface ProductOptionType { const [selectedLocation, setSelectedLocation] = useState('');
value: number; const [disabledLocation, setDisabledLocation] = useState(true);
label: string;
}
// ===== UTILITY FUNCTIONS ===== // ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = ( const isRepeaterInputError = (
@@ -160,11 +158,35 @@ const PurchaseRequestForm = ({
isLoadingOptions: isLoadingAreas, isLoadingOptions: isLoadingAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search'); } = 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 { const {
inputValue: warehouseSelectInputValue, inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouses, 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 ===== // ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<PurchaseRequestFormValues>( const formikInitialValues = useMemo<PurchaseRequestFormValues>(
@@ -267,70 +289,6 @@ const PurchaseRequestForm = ({
return data; return data;
}, [supplierData]); }, [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 addPurchaseItem = () => {
const newItems = [ const newItems = [
...(formik.values.items || []), ...(formik.values.items || []),
@@ -407,6 +365,18 @@ const PurchaseRequestForm = ({
} }
}, [formik.values.supplier_id]); }, [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 ===== // ===== FORM HANDLERS =====
const handleSupplierChange = useCallback( const handleSupplierChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
@@ -445,6 +415,16 @@ const PurchaseRequestForm = ({
formik.setFieldValue('area_id', (area as OptionType)?.value || 0); formik.setFieldValue('area_id', (area as OptionType)?.value || 0);
formik.setFieldTouched('area', true); formik.setFieldTouched('area', true);
formik.setFieldValue('area', area); 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.setFieldValue('location_id', (location as OptionType)?.value || 0);
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
formik.setFieldValue('location', location); formik.setFieldValue('location', location);
setSelectedLocation((location as OptionType)?.value as string);
}, },
[] []
); );
@@ -596,10 +578,15 @@ const PurchaseRequestForm = ({
placeholder='Pilih Lokasi...' placeholder='Pilih Lokasi...'
value={formik.values.location} value={formik.values.location}
onChange={handleLocationChange} onChange={handleLocationChange}
options={locationOptions} options={
selectedArea != '' || initialValues?.area?.id
? locationOptions
: []
}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
isDisabled={type === 'detail'} onMenuScrollToBottom={loadMoreLocations}
isDisabled={type === 'detail' || disabledLocation}
isClearable={type !== 'detail'} isClearable={type !== 'detail'}
/> />
@@ -713,6 +700,7 @@ const PurchaseRequestForm = ({
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
isRepeaterInputError(idx, 'warehouse_id').isError isRepeaterInputError(idx, 'warehouse_id').isError
} }
@@ -732,9 +720,9 @@ const PurchaseRequestForm = ({
required required
value={item.product ?? undefined} value={item.product ?? undefined}
onChange={(val) => { onChange={(val) => {
const product = val as ProductOptionType | null; const product = val as OptionType | null;
const productId = const productId =
(product as ProductOptionType)?.value || 0; (product as OptionType)?.value || 0;
formik.setFieldTouched( formik.setFieldTouched(
`items.${idx}.product`, `items.${idx}.product`,
@@ -540,31 +540,6 @@ const PurchaseOrderDetail = ({
accessorKey: 'travel_number', accessorKey: 'travel_number',
cell: (props) => props.row.original.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', header: 'No. Armada Pengangkut',
accessorKey: 'vehicle_number', accessorKey: 'vehicle_number',
@@ -588,7 +563,10 @@ const PurchaseOrderDetail = ({
{ {
header: 'Transport /Item', header: 'Transport /Item',
accessorKey: 'transport_per_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>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
:{' '} :{' '}
{purchaseData.items?.[0]?.warehouse?.type === 'LOKASI' && {purchaseData.items?.[0]?.warehouse &&
purchaseData.items?.[0]?.warehouse?.location?.name 'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.name ? purchaseData.items[0].warehouse.location.name
: '-'} : '-'}
</span> </span>
@@ -905,11 +883,29 @@ const PurchaseOrderDetail = ({
Informasi Penerimaan Barang Informasi Penerimaan Barang
</h3> </h3>
{canShowPenerimaanBarang && ( {canShowPenerimaanBarang && (
<RowDropdownOptions isLast2Rows> <div className='flex items-center gap-2'>
<PenerimaanBarangDropdown {goodsReceiptItems[0]?.travel_document_path && (
onEdit={penerimaanBarangModal.openModal} <Button
/> color='primary'
</RowDropdownOptions> 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>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
@@ -324,12 +324,14 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
PT LUMBUNG TELUR INDONESIA PT LUMBUNG TELUR INDONESIA
</Text> </Text>
<Text> <Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI' {purchaseData?.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.name ? purchaseData.items[0].warehouse.location.name
: '-'} : '-'}
</Text> </Text>
<Text> <Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI' {purchaseData?.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.address ? purchaseData.items[0].warehouse.location.address
: '-'} : '-'}
</Text> </Text>
@@ -434,7 +436,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
</View> </View>
<View style={pdfStyles.tableCell}> <View style={pdfStyles.tableCell}>
<Text> <Text>
{item.warehouse?.type === 'LOKASI' {item.warehouse && 'location' in item.warehouse
? item.warehouse.location.address ? item.warehouse.location.address
: '-'} : '-'}
</Text> </Text>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { pdf } from '@react-pdf/renderer'; import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -28,7 +28,10 @@ import {
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { Customer } from '@/types/api/master-data/customer'; import { Customer } from '@/types/api/master-data/customer';
import { MarketingReportApi } from '@/services/api/report/marketing-report'; import { MarketingReportApi } from '@/services/api/report/marketing-report';
import { MARKETING_TYPE_OPTIONS } from '@/config/constant'; import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import { httpClient } from '@/services/http/client'; import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
@@ -84,6 +87,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
options: areaOptions, options: areaOptions,
isLoadingOptions: isLoadingAreaOptions, isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name'); } = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -98,6 +102,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -115,6 +120,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setWarehouseInputValue, setInputValue: setWarehouseInputValue,
options: warehouseOptions, options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions, isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -132,6 +138,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setCustomerInputValue, setInputValue: setCustomerInputValue,
options: customerOptions, options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions, isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name'); } = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => { const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -150,6 +157,15 @@ const DailyMarketingReportContent = () => {
updateFilter('end_date', e.target.value ? e.target.value : ''); updateFilter('end_date', e.target.value ? e.target.value : '');
}; };
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
useState<OptionType | null>(null);
const marketingDateFilterTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingDateFilterType(val as OptionType);
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
};
const [selectedMarketingType, setSelectedMarketingType] = const [selectedMarketingType, setSelectedMarketingType] =
useState<OptionType | null>(null); useState<OptionType | null>(null);
const marketingTypeChangeHandler = ( const marketingTypeChangeHandler = (
@@ -252,6 +268,23 @@ const DailyMarketingReportContent = () => {
resetFilter(); resetFilter();
}; };
useEffect(() => {
if (
tableFilterState.filter_by === 'realization_date' ||
tableFilterState.filter_by === 'so_date'
) {
setSelectedMarketingDateFilterType({
label:
tableFilterState.filter_by === 'realization_date'
? 'Tanggal Realisasi'
: 'Tanggal SO',
value: tableFilterState.filter_by,
});
} else {
setSelectedMarketingDateFilterType(null);
}
}, [tableFilterState.filter_by]);
return ( return (
<div className='w-full border border-gray-200 p-4'> <div className='w-full border border-gray-200 p-4'>
<div> <div>
@@ -269,6 +302,7 @@ const DailyMarketingReportContent = () => {
value={selectedArea} value={selectedArea}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -283,6 +317,7 @@ const DailyMarketingReportContent = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -297,6 +332,7 @@ const DailyMarketingReportContent = () => {
value={selectedWarehouse} value={selectedWarehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -311,6 +347,7 @@ const DailyMarketingReportContent = () => {
value={selectedCustomer} value={selectedCustomer}
onChange={customerChangeHandler} onChange={customerChangeHandler}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -341,6 +378,18 @@ const DailyMarketingReportContent = () => {
</div> </div>
<div className='grid grid-cols-12 gap-4'> <div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Filter Tanggal'
placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
value={selectedMarketingDateFilterType}
onChange={marketingDateFilterTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput <SelectInput
label='Tipe Marketing' label='Tipe Marketing'
placeholder='Pilih Tipe Marketing' placeholder='Pilih Tipe Marketing'

Some files were not shown because too many files have changed in this diff Show More