Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into feat/closing-overhead-kandang

This commit is contained in:
randy-ar
2026-01-15 20:16:11 +07:00
70 changed files with 3454 additions and 1433 deletions
+3 -1
View File
@@ -38,9 +38,11 @@ const ExpenseEditPage = () => {
!isLoadingExpense &&
isResponseSuccess(expense) &&
expense.data.latest_approval.step_number !== 5 &&
expense.data.latest_approval.step_number !== 6 &&
(expense.data.latest_approval.step_number === 1 ||
expense.data.latest_approval.step_number === 2 ||
expense.data.latest_approval.step_number === 3);
expense.data.latest_approval.step_number === 3 ||
expense.data.latest_approval.step_number === 4);
if (!isLoadingExpense && !isExpenseCanBeEdited) {
router.back();
+6 -2
View File
@@ -148,7 +148,11 @@ const Card = ({
const hasContent = children || actions || footer;
const titleContent = (
<div className='group flex items-center !justify-between w-full'>
<div
className={
`group flex items-center justify-between! w-full` + getTitleClasses()
}
>
<div className='flex-1'>
{title && <h2 className={getTitleClasses()}>{title}</h2>}
{subtitle && <p className={getSubtitleClasses()}>{subtitle}</p>}
@@ -156,7 +160,7 @@ const Card = ({
{collapsible && (
<button
onClick={() => handleCollapsedChange(!isCollapsed)}
className='btn btn-ghost btn-sm btn-circle'
className={`btn btn-ghost btn-sm btn-circle` + getTitleClasses()}
aria-label={isCollapsed ? 'Expand content' : 'Collapse content'}
>
<Icon
+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;
return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'>
<Alert color='error' className='w-full flex flex-col gap-2 px-4'>
<div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} />
+134 -27
View File
@@ -9,15 +9,20 @@ import Select, {
SingleValue,
components as ReactSelectComponents,
ControlProps,
MenuListProps,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { isResponseSuccess } from '@/lib/api-helper';
import {
BaseApiResponse,
ErrorApiResponse,
SuccessApiResponse,
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType {
value: string | number;
@@ -35,6 +40,7 @@ interface SelectInputBaseProps<T = OptionType> {
bottomLabel?: ReactNode;
options: T[];
optionComponent?: OptionComponent<T>;
components?: Partial<typeof ReactSelectComponents>;
isDisabled?: boolean;
isLoading?: boolean;
isClearable?: boolean;
@@ -56,9 +62,13 @@ interface SelectInputBaseProps<T = OptionType> {
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
export interface SelectInputProps<T = OptionType>
extends SelectInputBaseProps<T> {
createables?: boolean;
value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void;
@@ -93,6 +103,29 @@ const CustomControl = <
);
};
const CustomMenuList = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>,
>(
props: MenuListProps<Option, IsMulti, Group>
) => {
const { children, selectProps, options } = props;
const { isLoading } = selectProps;
return (
<ReactSelectComponents.MenuList {...props}>
{children}
{options.length > 0 && isLoading && (
<div className='mt-1 px-3 py-2 rounded-md text-center text-gray-400'>
<span className='loading loading-spinner loading-md' />
</div>
)}
</ReactSelectComponents.MenuList>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
@@ -101,6 +134,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onChange,
options,
optionComponent,
components: customComponents,
isDisabled,
isLoading,
isClearable,
@@ -119,6 +153,9 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
onInputChange,
startAdornment,
menuPortalTarget,
closeMenuOnSelect,
hideSelectedOptions,
onMenuScrollToBottom,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
@@ -128,14 +165,18 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
const customComponents = { ...base, IndicatorSeparator: () => null };
const mergedComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) {
customComponents.Control = CustomControl;
mergedComponents.Control = CustomControl;
}
return customComponents;
}, [isAnimated, startAdornment]);
if (customComponents) {
Object.assign(mergedComponents, customComponents);
}
return mergedComponents;
}, [isAnimated, startAdornment, customComponents]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val);
@@ -205,6 +246,8 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={cn('w-full', className?.select)}
classNames={{
...(!startAdornment && {
@@ -256,6 +299,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
MenuList: CustomMenuList,
}}
{...(startAdornment && {
shouldShowAdornment,
@@ -269,6 +313,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}}
onMenuScrollToBottom={onMenuScrollToBottom}
/>
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
@@ -280,7 +325,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
};
const useSelect = <T,>(
basePath: string,
basePath: string | null,
valueKey: keyof T | string,
labelKey: keyof T | string,
searchKey: string = 'search',
@@ -288,34 +333,96 @@ const useSelect = <T,>(
) => {
const [inputValue, setInputValue] = useState('');
const optionsUrlParams = useMemo(() => {
return new URLSearchParams({
const pageKey = 'page';
const limitKey = 'limit';
const limit = params?.['limit'] ?? 10;
const getKey = (
pageIndex: number,
previousPageData?: BaseApiResponse<T[]>
) => {
// stop when backend says no more pages
if (previousPageData && isResponseSuccess(previousPageData)) {
const meta = previousPageData.meta;
if (meta && meta.page >= meta.total_pages) return null;
}
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey]: inputValue ?? '',
...params,
[pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString();
}, [inputValue, searchKey, params]);
const optionsUrl = `${basePath}?${optionsUrlParams}`;
return basePath ? `${basePath}?${qs}` : null;
};
const { data, isLoading } = useSWR(optionsUrl, async (url) => {
return await httpClientFetcher<BaseApiResponse<T[]>>(url);
});
const {
data: pages,
isLoading,
isValidating,
size,
setSize,
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
httpClientFetcher<BaseApiResponse<T[]>>(url)
);
const options = isResponseSuccess(data)
? data.data.map((item) => {
return {
value: getByPath<T, number>(item, valueKey as string),
label: getByPath<T, string>(item, labelKey as string),
};
})
: [];
const options = useMemo(() => {
if (!pages) return [];
return pages.flatMap((page) =>
isResponseSuccess(page)
? page.data.map((item) => ({
value: getByPath<T, number>(item, valueKey as string),
label: getByPath<T, string>(item, labelKey as string),
}))
: []
);
}, [pages, valueKey, labelKey]);
const lastPage = pages?.[pages.length - 1];
const hasMore =
!!lastPage &&
isResponseSuccess(lastPage) &&
!!lastPage.meta &&
lastPage.meta.page < lastPage.meta.total_pages;
const loadMore = () => {
if (!hasMore) return;
setSize(size + 1);
};
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedSuccessRawData = {
...pages?.[latestPagesIndex],
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return {
inputValue,
setInputValue,
options,
isLoadingOptions: isLoading,
rawData: data,
rawData: isResponseSuccess(pages?.[latestPagesIndex])
? formattedSuccessRawData
: formattedErrorRawData,
isLoadingOptions: isLoading || isValidating,
isLoadingMore: isValidating && size > 1,
hasMore,
loadMore,
};
};
@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import {
OptionProps,
GroupBase,
components as ReactSelectComponents,
} from 'react-select';
import SelectInput, { OptionType, SelectInputProps } from './SelectInput';
import { cn } from '@/lib/helper';
interface SelectInputCheckboxProps<T = OptionType>
extends Omit<
SelectInputProps<T>,
'closeMenuOnSelect' | 'hideSelectedOptions' | 'optionComponent'
> {
closeMenuOnSelect?: boolean;
hideSelectedOptions?: boolean;
}
const CheckboxOption = <
T extends OptionType,
IsMulti extends boolean,
Group extends GroupBase<T>,
>(
props: OptionProps<T, IsMulti, Group>
) => {
const { isSelected, label, innerRef, innerProps, className } = props;
return (
<div
ref={innerRef}
{...innerProps}
className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer',
className
)}
>
<input
type='checkbox'
checked={isSelected}
onChange={() => null}
className='checkbox checkbox-sm checkbox-primary pointer-events-none'
/>
<label className='cursor-pointer flex-1 select-none'>{label}</label>
</div>
);
};
const SelectInputCheckbox = <T extends OptionType>(
props: SelectInputCheckboxProps<T>
) => {
const {
closeMenuOnSelect = false,
hideSelectedOptions = false,
isMulti = true,
className,
...restProps
} = props;
const customComponents = useMemo(() => {
return {
Option: CheckboxOption as typeof ReactSelectComponents.Option,
};
}, []);
return (
<SelectInput<T>
{...restProps}
isMulti={isMulti}
closeMenuOnSelect={closeMenuOnSelect}
hideSelectedOptions={hideSelectedOptions}
className={{
...className,
select: cn(className?.select, 'select-checkbox'),
}}
components={customComponents}
/>
);
};
export default SelectInputCheckbox;
@@ -8,6 +8,7 @@ import {
HppPurchaseData,
ProfitLossDataAmount,
} from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
type HppTableRow =
@@ -55,9 +56,16 @@ const ClosingFinanceTable = ({
}: {
projectFlockId: number;
}) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`,
() => ClosingApi.getFinance(projectFlockId)
`/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
);
const staticHppRows: Array<{
@@ -283,6 +291,7 @@ const ClosingFinanceTable = ({
<div className='mt-6 p-0 mb-0'>
<Table<HppTableRow>
data={hppTableData}
isLoading={isLoading}
columns={[
{
header: 'No.',
@@ -299,7 +308,7 @@ const ClosingFinanceTable = ({
},
},
{
header: 'Type',
header: 'Jenis',
enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'),
},
@@ -455,6 +464,7 @@ const ClosingFinanceTable = ({
<div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow>
data={profitLossTableData}
isLoading={isLoading}
columns={[
{
header: 'Jenis',
@@ -1,6 +1,7 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingIncomingSapronaksTableProps {
const ClosingIncomingSapronaksTable = ({
projectFlockId,
}: ClosingIncomingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
@@ -43,7 +47,7 @@ const ClosingIncomingSapronaksTable = ({
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`,
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakFetcher,
{
keepPreviousData: true,
@@ -1,6 +1,7 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingOutgoingSapronaksTableProps {
const ClosingOutgoingSapronaksTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
@@ -43,7 +47,7 @@ const ClosingOutgoingSapronaksTable = ({
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`,
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllOutgoingSapronakFetcher,
{
keepPreviousData: true,
@@ -1,5 +1,6 @@
'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper';
@@ -12,9 +13,12 @@ interface ClosingProductionDataTabContentProps {
const ClosingProductionDataTabContent = ({
projectFlockId,
}: ClosingProductionDataTabContentProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: productionData, isLoading } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/production-data`,
() => ClosingApi.getProductionData(projectFlockId)
`${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
() => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
);
if (isLoading) {
@@ -197,7 +201,7 @@ const ClosingProductionDataTabContent = ({
value={formatNumber(performance.mor_diff)}
unitClassName='hidden'
/>
<DataRow
{/* <DataRow
label='AWG Std'
value={formatNumber(performance.awg_std)}
unit='Gr/Hari'
@@ -206,7 +210,7 @@ const ClosingProductionDataTabContent = ({
label='AWG Act'
value={formatNumber(performance.awg_act)}
unit='Gr/Hari'
/>
/> */}
<DataRow
label='Feed Intake Std'
value={formatNumber(performance.feed_intake_std)}
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6',
@@ -8,7 +8,7 @@ import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik';
@@ -30,6 +30,11 @@ import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import ButtonFilter from '@/components/helper/ButtonFilter';
import toast from 'react-hot-toast';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
// Helper function to normalize values to array
const normalizeToArray = (
@@ -49,6 +54,7 @@ const DashboardProduction = () => {
);
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]);
const [exporting, setExporting] = useState(false);
// ===== FETCH DATA =====
const {
@@ -64,22 +70,32 @@ const DashboardProduction = () => {
: undefined;
// ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueFlock,
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueLocation,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } =
useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' },
@@ -143,12 +159,27 @@ const DashboardProduction = () => {
console.log(endpointUrl);
filterModal.closeModal();
refreshDashboardProductionData();
formik.resetForm();
};
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Export PDF =====
const handleExportPDF = () => {
setExporting(true);
};
// Wait for state to render, then trigger print
useEffect(() => {
if (exporting) {
const timer = setTimeout(() => {
window.print();
setExporting(false);
}, 100);
return () => clearTimeout(timer);
}
}, [exporting]);
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
@@ -156,71 +187,39 @@ const DashboardProduction = () => {
</div>
);
}
return (
<>
<section className='w-full p-4 space-y-6'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
<div></div>
<div className='flex flex-row justify-end gap-2'>
<Button
<ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline'
className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg'
onClick={() => filterModal.openModal()}
/>
<Dropdown
trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'>
<Icon icon='heroicons:arrow-down-tray' />
Export
<Icon icon='heroicons:chevron-down' />
</Button>
}
className={{
content: 'w-full',
}}
>
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} />
Export
<Icon icon='heroicons:chevron-down' width={20} height={20} />
</Button>
<Menu className={exporting ? 'hidden' : ''}>
<MenuItem title='PDF' onClick={handleExportPDF} />
</Menu>
</Dropdown>
</div>
</div>
@@ -287,7 +286,7 @@ const DashboardProduction = () => {
{/* Rentang Waktu */}
<div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label>
<div className='flex items-center gap-2'>
<div className='flex items-start gap-2'>
<DateInput
name='startDate'
placeholder='Tanggal Mulai'
@@ -302,7 +301,7 @@ const DashboardProduction = () => {
Boolean(formik.touched.startDate)
}
/>
<span className='hidden md:block text-center'></span>
<div className='hidden md:block mt-3 text-center'></div>
<DateInput
name='endDate'
placeholder='Tanggal Akhir'
@@ -383,6 +382,8 @@ const DashboardProduction = () => {
<SelectInput
label='Farm'
value={formik.values.location}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
onChange={(selected) => {
formik.setFieldValue('location', selected);
// Update selectedLocationIds for kandang filter
@@ -422,6 +423,8 @@ const DashboardProduction = () => {
formik.setFieldValue('flock', selected)
}
errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
options={flockOptions}
isLoading={isLoadingFlockOptions}
isMulti={
@@ -450,6 +453,8 @@ const DashboardProduction = () => {
formik.setFieldValue('kandang', selected)
}
errorMessage={formik.errors.kandang as string}
onInputChange={setInputValueKandang}
onMenuScrollToBottom={loadMoreKandang}
options={kandangOptions}
isLoading={isLoadingKandangOptions}
isMulti={
@@ -465,7 +470,9 @@ const DashboardProduction = () => {
</div>
)}
<AlertErrorList formErrorList={formErrorList} onClose={close} />
<div className='w-full p-4'>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
@@ -283,261 +283,311 @@ const DashboardLineChart = ({
})()}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={(() => {
// Transform data based on analysisMode
if (analysisMode === 'OVERVIEW') {
// For OVERVIEW mode, use the selected chart data
if (isOverviewCharts(data.charts)) {
const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset) return [];
return selectedChartData.dataset;
{/* Chart Container with Empty State Overlay */}
<div className='relative'>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={(() => {
// Transform data based on analysisMode
if (analysisMode === 'OVERVIEW') {
// For OVERVIEW mode, use the selected chart data
if (isOverviewCharts(data.charts)) {
const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (value === undefined || name === undefined) return ['', ''];
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const unit = series?.unit || '';
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
return [`${value} ${unit}`, name];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
activeDot={isStandard ? undefined : { r: 5 }}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (value === undefined || name === undefined) return ['', ''];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const unit = series?.unit || '';
return [`${value} ${unit}`, name];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={24}
height={24}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
</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>
);
};
@@ -1,6 +1,6 @@
'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import {
CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data';
import Button from '@/components/Button';
import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage,
] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
});
// Fetch Data
const productCategoriesUrl = `${
ProductCategoryApi.basePath
}?${new URLSearchParams({
search: '',
}).toString()}`;
const { data: productCategories, isLoading: isLoadingProductCategories } =
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const {
setInputValue: setProductCategoryInputValue,
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategoryOptions,
loadMore: loadMoreProductCategories,
} = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({
search: '',
product_category_id: selectedProductCategories,
}).toString()}`;
const { data: products, isLoading: isLoadingProducts } = useSWR(
productUrl,
ProductApi.getAllFetcher
);
const {
setInputValue: setProductInputValue,
options: productOptions,
isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts,
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
product_category_id: formik.values.product_category_id
? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({
search: '',
limit: '100',
}).toString()}`;
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR(
warehouseUrl,
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
const {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
// Options Handler
const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect
useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false);
formik.setFieldValue(
'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
);
formik.setFieldValue('note', initialValues.note);
}
}, [
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
}, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function
const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk'
value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories}
options={optionsProductCategory}
isLoading={isLoadingProductCategories}
onInputChange={setProductCategoryInputValue}
options={productCategoryOptions}
onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={
formik.touched.product_category &&
Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk'
value={formik.values.product as OptionType}
onChange={productChangeHandler}
options={optionsProduct}
isLoading={isLoadingProducts}
onInputChange={setProductInputValue}
options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse'
value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler}
options={optionsWarehouse}
isLoading={isLoadingWarehouses}
onInputChange={setWarehouseInputValue}
options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse)
}
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
interface MovementFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT =====
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]);
@@ -93,10 +91,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== USE SELECT HOOKS =====
const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search');
loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
// ===== SELECT INPUT DATA =====
const {
@@ -107,12 +106,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
category: 'BOP',
});
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -269,25 +262,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
});
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const getProductWarehousesUrl = useCallback(() => {
const productWarehouseParams = new URLSearchParams({
search: productWarehouseSelectInputValue,
});
if (formik.values.source_warehouse_id) {
productWarehouseParams.append(
'warehouse_id',
formik.values.source_warehouse_id.toString()
);
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()
: '',
}
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`;
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
const productWarehousesUrl = getProductWarehousesUrl();
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
useSWR(
formik.values.source_warehouse_id ? productWarehousesUrl : null,
ProductWarehouseApi.getAllFetcher
);
);
const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({
@@ -1006,6 +996,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouses}
isError={
formik.touched.source_warehouse_id &&
@@ -1104,6 +1095,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={
formik.touched.destination_warehouse_id &&
Boolean(formik.errors.destination_warehouse_id)
@@ -1263,6 +1255,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}}
options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
isLoading={isLoadingProductWarehouses}
isDisabled={
type === 'detail' ||
@@ -11,6 +11,13 @@ import {
type MarketingSchemaType = {
customer_id: number | undefined;
sales_person_id: number | undefined;
sales_person:
| {
value: number;
label: string;
}
| undefined
| null;
customer:
| {
value: number;
@@ -33,7 +40,11 @@ type DeliveryOrderSchemaType = {
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'),
sales_person_id: Yup.number().required('Sales Person wajib diisi!'),
sales_person_id: Yup.number().required('Sales wajib diisi!'),
sales_person: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
customer: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
@@ -50,6 +50,8 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { CreatedUser } from '@/types/api/api-general';
import { UserApi } from '@/services/api/user';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -244,7 +246,15 @@ const MarketingForm = ({
const {
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
setInputValue: setInputCustomerValue,
loadMore: loadMoreCustomer,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const {
options: salesOptions,
isLoadingOptions: isLoadingSalesOptions,
setInputValue: setInputSalesValue,
loadMore: loadMoreSales,
} = useSelect<CreatedUser>(UserApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ==================
const formikInitialValues = useMemo<
@@ -255,6 +265,12 @@ const MarketingForm = ({
notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1,
sales_person: initialValues?.sales_person
? {
value: initialValues.sales_person.id,
label: initialValues.sales_person.name,
}
: null,
customer: initialValues?.customer
? {
value: initialValues.customer.id,
@@ -443,6 +459,13 @@ const MarketingForm = ({
},
[]
);
const handleChangeSalesPerson = useCallback(
(val: OptionType | OptionType[] | null) => {
formik.setFieldValue('sales_person_id', (val as OptionType)?.value);
formik.setFieldValue('sales_person', val as OptionType);
},
[]
);
const handleDelete = useCallback(() => {
deleteModal.openModal();
}, [deleteModal]);
@@ -580,6 +603,7 @@ const MarketingForm = ({
className={{
wrapper: 'bg-white w-full',
}}
variant='bordered'
>
<div className='grid sm:grid-cols-2 gap-3 mt-3'>
<SelectInput
@@ -588,6 +612,8 @@ const MarketingForm = ({
isLoading={isLoadingCustomerOptions}
value={formik.values.customer}
onChange={handleChangeCustomer}
onInputChange={setInputCustomerValue}
onMenuScrollToBottom={loadMoreCustomer}
isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id)
}
@@ -617,6 +643,7 @@ const MarketingForm = ({
className={{
wrapper: 'bg-white w-full',
}}
variant='bordered'
>
<MemoizedSalesOrderProductTable
formType={formType}
@@ -651,19 +678,42 @@ const MarketingForm = ({
{/* Input Notes */}
<div className='grid sm:grid-cols-2 gap-3'>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={formType === 'add_deliver' || formType === 'edit_deliver'}
/>
<div className='flex flex-col h-full justify-between items-end py-6'>
<div className='flex flex-col h-full items-end gap-3'>
<SelectInput
label='Sales'
options={salesOptions}
isLoading={isLoadingSalesOptions}
value={formik.values.sales_person}
onChange={handleChangeSalesPerson}
onInputChange={setInputSalesValue}
onMenuScrollToBottom={loadMoreSales}
isError={
formik.touched.sales_person_id &&
Boolean(formik.errors.sales_person_id)
}
errorMessage={formik.errors.sales_person_id}
isClearable
placeholder='Pilih Sales'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
</div>
<div className='flex flex-col h-full justify-end items-end'>
<span>Total Penjualan</span>
<span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '}
@@ -18,6 +18,11 @@ import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
import { ProductApi } from '@/services/api/master-data';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const DeliveryOrderProductForm = ({
formState,
@@ -43,6 +48,17 @@ const DeliveryOrderProductForm = ({
);
const [currentInput, setCurrentInput] = useState<string>('');
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id
);
@@ -113,22 +129,60 @@ const DeliveryOrderProductForm = ({
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue('total_price', Number(qty) * Number(unit_price));
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue('unit_price', Number(total_price) / Number(qty));
const qty = Number(formik.values.qty || 0);
const avgWeight = Number(formik.values.avg_weight || 0);
const totalWeight = Number(formik.values.total_weight || 0);
const unitPrice = Number(formik.values.unit_price || 0);
const totalPrice = Number(formik.values.total_price || 0);
if (qty <= 0) return;
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
}
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight));
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
case 'avg_weight': {
if (avgWeight > 0) {
const tw = roundWeight(qty * avgWeight);
formik.setFieldValue('total_weight', tw);
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
break;
}
}
};
@@ -183,7 +237,7 @@ const DeliveryOrderProductForm = ({
</div>
)}
<div className='grid sm:grid-cols-2 gap-4'>
<div className='grid sm:grid-cols-3 gap-4'>
<SelectInput
options={options}
label='Produk'
@@ -287,7 +341,9 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4'>
<NumberInput
required
label='Kuantitas'
@@ -301,33 +357,28 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={
formik.values.marketing_product_id
? 'Stok dijual: ' +
salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id
)?.qty
)?.qty +
' ' +
(isResponseSuccess(productData)
? productData?.data?.uom.name
: '')
: ''
}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
@@ -342,7 +393,20 @@ const DeliveryOrderProductForm = ({
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Total Bobot (Kg)'
@@ -11,7 +11,7 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data';
import { ProductApi, UomApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput';
@@ -26,6 +26,10 @@ import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const SalesOrderProductForm = ({
initialValues,
@@ -39,6 +43,19 @@ const SalesOrderProductForm = ({
}) => {
const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>('');
const [selectedProductWarehouse, setSelectedProductWarehouse] =
useState<ProductWarehouse | null>(null);
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProductWarehouse?.product_id
? ProductApi.basePath + '/' + selectedProductWarehouse?.product_id
: null,
() =>
selectedProductWarehouse?.product_id
? ProductApi.getSingle(selectedProductWarehouse?.product_id)
: undefined
);
// ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({
@@ -69,17 +86,21 @@ const SalesOrderProductForm = ({
const {
options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouse,
} = useSelect<ProductWarehouse>(
ProductWarehouseApi.basePath,
'id',
'product.name',
'search',
'',
{
warehouse_id: formik.values.kandang_id?.toString() ?? '',
}
@@ -112,6 +133,7 @@ const SalesOrderProductForm = ({
const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === newId
);
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty');
} else {
@@ -139,34 +161,60 @@ const SalesOrderProductForm = ({
const handleBlurField = (field: string) => {
setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') {
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) {
formik.setFieldValue(
'total_price',
(qty as number) * (unit_price as number)
);
} else if (qty && total_price && field === 'total_price') {
formik.setFieldValue(
'unit_price',
(total_price as number) / (qty as number)
);
const qty = Number(formik.values.qty || 0);
const avgWeight = Number(formik.values.avg_weight || 0);
const totalWeight = Number(formik.values.total_weight || 0);
const unitPrice = Number(formik.values.unit_price || 0);
const totalPrice = Number(formik.values.total_price || 0);
if (qty <= 0) return;
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
}
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) {
formik.setFieldValue(
'total_weight',
(qty as number) * (avg_weight as number)
);
} else if (qty && total_weight && field === 'total_weight') {
formik.setFieldValue(
'avg_weight',
(total_weight as number) / (qty as number)
);
case 'avg_weight': {
if (avgWeight > 0) {
const tw = roundWeight(qty * avgWeight);
formik.setFieldValue('total_weight', tw);
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
break;
}
}
};
@@ -188,7 +236,7 @@ const SalesOrderProductForm = ({
</Alert>
</div>
)}
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<div className='grid sm:grid-cols-3 gap-4 z-200'>
<PatternInput
name='vehicle_number'
label='No. Polisi'
@@ -215,6 +263,8 @@ const SalesOrderProductForm = ({
value={formik.values.kandang}
onChange={kandangChangeHandler}
isClearable
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
@@ -228,6 +278,8 @@ const SalesOrderProductForm = ({
isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable
placeholder={
formik.values.kandang_id
@@ -243,6 +295,9 @@ const SalesOrderProductForm = ({
}
errorMessage={formik.errors.product_warehouse_id}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4 z-200'>
<NumberInput
required
label='Kuantitas'
@@ -256,6 +311,15 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={
isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id
@@ -264,32 +328,13 @@ const SalesOrderProductForm = ({
(item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0
)} ${
warehouseSourceRawData?.data?.find(
(item) => item.id === formik.values.product_warehouse_id
)?.product?.uom?.name ?? ''
isResponseSuccess(productData)
? productData?.data?.uom.name
: ''
}`
: ''
}
/>
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Harga Satuan (Rp)'
@@ -306,6 +351,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput
required
label='Total Bobot (Kg)'
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const AreasTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await AreaApi.delete(selectedArea?.id as number);
const deleteResponse = await AreaApi.delete(selectedArea?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshAreas();
deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -177,7 +177,14 @@ const BanksTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await BankApi.delete(selectedBank?.id as number);
const deleteResponse = await BankApi.delete(selectedBank?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshBanks();
deleteModal.closeModal();
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -186,7 +186,16 @@ const CustomersTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await CustomerApi.delete(selectedCustomer?.id as number);
const deleteResponse = await CustomerApi.delete(
selectedCustomer?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshCustomers();
deleteModal.closeModal();
@@ -23,13 +23,17 @@ import TextInput from '@/components/input/TextInput';
import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import TextArea from '@/components/input/TextArea';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import useSWR from 'swr';
import { UserApi } from '@/services/api/user';
import { TYPE_OPTIONS } from '@/config/constant';
import RequirePermission from '@/components/helper/RequirePermission';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
interface CustomerFormProps {
formType?: 'add' | 'edit' | 'detail';
@@ -47,25 +51,15 @@ const CustomerForm = ({
// Setup State
const [customerFormErrorMessage, setCustomerFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [picSelectInputValue, setPicSelectInputValue] = useState('');
// Fetch Data
const picUrl = `${UserApi.basePath}?${new URLSearchParams({
search: picSelectInputValue ?? '',
})}`;
const { data: pic, isLoading: isLoadingPic } = useSWR(
picUrl,
UserApi.getAllFetcher
);
const {
setInputValue: setPicSelectInputValue,
options: picOptions,
isLoadingOptions: isLoadingPicOptions,
loadMore: loadMorePic,
} = useSelect<User>(UserApi.basePath, 'id', 'name');
// -- Options data mapping
const picOptions = isResponseSuccess(pic)
? pic?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const typeOptions = TYPE_OPTIONS;
// Handler Event
@@ -240,11 +234,12 @@ const CustomerForm = ({
required
placeholder='Pilih PIC'
label='PIC'
value={formik.values.pic ?? undefined}
value={formik.values.pic?.value ? formik.values.pic : undefined}
onChange={picChangeHandler}
options={picOptions}
onInputChange={setPicSelectInputValue}
isLoading={isLoadingPic}
onMenuScrollToBottom={loadMorePic}
isLoading={isLoadingPicOptions}
isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string}
isDisabled={formType === 'detail'}
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const FcrsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FcrApi.delete(selectedFcr?.id as number);
const deleteResponse = await FcrApi.delete(selectedFcr?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFcrs();
deleteModal.closeModal();
@@ -19,7 +19,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
const RowsOptions = ({
@@ -182,7 +182,14 @@ const FlockTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await FlockApi.delete(selectedFlock?.id as number);
const deleteResponse = await FlockApi.delete(selectedFlock?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFlocks();
deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -199,7 +199,16 @@ const KandangsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await KandangApi.delete(selectedKandang?.id as number);
const deleteResponse = await KandangApi.delete(
selectedKandang?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshKandangs();
deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -31,6 +34,7 @@ import { UserApi } from '@/services/api/user';
import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
interface KandangFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -128,23 +132,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
const { setValues: formikSetValues } = formik;
// location
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({
search: locationSelectInputValue ?? '',
}).toString()}`;
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
const {
setInputValue: setLocationSelectInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true);
@@ -155,23 +148,12 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
};
// PIC
const [picSelectInputValue, setPicSelectInputValue] = useState('');
const picsUrl = `${UserApi.basePath}?${new URLSearchParams({
search: picSelectInputValue ?? '',
}).toString()}`;
const { data: pics, isLoading: isLoadingPics } = useSWR(
picsUrl,
LocationApi.getAllFetcher
);
const picOptions = isResponseSuccess(pics)
? pics?.data.map((pic) => ({
value: pic.id,
label: pic.name,
}))
: [];
const {
setInputValue: setPicSelectInputValue,
options: picOptions,
isLoadingOptions: isLoadingPicOptions,
loadMore: loadMorePics,
} = useSelect<User>(UserApi.basePath, 'id', 'name');
const picChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('pic', true);
@@ -233,7 +215,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
placeholder='Masukkan nama kandang'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
@@ -249,7 +231,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
onChange={locationChangeHandler}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isError={
formik.touched.locationId && Boolean(formik.errors.locationId)
}
@@ -280,7 +263,8 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
onChange={picChangeHandler}
options={picOptions}
onInputChange={setPicSelectInputValue}
isLoading={isLoadingPics}
onMenuScrollToBottom={loadMorePics}
isLoading={isLoadingPicOptions}
isError={formik.touched.picId && Boolean(formik.errors.picId)}
errorMessage={formik.errors.picId as string}
isDisabled={type === 'detail'}
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -186,7 +186,16 @@ const LocationsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await LocationApi.delete(selectedLocation?.id as number);
const deleteResponse = await LocationApi.delete(
selectedLocation?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshLocations();
deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -29,6 +32,7 @@ import { AreaApi, LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Area } from '@/types/api/master-data/area';
interface LocationFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -117,23 +121,12 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
const { setValues: formikSetValues } = formik;
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({
search: areaSelectInputValue ?? '',
}).toString()}`;
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const {
setInputValue: setAreaSelectInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('area', true);
@@ -224,7 +217,8 @@ const LocationForm = ({ type = 'add', initialValues }: LocationFormProps) => {
onChange={areaChangeHandler}
options={areaOptions}
onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'}
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -198,7 +198,16 @@ const NonstocksTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await NonstockApi.delete(selectedNonstock?.id as number);
const deleteResponse = await NonstockApi.delete(
selectedNonstock?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshNonstocks();
deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -31,6 +34,8 @@ import { flags } from '@/types/api/api-general';
import { SUPPLIER_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Supplier } from '@/types/api/master-data/supplier';
import { Uom } from '@/types/api/master-data/uom';
interface NonstockFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -78,7 +83,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const formikInitialValues = useMemo<NonstockFormValues>(() => {
return {
name: initialValues?.name ?? '',
uomId: initialValues?.uom_id ?? 0,
uomId: initialValues?.uom?.id ?? 0,
uom: initialValues?.uom
? {
value: initialValues?.uom?.id,
@@ -129,23 +134,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const { setValues: formikSetValues } = formik;
// UOM
const [uomSelectInputValue, setUomSelectInputValue] = useState('');
const uomsUrl = `${UomApi.basePath}?${new URLSearchParams({
search: uomSelectInputValue ?? '',
}).toString()}`;
const { data: uoms, isLoading: isLoadingUoms } = useSWR(
uomsUrl,
UomApi.getAllFetcher
);
const uomOptions = isResponseSuccess(uoms)
? uoms?.data.map((uom) => ({
value: uom.id,
label: uom.name,
}))
: [];
const {
setInputValue: setUomSelectInputValue,
options: uomOptions,
isLoadingOptions: isLoadingUomOptions,
loadMore: loadMoreUoms,
} = useSelect<Uom>(UomApi.basePath, 'id', 'name');
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true);
@@ -156,25 +150,12 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
};
// supplier
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({
search: supplierSelectInputValue ?? '',
}).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'BOP')
.map((supplier) => ({
value: supplier.id,
label: supplier.name,
}))
: [];
const {
setInputValue: setSupplierSelectInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('suppliers', true);
@@ -248,7 +229,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
placeholder='Masukkan nama nonstock'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
@@ -264,7 +245,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
onChange={uomChangeHandler}
options={uomOptions}
onInputChange={setUomSelectInputValue}
isLoading={isLoadingUoms}
isLoading={isLoadingUomOptions}
onMenuScrollToBottom={loadMoreUoms}
isError={formik.touched.uomId && Boolean(formik.errors.uomId)}
errorMessage={formik.errors.uomId as string}
isDisabled={type === 'detail'}
@@ -278,7 +260,8 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
onChange={supplierChangeHandler}
options={supplierOptions ?? []}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isError={
formik.touched.suppliers && Boolean(formik.errors.suppliers)
}
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -170,7 +170,16 @@ const ProductCategoryTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductCategoryApi.delete(selectedProductCategory?.id as number);
const deleteResponse = await ProductCategoryApi.delete(
selectedProductCategory?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductCategories();
deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Product } from '@/types/api/master-data/product';
import { ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -230,8 +230,19 @@ const ProductsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductApi.delete(selectedProduct?.id as number);
const deleteResponse = await ProductApi.delete(
selectedProduct?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProducts();
deleteModal.closeModal();
toast.success('Successfully delete Product!');
setIsDeleteLoading(false);
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = {
name: string;
brand: string;
sku: string;
sku?: string;
uom?: {
value: number;
label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null;
product_category_id: number;
product_price: number | string;
selling_price: number | string;
tax: number | string;
expiry_period: number | string;
supplier_ids: number[];
selling_price?: number | string;
tax?: number | string;
expiry_period?: number | string;
suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[];
};
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'),
sku: Yup.string(),
uom: Yup.object({
value: Yup.number()
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number()
.required('Harga jual wajib diisi!')
.typeError('Harga jual wajib diisi!')
.typeError('Harga hanya boleh angka!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number()
.required('Pajak wajib diisi!')
.typeError('Pajak wajib diisi!')
.typeError('Pajak hanya boleh angka!')
.min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa wajib diisi!')
.typeError('Periode kadaluarsa hanya boleh angka!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!'))
.min(1, 'Minimal harus ada 1 supplier!')
suppliers: Yup.array()
.of(
Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.required('Supplier wajib diisi!'),
flags: Yup.array()
@@ -40,6 +40,9 @@ import {
import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ProductFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -100,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [],
suppliers: initialValues?.suppliers
? initialValues.suppliers.map((supplier) => ({
supplier: {
value: supplier.id,
label: supplier.name,
},
price: supplier.price,
}))
: [],
flags: initialValues?.flags ?? [],
}),
[initialValues]
@@ -119,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
uom_id: values.uom_id,
product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0,
selling_price: parseInt(values.selling_price.toString()) || 0,
tax: parseInt(values.tax.toString()) || 0,
expiry_period: parseInt(values.expiry_period.toString()) || 0,
supplier_ids: values.supplier_ids.filter(
(id): id is number => typeof id === 'number'
),
selling_price: values.selling_price
? parseInt(values.selling_price.toString()) || 0
: undefined,
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period
? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
})),
flags: values.flags.filter((f): f is string => typeof f === 'string'),
};
switch (type) {
@@ -145,6 +161,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
setInputValue: setUomSelectInputValue,
options: uomOptions,
isLoadingOptions: isLoadingUoms,
loadMore: loadMoreUoms,
} = useSelect(UomApi.basePath, 'id', 'name');
const uomChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('uom', true);
@@ -158,6 +175,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
setInputValue: setCategorySelectInputValue,
options: categoryOptions,
isLoadingOptions: isLoadingCategories,
loadMore: loadMoreCategories,
} = useSelect(ProductCategoryApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('product_category', true);
@@ -167,24 +185,38 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
};
// Supplier (multi select) - using SWR to filter by category
const [supplierSelectInputValue, setSupplierSelectInputValue] = useState('');
const suppliersUrl = `${SupplierApi.basePath}?${new URLSearchParams({ search: supplierSelectInputValue ?? '' }).toString()}`;
const { data: suppliers, isLoading: isLoadingSuppliers } = useSWR(
suppliersUrl,
SupplierApi.getAllFetcher
);
const supplierOptions = isResponseSuccess(suppliers)
? suppliers?.data
.filter((sup) => sup.category === 'SAPRONAK')
.map((sup) => ({ value: sup.id, label: sup.name }))
: [];
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
formik.setFieldTouched('supplier_ids', true);
formik.setFieldValue(
'supplier_ids',
arr.map((v) => (v as OptionType).value)
);
const {
setInputValue: setSupplierSelectInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSuppliers,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'SAPRONAK',
});
const filteredSupplierOptions = useMemo(() => {
return supplierOptions.filter((opt) => {
return !formik.values.suppliers.some(
(s) => s.supplier?.value === opt.value
);
});
}, [supplierOptions, formik.values.suppliers]);
const addSupplierHandler = () => {
formik.setFieldValue('suppliers', [
...formik.values.suppliers,
{
supplier_id: '',
price: formik.values.product_price,
},
]);
};
const deleteSupplierItemHandler = (idx: number) => {
const path = 'suppliers';
// trims values, errors, and touched at idx
removeArrayItemAndSync(formik, path, idx);
};
const deleteProductClickHandler = () => {
@@ -200,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
router.push('/master-data/product');
};
const isSupplierRepeaterError = (
column: 'supplier' | 'price',
supplierIdx: number
) => {
return (
formik.touched.suppliers?.[supplierIdx]?.[column] &&
Boolean(
formik.errors.suppliers?.[supplierIdx] instanceof Object &&
formik.errors.suppliers?.[supplierIdx]?.[column]
)
);
};
useEffect(() => {
formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]);
@@ -270,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<TextInput
required
label='SKU'
name='sku'
placeholder='Masukkan SKU...'
@@ -291,6 +335,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onChange={uomChangeHandler}
options={uomOptions}
onInputChange={setUomSelectInputValue}
onMenuScrollToBottom={loadMoreUoms}
isLoading={isLoadingUoms}
isError={
(formik.touched.uom || formik.touched.uom_id) &&
@@ -308,6 +353,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
onChange={categoryChangeHandler}
options={categoryOptions}
onInputChange={setCategorySelectInputValue}
onMenuScrollToBottom={loadMoreCategories}
isLoading={isLoadingCategories}
isError={
(formik.touched.product_category ||
@@ -341,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<NumberInput
required
label='Harga Jual'
name='selling_price'
placeholder='Masukkan harga jual...'
@@ -363,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Pajak (%)'
name='tax'
placeholder='Masukkan pajak...'
@@ -380,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
<NumberInput
required
label='Periode Kadaluarsa (hari)'
name='expiry_period'
placeholder='Masukkan periode kadaluarsa...'
@@ -400,27 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'}
/>
</div>
<div className='grid sm:grid-cols-2 gap-4'>
<SelectInput
required
label='Supplier'
placeholder='Pilih supplier...'
isMulti
value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<div className='grid sm:grid-cols-1 gap-4'>
<SelectInput
required
label='Flags'
@@ -443,6 +466,129 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable
/>
</div>
<div className='grid sm:grid-cols-1 gap-4'>
{type !== 'detail' && formik.values.suppliers.length === 0 && (
<Button
type='button'
color='success'
onClick={addSupplierHandler}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah
Supplier
</Button>
)}
{formik.values.suppliers.length > 0 && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>Supplier</h4>
</div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Supplier
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga
</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.suppliers.map((supplier, idx) => (
<tr key={idx}>
<td className='p-2 w-full max-w-1/2'>
<SelectInput
placeholder='Pilih Supplier'
options={filteredSupplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
value={formik.values.suppliers[idx].supplier}
onChange={(val) => {
formik.setFieldValue(
`suppliers.${idx}.supplier`,
val
);
}}
isError={isSupplierRepeaterError(
'supplier',
idx
)}
isClearable
isDisabled={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
<td className='p-2 w-full max-w-1/2'>
<NumberInput
required
name={`suppliers.${idx}.price`}
placeholder='Masukkan harga...'
value={formik.values.suppliers[idx].price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={isSupplierRepeaterError('price', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => deleteSupplierItemHandler(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='w-full flex flex-row justify-center'>
<Button
type='button'
color='success'
onClick={addSupplierHandler}
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah Supplier
</Button>
</div>
)}
</Card>
)}
</div>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && (
@@ -7,7 +7,7 @@ import { ProductionStandard } from '@/types/api/master-data/production-standard'
import { Icon } from '@iconify/react';
import useSWR from 'swr';
import { ProductionStandardApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { CellContext } from '@tanstack/react-table';
import { useModal } from '@/components/Modal';
@@ -94,9 +94,16 @@ const ProductionStandardTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await ProductionStandardApi.delete(
const deleteResponse = await ProductionStandardApi.delete(
selectedProductionStandard?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductionStandards();
deleteModal.closeModal();
@@ -2,34 +2,30 @@ import * as Yup from 'yup';
// Schema for LAYING category (production_standard_details is required)
const LayingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'),
week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'),
target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Wajib diisi!'),
}),
production_standard_details: Yup.object({
target_hen_day_production: Yup.number().required(
'Produksi telur per hari wajib diisi!'
),
target_hen_house_production: Yup.number().required(
'Produksi telur per kandang wajib diisi!'
),
target_egg_weight: Yup.number().required('Berat telur wajib diisi!'),
target_egg_mass: Yup.number().required('Massa telur wajib diisi!'),
standard_fcr: Yup.number().required('FCR wajib diisi!'),
target_hen_day_production: Yup.number().required('Wajib diisi!'),
target_hen_house_production: Yup.number().required('Wajib diisi!'),
target_egg_weight: Yup.number().required('Wajib diisi!'),
target_egg_mass: Yup.number().required('Wajib diisi!'),
standard_fcr: Yup.number().required('Wajib diisi!'),
}).required(),
});
// Schema for GROWING category (production_standard_details is optional)
const GrowingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'),
week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'),
target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Wajib diisi!'),
}),
production_standard_details: Yup.object({
target_hen_day_production: Yup.number().optional(),
@@ -344,7 +344,7 @@ const ProductionStandardForm = ({
const columns = useMemo<ColumnDef<TableRowsType>[]>(() => {
const baseColumns: ColumnDef<TableRowsType>[] = [
{
header: 'Minggu',
header: 'Week',
accessorKey: 'week',
enableSorting: false,
},
@@ -358,30 +358,40 @@ const ProductionStandardForm = ({
header: 'Hen Day',
accessorFn: (row) =>
row.production_standard_details?.target_hen_day_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_day_production}%`,
enableSorting: false,
},
{
header: 'Hen House',
accessorFn: (row) =>
row.production_standard_details?.target_hen_house_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_house_production} pc`,
enableSorting: false,
},
{
header: 'Egg Weight',
accessorFn: (row) =>
row.production_standard_details?.target_egg_weight,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_weight} g`,
enableSorting: false,
},
{
header: 'Egg Mass',
accessorFn: (row) =>
row.production_standard_details?.target_egg_mass,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_mass} g`,
enableSorting: false,
},
{
header: 'FCR',
accessorFn: (row) =>
row.production_standard_details?.standard_fcr,
cell: ({ row }) =>
`${row.original.production_standard_details?.standard_fcr} g`,
enableSorting: false,
},
]
@@ -393,24 +403,32 @@ const ProductionStandardForm = ({
header: 'Mean BW',
accessorFn: (row) =>
row.production_standard_uniformity_details?.target_mean_bw,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.target_mean_bw} g`,
enableSorting: false,
},
{
header: 'Max Depletion',
accessorFn: (row) =>
row.production_standard_uniformity_details?.max_depletion,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.max_depletion}%`,
enableSorting: false,
},
{
header: 'Min Uniformity',
accessorFn: (row) =>
row.production_standard_uniformity_details?.min_uniformity,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.min_uniformity}%`,
enableSorting: false,
},
{
header: 'Feed Intake',
accessorFn: (row) =>
row.production_standard_uniformity_details?.feed_intake,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.feed_intake} g`,
enableSorting: false,
},
];
@@ -728,7 +746,52 @@ const ProductionStandardForm = ({
};
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
formik,
{
onBeforeSubmit: (e) => {
e.preventDefault();
// For GROWING category, clear production_standard_details errors and set default values
if (formik.values.project_category === 'GROWING') {
// Set default values for production_standard_details
formik.values.details?.forEach((detail) => {
detail.production_standard_details = {
target_hen_day_production: 0,
target_hen_house_production: 0,
target_egg_weight: 0,
target_egg_mass: 0,
standard_fcr: 0,
};
});
// Clear any errors related to production_standard_details
const currentErrors = { ...formik.errors };
if (currentErrors.details && Array.isArray(currentErrors.details)) {
const cleanedDetails = currentErrors.details
.map((detailError) => {
if (detailError && typeof detailError === 'object') {
const { production_standard_details, ...rest } = detailError;
return Object.keys(rest).length > 0 ? rest : undefined;
}
return detailError;
})
.filter(
(error): error is Exclude<typeof error, undefined> =>
error !== undefined
);
currentErrors.details = (
cleanedDetails.length > 0 ? cleanedDetails : undefined
) as typeof currentErrors.details;
}
formik.setErrors(currentErrors);
}
return true;
},
}
);
return (
<>
@@ -821,19 +884,20 @@ const ProductionStandardForm = ({
key={`row-${row.index}`}
className='sticky bottom-0 bg-base-100 shadow-lg'
>
<td colSpan={colSpan} className='p-6'>
<td colSpan={colSpan} className='p-2'>
<form
className='h-full w-full flex flex-col justify-end'
onSubmit={repeaterFormik.handleSubmit}
onReset={repeaterFormik.handleReset}
>
<div
className={cn(
'grid gap-4 items-start',
formik.values.project_category === 'LAYING'
? 'grid-cols-10'
: 'grid-cols-5'
)}
className='grid gap-2 items-start w-full'
style={{
gridTemplateColumns:
formik.values.project_category === 'LAYING'
? 'repeat(10, minmax(auto, 1fr)) minmax(auto, auto)'
: 'repeat(4, minmax(auto, 1fr)) minmax(auto, auto)',
}}
>
<NumberInput
name='week'
@@ -862,7 +926,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />}
bottomLabel='Persen (%)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -894,11 +958,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
Butir
</div>
}
bottomLabel='Butir (pc)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -930,11 +990,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
bottomLabel='Gram (g)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -959,17 +1015,13 @@ const ProductionStandardForm = ({
name='production_standard_details.target_egg_mass'
label='Egg Mass'
placeholder='1'
bottomLabel='Gram (g)'
value={
repeaterFormik.values
.production_standard_details?.target_egg_mass
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -1000,11 +1052,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
bottomLabel='Gram (g)'
errorMessage={getProductionDetailsError(
repeaterFormik.errors
.production_standard_details,
@@ -1038,11 +1086,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
bottomLabel='Gram (g)'
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1072,7 +1116,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />}
bottomLabel='Persen (%)'
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1102,7 +1146,7 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />}
bottomLabel='Persen (%)'
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1132,11 +1176,8 @@ const ProductionStandardForm = ({
}
onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr/ekor
</div>
}
bottomLabel='Gram/Ekor (g)'
endAdornment
errorMessage={
repeaterFormik.errors
.production_standard_uniformity_details
@@ -1162,7 +1203,7 @@ const ProductionStandardForm = ({
type='button'
color='error'
variant='outline'
className='min-w-24'
className='min-w-xs'
onClick={handleCancelEdit}
>
<Icon icon='mdi:close' /> Batal
@@ -1178,7 +1219,7 @@ const ProductionStandardForm = ({
<Button
type='submit'
color={editMode ? 'warning' : 'success'}
className='min-w-24'
className='min-w-xs'
disabled={
isAddingRow ||
formik.values.project_category === ''
@@ -1195,7 +1236,7 @@ const ProductionStandardForm = ({
variant='outline'
color='primary'
onClick={toggleTableHeight}
className='absolute bottom-6 right-6'
className='absolute bottom-2 right-2'
>
<Icon
icon={
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -205,7 +205,16 @@ const SuppliersTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await SupplierApi.delete(selectedSupplier?.id as number);
const deleteResponse = await SupplierApi.delete(
selectedSupplier?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshSuppliers();
deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const UomsTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await UomApi.delete(selectedUom?.id as number);
const deleteResponse = await UomApi.delete(selectedUom?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshUoms();
deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant';
@@ -220,7 +220,16 @@ const WarehousesTable = () => {
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
await WarehouseApi.delete(selectedWarehouse?.id as number);
const deleteResponse = await WarehouseApi.delete(
selectedWarehouse?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshWarehouses();
deleteModal.closeModal();
@@ -9,7 +9,10 @@ import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -35,6 +38,8 @@ import { cn } from '@/lib/helper';
import { WAREHOUSE_TYPE_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Area } from '@/types/api/master-data/area';
import { Kandang } from '@/types/api/master-data/kandang';
interface WarehouseFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -221,61 +226,28 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
const { setValues: formikSetValues } = formik;
// Area
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
const areasUrl = `${AreaApi.basePath}?${new URLSearchParams({
search: areaSelectInputValue ?? '',
}).toString()}`;
const { data: areas, isLoading: isLoadingAreas } = useSWR(
areasUrl,
AreaApi.getAllFetcher
);
const areaOptions = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const {
setInputValue: setAreaSelectInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
// Location
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const locationsUrl = `${LocationApi.basePath}?${new URLSearchParams({
search: locationSelectInputValue ?? '',
}).toString()}`;
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
const {
setInputValue: setLocationSelectInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
// Kandang
const [kandangSelectInputValue, setKandangSelectInputValue] = useState('');
const kandangsUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: kandangSelectInputValue ?? '',
}).toString()}`;
const { data: kandangs, isLoading: isLoadingKandangs } = useSWR(
kandangsUrl,
KandangApi.getAllFetcher
);
const kandangOptions = isResponseSuccess(kandangs)
? kandangs?.data.map((kandang) => ({
value: kandang.id,
label: kandang.name,
}))
: [];
const {
setInputValue: setKandangSelectInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('type', true);
@@ -358,7 +330,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
required
label='Nama'
name='name'
placeholder='Masukkan nama lokasi'
placeholder='Masukkan nama warehouse'
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
@@ -393,7 +365,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={areaChangeHandler}
options={areaOptions}
onInputChange={setAreaSelectInputValue}
isLoading={isLoadingAreas}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isError={formik.touched.areaId && Boolean(formik.errors.areaId)}
errorMessage={formik.errors.areaId as string}
isDisabled={type === 'detail'}
@@ -409,7 +382,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={locationChangeHandler}
options={locationOptions}
onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isError={
formik.touched.locationId && Boolean(formik.errors.locationId)
}
@@ -427,7 +401,8 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
onChange={kandangChangeHandler}
options={kandangOptions}
onInputChange={setKandangSelectInputValue}
isLoading={isLoadingKandangs}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangOptions}
isError={
formik.touched.kandangId && Boolean(formik.errors.kandangId)
}
@@ -102,34 +102,47 @@ const ProjectFlockForm = ({
);
// Fetch Data
const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } =
useSelect(FlockApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueFlock,
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
});
const { options: optionsArea, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name'
);
const {
setInputValue: setInputValueArea,
options: optionsArea,
isLoadingOptions: isLoadingAreas,
loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name');
const { options: optionsLocation, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const {
options: optionsLocation,
isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const { options: optionsFcr, isLoadingOptions: isLoadingFcrs } = useSelect(
FcrApi.basePath,
'id',
'name'
);
const {
options: optionsFcr,
isLoadingOptions: isLoadingFcrs,
setInputValue: setInputValueFcr,
loadMore: loadMoreFcr,
} = useSelect(FcrApi.basePath, 'id', 'name');
const {
options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory,
});
@@ -153,6 +166,8 @@ const ProjectFlockForm = ({
options: optionsNonstock,
rawData: nonstocks,
isLoadingOptions: isLoadingNonstocks,
setInputValue: setInputValueNonstock,
loadMore: loadMoreNonstock,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
useEffect(() => {
@@ -722,6 +737,8 @@ const ProjectFlockForm = ({
formik.touched.area_id && Boolean(formik.errors.area_id)
}
errorMessage={formik.errors.area_id as string}
onInputChange={setInputValueArea}
onMenuScrollToBottom={loadMoreArea}
isClearable
isDisabled={formType != 'add'}
/>
@@ -740,6 +757,8 @@ const ProjectFlockForm = ({
formik.touched.location_id &&
Boolean(formik.errors.location_id)
}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
errorMessage={formik.errors.location_id as string}
isClearable
isDisabled={formType != 'add' || disabledLocation}
@@ -766,6 +785,8 @@ const ProjectFlockForm = ({
);
}}
options={optionsFlock}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
isLoading={isLoadingFlocks}
isError={
formik.touched.flock_name && Boolean(formik.errors.flock_name)
@@ -781,6 +802,8 @@ const ProjectFlockForm = ({
onChange={(val) => {
optionChangeHandler(val, 'fcr');
}}
onInputChange={setInputValueFcr}
onMenuScrollToBottom={loadMoreFcr}
options={optionsFcr}
isLoading={isLoadingFcrs}
isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)}
@@ -808,6 +831,8 @@ const ProjectFlockForm = ({
onChange={(val) => {
optionChangeHandler(val, 'production_standard');
}}
onInputChange={setInputValueProductionStandard}
onMenuScrollToBottom={loadMoreProductionStandard}
options={optionsProductionStandards}
isLoading={isLoadingProductionStandards}
isError={
@@ -892,6 +917,8 @@ const ProjectFlockForm = ({
isLoading={isLoadingNonstocks}
placeholder='Pilih barang non stock'
value={formik.values.project_budgets[index].nonstock}
onInputChange={setInputValueNonstock}
onMenuScrollToBottom={loadMoreNonstock}
onChange={(val) => {
const updatedBudgets = [
...formik.values.project_budgets,
@@ -678,12 +678,13 @@ const RecordingTable = () => {
{
header: 'Nama Project',
cell: (props) =>
`Project ${props.row.original.project_flock_kandang_id}`,
props.row.original.project_flock?.flock_name || '-',
},
{
header: 'Kategori',
cell: (props) => {
const category = props.row.original.project_flock_category;
const category =
props.row.original.project_flock?.project_flock_category;
if (!category) return '-';
const color = category === 'LAYING' ? 'info' : 'warning';
return (
@@ -706,7 +707,8 @@ const RecordingTable = () => {
{
header: 'Populasi Awal',
cell: (props) =>
props.row.original.total_chick_qty?.toLocaleString() || '-',
props.row.original.project_flock?.total_chick_qty?.toLocaleString() ||
'-',
},
{
header: 'Status Approval',
@@ -117,8 +117,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
// ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => {
const today = new Date().toISOString().split('T')[0];
return {
project_flock_kandang_id: values.project_flock_kandang_id,
record_date: today,
stocks: (values.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0,
@@ -134,8 +136,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => {
const today = new Date().toISOString().split('T')[0];
return {
project_flock_kandang_id: values.project_flock_kandang_id,
record_date: today,
stocks: (values.stocks ?? []).map((stock) => ({
product_warehouse_id: stock.product_warehouse_id,
qty: Number(stock.qty) || 0,
@@ -252,9 +256,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
: undefined;
const projectFlockKandangDetailUrl = useMemo(() => {
if (type === 'add' || !initialValues?.project_flock_kandang_id) return null;
return `${ProjectFlockKandangApi.basePath}/${initialValues.project_flock_kandang_id}`;
}, [type, initialValues?.project_flock_kandang_id]);
if (
type === 'add' ||
!initialValues?.project_flock?.project_flock_kandang_id
)
return null;
return `${ProjectFlockKandangApi.basePath}/${initialValues.project_flock.project_flock_kandang_id}`;
}, [type, initialValues?.project_flock?.project_flock_kandang_id]);
const { data: projectFlockKandangDetailData } = useSWR(
projectFlockKandangDetailUrl,
@@ -404,12 +412,12 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, [approvedProjectFlockKandangsData]);
const isLayingCategory =
initialValues?.project_flock_category === 'LAYING' ||
initialValues?.project_flock?.project_flock_category === 'LAYING' ||
projectFlockKandangLookup?.project_flock?.category === 'LAYING' ||
projectFlockKandangDetail?.project_flock?.category === 'LAYING';
const isGrowingCategory =
initialValues?.project_flock_category === 'GROWING' ||
initialValues?.project_flock?.project_flock_category === 'GROWING' ||
projectFlockKandangLookup?.project_flock?.category === 'GROWING' ||
projectFlockKandangDetail?.project_flock?.category === 'GROWING';
@@ -555,7 +563,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
todayRecordings.forEach((recording) => {
const recordingDate = recording.record_datetime?.split('T')[0];
if (recordingDate === today) {
recordedIds.add(recording.project_flock_kandang_id);
recordedIds.add(recording.project_flock?.project_flock_kandang_id);
}
});
@@ -1005,7 +1013,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const hasSameDayRecording = isResponseSuccess(existingRecordings)
? existingRecordings.data?.some(
(recording: Recording) =>
recording.project_flock_kandang_id ===
recording.project_flock.project_flock_kandang_id ===
projectFlockKandangId &&
recording.day === nextDayRecording.next_day
)
@@ -1543,13 +1551,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<Badge
variant='soft'
color={
initialValues.project_flock_category === 'LAYING'
initialValues.project_flock
?.project_flock_category === 'LAYING'
? 'info'
: 'warning'
}
size='sm'
>
{initialValues.project_flock_category}
{initialValues.project_flock?.project_flock_category}
</Badge>
</p>
</div>
@@ -1579,7 +1588,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{type === 'detail' && initialValues && (
<div
className={`grid gap-6 mb-6 grid-cols-1 ${
initialValues.project_flock_category === 'LAYING'
initialValues.project_flock?.project_flock_category === 'LAYING'
? 'xl:grid-cols-3'
: 'xl:grid-cols-2'
}`}
@@ -1614,8 +1623,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.fcr_std && initialValues.fcr_std > 0
? formatNumber(initialValues.fcr_std)
{initialValues.project_flock?.fcr?.fcr_std &&
initialValues.project_flock?.fcr?.fcr_std > 0
? formatNumber(
initialValues.project_flock?.fcr?.fcr_std
)
: '-'}
</td>
</tr>
@@ -1630,9 +1642,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.feed_intake_std &&
initialValues.feed_intake_std > 0
? formatNumber(initialValues.feed_intake_std)
{initialValues.project_flock?.production_standart
?.feed_intake_std &&
initialValues.project_flock?.production_standart
?.feed_intake_std > 0
? formatNumber(
initialValues.project_flock?.production_standart
?.feed_intake_std
)
: '-'}
</td>
</tr>
@@ -1650,59 +1667,39 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</div>
<div className='p-4'>
<table className='w-full text-sm'>
<thead>
<tr className='border-b border-gray-200'>
<th
colSpan={2}
className='text-center py-2 font-semibold text-gray-600'
>
DEPLESI KUMULATIF
</th>
</tr>
<tr className='border-b border-gray-200'>
<th className='text-center py-2 font-semibold text-xs text-gray-500'>
Total
</th>
<th className='text-center py-2 font-semibold text-xs text-gray-500'>
(%)
</th>
</tr>
</thead>
<tbody>
<tr>
<td className='text-center py-3 border-r border-gray-100'>
<td className='py-2 font-medium'>Deplesi Kumulatif</td>
<td className='text-right py-2'>
<span className='font-semibold'>
{initialValues.total_depletion_qty &&
initialValues.total_depletion_qty > 0
? formatNumber(initialValues.total_depletion_qty)
{initialValues.cum_depletion_rate &&
initialValues.cum_depletion_rate > 0
? `${initialValues.cum_depletion_rate.toFixed(2)}%`
: '-'}
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.cum_depletion_rate &&
initialValues.cum_depletion_rate > 0
? initialValues.cum_depletion_rate.toFixed(2)
: '-'}
</td>
</tr>
<tr>
<td
colSpan={2}
className='text-center py-3 border-r border-gray-200 text-gray-600'
>
Total Ayam
</td>
</tr>
<tr>
<td
colSpan={2}
className='text-center py-3 font-semibold'
>
{initialValues.total_chick_qty &&
initialValues.total_chick_qty > 0
? formatNumber(initialValues.total_chick_qty)
<td className='py-2 font-medium'>Total Depletion</td>
<td className='text-right py-2 font-semibold'>
{initialValues.total_depletion_qty &&
initialValues.total_depletion_qty > 0
? formatNumber(initialValues.total_depletion_qty)
: '-'}
</td>
<td></td>
</tr>
<tr className='border-t border-gray-200'>
<td className='py-2 text-gray-600'>Total Ayam</td>
<td className='text-right py-2 font-semibold'>
{initialValues.project_flock?.total_chick_qty &&
initialValues.project_flock?.total_chick_qty > 0
? formatNumber(
initialValues.project_flock?.total_chick_qty
)
: '-'}
</td>
<td></td>
</tr>
</tbody>
</table>
@@ -1712,7 +1709,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
{/* Egg Production Section - Only for LAYING category */}
{type === 'detail' &&
initialValues &&
initialValues.project_flock_category === 'LAYING' && (
initialValues.project_flock?.project_flock_category ===
'LAYING' && (
<div className='border border-gray-200 rounded-lg bg-white'>
<div className='px-4 py-3 border-b border-gray-200'>
<span className='card-title font-bold text-xl'>
@@ -1744,9 +1742,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.egg_mass_std &&
initialValues.egg_mass_std > 0
? formatNumber(initialValues.egg_mass_std)
{initialValues.project_flock?.production_standart
?.egg_mass_std &&
initialValues.project_flock?.production_standart
?.egg_mass_std > 0
? formatNumber(
initialValues.project_flock
?.production_standart?.egg_mass_std
)
: '-'}
</td>
</tr>
@@ -1763,9 +1766,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.egg_weight_std &&
initialValues.egg_weight_std > 0
? formatNumber(initialValues.egg_weight_std)
{initialValues.project_flock?.production_standart
?.egg_weight_std &&
initialValues.project_flock?.production_standart
?.egg_weight_std > 0
? formatNumber(
initialValues.project_flock
?.production_standart?.egg_weight_std
)
: '-'}
</td>
</tr>
@@ -1780,9 +1788,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.hen_day_std !== undefined &&
initialValues.hen_day_std > 0
? `${initialValues.hen_day_std}%`
{initialValues.project_flock?.production_standart
?.hen_day_std !== undefined &&
initialValues.project_flock?.production_standart
?.hen_day_std > 0
? `${initialValues.project_flock?.production_standart?.hen_day_std}%`
: '-'}
</td>
</tr>
@@ -1797,9 +1807,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</span>
</td>
<td className='text-center py-3 text-gray-600'>
{initialValues.hen_house_std !== undefined &&
initialValues.hen_house_std > 0
? `${initialValues.hen_house_std}%`
{initialValues.project_flock?.production_standart
?.hen_house_std !== undefined &&
initialValues.project_flock?.production_standart
?.hen_house_std > 0
? `${initialValues.project_flock?.production_standart?.hen_house_std}%`
: '-'}
</td>
</tr>
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useState } from 'react';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast';
@@ -28,7 +28,10 @@ import {
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Customer } from '@/types/api/master-data/customer';
import { MarketingReportApi } from '@/services/api/report/marketing-report';
import { MARKETING_TYPE_OPTIONS } from '@/config/constant';
import {
MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS,
} from '@/config/constant';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import {
@@ -84,6 +87,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -98,6 +102,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -115,6 +120,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setWarehouseInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -132,6 +138,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setCustomerInputValue,
options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -150,6 +157,15 @@ const DailyMarketingReportContent = () => {
updateFilter('end_date', e.target.value ? e.target.value : '');
};
const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] =
useState<OptionType | null>(null);
const marketingDateFilterTypeChangeHandler = (
val: OptionType | OptionType[] | null
) => {
setSelectedMarketingDateFilterType(val as OptionType);
updateFilter('filter_by', val ? ((val as OptionType).value as string) : '');
};
const [selectedMarketingType, setSelectedMarketingType] =
useState<OptionType | null>(null);
const marketingTypeChangeHandler = (
@@ -252,6 +268,23 @@ const DailyMarketingReportContent = () => {
resetFilter();
};
useEffect(() => {
if (
tableFilterState.filter_by === 'realization_date' ||
tableFilterState.filter_by === 'so_date'
) {
setSelectedMarketingDateFilterType({
label:
tableFilterState.filter_by === 'realization_date'
? 'Tanggal Realisasi'
: 'Tanggal SO',
value: tableFilterState.filter_by,
});
} else {
setSelectedMarketingDateFilterType(null);
}
}, [tableFilterState.filter_by]);
return (
<div className='w-full border border-gray-200 p-4'>
<div>
@@ -269,6 +302,7 @@ const DailyMarketingReportContent = () => {
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -283,6 +317,7 @@ const DailyMarketingReportContent = () => {
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -297,6 +332,7 @@ const DailyMarketingReportContent = () => {
value={selectedWarehouse}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -311,6 +347,7 @@ const DailyMarketingReportContent = () => {
value={selectedCustomer}
onChange={customerChangeHandler}
onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -341,6 +378,18 @@ const DailyMarketingReportContent = () => {
</div>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Filter Tanggal'
placeholder='Pilih Filter Tanggal'
options={MARKETING_DATE_FILTER_TYPE_OPTIONS}
value={selectedMarketingDateFilterType}
onChange={marketingDateFilterTypeChangeHandler}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
}}
/>
<SelectInput
label='Tipe Marketing'
placeholder='Pilih Tipe Marketing'
@@ -71,19 +71,22 @@ const DailyMarketingsTable = ({
cell: (props) => `${props.row.original.aging_days} hari`,
},
{
accessorKey: 'warehouse.name',
accessorKey: 'warehouse',
header: 'Gudang',
cell: ({ row }) => row.original.warehouse.name,
},
{
accessorKey: 'customer.name',
accessorKey: 'customer',
header: 'Pelanggan',
cell: ({ row }) => row.original.customer.name,
},
{
accessorKey: 'do_number',
header: 'No. DO',
enableSorting: false,
},
{
accessorKey: 'sales',
accessorKey: 'sales_person',
header: 'Sales/Marketing',
cell: (props) => props.row.original.sales.name,
},
@@ -97,10 +100,12 @@ const DailyMarketingsTable = ({
{
accessorKey: 'marketing_type',
header: 'Marketing Type',
enableSorting: false,
},
{
accessorKey: 'product.name',
accessorKey: 'product',
header: 'Produk',
cell: ({ row }) => row.original.product.name,
},
{
accessorKey: 'qty',
@@ -115,12 +120,12 @@ const DailyMarketingsTable = ({
},
},
{
accessorKey: 'average_weight_kg',
accessorKey: 'average_weight',
header: 'Bobot Rata-Rata (Kg)',
cell: (props) => formatNumber(props.row.original.average_weight_kg),
},
{
accessorKey: 'total_weight_kg',
accessorKey: 'total_weight',
header: 'Bobot Total (Kg)',
cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => {
@@ -132,12 +137,12 @@ const DailyMarketingsTable = ({
},
},
{
accessorKey: 'sales_price_per_kg',
accessorKey: 'sales_price',
header: 'Harga Jual (Rp)',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
},
{
accessorKey: 'hpp_price_per_kg',
accessorKey: 'hpp_price',
header: 'HPP (Rp)',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => {
@@ -163,6 +168,8 @@ const DailyMarketingsTable = ({
];
useEffect(() => {
console.log({ sorting });
if (sorting.length === 1) {
onFilterByChange(sorting[0].id);
onSortByChange(sorting[0].desc ? 'desc' : 'asc');
@@ -33,7 +33,7 @@ const MarketingReportContent = () => {
const [activeTab, setActiveTab] = useState<string>('daily');
return (
<section className='w-full max-w-7xl pb-16'>
<section className='w-full max-w-full pb-16'>
<Tabs
activeTabId={activeTab}
onTabChange={setActiveTab}
@@ -26,6 +26,15 @@ import MenuItem from '@/components/menu/MenuItem';
import * as XLSX from 'xlsx';
import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
import toast from 'react-hot-toast';
import {
KandangApi,
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
import { Nonstock } from '@/types/api/master-data/nonstock';
const ReportExpenseTable = () => {
// ===== STATE MANAGEMENT =====
@@ -64,16 +73,33 @@ const ReportExpenseTable = () => {
});
// ===== SELECT OPTIONS =====
const { options: optionsLocation, isLoadingOptions: isLoadingLocation } =
useSelect(`/master-data/locations`, 'id', 'name');
const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } =
useSelect(`/master-data/suppliers`, 'id', 'name');
const { options: optionsKandang, isLoadingOptions: isLoadingKandang } =
useSelect(`/master-data/kandangs`, 'id', 'name', '', {
location_id: filterState.location_id,
});
const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } =
useSelect(`/master-data/nonstocks`, 'id', 'name');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const categoryOptions = useMemo(
() => [
@@ -86,31 +112,31 @@ const ReportExpenseTable = () => {
// Mendapatkan value option select dari filter state
const selectedLocation = useMemo(
() =>
optionsLocation.find(
locationOptions.find(
(opt) => String(opt.value) === filterState.location_id
) || null,
[optionsLocation, filterState.location_id]
[locationOptions, filterState.location_id]
);
const selectedSupplier = useMemo(
() =>
optionsSupplier.find(
supplierOptions.find(
(opt) => String(opt.value) === filterState.supplier_id
) || null,
[optionsSupplier, filterState.supplier_id]
[supplierOptions, filterState.supplier_id]
);
const selectedKandang = useMemo(
() =>
optionsKandang.find(
kandangOptions.find(
(opt) => String(opt.value) === filterState.kandang_id
) || null,
[optionsKandang, filterState.kandang_id]
[kandangOptions, filterState.kandang_id]
);
const selectedNonstock = useMemo(
() =>
optionsNonstock.find(
nonstockOptions.find(
(opt) => String(opt.value) === filterState.nonstock_id
) || null,
[optionsNonstock, filterState.nonstock_id]
[nonstockOptions, filterState.nonstock_id]
);
const selectedCategory = useMemo(
() =>
@@ -756,38 +782,46 @@ const ReportExpenseTable = () => {
<SelectInput
isClearable
label='Lokasi'
options={optionsLocation}
isLoading={isLoadingLocation}
options={locationOptions}
isLoading={isLoadingLocationOptions}
placeholder='Lokasi'
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
/>
<SelectInput
isClearable
label='Kandang'
options={optionsKandang}
isLoading={isLoadingKandang}
options={kandangOptions}
isLoading={isLoadingKandangOptions}
placeholder='Kandang'
value={selectedKandang}
onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
/>
<SelectInput
isClearable
label='Supplier'
options={optionsSupplier}
isLoading={isLoadingSupplier}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
placeholder='Supplier'
value={selectedSupplier}
onChange={supplierChangeHandler}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
/>
<SelectInput
isClearable
label='Produk'
options={optionsNonstock}
isLoading={isLoadingNonstock}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
placeholder='Produk'
value={selectedNonstock}
onChange={nonstockChangeHandler}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
/>
<SelectInput
isClearable
@@ -8,16 +8,16 @@ const FinanceTabs = () => {
const tabs = [
{
id: '1',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab />,
},
{
id: '2',
label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab />,
},
{
id: '2',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab />,
},
];
return (
@@ -136,41 +136,132 @@ const pdfStyles = StyleSheet.create({
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'center',
marginRight: 4,
},
badgeLunas: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
},
badgeBelumLunas: {
backgroundColor: '#F97316',
color: '#FFFFFF',
},
textError: {
color: '#DC2626',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface CustomerPaymentExportPDFParams {
data: CustomerPaymentReport[];
params?: {
customer_name?: string;
sales?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
};
}
const getParameterText = (
params?: CustomerPaymentExportPDFParams['params']
) => {
const paramsText = [];
if (params?.customer_name) {
paramsText.push(`Customer: ${params.customer_name}`);
} else {
paramsText.push('Semua Customer');
}
if (params?.sales) {
paramsText.push(`Sales: ${params.sales}`);
}
if (params?.start_date && params?.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
const endDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Periode: ${startDate} - ${endDate}`);
} else if (params?.start_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${startDate}`);
}
const currentDate = formatDate(new Date(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText;
};
const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
return (
<Document>
{params.data.map((customerReport, customerIndex) => (
<Page
key={customerIndex}
size='A4'
size='A3'
orientation='landscape'
style={pdfStyles.page}
>
{/* Title and Customer Info */}
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Kontrol Pembayaran Customer
</Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Periode:{' '}
{params.params?.start_date
? formatDate(params.params.start_date, 'DD MMM YYYY')
: '-'}{' '}
s.d{' '}
{params.params?.end_date
? formatDate(params.params.end_date, 'DD MMM YYYY')
: '-'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>Filter Tanggal: Tanggal DO</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Customer: {params.params?.customer_name || 'Semua Customer'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text>
</View>
</View>
<Text style={pdfStyles.supplierTitle}>
{customerReport.customer.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
{customerReport.customer.address || ''}
Alamat: {customerReport.customer.address || '-'}
</Text>
{customerReport.summary && (
<Text style={pdfStyles.supplierInfo}>
Total Saldo Piutang:{' '}
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
</Text>
)}
</View>
{/* Table */}
@@ -181,10 +272,10 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl DO/Bayar</Text>
<Text>Tanggal DO</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl Realisasi</Text>
<Text>Tanggal Realisasi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
<Text>Aging</Text>
@@ -193,16 +284,16 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Referensi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>No. Polisi</Text>
<Text>No Polisi</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Berat (Kg)</Text>
<Text>Berat</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>AVG</Text>
<Text>Rata-Rata</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text>
@@ -214,7 +305,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Harga Akhir</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>PPN (%)</Text>
<Text>Pajak</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Total</Text>
@@ -223,10 +314,10 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Pembayaran</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Saldo Piutang</Text>
<Text>Saldo</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Ket</Text>
<Text>Keterangan</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Pengambilan</Text>
@@ -301,10 +392,29 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>{formatCurrency(item.payment)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.accounts_receivable)}</Text>
<Text style={pdfStyles.textError}>
{formatCurrency(item.accounts_receivable)}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.notes || '-'}</Text>
{item.notes ? (
<Text>{item.notes}</Text>
) : (
<View
style={[
pdfStyles.badge,
item.accounts_receivable === 0
? pdfStyles.badgeLunas
: pdfStyles.badgeBelumLunas,
]}
>
<Text>
{item.accounts_receivable === 0
? 'Lunas'
: 'Belum Lunas'}
</Text>
</View>
)}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pickup_info || '-'}</Text>
@@ -378,7 +488,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
<Text style={pdfStyles.textError}>
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
@@ -18,6 +18,47 @@ Font.register({
src: 'helvetica',
});
// Status color mappings (same as in DebtSupplierTab)
const dueStatusColors: Record<
string,
{ bg: string; text: string; border: string }
> = {
'Sudah Jatuh Tempo': { bg: '#FEE2E2', text: '#991B1B', border: '#F87171' }, // error/red
'Belum Jatuh Tempo': { bg: '#D1FAE5', text: '#065F46', border: '#34D399' }, // success/green
'Mendekati Jatuh Tempo': {
bg: '#FEF3C7',
text: '#92400E',
border: '#FBBF24',
}, // warning/yellow
};
const paymentStatusColors: Record<
string,
{ bg: string; text: string; border: string }
> = {
'Belum Lunas': { bg: '#FEF3C7', text: '#92400E', border: '#FBBF24' }, // warning/yellow
Lunas: { bg: '#DBEAFE', text: '#1E40AF', border: '#60A5FA' }, // primary/blue
Pembayaran: { bg: '#D1FAE5', text: '#065F46', border: '#34D399' }, // success/green
};
/**
* Get badge style for PDF rendering
* @param statusText - The status text
* @param type - Type of status: 'due' or 'payment'
* @returns Style object with background and text colors
*/
const getPDFBadgeStyle = (
statusText: string,
type: 'due' | 'payment' = 'payment'
) => {
const colors =
type === 'due'
? dueStatusColors[statusText]
: paymentStatusColors[statusText];
return colors || { bg: '#F3F4F6', text: '#374151', border: '#D1D5DB' }; // neutral fallback
};
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
@@ -136,10 +177,40 @@ const pdfStyles = StyleSheet.create({
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
badge: {
paddingVertical: 2,
paddingHorizontal: 4,
borderRadius: 12,
fontSize: 5,
fontWeight: 'bold',
borderWidth: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
},
parameterBadge: {
backgroundColor: '#F5F5F5',
color: '#333333',
padding: 4,
borderRadius: 4,
fontSize: 8,
marginRight: 8,
marginBottom: 4,
},
parameterContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
});
interface DebtSupplierExportPDFParams {
data: DebtSupplier[];
params?: {
supplier_name?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
};
}
const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
@@ -157,9 +228,50 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Rekapitulasi Hutang ke Supplier
</Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Periode:{' '}
{params.params?.start_date
? formatDate(params.params.start_date, 'DD MMM YYYY')
: '-'}{' '}
s.d{' '}
{params.params?.end_date
? formatDate(params.params.end_date, 'DD MMM YYYY')
: '-'}
</Text>
</View>
{params.params?.filter_by && (
<View style={pdfStyles.parameterBadge}>
<Text>
Filter Tanggal:{' '}
{params.params.filter_by === 'po_date'
? 'Tanggal PO'
: params.params.filter_by === 'received_date'
? 'Tanggal Terima'
: params.params.filter_by === 'due_date'
? 'Tanggal Jatuh Tempo'
: params.params.filter_by}
</Text>
</View>
)}
<View style={pdfStyles.parameterBadge}>
<Text>
Supplier: {params.params?.supplier_name || 'Semua Supplier'}
</Text>
</View>
<View style={pdfStyles.parameterBadge}>
<Text>
Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text>
</View>
</View>
<Text style={pdfStyles.supplierTitle}>
{supplierReport.supplier.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
{supplierReport.supplier.category}
</Text>
</View>
{/* Table */}
@@ -176,7 +288,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text>No. PO</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Tgl Terima</Text>
<Text>Tgl Terima/Bayar</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Tgl PO</Text>
@@ -191,21 +303,21 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text>Gudang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Tgl Jatuh Tempo</Text>
<Text>Jatuh Tempo</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<View style={[pdfStyles.tableCellHeader, { flex: 2 }]}>
<Text>Status Jatuh Tempo</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Total Harga</Text>
<Text>Nominal Pembelian (Rp)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Pembayaran</Text>
<Text>Pembayaran (Rp)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Hutang</Text>
<Text>Sisa Saldo Hutang (Rp)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Status</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
@@ -213,6 +325,67 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
</View>
</View>
{/* Initial Balance Row */}
<View style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text></Text> {/* NO */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text> {/* No. PR */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text> {/* No. PO */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> {/* Tgl Terima/Bayar */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> {/* Tgl PO */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text></Text> {/* Aging */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* Area */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* Gudang */}
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> {/* Jatuh Tempo */}
</View>
<View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text></Text> {/* Status Jatuh Tempo */}
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text> {/* Nominal Pembelian (Rp) */}
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text> {/* Pembayaran (Rp) */}
</View>
<View
style={[
pdfStyles.tableCellRight,
{
flex: 1.5,
color: supplierReport.initial_balance < 0 ? 'red' : 'black',
},
]}
>
<Text>
{' '}
{/* Sisa Saldo Hutang (Rp) */}
{formatCurrency(supplierReport.initial_balance || 0)}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text> {/* Status */}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> {/* No. Perjalanan */}
</View>
</View>
{/* Table Body */}
{supplierReport.rows.map((item, index) => (
<View
@@ -269,8 +442,32 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.due_status || '-'}</Text>
<View style={[pdfStyles.tableCell, { flex: 2 }]}>
{item.due_status && item.due_status !== '-' ? (
<View
style={[
pdfStyles.badge,
{
backgroundColor: getPDFBadgeStyle(
item.due_status,
'due'
).bg,
borderColor: getPDFBadgeStyle(item.due_status, 'due')
.border,
},
]}
>
<Text
style={{
color: getPDFBadgeStyle(item.due_status, 'due').text,
}}
>
{item.due_status}
</Text>
</View>
) : (
<Text>-</Text>
)}
</View>
<View
style={[
@@ -297,13 +494,37 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View
style={[
pdfStyles.tableCellRight,
{ flex: 1.5, color: item.debt_price < 0 ? 'red' : 'black' },
{ flex: 1.5, color: item.balance < 0 ? 'red' : 'black' },
]}
>
<Text>{formatCurrency(item.debt_price)}</Text>
<Text>{formatCurrency(item.balance)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.status || '-'}</Text>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
{item.status && item.status !== '-' ? (
<View
style={[
pdfStyles.badge,
{
backgroundColor: getPDFBadgeStyle(
item.status,
'payment'
).bg,
borderColor: getPDFBadgeStyle(item.status, 'payment')
.border,
},
]}
>
<Text
style={{
color: getPDFBadgeStyle(item.status, 'payment').text,
}}
>
{item.status}
</Text>
</View>
) : (
<Text>-</Text>
)}
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.travel_number || '-'}</Text>
@@ -341,7 +562,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text></Text>
</View>
<View
@@ -386,7 +607,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
>
<Text>{formatCurrency(supplierReport.total.debt_price)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellLast, { flex: 1 }]}>
@@ -2,7 +2,7 @@
import * as XLSX from 'xlsx';
import { formatDate } from '@/lib/helper';
import { DebtSupplier } from '@/types/api/report/debt-supplier';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
interface DebtSupplierExportExcelParams {
data: DebtSupplier[];
@@ -21,12 +21,29 @@ export const generateDebtSupplierExcel = (
const supplierData = supplierReport.rows;
const supplierName = supplierReport.supplier.name || 'Unknown Supplier';
const excelData: { [key: string]: string | number }[] = supplierData.map(
(item, index) => ({
const excelData: { [key: string]: string | number }[] = [
{
No: '',
'Nomor PR': '',
'Nomor PO': '',
'Tanggal Terima/Bayar': '',
'Tanggal PO': '',
'Aging (Hari)': '',
Area: '',
Gudang: '',
'Jatuh Tempo': '',
'Status Jatuh Tempo': '',
'Nominal Pembelian (Rp)': '',
'Pembayaran (Rp)': '',
'Sisa Saldo Hutang (Rp)': supplierReport.initial_balance || 0,
Status: '',
'Nomor Perjalanan': '',
},
...supplierData.map((item, index) => ({
No: index + 1,
'Nomor PR': item.pr_number || '',
'Nomor PO': item.po_number || '',
'Tanggal Terima': item.received_date
'Tanggal Terima/Bayar': item.received_date
? item.received_date != '-'
? formatDate(item.received_date, 'MM/DD/YYYY')
: '-'
@@ -39,35 +56,35 @@ export const generateDebtSupplierExcel = (
'Aging (Hari)': item.aging || 0,
Area: item.area?.name || '',
Gudang: item.warehouse?.name || '',
'Tanggal Jatuh Tempo': item.due_date
'Jatuh Tempo': item.due_date
? item.due_date != '-'
? formatDate(item.due_date, 'MM/DD/YYYY')
: '-'
: '-',
'Status Jatuh Tempo': item.due_status || '',
'Total Harga': item.total_price || 0,
'Harga Pembayaran': item.payment_price || 0,
'Harga Hutang': item.debt_price || 0,
'Nominal Pembelian (Rp)': item.total_price || 0,
'Pembayaran (Rp)': item.payment_price || 0,
'Sisa Saldo Hutang (Rp)': item.balance || 0,
Status: item.status || '',
'Nomor Perjalanan': item.travel_number || '',
})
);
})),
];
if (supplierReport.total) {
excelData.push({
No: 'Total',
'Nomor PR': '',
'Nomor PO': '',
'Tanggal Terima': '',
'Tanggal Terima/Bayar': '',
'Tanggal PO': '',
'Aging (Hari)': supplierReport.total.aging || 0,
Area: '',
Gudang: '',
'Tanggal Jatuh Tempo': '',
'Jatuh Tempo': '',
'Status Jatuh Tempo': '',
'Total Harga': supplierReport.total.total_price || 0,
'Harga Pembayaran': supplierReport.total.payment_price || 0,
'Harga Hutang': supplierReport.total.debt_price || 0,
'Nominal Pembelian (Rp)': supplierReport.total.total_price || 0,
'Pembayaran (Rp)': supplierReport.total.payment_price || 0,
'Sisa Saldo Hutang (Rp)': supplierReport.total.debt_price || 0,
Status: '',
'Nomor Perjalanan': '',
});
@@ -77,18 +94,18 @@ export const generateDebtSupplierExcel = (
const colWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Nomor PR
{ wch: 15 }, // Nomor PO
{ wch: 15 }, // Tanggal PR
{ wch: 15 }, // Tanggal PO
{ wch: 12 }, // Aging
{ wch: 10 }, // Nomor PR
{ wch: 10 }, // Nomor PO
{ wch: 20 }, // Tanggal Terima/Bayar
{ wch: 10 }, // Tanggal PO
{ wch: 10 }, // Aging
{ wch: 15 }, // Area
{ wch: 15 }, // Gudang
{ wch: 18 }, // Tanggal Jatuh Tempo
{ wch: 18 }, // Status Jatuh Tempo
{ wch: 15 }, // Total Harga
{ wch: 15 }, // Harga Pembayaran
{ wch: 15 }, // Harga Hutang
{ wch: 12 }, // Jatuh Tempo
{ wch: 20 }, // Status Jatuh Tempo
{ wch: 20 }, // Nominal Pembelian (Rp)
{ wch: 15 }, // Pembayaran (Rp)
{ wch: 20 }, // Sisa Saldo Hutang (Rp)
{ wch: 12 }, // Status
{ wch: 15 }, // Nomor Perjalanan
];
@@ -0,0 +1,36 @@
import { OptionType } from '@/components/input/SelectInput';
import * as yup from 'yup';
export type DebtSupplierFilterType = {
startDate: string | null | undefined;
endDate: string | null | undefined;
supplierIds: OptionType[] | null | undefined;
filterBy: OptionType | null | undefined;
};
export const DebtSupplierFilterSchema: yup.ObjectSchema<DebtSupplierFilterType> =
yup.object({
startDate: yup.string().optional().notRequired(),
endDate: yup.string().optional().notRequired(),
supplierIds: yup
.array()
.of(
yup.object({
value: yup.mixed<string | number>().required(),
label: yup.string().required(),
})
)
.optional()
.notRequired(),
filterBy: yup
.object({
value: yup.mixed<string | number>().required(),
label: yup.string().required(),
})
.optional()
.notRequired(),
});
export type DebtSupplierFilterValues = yup.InferType<
typeof DebtSupplierFilterSchema
>;
@@ -2,13 +2,16 @@ import { useState, useMemo, useCallback } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
import { UserApi } from '@/services/api/user';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
@@ -17,7 +20,6 @@ import {
CustomerPaymentSummary,
} from '@/types/api/report/customer-payment';
import { isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
@@ -36,38 +38,74 @@ const CustomerPaymentTab = () => {
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [pageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterCustomer, setFilterCustomer] = useState<OptionType[]>([]);
const [filterSales, setFilterSales] = useState<OptionType[]>([]);
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
[]
);
const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const filterModal = useModal();
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: customerOptions,
setInputValue: setCustomerInputValue,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const salesOptions = useMemo(
() => [
{ value: 'Sales A', label: 'Sales A' },
{ value: 'Sales B', label: 'Sales B' },
{ value: 'Sales C', label: 'Sales C' },
// TODO: Fetch sales options from API
],
[]
);
const {
options: salesOptions,
setInputValue: setSalesInputValue,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
hasMore: hasMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const dataTypeOptions = useMemo(
() => [{ value: 'do_date', label: 'Tanggal Jual' }],
[]
);
const getPaymentStatusColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info/10 text-info border-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning/10 text-warning border-warning';
}
return 'bg-gray-100 text-gray-600 border-gray-300';
};
const getPaymentStatusIndicatorColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning';
}
return 'bg-gray-400';
};
const getPaymentStatusText = (notes: string) => {
return notes;
};
// ===== FILTER HANDLERS =====
const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
@@ -75,27 +113,48 @@ const CustomerPaymentTab = () => {
setFilterSales([]);
setFilterStartDate('');
setFilterEndDate('');
setFilterErrors({});
}, []);
const handleApplyFilters = useCallback(() => {
const errors: Record<string, string> = {};
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}, [filterModal]);
if (!filterStartDate) {
errors.start_date = 'Tanggal mulai wajib diisi';
}
if (!filterEndDate) {
errors.end_date = 'Tanggal akhir wajib diisi';
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Date filter (start_date + end_date = 1 filter)
if (filterStartDate || filterEndDate) {
count += 1;
}
setFilterErrors(errors);
if (Object.keys(errors).length === 0) {
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
// Customer filter
if (filterCustomer.length > 0) {
count += 1;
}
}, [filterModal, filterStartDate, filterEndDate]);
// Sales filter
if (filterSales.length > 0) {
count += 1;
}
// Filter by (always count if submitted)
if (isSubmitted) {
count += 1;
}
return count;
}, [
filterStartDate,
filterEndDate,
filterCustomer,
filterSales,
isSubmitted,
]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
@@ -106,7 +165,7 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales:
sales_id:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
@@ -123,7 +182,7 @@ const CustomerPaymentTab = () => {
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.sales_id,
params.filter_by,
params.start_date,
params.end_date,
@@ -140,11 +199,6 @@ const CustomerPaymentTab = () => {
[customerPayment]
);
const meta =
isResponseSuccess(customerPayment) && customerPayment?.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
@@ -154,7 +208,7 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
sales:
sales_id:
filterSales.length > 0
? filterSales.map((v) => String(v.value)).join(',')
: undefined,
@@ -167,7 +221,7 @@ const CustomerPaymentTab = () => {
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.sales_id,
params.filter_by,
params.start_date,
params.end_date,
@@ -218,7 +272,22 @@ const CustomerPaymentTab = () => {
return;
}
await generateCustomerPaymentPDF({ data: allDataForExport });
await generateCustomerPaymentPDF({
data: allDataForExport,
params: {
customer_name:
filterCustomer.length > 0
? filterCustomer.map((c) => c.label).join(', ')
: undefined,
sales:
filterSales.length > 0
? filterSales.map((s) => s.label).join(', ')
: undefined,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
filter_by: 'do_date',
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
@@ -227,27 +296,6 @@ const CustomerPaymentTab = () => {
}
}, [customerPaymentExport]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (
summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
@@ -435,7 +483,9 @@ const CustomerPaymentTab = () => {
accessorKey: 'accounts_receivable',
cell: (props) => {
const value = props.row.original.accounts_receivable;
return <div className='text-right'>{formatCurrency(value)}</div>;
return (
<div className='text-right text-error'>{formatCurrency(value)}</div>
);
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
@@ -449,7 +499,23 @@ const CustomerPaymentTab = () => {
accessorKey: 'notes',
cell: (props) => {
const value = props.row.original.notes;
return value || '-';
if (!value) {
return '-';
}
return (
<Badge
statusIndicator={true}
variant='soft'
className={{
badge: `rounded-xl justify-start border border-gray-200 ${getPaymentStatusColor(value)}`,
status: getPaymentStatusIndicatorColor(value),
}}
>
{getPaymentStatusText(value)}
</Badge>
);
},
},
{
@@ -481,14 +547,37 @@ const CustomerPaymentTab = () => {
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button variant='outline' onClick={filterModal.openModal}>
<Button
variant='outline'
onClick={filterModal.openModal}
className={
hasFilters
? 'bg-linear-to-b from-[#0069E0]/40 to-white text-[#0069E0] rounded-lg'
: 'rounded-lg'
}
>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
{hasFilters && (
<Badge
variant='default'
className={{
badge:
'rounded-lg px-1.5 py-2.5 text-xs font-semibold bg-error text-white',
}}
>
{activeFiltersCount}
</Badge>
)}
</Button>
<Dropdown
trigger={
<Button variant='outline' isLoading={isAnyExportLoading}>
<Button
variant='outline'
isLoading={isAnyExportLoading}
className='rounded-lg'
>
<Icon
icon='heroicons:cloud-arrow-down'
width={18}
@@ -499,7 +588,7 @@ const CustomerPaymentTab = () => {
}
align='end'
>
<Menu>
<Menu className={'w-full'}>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
@@ -538,15 +627,9 @@ const CustomerPaymentTab = () => {
value={filterStartDate}
onChange={(e) => {
setFilterStartDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
}}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.start_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.start_date}
</p>
)}
</div>
<div>
@@ -556,23 +639,16 @@ const CustomerPaymentTab = () => {
value={filterEndDate}
onChange={(e) => {
setFilterEndDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
}}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.end_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.end_date}
</p>
)}
</div>
</div>
<div>
<SelectInput
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
isMulti
options={customerOptions}
value={filterCustomer}
onChange={(val) => {
@@ -580,23 +656,27 @@ const CustomerPaymentTab = () => {
Array.isArray(val) ? val : val ? [val] : []
);
}}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
</div>
<div>
<SelectInput
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
isMulti
options={salesOptions}
value={filterSales}
onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}}
onInputChange={setSalesInputValue}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
</div>
@@ -659,15 +739,18 @@ const CustomerPaymentTab = () => {
total_accounts_receivable: 0,
};
const totalAccountsReceivable = summary.total_accounts_receivable;
const tableColumns = getTableColumns(summary);
return (
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`${customerReport.customer.address || ''}\nSaldo Piutang: ${formatCurrency(totalAccountsReceivable)}`}
className={{ wrapper: 'w-full' }}
className={{
wrapper: 'w-full rounded-2xl',
body: 'p-0',
title:
'py-1.5 px-3 bg-[#0069E0] text-white text-lg font-normal',
}}
variant='bordered'
collapsible={true}
>
@@ -678,7 +761,7 @@ const CustomerPaymentTab = () => {
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
@@ -700,20 +783,6 @@ const CustomerPaymentTab = () => {
})
)}
</Card>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</div>
);
};
@@ -9,11 +9,15 @@ import SelectInput, {
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Modal, { useModal } from '@/components/Modal';
import Table from '@/components/Table';
import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
import {
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react';
@@ -21,8 +25,53 @@ import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import Pagination from '@/components/Pagination';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Badge from '@/components/Badge';
import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error',
'Belum Jatuh Tempo': 'success',
'Mendekati Jatuh Tempo': 'warning',
};
const paymentStatus: Record<string, Color> = {
'Belum Lunas': 'warning',
Lunas: 'primary',
Pembayaran: 'success',
};
const getPillBadge = (
statusText: string,
type: 'due' | 'payment' = 'payment'
) => {
// Get color based on type
const color =
type === 'due'
? dueStatus[statusText] || 'neutral'
: paymentStatus[statusText] || 'neutral';
return (
<Badge
color={color as Color}
size='sm'
variant='soft'
className={{
badge: `py-2.5 px-2 font-medium text-base-content rounded-full border border-${color}`,
}}
statusIndicator
>
{statusText}
</Badge>
);
};
const DebtSupplierTab = () => {
// ===== STATE MANAGEMENT =====
@@ -30,26 +79,23 @@ const DebtSupplierTab = () => {
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterSupplier, setFilterSupplier] = useState<OptionType[]>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [filterDateType, setFilterDateType] = useState<OptionType>();
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const filterModal = useModal();
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
useSelect(SupplierApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
@@ -59,48 +105,51 @@ const DebtSupplierTab = () => {
[]
);
// ===== FILTER HANDLERS =====
const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterSupplier([]);
setFilterStartDate('');
setFilterEndDate('');
setFilterErrors({});
}, []);
const handleFilterModalOpen = () => {
filterModal.openModal();
};
const handleApplyFilters = useCallback(() => {
const errors: Record<string, string> = {};
if (!filterStartDate) {
errors.start_date = 'Tanggal mulai wajib diisi';
}
if (!filterEndDate) {
errors.end_date = 'Tanggal akhir wajib diisi';
}
setFilterErrors(errors);
if (Object.keys(errors).length === 0) {
setIsSubmitted(true);
setCurrentPage(1);
// ===== FORMIK SETUP =====
const formik = useFormik<DebtSupplierFilterType>({
initialValues: {
startDate: null,
endDate: null,
supplierIds: null,
filterBy: null,
},
validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => {
setFilterParams({
start_date: values.startDate?.toString() || undefined,
end_date: values.endDate?.toString() || undefined,
supplier_ids:
values.supplierIds?.map((v) => String(v.value)).join(',') ||
undefined,
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
}
}, [filterModal, filterStartDate, filterEndDate]);
setIsSubmitted(true);
},
onReset: (values) => {
setFilterParams({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
setIsSubmitted(false);
},
});
// ===== DATA FETCHING =====
const { data: debtSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
supplier_ids:
filterSupplier.length > 0
? filterSupplier.map((v) => String(v.value)).join(',')
: undefined,
filter_by: filterDateType?.value,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
page: currentPage,
limit: pageSize,
supplier_ids: filterParams.supplier_ids,
filter_by: filterParams.filter_by,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
};
return ['debt-supplier-report', params];
@@ -109,11 +158,9 @@ const DebtSupplierTab = () => {
([, params]) =>
DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by?.toString(),
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
params.end_date
)
);
@@ -135,13 +182,15 @@ const DebtSupplierTab = () => {
> => {
const params = {
supplier_ids:
filterSupplier.length > 0
? filterSupplier.map((v) => String(v.value)).join(',')
formik.values.supplierIds && formik.values.supplierIds.length > 0
? formik.values.supplierIds.map((v) => String(v.value)).join(',')
: undefined,
filter_by: filterDateType?.value?.toString(),
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
date_type: filterDateType ? filterDateType.value : undefined,
filter_by: formik.values.filterBy?.value?.toString() || undefined,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
date_type: formik.values.filterBy
? formik.values.filterBy.value
: undefined,
limit: 100,
page: 1,
};
@@ -150,15 +199,18 @@ const DebtSupplierTab = () => {
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
params.end_date
);
return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[])
: null;
}, [filterSupplier, filterStartDate, filterEndDate]);
}, [
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
@@ -198,7 +250,17 @@ const DebtSupplierTab = () => {
return;
}
await generateDebtSupplierPDF({ data: allDataForExport });
await generateDebtSupplierPDF({
data: allDataForExport,
params: {
supplier_name: formik.values.supplierIds
?.map((v) => v.label)
.join(', '),
filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
@@ -207,37 +269,19 @@ const DebtSupplierTab = () => {
}
}, [debtSupplierExport]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
enableSorting: false,
cell: (props) => props.row.index,
footer: () => 'Total',
},
{
id: 'pr_number',
header: 'Nomor PR',
accessorKey: 'pr_number',
enableSorting: false,
cell: (props) => {
const value = props.row.original.pr_number;
return value || '-';
@@ -247,6 +291,7 @@ const DebtSupplierTab = () => {
id: 'po_number',
header: 'Nomor PO',
accessorKey: 'po_number',
enableSorting: false,
cell: (props) => {
const value = props.row.original.po_number;
return value || '-';
@@ -254,8 +299,9 @@ const DebtSupplierTab = () => {
},
{
id: 'received_date',
header: 'Tanggal Terima',
header: 'Tanggal Terima/Bayar',
accessorKey: 'received_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.received_date;
return value
@@ -269,6 +315,7 @@ const DebtSupplierTab = () => {
id: 'po_date',
header: 'Tanggal PO',
accessorKey: 'po_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.po_date;
return value
@@ -282,6 +329,7 @@ const DebtSupplierTab = () => {
id: 'aging',
header: 'Aging',
accessorKey: 'aging',
enableSorting: false,
cell: (props) => {
const value = props.row.original.aging;
return <div className='text-center'>{formatNumber(value)} Hari</div>;
@@ -295,6 +343,7 @@ const DebtSupplierTab = () => {
id: 'area',
header: 'Area',
accessorKey: 'area',
enableSorting: false,
cell: (props) => {
const value = props.row.original.area?.name;
return value || '-';
@@ -304,6 +353,7 @@ const DebtSupplierTab = () => {
id: 'warehouse',
header: 'Gudang',
accessorKey: 'warehouse',
enableSorting: false,
cell: (props) => {
const value = props.row.original.warehouse?.name;
return value || '-';
@@ -311,8 +361,9 @@ const DebtSupplierTab = () => {
},
{
id: 'due_date',
header: 'Tanggal Jatuh Tempo',
header: 'Jatuh Tempo',
accessorKey: 'due_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.due_date;
return value
@@ -326,15 +377,17 @@ const DebtSupplierTab = () => {
id: 'due_status',
header: 'Status Jatuh Tempo',
accessorKey: 'due_status',
enableSorting: false,
cell: (props) => {
const value = props.row.original.due_status;
return value || '-';
return value ? (value != '-' ? getPillBadge(value, 'due') : '-') : '-';
},
},
{
id: 'total_price',
header: 'Total Harga',
header: 'Nominal Pembelian',
accessorKey: 'total_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.total_price;
return (
@@ -354,8 +407,9 @@ const DebtSupplierTab = () => {
},
{
id: 'payment_price',
header: 'Harga Pembayaran',
header: 'Pembayaran',
accessorKey: 'payment_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.payment_price;
return (
@@ -374,11 +428,12 @@ const DebtSupplierTab = () => {
},
},
{
id: 'debt_price',
header: 'Harga Hutang',
accessorKey: 'debt_price',
id: 'balance',
header: 'Sisa Saldo Hutang',
accessorKey: 'balance',
enableSorting: false,
cell: (props) => {
const value = props.row.original.debt_price;
const value = props.row.original.balance;
return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)}
@@ -398,15 +453,21 @@ const DebtSupplierTab = () => {
id: 'status',
header: 'Status',
accessorKey: 'status',
enableSorting: false,
cell: (props) => {
const value = props.row.original.status;
return value || '-';
return value
? value != '-'
? getPillBadge(value, 'payment')
: '-'
: '-';
},
},
{
id: 'travel_number',
header: 'Nomor Perjalanan',
accessorKey: 'travel_number',
enableSorting: false,
cell: (props) => {
const value = props.row.original.travel_number;
return value || '-';
@@ -421,10 +482,11 @@ const DebtSupplierTab = () => {
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button variant='outline' onClick={filterModal.openModal}>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
</Button>
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
variant='outline'
/>
<Dropdown
trigger={
@@ -466,53 +528,87 @@ const DebtSupplierTab = () => {
<Card
key={supplierReport.supplier.id}
title={supplierReport.supplier.name}
className={{ wrapper: 'w-full' }}
className={{
wrapper: 'w-full !rounded-lg',
body: 'p-0 rounded-lg',
title:
'ps-2 pt-1 pb-1 font-normal text-md bg-primary text-white',
}}
variant='bordered'
collapsible={true}
defaultCollapsed={true}
>
<Table
data={supplierReport.rows}
data={[
{
balance: supplierReport.initial_balance,
} as DebtRow,
...supplierReport.rows,
]}
columns={getTableColumns(supplierReport)}
pageSize={supplierReport.rows.length}
pageSize={supplierReport.rows.length + 1}
renderFooter={supplierReport.rows.length > 0}
className={{
containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableWrapperClassName: 'overflow-x-auto',
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap'
),
bodyColumnClassName: cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName,
'whitespace-nowrap'
),
footerRowClassName: cn(
TABLE_DEFAULT_STYLING.footerRowClassName,
'bg-white'
),
footerColumnClassName: cn(
TABLE_DEFAULT_STYLING.footerColumnClassName,
'whitespace-nowrap p-3'
),
paginationClassName: 'hidden',
}}
renderCustomRow={(row) => {
if (row.index == 0) {
return (
<tr
className={cn(TABLE_DEFAULT_STYLING.bodyRowClassName)}
key={row.index}
>
<td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
colSpan={12}
></td>
<td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
>
<div
className={`text-right ${row.original.balance < 0 ? 'text-red-500' : ''}`}
>
{formatCurrency(row.original.balance)}
</div>
</td>
<td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
colSpan={2}
></td>
</tr>
);
}
}}
/>
</Card>
);
})
)}
</div>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
{/* Filter Modal */}
<Modal
@@ -522,7 +618,11 @@ const DebtSupplierTab = () => {
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm',
}}
>
<div className='space-y-6'>
<form
className='space-y-6'
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'>
<div className='flex items-center gap-2 text-primary'>
@@ -542,37 +642,31 @@ const DebtSupplierTab = () => {
<div>
<DateInput
label='Tanggal'
name='start_date'
value={filterStartDate}
name='startDate'
value={formik.values.startDate || ''}
onChange={(e) => {
setFilterStartDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
formik.setFieldValue('startDate', e.target.value || null);
}}
className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
/>
{filterErrors.start_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.start_date}
</p>
)}
</div>
<div className='mt-auto'>
<DateInput
label=' '
name='end_date'
value={filterEndDate}
name='endDate'
value={formik.values.endDate || ''}
onChange={(e) => {
setFilterEndDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
formik.setFieldValue('endDate', e.target.value || null);
}}
className={{ wrapper: 'w-full' }}
isError={formik.touched.endDate && !!formik.errors.endDate}
errorMessage={formik.errors.endDate}
/>
{filterErrors.end_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.end_date}
</p>
)}
</div>
</div>
@@ -582,15 +676,22 @@ const DebtSupplierTab = () => {
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={filterSupplier}
value={formik.values.supplierIds || []}
onChange={(val) => {
setFilterSupplier(
Array.isArray(val) ? val : val ? [val] : []
formik.setFieldValue(
'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
);
}}
isLoading={isLoadingSuppliers}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/>
</div>
@@ -599,12 +700,17 @@ const DebtSupplierTab = () => {
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={filterDateType}
value={formik.values.filterBy || null}
onChange={(val) => {
setFilterDateType(val ? (val as OptionType) : undefined);
formik.setFieldValue(
'filterBy',
val ? (val as OptionType) : null
);
}}
className={{ wrapper: 'w-full' }}
isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/>
</div>
</div>
@@ -614,18 +720,15 @@ const DebtSupplierTab = () => {
<Button
variant='soft'
className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilters}
type='reset'
>
Reset Filter
</Button>
<Button
className='me-4 min-w-36 rounded-lg'
onClick={handleApplyFilters}
>
<Button className='me-4 min-w-36 rounded-lg' type='submit'>
Apply Filter
</Button>
</div>
</div>
</form>
</Modal>
</>
);
@@ -21,10 +21,18 @@ import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { isResponseError } from '@/lib/api-helper';
import {
BaseProjectFlockKandang,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination';
import { ProductionResultReportApi } from '@/services/api/report/production-result';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import { ProductionResult } from '@/types/api/report/production-result';
import ProductionResultReportPDF from './ProductionResultReportPDF';
import { pdf } from '@react-pdf/renderer';
const ProductionResultContent = () => {
const [projectFlockKandangs, setProjectFlockKandangs] = useState<
@@ -49,6 +57,8 @@ const ProductionResultContent = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null
@@ -62,6 +72,7 @@ const ProductionResultContent = () => {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -78,6 +89,7 @@ const ProductionResultContent = () => {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
});
@@ -94,6 +106,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>(
ProjectFlockApi.basePath,
'id',
@@ -120,6 +133,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>(
ProjectFlockKandangApi.basePath,
'id',
@@ -154,6 +168,87 @@ const ProductionResultContent = () => {
setIsLoadingExportingToExcel(false);
};
const exportToPdfHandler = async () => {
setIsLoadingExportingToPdf(true);
try {
let projectFlockKandangsData: BaseProjectFlockKandang[] = [];
if (selectedProjectFlockKandang) {
const projectFlockKandangResponse =
await ProjectFlockKandangApi.getSingle(
selectedProjectFlockKandang?.value as number
);
projectFlockKandangsData = isResponseSuccess(
projectFlockKandangResponse
)
? [projectFlockKandangResponse.data]
: [];
} else {
const projectFlockKandangsResponse =
await ProjectFlockKandangApi.getAll({
area_id: selectedArea?.value,
project_flock_id: selectedProjectFlock?.value,
});
projectFlockKandangsData = isResponseSuccess(
projectFlockKandangsResponse
)
? projectFlockKandangsResponse.data
: [];
}
const mappedProductionResults: {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
}[] = await Promise.all(
projectFlockKandangsData.map(async (projectFlockKandang) => {
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
const getProductionResultRes = await httpClient<
BaseApiResponse<ProductionResult[]>
>(getProductionResultPath);
return {
projectFlockKandang,
productionResult: isResponseSuccess(getProductionResultRes)
? getProductionResultRes.data
: null,
};
})
);
if (mappedProductionResults.length === 0) {
toast.error('Tidak ada data untuk diexport.');
setIsLoadingExportingToPdf(false);
return;
}
const openPdf = async () => {
const productionResultPdfBlob = await pdf(
<ProductionResultReportPDF
mappedProductionResults={mappedProductionResults}
/>
).toBlob();
const productionResultReportPdfUrl = URL.createObjectURL(
productionResultPdfBlob
);
window.open(productionResultReportPdfUrl, '_blank');
};
await openPdf();
} catch (error) {
console.error(error);
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
}
// await ProductionResultReportApi.exportProductionResultToPdf(
// projectFlockKandangs
// );
setIsLoadingExportingToPdf(false);
};
const searchHandler = async () => {
setProjectFlockKandangs(null);
setIsLoadingSearch(true);
@@ -235,6 +330,7 @@ const ProductionResultContent = () => {
value={selectedArea}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -251,6 +347,7 @@ const ProductionResultContent = () => {
value={selectedLocation}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!selectedArea}
className={{
@@ -270,6 +367,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlock}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!selectedArea || !selectedLocation}
className={{
@@ -289,6 +387,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlockKandang}
onChange={projectFlockKandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable
isDisabled={!selectedProjectFlock}
className={{
@@ -347,6 +446,13 @@ const ProductionResultContent = () => {
onClick={exportToExcelHandler}
className='text-nowrap'
/>
<MenuItem
title='Export to PDF'
icon='icon-park-outline:file-pdf-one'
isLoading={isLoadingExportingToPdf}
onClick={exportToPdfHandler}
className='text-nowrap'
/>
</Menu>
</Dropdown>
</div>
@@ -0,0 +1,388 @@
'use client';
import React from 'react';
import {
Document,
Page,
StyleSheet,
Text,
View,
Image,
} from '@react-pdf/renderer';
import { formatDate, formatNumber } from '@/lib/helper';
import { BaseProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProductionResult } from '@/types/api/report/production-result';
type MappedProductionResultsItem = {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
};
interface ProductionResultReportPDFProps {
mappedProductionResults?: MappedProductionResultsItem[];
}
const styles = StyleSheet.create({
page: {
paddingTop: 24,
paddingBottom: 52,
paddingHorizontal: 16,
},
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
companyLogo: {
width: 64,
height: 'auto',
},
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 10,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 420,
marginBottom: 10,
},
doubleDivider: {
width: '100%',
height: 6,
borderTopWidth: 2,
borderTopColor: '#000',
borderBottomWidth: 2,
borderBottomColor: '#000',
},
title: {
marginTop: 14,
fontSize: 14,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 22,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
section: {
marginTop: 12,
borderWidth: 1,
borderColor: '#000',
padding: 8,
},
sectionHeader: {
marginBottom: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
},
sectionSubtitle: {
fontSize: 8,
color: '#444',
},
// Simple grid table (label/value pairs)
grid: {
width: '100%',
borderWidth: 1,
borderColor: '#000',
},
gridRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000',
},
gridRowLast: {
borderBottomWidth: 0,
},
gridCellLabel: {
width: '40%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
borderRightWidth: 1,
borderRightColor: '#000',
fontWeight: 'bold',
},
gridCellValue: {
width: '60%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
textAlign: 'right',
},
// Subsection headings
groupTitle: {
marginTop: 8,
marginBottom: 4,
fontSize: 9,
fontWeight: 'bold',
},
emptyText: {
fontSize: 8,
color: '#666',
fontStyle: 'italic',
},
});
function safeNum(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function valueText(v: unknown) {
if (v === null || v === undefined) return '-';
if (typeof v === 'number') return formatNumber(v);
return String(v);
}
/**
* Render label/value table for one ProductionResult.
* Uses a compact grid to keep page readable.
*/
function ProductionResultGrid({ pr }: { pr: ProductionResult }) {
const rows: Array<[string, string]> = [
['WOA', valueText(pr.woa)],
// BW
['BW', valueText(pr.bw)],
['Std BW', valueText(pr.std_bw)],
['Uniformity', valueText(pr.uniformity)],
['Std Uniformity', valueText(pr.std_uniformity)],
// Dep
['Dep Kum', valueText(pr.dep_kum)],
['Dep Std', valueText(pr.dep_std)],
// Butiran
['Butiran Utuh', valueText(pr.butiran_utuh)],
['Butiran Putih', valueText(pr.butiran_putih)],
['Butiran Retak', valueText(pr.butiran_retak)],
['Butiran Pecah', valueText(pr.butiran_pecah)],
['Butiran Jumlah', valueText(pr.butiran_jumlah)],
['Total Butir', valueText(pr.total_butir)],
// Kg
['Kg Utuh', valueText(pr.kg_utuh)],
['Kg Putih', valueText(pr.kg_putih)],
['Kg Retak', valueText(pr.kg_retak)],
['Kg Pecah', valueText(pr.kg_pecah)],
['Kg Jumlah', valueText(pr.kg_jumlah)],
['Total Kg', valueText(pr.total_kg)],
// %
['% Utuh', valueText(pr.persen_utuh)],
['% Putih', valueText(pr.persen_putih)],
['% Retak', valueText(pr.persen_retak)],
['% Pecah', valueText(pr.persen_pecah)],
// Produksi
['HD', valueText(pr.hd)],
['HD Std', valueText(pr.hd_std)],
['FI', valueText(pr.fi)],
['FI Std', valueText(pr.fi_std)],
['EM', valueText(pr.em)],
['EM Std', valueText(pr.em_std)],
['EW', valueText(pr.ew)],
['EW Std', valueText(pr.ew_std)],
['FCR', valueText(pr.fcr)],
['FCR Std', valueText(pr.fcr_std)],
['HH', valueText(pr.hh)],
['HH Std', valueText(pr.hh_std)],
];
return (
<View style={styles.grid}>
{rows.map(([label, value], idx) => {
const isLast = idx === rows.length - 1;
return (
<View
key={label}
style={[styles.gridRow, ...(isLast ? [styles.gridRowLast] : [])]}
>
<Text style={styles.gridCellLabel}>{label}</Text>
<Text style={styles.gridCellValue}>{value}</Text>
</View>
);
})}
</View>
);
}
/**
* If there are multiple ProductionResult entries for a kandang,
* we show them sequentially with a small header per result.
*
* You can later change this to render only the latest WOA, or group by week.
*/
function ProductionResultList({
productionResults,
}: {
productionResults: ProductionResult[];
}) {
return (
<View>
{productionResults.map((pr, idx) => {
const kandangName =
pr.project_flock?.kandang?.name ||
pr.project_flock?.kandang?.id?.toString() ||
'';
// Optional: show a compact subheader
const headerLeft = `Data #${idx + 1}`;
const headerRight =
kandangName && pr.woa !== undefined
? `${kandangName} • WOA ${safeNum(pr.woa)}`
: pr.woa !== undefined
? `WOA ${safeNum(pr.woa)}`
: '';
return (
<View
key={`${pr.project_flock?.id ?? 'pf'}-${idx}`}
style={{ marginTop: idx === 0 ? 0 : 10 }}
wrap={false}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{headerLeft}</Text>
<Text style={styles.sectionSubtitle}>{headerRight}</Text>
</View>
<ProductionResultGrid pr={pr} />
</View>
);
})}
</View>
);
}
/**
* Main PDF Component
*/
const ProductionResultReportPDF = ({
mappedProductionResults = [],
}: ProductionResultReportPDFProps) => {
return (
<Document>
<Page style={styles.page} size='A4'>
{/* Header */}
<View>
<View style={styles.companyInfoHeader}>
<Image style={styles.companyLogo} src='/assets/img/lti-logo.png' />
<Text style={styles.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
</View>
<View>
<Text style={styles.companyName}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={styles.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={styles.doubleDivider} />
</View>
</View>
<Text style={styles.title}>Laporan Production Result</Text>
{/* Sections per ProjectFlockKandang */}
{mappedProductionResults.length === 0 ? (
<View style={{ marginTop: 16 }}>
<Text style={styles.emptyText}>Tidak ada data.</Text>
</View>
) : (
mappedProductionResults.map((item, idx) => {
const pfk = item.projectFlockKandang;
// Try to display meaningful identifiers.
// Adjust these fields based on your real BaseProjectFlockKandang structure.
const kandangName =
pfk?.kandang?.name ?? `Kandang #${pfk?.kandang_id ?? idx + 1}`;
const projectName = pfk?.project_flock?.name ?? '';
const locationName = pfk?.project_flock?.location?.name ?? '';
const areaName = pfk?.project_flock?.area?.name ?? '';
return (
<View
key={`pfk-${pfk?.id ?? idx}`}
style={styles.section}
break={idx > 0} // each kandang starts on a new page for clarity
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{projectName
? `${projectName}${kandangName}`
: kandangName}
</Text>
<Text style={styles.sectionSubtitle}>
{[areaName, locationName].filter(Boolean).join(' • ')}
</Text>
</View>
{item.productionResult && item.productionResult.length > 0 ? (
<ProductionResultList
productionResults={item.productionResult}
/>
) : (
<Text style={styles.emptyText}>
Tidak ada production result untuk kandang ini.
</Text>
)}
</View>
);
})
)}
{/* Footer */}
<View style={styles.footer} fixed>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
};
export default ProductionResultReportPDF;
@@ -58,18 +58,26 @@ const HppPerKandangTab = () => {
},
});
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect(
AreaApi.basePath,
'id',
'name',
'search'
);
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreas,
loadMore: loadMoreAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const { options: locationOptions, isLoadingOptions: isLoadingLocations } =
useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } =
useSelect(KandangApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect(KandangApi.basePath, 'id', 'name', 'search');
const showUnrecordedOptions: OptionType[] = [
{ value: 'false', label: 'Sembunyikan' },
@@ -810,6 +818,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value))
)}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreas}
isClearable
/>
@@ -824,6 +834,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value))
)}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocations}
isClearable
/>
@@ -838,6 +850,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value))
)}
onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangs}
isClearable
/>
+33 -18
View File
@@ -10,61 +10,65 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
text: 'Daily Checklist',
link: '/daily-checklist',
icon: 'heroicons-outline:clipboard-check',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: [
'lti.daily_checklist.dashboard.list',
'lti.daily_checklist.create',
'lti.daily_checklist.list',
'lti.daily_checklist.detail',
'lti.daily_checklist.reports',
'lti.daily_checklist.master_data.employee',
'lti.daily_checklist.master_data.activity',
'lti.daily_checklist.master_data.configuration',
],
submenu: [
{
text: 'Dashboard',
link: '/daily-checklist/dashboard',
icon: 'lucide:layout-dashboard',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.dashboard.list'],
},
{
text: 'Daily Checklist',
link: '/daily-checklist/daily-checklist',
icon: 'lucide:clipboard-check',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.create'],
},
{
text: 'Daftar Daily Checklist',
link: '/daily-checklist/list-daily-checklist',
icon: 'lucide:circle-check',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.list'],
},
{
text: 'Laporan',
link: '/daily-checklist/reports',
icon: 'lucide:file-text',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.reports'],
},
{
text: 'Master Data',
link: '/daily-checklist/master-data',
icon: 'lucide:database',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: [
'lti.daily_checklist.master_data.employee',
'lti.daily_checklist.master_data.activity',
'lti.daily_checklist.master_data.configuration',
],
submenu: [
{
text: 'Employee (ABK)',
link: '/daily-checklist/master-data/employee',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.master_data.employee'],
},
{
text: 'Aktivitas',
link: '/daily-checklist/master-data/activity',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.master_data.activity'],
},
{
text: 'Konfigurasi',
link: '/daily-checklist/master-data/configuration',
// TODO: add permission
// permission: ['lti.daily_checklist.list'],
permission: ['lti.daily_checklist.master_data.configuration'],
},
],
},
@@ -457,3 +461,14 @@ export const MARKETING_TYPE_OPTIONS = [
value: 'trading',
},
];
export const MARKETING_DATE_FILTER_TYPE_OPTIONS = [
{
label: 'Tanggal Realisasi',
value: 'realization_date',
},
{
label: 'Tanggal SO',
value: 'so_date',
},
];
+16 -16
View File
@@ -5,22 +5,22 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/dashboard/': ['lti.dashboard.list'],
// Daily Checklist
// TODO: use real daily checklist permission name
// '/daily-checklist/': ['lti.daily_checklist.list'],
// '/daily-checklist/dashboard/': ['lti.daily_checklist.list'],
// '/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'],
// '/daily-checklist/list-daily-checklist/detail/': ['lti.daily_checklist.detail'],
// '/daily-checklist/reports/': ['lti.daily_checklist.reports'],
// '/daily-checklist/master-data/employee/': ['lti.dashboard.master_data.employee'],
// '/daily-checklist/master-data/activity/': ['lti.dashboard.master_data.activity'],
'/daily-checklist/dashboard/': ['lti.dashboard.list'],
'/daily-checklist/daily-checklist/': ['lti.dashboard.list'],
'/daily-checklist/list-daily-checklist/': ['lti.dashboard.list'],
'/daily-checklist/list-daily-checklist/detail/': ['lti.dashboard.list'],
'/daily-checklist/reports/': ['lti.dashboard.list'],
'/daily-checklist/master-data/employee/': ['lti.dashboard.list'],
'/daily-checklist/master-data/activity/': ['lti.dashboard.list'],
'/daily-checklist/master-data/configuration/': ['lti.dashboard.list'],
'/daily-checklist/dashboard/': ['lti.daily_checklist.dashboard.list'],
'/daily-checklist/daily-checklist/': ['lti.daily_checklist.create'],
'/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'],
'/daily-checklist/list-daily-checklist/detail/': [
'lti.daily_checklist.detail',
],
'/daily-checklist/reports/': ['lti.daily_checklist.reports'],
'/daily-checklist/master-data/employee/': [
'lti.daily_checklist.master_data.employee',
],
'/daily-checklist/master-data/activity/': [
'lti.daily_checklist.master_data.activity',
],
'/daily-checklist/master-data/configuration/': [
'lti.daily_checklist.master_data.configuration',
],
// Production
// Production - Project Flock
+64 -1
View File
@@ -1,4 +1,4 @@
import { FormikErrors } from 'formik';
import { FormikErrors, FormikValues } from 'formik';
export type ErrorMessage = {
key: string;
@@ -69,3 +69,66 @@ export function getUniqueFormikErrors<T>(errors: FormikErrors<T>): string[] {
export function getAllFormikErrors<T>(errors: FormikErrors<T>): ErrorMessage[] {
return parseFormikErrors(errors);
}
/**
* Check if a value is considered "filled" (not empty)
* @param value - Value to check
* @returns True if value is filled, false otherwise
*/
function isValueFilled(value: unknown): boolean {
// Check for null or undefined
if (value === null || value === undefined) {
return false;
}
// Check for empty string
if (typeof value === 'string' && value.trim() === '') {
return false;
}
// Check for empty array
if (Array.isArray(value) && value.length === 0) {
return false;
}
// Check for empty object (but not Date or other special objects)
if (
typeof value === 'object' &&
!Array.isArray(value) &&
!(value instanceof Date) &&
Object.keys(value).length === 0
) {
return false;
}
return true;
}
/**
* Count the number of filled (non-empty) values in Formik values object
* @param values - Formik values object
* @returns Number of filled values
* @example
* const values = {
* name: 'John',
* email: '',
* age: null,
* tags: ['tag1', 'tag2'],
* emptyArray: [],
* };
* getFilledFormikValuesCount(values); // Returns 2 (name and tags)
*/
export function getFilledFormikValuesCount<T extends FormikValues>(
values: T
): number {
let count = 0;
Object.keys(values).forEach((key) => {
const value = values[key];
if (isValueFilled(value)) {
count++;
}
});
return count;
}
+6 -4
View File
@@ -91,10 +91,11 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
}
async getProductionData(
id: number
id: number,
kandangId?: number
): Promise<BaseApiResponse<ClosingProductionData> | undefined> {
try {
const getProductionDataPath = `${this.basePath}/${id}/production-data`;
const getProductionDataPath = `${this.basePath}/${id}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`;
const getProductionDataRes = await httpClient<
BaseApiResponse<ClosingProductionData>
>(getProductionDataPath);
@@ -148,10 +149,11 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
}
async getFinance(
id: number
id: number,
kandangId?: number
): Promise<BaseApiResponse<ClosingFinance> | undefined> {
try {
const path = `${this.basePath}/${id}/keuangan`;
const path = `${this.basePath}/${id}${kandangId ? `/${kandangId}` : ''}/keuangan`;
return await httpClient<BaseApiResponse<ClosingFinance>>(path, {
method: 'GET',
});
+1 -5
View File
@@ -15,9 +15,7 @@ export class DebtSupplierApiService extends BaseApiService<
supplier_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string,
page?: number,
limit?: number
end_date?: string
): Promise<BaseApiResponse<DebtSupplier[]> | undefined> {
return await this.customRequest<BaseApiResponse<DebtSupplier[]>>(
`debt-supplier`,
@@ -28,8 +26,6 @@ export class DebtSupplierApiService extends BaseApiService<
filter_by: filter_by,
start_date: start_date,
end_date: end_date,
page: page,
limit: limit,
},
}
);
+2 -2
View File
@@ -14,7 +14,7 @@ export class FinanceApiService extends BaseApiService<
async getCustomerPaymentReport(
customer_id?: string,
sales?: string,
sales_id?: string,
filter_by?: 'do_date',
start_date?: string,
end_date?: string,
@@ -27,7 +27,7 @@ export class FinanceApiService extends BaseApiService<
method: 'GET',
params: {
customer_id: customer_id,
sales: sales,
sales_id: sales_id,
filter_by: filter_by,
start_date: start_date,
end_date: end_date,
+1 -3
View File
@@ -44,9 +44,7 @@ export class MarketingSaleReportService extends BaseApiService<
}
}
export const SaleReportApi = new MarketingSaleReportService(
'reports/marketings'
);
export const SaleReportApi = new MarketingSaleReportService('reports');
// export const SaleReportApi = new MarketingSaleReportService(
// 'http://localhost:4010/api/reports/marketings'
+3
View File
@@ -38,6 +38,9 @@ export const useFormikErrorList = <T>(
// Validate form
const isValid = await handleValidateForm();
if (isValid) {
close();
}
// Call onAfterValidation callback if validation passed
if (options?.onAfterValidation) {
+12 -9
View File
@@ -1,20 +1,20 @@
import { BaseMetadata } from '@/types/api/api-general';
import { Uom } from '@/types/api/master-data/uom';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Supplier } from '@/types/api/master-data/supplier';
import { BaseSupplier, Supplier } from '@/types/api/master-data/supplier';
export type BaseProduct = {
id: number;
name: string;
brand: string;
sku: string;
sku?: string;
product_price: number;
selling_price?: number;
tax?: number;
expiry_period: number;
expiry_period?: number;
uom: Uom;
product_category: ProductCategory;
suppliers: Supplier[];
suppliers: (BaseSupplier & { price: number })[];
flags: string[];
};
@@ -23,14 +23,17 @@ export type Product = BaseMetadata & BaseProduct;
export type CreateProductPayload = {
name: string;
brand: string;
sku: string;
sku?: string;
uom_id: number;
product_category_id: number;
product_price: number;
selling_price: number;
tax: number;
expiry_period: number;
supplier_ids: number[];
selling_price?: number;
tax?: number;
expiry_period?: number;
suppliers: {
supplier_id: number;
price: number;
}[];
flags: string[];
};
+33 -12
View File
@@ -1,34 +1,52 @@
import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Warehouse } from '@/types/api/master-data/warehouse';
export type ProductionStandard = {
id: number;
week: number;
name: string;
hen_day_std: number;
hen_house_std: number;
feed_intake_std: number;
max_depletion_std: number;
egg_mass_std: number;
egg_weight_std: number;
};
export type FCR = {
id: number;
name: string;
fcr_std: number;
};
export type ProjectFlock = {
project_flock_kandang_id: number;
flock_name: string;
project_flock_category: 'GROWING' | 'LAYING';
period: number;
production_standart: ProductionStandard;
fcr: FCR;
total_chick_qty: number;
};
export type ProductionMetrics = {
total_depletion_qty: number;
cum_depletion_rate: number;
cum_intake: number;
fcr_value: number;
fcr_std?: number;
total_chick_qty: number;
hen_day?: number;
hen_house?: number;
feed_intake?: number;
feed_intake_std?: number;
egg_mass?: number;
egg_weight?: number;
hen_day_std?: number;
hen_house_std?: number;
egg_mass_std?: number;
egg_weight_std?: number;
daily_gain?: number;
avg_daily_gain?: number;
cum_depletion?: number;
};
export type BaseRecording = {
id: number;
project_flock_kandang_id: number;
project_flock: ProjectFlock;
record_datetime: string;
day: number;
project_flock_category?: 'GROWING' | 'LAYING';
} & ProductionMetrics;
export type RecordingDepletion = {
@@ -68,6 +86,8 @@ export type Recording = BaseMetadata &
BaseRecording & {
approval?: BaseApproval;
created_user: User;
warehouse?: Warehouse;
product_category?: 'GROWING' | 'LAYING';
depletions?: RecordingDepletion[];
stocks?: RecordingStock[];
eggs?: RecordingEgg[];
@@ -81,6 +101,7 @@ export type NextDayRecording = {
export type CreateGrowingRecordingPayload = {
project_flock_kandang_id: number;
record_date: string;
stocks?: {
product_warehouse_id: number;
qty: number;
+8
View File
@@ -33,3 +33,11 @@ export interface DebtRow {
travel_number: string;
balance: number;
}
// Filter Param
export interface DebtSupplierFilter {
start_date?: string;
end_date?: string;
supplier_ids?: string;
filter_by?: string;
}