Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/project-flock-select-options

This commit is contained in:
randy-ar
2026-01-15 14:45:40 +07:00
27 changed files with 1317 additions and 635 deletions
+2 -2
View File
@@ -325,7 +325,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}; };
const useSelect = <T,>( const useSelect = <T,>(
basePath: string, basePath: string | null,
valueKey: keyof T | string, valueKey: keyof T | string,
labelKey: keyof T | string, labelKey: keyof T | string,
searchKey: string = 'search', searchKey: string = 'search',
@@ -354,7 +354,7 @@ const useSelect = <T,>(
[limitKey]: String(limit), [limitKey]: String(limit),
}).toString(); }).toString();
return `${basePath}?${qs}`; return basePath ? `${basePath}?${qs}` : null;
}; };
const { const {
@@ -8,6 +8,7 @@ import {
HppPurchaseData, HppPurchaseData,
ProfitLossDataAmount, ProfitLossDataAmount,
} from '@/types/api/closing'; } from '@/types/api/closing';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
type HppTableRow = type HppTableRow =
@@ -55,9 +56,16 @@ const ClosingFinanceTable = ({
}: { }: {
projectFlockId: number; projectFlockId: number;
}) => { }) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR( const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`, `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() => ClosingApi.getFinance(projectFlockId) () =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
); );
const staticHppRows: Array<{ const staticHppRows: Array<{
@@ -283,6 +291,7 @@ const ClosingFinanceTable = ({
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<HppTableRow> <Table<HppTableRow>
data={hppTableData} data={hppTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'No.', header: 'No.',
@@ -455,6 +464,7 @@ const ClosingFinanceTable = ({
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow> <Table<ProfitLossTableRow>
data={profitLossTableData} data={profitLossTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'Jenis', header: 'Jenis',
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6', wrapper: 'col-span-12 sm:col-span-6',
@@ -283,261 +283,311 @@ const DashboardLineChart = ({
})()} })()}
</div> </div>
{/* Chart */} {/* Chart Container with Empty State Overlay */}
<ResponsiveContainer width='100%' height={350}> <div className='relative'>
<LineChart {/* Chart */}
data={(() => { <ResponsiveContainer width='100%' height={350}>
// Transform data based on analysisMode <LineChart
if (analysisMode === 'OVERVIEW') { data={(() => {
// For OVERVIEW mode, use the selected chart data // Transform data based on analysisMode
if (isOverviewCharts(data.charts)) { if (analysisMode === 'OVERVIEW') {
const selectedChartData = data.charts[chartData]; // For OVERVIEW mode, use the selected chart data
if (!selectedChartData || !selectedChartData.dataset) return []; if (isOverviewCharts(data.charts)) {
return selectedChartData.dataset; const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
} }
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()} })()}
ticks={(() => { margin={{
// Calculate dynamic ticks based on domain top: 5,
let seriesData: DashboardChartsSeries[] = []; right: 10,
let dataset: DashboardChartsDataset[] = []; left: 0,
bottom: 5,
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} >
itemStyle={{ color: 'white', fontSize: '12px' }} <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
labelFormatter={(value) => `Week ${value}`} <XAxis
formatter={( dataKey='week'
value: number | undefined, tick={{ fontSize: 11, fill: '#9ca3af' }}
name: string | undefined tickLine={false}
) => { axisLine={{ stroke: '#e5e7eb' }}
if (value === undefined || name === undefined) return ['', '']; label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
// Get series data to find the unit if (
let seriesData: DashboardChartsSeries[] = []; analysisMode === 'OVERVIEW' &&
if ( isOverviewCharts(data.charts)
analysisMode === 'OVERVIEW' && ) {
isOverviewCharts(data.charts) seriesData = data.charts[chartData]?.series || [];
) { dataset = data.charts[chartData]?.dataset || [];
seriesData = data.charts[chartData]?.series || []; } else if (
} else if ( analysisMode === 'COMPARISON' &&
analysisMode === 'COMPARISON' && isComparisonCharts(data.charts)
isComparisonCharts(data.charts) ) {
) { const comparisonChart =
const comparisonChart = data.charts.location ||
data.charts.location || data.charts.flock ||
data.charts.flock || data.charts.kandang;
data.charts.kandang; seriesData = comparisonChart?.series || [];
seriesData = comparisonChart?.series || []; dataset = comparisonChart?.dataset || [];
} }
// Find the series that matches this line's name // Get all values from visible series
const series = seriesData.find((s) => s.label === name); const visibleSeriesIds = Array.from(visibleSeries);
const unit = series?.unit || ''; const allValues: number[] = [];
return [`${value} ${unit}`, name]; dataset.forEach((item: DashboardChartsDataset) => {
}} visibleSeriesIds.forEach((seriesId) => {
/> const value = item[seriesId];
{/* Dynamic Line rendering based on visible series */} if (typeof value === 'number') {
{(() => { allValues.push(value);
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
} }
activeDot={isStandard ? undefined : { r: 5 }} });
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
formatter={(
value: number | undefined,
name: string | undefined
) => {
if (value === undefined || name === undefined) return ['', ''];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const unit = series?.unit || '';
return [`${value} ${unit}`, name];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={24}
height={24}
/> />
); </div>
});
})()} {/* Empty state text */}
</LineChart> <h3 className='text-gray-900 font-semibold text-base mb-2'>
</ResponsiveContainer> Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</div>
);
}
return null;
})()}
</div>
</Card> </Card>
); );
}; };
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage, InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage, setInventoryAdjustmentFormErrorMessage,
] = useState(''); ] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true); const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok'); const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler // Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
}); });
// Fetch Data // Fetch Data
const productCategoriesUrl = `${ const {
ProductCategoryApi.basePath setInputValue: setProductCategoryInputValue,
}?${new URLSearchParams({ options: productCategoryOptions,
search: '', isLoadingOptions: isLoadingProductCategoryOptions,
}).toString()}`; loadMore: loadMoreProductCategories,
const { data: productCategories, isLoading: isLoadingProductCategories } = } = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setProductInputValue,
product_category_id: selectedProductCategories, options: productOptions,
}).toString()}`; isLoadingOptions: isLoadingProductOptions,
const { data: products, isLoading: isLoadingProducts } = useSWR( loadMore: loadMoreProducts,
productUrl, } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
ProductApi.getAllFetcher product_category_id: formik.values.product_category_id
); ? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setWarehouseInputValue,
limit: '100', options: warehouseOptions,
}).toString()}`; isLoadingOptions: isLoadingWarehouseOptions,
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( loadMore: loadMoreWarehouses,
warehouseUrl, } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
// Options Handler // Options Handler
const productCategoryChangeHandler = ( const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null; const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled); setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0); formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect // Effect
useEffect(() => { useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) { if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false); setDisabledProduct(false);
formik.setFieldValue( formik.setFieldValue(
'product_id', 'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
); );
formik.setFieldValue('note', initialValues.note); formik.setFieldValue('note', initialValues.note);
} }
}, [ }, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function // Utils Function
const formatNumber = (value: string) => { const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk' label='Kategori Produk'
value={formik.values.product_category as OptionType} value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler} onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories} onInputChange={setProductCategoryInputValue}
options={optionsProductCategory} options={productCategoryOptions}
isLoading={isLoadingProductCategories} onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={ isError={
formik.touched.product_category && formik.touched.product_category &&
Boolean(formik.errors.product_category) Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk' label='Produk'
value={formik.values.product as OptionType} value={formik.values.product as OptionType}
onChange={productChangeHandler} onChange={productChangeHandler}
options={optionsProduct} onInputChange={setProductInputValue}
isLoading={isLoadingProducts} options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)} isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string} errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct} isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse' label='Warehouse'
value={formik.values.warehouse as OptionType} value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
options={optionsWarehouse} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouses} options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={ isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse) formik.touched.warehouse && Boolean(formik.errors.warehouse)
} }
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant'; import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper'; import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]); const [formErrorList, setFormErrorList] = useState<string[]>([]);
@@ -93,10 +91,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
const { const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses, isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
// ===== SELECT INPUT DATA ===== // ===== SELECT INPUT DATA =====
const { const {
@@ -107,12 +106,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
category: 'BOP', category: 'BOP',
}); });
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING ===== // ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => { const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map(); if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -269,25 +262,22 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}); });
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) ===== // ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const getProductWarehousesUrl = useCallback(() => { const {
const productWarehouseParams = new URLSearchParams({ setInputValue: setProductWarehouseSelectInputValue,
search: productWarehouseSelectInputValue, isLoadingOptions: isLoadingProductWarehouses,
}); loadMore: loadMoreProductWarehouses,
if (formik.values.source_warehouse_id) { rawData: productWarehouses,
productWarehouseParams.append( } = useSelect<ProductWarehouse>(
'warehouse_id', formik.values.source_warehouse_id ? ProductWarehouseApi.basePath : null,
formik.values.source_warehouse_id.toString() 'id',
); 'name',
'search',
{
warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString()
: '',
} }
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`; );
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
const productWarehousesUrl = getProductWarehousesUrl();
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } =
useSWR(
formik.values.source_warehouse_id ? productWarehousesUrl : null,
ProductWarehouseApi.getAllFetcher
);
const productWarehouseOptions = isResponseSuccess(productWarehouses) const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({ ? productWarehouses?.data.map((pw) => ({
@@ -1006,6 +996,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}} }}
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
isError={ isError={
formik.touched.source_warehouse_id && formik.touched.source_warehouse_id &&
@@ -1104,6 +1095,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
formik.touched.destination_warehouse_id && formik.touched.destination_warehouse_id &&
Boolean(formik.errors.destination_warehouse_id) Boolean(formik.errors.destination_warehouse_id)
@@ -1263,6 +1255,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}} }}
options={productWarehouseOptions} options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue} onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
isLoading={isLoadingProductWarehouses} isLoading={isLoadingProductWarehouses}
isDisabled={ isDisabled={
type === 'detail' || type === 'detail' ||
@@ -11,6 +11,13 @@ import {
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
sales_person_id: number | undefined; sales_person_id: number | undefined;
sales_person:
| {
value: number;
label: string;
}
| undefined
| null;
customer: customer:
| { | {
value: number; value: number;
@@ -33,7 +40,11 @@ type DeliveryOrderSchemaType = {
export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> = export const SalesOrderSchema: Yup.ObjectSchema<SalesOrderSchemaType> =
Yup.object({ Yup.object({
customer_id: Yup.number().required('Customer wajib diisi!'), customer_id: Yup.number().required('Customer wajib diisi!'),
sales_person_id: Yup.number().required('Sales Person wajib diisi!'), sales_person_id: Yup.number().required('Sales wajib diisi!'),
sales_person: Yup.object({
value: Yup.number().required(),
label: Yup.string().required(),
}).nullable(),
customer: Yup.object({ customer: Yup.object({
value: Yup.number().required(), value: Yup.number().required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -50,6 +50,8 @@ import { DeliveryOrderProductFormValues } from '@/components/pages/marketing/for
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { CreatedUser } from '@/types/api/api-general';
import { UserApi } from '@/services/api/user';
const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable); const MemoizedSalesOrderProductTable = memo(SalesOrderProductTable);
const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm); const MemoizedSalesOrderProductForm = memo(SalesOrderProductForm);
@@ -244,7 +246,15 @@ const MarketingForm = ({
const { const {
options: customerOptions, options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions, isLoadingOptions: isLoadingCustomerOptions,
setInputValue: setInputCustomerValue,
loadMore: loadMoreCustomer,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name'); } = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const {
options: salesOptions,
isLoadingOptions: isLoadingSalesOptions,
setInputValue: setInputSalesValue,
loadMore: loadMoreSales,
} = useSelect<CreatedUser>(UserApi.basePath, 'id', 'name');
// ================== SETUP FORMIK ================== // ================== SETUP FORMIK ==================
const formikInitialValues = useMemo< const formikInitialValues = useMemo<
@@ -255,6 +265,12 @@ const MarketingForm = ({
notes: initialValues?.notes || undefined, notes: initialValues?.notes || undefined,
customer_id: initialValues?.customer?.id || undefined, customer_id: initialValues?.customer?.id || undefined,
sales_person_id: initialValues?.sales_person?.id || 1, sales_person_id: initialValues?.sales_person?.id || 1,
sales_person: initialValues?.sales_person
? {
value: initialValues.sales_person.id,
label: initialValues.sales_person.name,
}
: null,
customer: initialValues?.customer customer: initialValues?.customer
? { ? {
value: initialValues.customer.id, value: initialValues.customer.id,
@@ -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(() => { const handleDelete = useCallback(() => {
deleteModal.openModal(); deleteModal.openModal();
}, [deleteModal]); }, [deleteModal]);
@@ -580,6 +603,7 @@ const MarketingForm = ({
className={{ className={{
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
variant='bordered'
> >
<div className='grid sm:grid-cols-2 gap-3 mt-3'> <div className='grid sm:grid-cols-2 gap-3 mt-3'>
<SelectInput <SelectInput
@@ -588,6 +612,8 @@ const MarketingForm = ({
isLoading={isLoadingCustomerOptions} isLoading={isLoadingCustomerOptions}
value={formik.values.customer} value={formik.values.customer}
onChange={handleChangeCustomer} onChange={handleChangeCustomer}
onInputChange={setInputCustomerValue}
onMenuScrollToBottom={loadMoreCustomer}
isError={ isError={
formik.touched.customer_id && Boolean(formik.errors.customer_id) formik.touched.customer_id && Boolean(formik.errors.customer_id)
} }
@@ -617,6 +643,7 @@ const MarketingForm = ({
className={{ className={{
wrapper: 'bg-white w-full', wrapper: 'bg-white w-full',
}} }}
variant='bordered'
> >
<MemoizedSalesOrderProductTable <MemoizedSalesOrderProductTable
formType={formType} formType={formType}
@@ -651,19 +678,42 @@ const MarketingForm = ({
{/* Input Notes */} {/* Input Notes */}
<div className='grid sm:grid-cols-2 gap-3'> <div className='grid sm:grid-cols-2 gap-3'>
<DebouncedTextArea <div className='flex flex-col h-full items-end gap-3'>
required <SelectInput
name='notes' label='Sales'
label='Catatan' options={salesOptions}
rows={3} isLoading={isLoadingSalesOptions}
placeholder='Masukan catatan penjualan' value={formik.values.sales_person}
value={formik.values.notes} onChange={handleChangeSalesPerson}
onChange={formik.handleChange} onInputChange={setInputSalesValue}
isError={formik.touched.notes && Boolean(formik.errors.notes)} onMenuScrollToBottom={loadMoreSales}
errorMessage={formik.errors.notes} isError={
disabled={formType === 'add_deliver' || formType === 'edit_deliver'} formik.touched.sales_person_id &&
/> Boolean(formik.errors.sales_person_id)
<div className='flex flex-col h-full justify-between items-end py-6'> }
errorMessage={formik.errors.sales_person_id}
isClearable
placeholder='Pilih Sales'
isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
<DebouncedTextArea
required
name='notes'
label='Catatan'
rows={3}
placeholder='Masukan catatan penjualan'
value={formik.values.notes}
onChange={formik.handleChange}
isError={formik.touched.notes && Boolean(formik.errors.notes)}
errorMessage={formik.errors.notes}
disabled={
formType === 'add_deliver' || formType === 'edit_deliver'
}
/>
</div>
<div className='flex flex-col h-full justify-end items-end'>
<span>Total Penjualan</span> <span>Total Penjualan</span>
<span className='text-lg font-semibold'> <span className='text-lg font-semibold'>
{formatCurrency(grandTotal)}{' '} {formatCurrency(grandTotal)}{' '}
@@ -18,6 +18,11 @@ import * as Yup from 'yup';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
import { ProductApi } from '@/services/api/master-data';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
@@ -43,6 +48,17 @@ const DeliveryOrderProductForm = ({
); );
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
const salesOrder = salesOrders.find( const salesOrder = salesOrders.find(
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
@@ -113,22 +129,60 @@ const DeliveryOrderProductForm = ({
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field); setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { const qty = Number(formik.values.qty || 0);
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) { const avgWeight = Number(formik.values.avg_weight || 0);
formik.setFieldValue('total_price', Number(qty) * Number(unit_price)); const totalWeight = Number(formik.values.total_weight || 0);
} else if (qty && total_price && field === 'total_price') { const unitPrice = Number(formik.values.unit_price || 0);
formik.setFieldValue('unit_price', Number(total_price) / Number(qty)); const totalPrice = Number(formik.values.total_price || 0);
if (qty <= 0) return;
switch (field) {
// ===== SOURCE FIELDS =====
case 'qty': {
if (avgWeight > 0) {
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
}
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
} }
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') { case 'avg_weight': {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) { if (avgWeight > 0) {
formik.setFieldValue('total_weight', Number(qty) * Number(avg_weight)); const tw = roundWeight(qty * avgWeight);
} else if (qty && total_weight && field === 'total_weight') { formik.setFieldValue('total_weight', tw);
formik.setFieldValue('avg_weight', Number(total_weight) / Number(qty));
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
}
break;
}
case 'unit_price': {
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
break;
} }
} }
}; };
@@ -183,7 +237,7 @@ const DeliveryOrderProductForm = ({
</div> </div>
)} )}
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-3 gap-4'>
<SelectInput <SelectInput
options={options} options={options}
label='Produk' label='Produk'
@@ -287,7 +341,9 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.vehicle_number)} isError={Boolean(formik.errors.vehicle_number)}
errorMessage={formik.errors.vehicle_number} errorMessage={formik.errors.vehicle_number}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4'>
<NumberInput <NumberInput
required required
label='Kuantitas' label='Kuantitas'
@@ -301,33 +357,28 @@ const DeliveryOrderProductForm = ({
isError={Boolean(formik.errors.qty)} isError={Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={ bottomLabel={
formik.values.marketing_product_id formik.values.marketing_product_id
? 'Stok dijual: ' + ? 'Stok dijual: ' +
salesOrders?.find( salesOrders?.find(
(item) => item.id === formik.values.marketing_product_id (item) => item.id === formik.values.marketing_product_id
)?.qty )?.qty +
' ' +
(isResponseSuccess(productData)
? productData?.data?.uom.name
: '')
: '' : ''
} }
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4'>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput <NumberInput
required required
label='Harga Satuan (Rp)' label='Harga Satuan (Rp)'
@@ -342,7 +393,20 @@ const DeliveryOrderProductForm = ({
errorMessage={formik.errors.unit_price} errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan' placeholder='Masukan Harga Satuan'
/> />
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={Boolean(formik.errors.avg_weight)}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput <NumberInput
required required
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
@@ -11,7 +11,7 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data'; import { ProductApi, UomApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import NumberInput from '@/components/input/NumberInput'; import NumberInput from '@/components/input/NumberInput';
@@ -26,6 +26,10 @@ import PatternInput from '@/components/input/PatternInput';
import Alert from '@/components/Alert'; import Alert from '@/components/Alert';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import useSWR from 'swr';
const roundWeight = (value: number) => Number(value.toFixed(2));
const roundPrice = (value: number) => Math.round(value);
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -39,6 +43,19 @@ const SalesOrderProductForm = ({
}) => { }) => {
const [formErrorMessage, setFormErrorMessage] = useState(''); const [formErrorMessage, setFormErrorMessage] = useState('');
const [currentInput, setCurrentInput] = useState<string>(''); const [currentInput, setCurrentInput] = useState<string>('');
const [selectedProductWarehouse, setSelectedProductWarehouse] =
useState<ProductWarehouse | null>(null);
// ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProductWarehouse?.product_id
? ProductApi.basePath + '/' + selectedProductWarehouse?.product_id
: null,
() =>
selectedProductWarehouse?.product_id
? ProductApi.getSingle(selectedProductWarehouse?.product_id)
: undefined
);
// ============ Formik ============ // ============ Formik ============
const formik = useFormik<SalesOrderProductFormValues>({ const formik = useFormik<SalesOrderProductFormValues>({
@@ -69,17 +86,21 @@ const SalesOrderProductForm = ({
const { const {
options: kandangSourceOptions, options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions, isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
const { const {
options: warehouseSourceOptions, options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions, isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouse,
} = useSelect<ProductWarehouse>( } = useSelect<ProductWarehouse>(
ProductWarehouseApi.basePath, ProductWarehouseApi.basePath,
'id', 'id',
'product.name', 'product.name',
'search', '',
{ {
warehouse_id: formik.values.kandang_id?.toString() ?? '', warehouse_id: formik.values.kandang_id?.toString() ?? '',
} }
@@ -112,6 +133,7 @@ const SalesOrderProductForm = ({
const productWarehouse = warehouseSourceRawData?.data.find( const productWarehouse = warehouseSourceRawData?.data.find(
(item: ProductWarehouse) => item.id === newId (item: ProductWarehouse) => item.id === newId
); );
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
@@ -139,34 +161,60 @@ const SalesOrderProductForm = ({
const handleBlurField = (field: string) => { const handleBlurField = (field: string) => {
setCurrentInput(field); setCurrentInput(field);
const { qty, unit_price, total_price, avg_weight, total_weight } =
formik.values;
if (field === 'unit_price' || field === 'total_price' || field === 'qty') { const qty = Number(formik.values.qty || 0);
if (qty && unit_price && (field === 'unit_price' || field === 'qty')) { const avgWeight = Number(formik.values.avg_weight || 0);
formik.setFieldValue( const totalWeight = Number(formik.values.total_weight || 0);
'total_price', const unitPrice = Number(formik.values.unit_price || 0);
(qty as number) * (unit_price as number) const totalPrice = Number(formik.values.total_price || 0);
);
} else if (qty && total_price && field === 'total_price') { if (qty <= 0) return;
formik.setFieldValue(
'unit_price', switch (field) {
(total_price as number) / (qty as number) // ===== SOURCE FIELDS =====
); case 'qty': {
if (avgWeight > 0) {
formik.setFieldValue('total_weight', roundWeight(qty * avgWeight));
}
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
} }
}
if (field === 'avg_weight' || field === 'total_weight' || field === 'qty') { case 'avg_weight': {
if (qty && avg_weight && (field === 'avg_weight' || field === 'qty')) { if (avgWeight > 0) {
formik.setFieldValue( const tw = roundWeight(qty * avgWeight);
'total_weight', formik.setFieldValue('total_weight', tw);
(qty as number) * (avg_weight as number)
); if (unitPrice > 0) {
} else if (qty && total_weight && field === 'total_weight') { formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
formik.setFieldValue( }
'avg_weight', }
(total_weight as number) / (qty as number) break;
); }
case 'unit_price': {
if (unitPrice > 0) {
formik.setFieldValue('total_price', roundPrice(qty * unitPrice));
}
break;
}
// ===== TOTAL EDITABLE =====
case 'total_weight': {
if (totalWeight > 0) {
formik.setFieldValue('avg_weight', roundWeight(totalWeight / qty));
}
break;
}
case 'total_price': {
if (totalPrice > 0) {
formik.setFieldValue('unit_price', roundPrice(totalPrice / qty));
}
break;
} }
} }
}; };
@@ -188,7 +236,7 @@ const SalesOrderProductForm = ({
</Alert> </Alert>
</div> </div>
)} )}
<div className='grid sm:grid-cols-2 gap-4 z-200'> <div className='grid sm:grid-cols-3 gap-4 z-200'>
<PatternInput <PatternInput
name='vehicle_number' name='vehicle_number'
label='No. Polisi' label='No. Polisi'
@@ -215,6 +263,8 @@ const SalesOrderProductForm = ({
value={formik.values.kandang} value={formik.values.kandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
isClearable isClearable
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -228,6 +278,8 @@ const SalesOrderProductForm = ({
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse} value={formik.values.product_warehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable isClearable
placeholder={ placeholder={
formik.values.kandang_id formik.values.kandang_id
@@ -243,6 +295,9 @@ const SalesOrderProductForm = ({
} }
errorMessage={formik.errors.product_warehouse_id} errorMessage={formik.errors.product_warehouse_id}
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-3 gap-4 z-200'>
<NumberInput <NumberInput
required required
label='Kuantitas' label='Kuantitas'
@@ -256,6 +311,15 @@ const SalesOrderProductForm = ({
isError={formik.touched.qty && Boolean(formik.errors.qty)} isError={formik.touched.qty && Boolean(formik.errors.qty)}
errorMessage={formik.errors.qty} errorMessage={formik.errors.qty}
placeholder='Masukan Kuantitas' placeholder='Masukan Kuantitas'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{isResponseSuccess(productData)
? productData?.data?.uom.name
: ''}
</span>
</div>
}
bottomLabel={ bottomLabel={
isResponseSuccess(warehouseSourceRawData) && isResponseSuccess(warehouseSourceRawData) &&
formik.values.product_warehouse_id formik.values.product_warehouse_id
@@ -264,32 +328,13 @@ const SalesOrderProductForm = ({
(item) => item.id === formik.values.product_warehouse_id (item) => item.id === formik.values.product_warehouse_id
)?.quantity ?? 0 )?.quantity ?? 0
)} ${ )} ${
warehouseSourceRawData?.data?.find( isResponseSuccess(productData)
(item) => item.id === formik.values.product_warehouse_id ? productData?.data?.uom.name
)?.product?.uom?.name ?? '' : ''
}` }`
: '' : ''
} }
/> />
</div>
<div className='divider my-6'></div>
<div className='grid sm:grid-cols-2 gap-4 z-200'>
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput <NumberInput
required required
label='Harga Satuan (Rp)' label='Harga Satuan (Rp)'
@@ -306,6 +351,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.unit_price} errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan' placeholder='Masukan Harga Satuan'
/> />
<NumberInput
required
label='Avg. Bobot (Kg)'
name='avg_weight'
value={formik.values.avg_weight}
onChange={(e) => {
formik.handleChange(e);
setCurrentInput(e.target.name);
}}
onBlur={() => handleBlurField('avg_weight')}
isError={
formik.touched.avg_weight && Boolean(formik.errors.avg_weight)
}
errorMessage={formik.errors.avg_weight}
placeholder='Masukan Bobot Rata-rata'
/>
<NumberInput <NumberInput
required required
label='Total Bobot (Kg)' label='Total Bobot (Kg)'
@@ -215,7 +215,7 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama kandang'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -229,7 +229,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama nonstock'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = { type ProductFormSchemaType = {
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
uom?: { uom?: {
value: number; value: number;
label: string; label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null; } | null;
product_category_id: number; product_category_id: number;
product_price: number | string; product_price: number | string;
selling_price: number | string; selling_price?: number | string;
tax: number | string; tax?: number | string;
expiry_period: number | string; expiry_period?: number | string;
supplier_ids: number[]; suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[]; flags: string[];
}; };
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({ Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'), brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string(),
uom: Yup.object({ uom: Yup.object({
value: Yup.number() value: Yup.number()
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'), .min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .typeError('Harga hanya boleh angka!')
.typeError('Harga jual wajib diisi!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'), .min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .typeError('Pajak hanya boleh angka!')
.typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa hanya boleh angka!')
.typeError('Periode kadaluarsa wajib diisi!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'), .min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array() suppliers: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(
.min(1, 'Minimal harus ada 1 supplier!') Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flags: Yup.array()
@@ -41,6 +41,8 @@ import { cn } from '@/lib/helper';
import { PRODUCT_FLAG_OPTIONS } from '@/config/constant'; import { PRODUCT_FLAG_OPTIONS } from '@/config/constant';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import Card from '@/components/Card';
import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ProductFormProps { interface ProductFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -101,7 +103,15 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
selling_price: initialValues?.selling_price ?? '', selling_price: initialValues?.selling_price ?? '',
tax: initialValues?.tax ?? '', tax: initialValues?.tax ?? '',
expiry_period: initialValues?.expiry_period ?? '', expiry_period: initialValues?.expiry_period ?? '',
supplier_ids: initialValues?.suppliers?.map((s) => s.id) ?? [], suppliers: initialValues?.suppliers
? initialValues.suppliers.map((supplier) => ({
supplier: {
value: supplier.id,
label: supplier.name,
},
price: supplier.price,
}))
: [],
flags: initialValues?.flags ?? [], flags: initialValues?.flags ?? [],
}), }),
[initialValues] [initialValues]
@@ -120,12 +130,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: parseInt(values.product_price.toString()) || 0, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: parseInt(values.selling_price.toString()) || 0, selling_price: values.selling_price
tax: parseInt(values.tax.toString()) || 0, ? parseInt(values.selling_price.toString()) || 0
expiry_period: parseInt(values.expiry_period.toString()) || 0, : undefined,
supplier_ids: values.supplier_ids.filter( tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
(id): id is number => typeof id === 'number' expiry_period: values.expiry_period
), ? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseInt(s.price.toString()) || 0,
})),
flags: values.flags.filter((f): f is string => typeof f === 'string'), flags: values.flags.filter((f): f is string => typeof f === 'string'),
}; };
switch (type) { switch (type) {
@@ -179,13 +194,29 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
category: 'SAPRONAK', category: 'SAPRONAK',
}); });
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const filteredSupplierOptions = useMemo(() => {
const arr = Array.isArray(val) ? val : val ? [val] : []; return supplierOptions.filter((opt) => {
formik.setFieldTouched('supplier_ids', true); return !formik.values.suppliers.some(
formik.setFieldValue( (s) => s.supplier?.value === opt.value
'supplier_ids', );
arr.map((v) => (v as OptionType).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 = () => { const deleteProductClickHandler = () => {
@@ -201,6 +232,19 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
router.push('/master-data/product'); router.push('/master-data/product');
}; };
const isSupplierRepeaterError = (
column: 'supplier' | 'price',
supplierIdx: number
) => {
return (
formik.touched.suppliers?.[supplierIdx]?.[column] &&
Boolean(
formik.errors.suppliers?.[supplierIdx] instanceof Object &&
formik.errors.suppliers?.[supplierIdx]?.[column]
)
);
};
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues); formikSetValues(formikInitialValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
@@ -271,7 +315,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<TextInput <TextInput
required
label='SKU' label='SKU'
name='sku' name='sku'
placeholder='Masukkan SKU...' placeholder='Masukkan SKU...'
@@ -344,7 +387,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<NumberInput <NumberInput
required
label='Harga Jual' label='Harga Jual'
name='selling_price' name='selling_price'
placeholder='Masukkan harga jual...' placeholder='Masukkan harga jual...'
@@ -366,7 +408,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
</div> </div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-2 gap-4'>
<NumberInput <NumberInput
required
label='Pajak (%)' label='Pajak (%)'
name='tax' name='tax'
placeholder='Masukkan pajak...' placeholder='Masukkan pajak...'
@@ -383,7 +424,6 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
<NumberInput <NumberInput
required
label='Periode Kadaluarsa (hari)' label='Periode Kadaluarsa (hari)'
name='expiry_period' name='expiry_period'
placeholder='Masukkan periode kadaluarsa...' placeholder='Masukkan periode kadaluarsa...'
@@ -403,28 +443,7 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
readOnly={type === 'detail'} readOnly={type === 'detail'}
/> />
</div> </div>
<div className='grid sm:grid-cols-2 gap-4'> <div className='grid sm:grid-cols-1 gap-4'>
<SelectInput
required
label='Supplier'
placeholder='Pilih supplier...'
isMulti
value={supplierOptions.filter((opt) =>
(formik.values.supplier_ids || []).includes(opt.value)
)}
onChange={supplierChangeHandler}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
isError={
formik.touched.supplier_ids &&
Boolean(formik.errors.supplier_ids)
}
errorMessage={formik.errors.supplier_ids as string}
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput <SelectInput
required required
label='Flags' label='Flags'
@@ -447,6 +466,129 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
isClearable isClearable
/> />
</div> </div>
<div className='grid sm:grid-cols-1 gap-4'>
{type !== 'detail' && formik.values.suppliers.length === 0 && (
<Button
type='button'
color='success'
onClick={addSupplierHandler}
className='w-fit mx-auto'
>
<Icon icon='ic:round-plus' width={24} height={24} /> Tambah
Supplier
</Button>
)}
{formik.values.suppliers.length > 0 && (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<div className='mb-4 text-center'>
<h4 className='font-bold text-xl'>Supplier</h4>
</div>
<div className='overflow-x-auto'>
<table className='table'>
<thead>
<tr>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Supplier
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga
</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{formik.values.suppliers.map((supplier, idx) => (
<tr key={idx}>
<td className='p-2 w-full max-w-1/2'>
<SelectInput
placeholder='Pilih Supplier'
options={filteredSupplierOptions}
onInputChange={setSupplierSelectInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSuppliers}
value={formik.values.suppliers[idx].supplier}
onChange={(val) => {
formik.setFieldValue(
`suppliers.${idx}.supplier`,
val
);
}}
isError={isSupplierRepeaterError(
'supplier',
idx
)}
isClearable
isDisabled={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
<td className='p-2 w-full max-w-1/2'>
<NumberInput
required
name={`suppliers.${idx}.price`}
placeholder='Masukkan harga...'
value={formik.values.suppliers[idx].price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
decimalScale={2}
allowNegative={false}
thousandSeparator=','
decimalSeparator='.'
inputPrefix='Rp '
isError={isSupplierRepeaterError('price', idx)}
readOnly={type === 'detail'}
className={{
wrapper: 'min-w-48 w-full',
}}
/>
</td>
{type !== 'detail' && (
<td>
<Button
type='button'
color='error'
onClick={() => deleteSupplierItemHandler(idx)}
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={24}
height={24}
/>
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{type !== 'detail' && (
<div className='w-full flex flex-row justify-center'>
<Button
type='button'
color='success'
onClick={addSupplierHandler}
>
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
Tambah Supplier
</Button>
</div>
)}
</Card>
)}
</div>
</div> </div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
{type !== 'add' && ( {type !== 'add' && (
@@ -330,7 +330,7 @@ const WarehouseForm = ({ type = 'add', initialValues }: WarehouseFormProps) => {
required required
label='Nama' label='Nama'
name='name' name='name'
placeholder='Masukkan nama lokasi' placeholder='Masukkan nama warehouse'
value={formik.values.name} value={formik.values.name}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
@@ -563,7 +563,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
todayRecordings.forEach((recording) => { todayRecordings.forEach((recording) => {
const recordingDate = recording.record_datetime?.split('T')[0]; const recordingDate = recording.record_datetime?.split('T')[0];
if (recordingDate === today) { if (recordingDate === today) {
recordedIds.add(recording.project_flock.project_flock_kandang_id); recordedIds.add(recording.project_flock?.project_flock_kandang_id);
} }
}); });
@@ -87,6 +87,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
options: areaOptions, options: areaOptions,
isLoadingOptions: isLoadingAreaOptions, isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name'); } = useSelect<Area>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -101,6 +102,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -118,6 +120,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setWarehouseInputValue, setInputValue: setWarehouseInputValue,
options: warehouseOptions, options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions, isLoadingOptions: isLoadingWarehouseOptions,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -135,6 +138,7 @@ const DailyMarketingReportContent = () => {
setInputValue: setCustomerInputValue, setInputValue: setCustomerInputValue,
options: customerOptions, options: customerOptions,
isLoadingOptions: isLoadingCustomerOptions, isLoadingOptions: isLoadingCustomerOptions,
loadMore: loadMoreCustomers,
} = useSelect<Customer>(CustomerApi.basePath, 'id', 'name'); } = useSelect<Customer>(CustomerApi.basePath, 'id', 'name');
const customerChangeHandler = (val: OptionType | OptionType[] | null) => { const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -298,6 +302,7 @@ const DailyMarketingReportContent = () => {
value={selectedArea} value={selectedArea}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -312,6 +317,7 @@ const DailyMarketingReportContent = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -326,6 +332,7 @@ const DailyMarketingReportContent = () => {
value={selectedWarehouse} value={selectedWarehouse}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -340,6 +347,7 @@ const DailyMarketingReportContent = () => {
value={selectedCustomer} value={selectedCustomer}
onChange={customerChangeHandler} onChange={customerChangeHandler}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
onMenuScrollToBottom={loadMoreCustomers}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -26,6 +26,15 @@ import MenuItem from '@/components/menu/MenuItem';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { generateReportExpensePDF } from './pdf/ReportExpenseExport'; import { generateReportExpensePDF } from './pdf/ReportExpenseExport';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import {
KandangApi,
LocationApi,
NonstockApi,
SupplierApi,
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Kandang } from '@/types/api/master-data/kandang';
import { Nonstock } from '@/types/api/master-data/nonstock';
const ReportExpenseTable = () => { const ReportExpenseTable = () => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
@@ -64,16 +73,33 @@ const ReportExpenseTable = () => {
}); });
// ===== SELECT OPTIONS ===== // ===== SELECT OPTIONS =====
const { options: optionsLocation, isLoadingOptions: isLoadingLocation } = const {
useSelect(`/master-data/locations`, 'id', 'name'); setInputValue: setLocationInputValue,
const { options: optionsSupplier, isLoadingOptions: isLoadingSupplier } = options: locationOptions,
useSelect(`/master-data/suppliers`, 'id', 'name'); isLoadingOptions: isLoadingLocationOptions,
const { options: optionsKandang, isLoadingOptions: isLoadingKandang } = loadMore: loadMoreLocations,
useSelect(`/master-data/kandangs`, 'id', 'name', '', { } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
location_id: filterState.location_id,
}); const {
const { options: optionsNonstock, isLoadingOptions: isLoadingNonstock } = setInputValue: setSupplierInputValue,
useSelect(`/master-data/nonstocks`, 'id', 'name'); options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(KandangApi.basePath, 'id', 'name');
const {
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
const categoryOptions = useMemo( const categoryOptions = useMemo(
() => [ () => [
@@ -86,31 +112,31 @@ const ReportExpenseTable = () => {
// Mendapatkan value option select dari filter state // Mendapatkan value option select dari filter state
const selectedLocation = useMemo( const selectedLocation = useMemo(
() => () =>
optionsLocation.find( locationOptions.find(
(opt) => String(opt.value) === filterState.location_id (opt) => String(opt.value) === filterState.location_id
) || null, ) || null,
[optionsLocation, filterState.location_id] [locationOptions, filterState.location_id]
); );
const selectedSupplier = useMemo( const selectedSupplier = useMemo(
() => () =>
optionsSupplier.find( supplierOptions.find(
(opt) => String(opt.value) === filterState.supplier_id (opt) => String(opt.value) === filterState.supplier_id
) || null, ) || null,
[optionsSupplier, filterState.supplier_id] [supplierOptions, filterState.supplier_id]
); );
const selectedKandang = useMemo( const selectedKandang = useMemo(
() => () =>
optionsKandang.find( kandangOptions.find(
(opt) => String(opt.value) === filterState.kandang_id (opt) => String(opt.value) === filterState.kandang_id
) || null, ) || null,
[optionsKandang, filterState.kandang_id] [kandangOptions, filterState.kandang_id]
); );
const selectedNonstock = useMemo( const selectedNonstock = useMemo(
() => () =>
optionsNonstock.find( nonstockOptions.find(
(opt) => String(opt.value) === filterState.nonstock_id (opt) => String(opt.value) === filterState.nonstock_id
) || null, ) || null,
[optionsNonstock, filterState.nonstock_id] [nonstockOptions, filterState.nonstock_id]
); );
const selectedCategory = useMemo( const selectedCategory = useMemo(
() => () =>
@@ -756,38 +782,46 @@ const ReportExpenseTable = () => {
<SelectInput <SelectInput
isClearable isClearable
label='Lokasi' label='Lokasi'
options={optionsLocation} options={locationOptions}
isLoading={isLoadingLocation} isLoading={isLoadingLocationOptions}
placeholder='Lokasi' placeholder='Lokasi'
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
/> />
<SelectInput <SelectInput
isClearable isClearable
label='Kandang' label='Kandang'
options={optionsKandang} options={kandangOptions}
isLoading={isLoadingKandang} isLoading={isLoadingKandangOptions}
placeholder='Kandang' placeholder='Kandang'
value={selectedKandang} value={selectedKandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
/> />
<SelectInput <SelectInput
isClearable isClearable
label='Supplier' label='Supplier'
options={optionsSupplier} options={supplierOptions}
isLoading={isLoadingSupplier} isLoading={isLoadingSupplierOptions}
placeholder='Supplier' placeholder='Supplier'
value={selectedSupplier} value={selectedSupplier}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
/> />
<SelectInput <SelectInput
isClearable isClearable
label='Produk' label='Produk'
options={optionsNonstock} options={nonstockOptions}
isLoading={isLoadingNonstock} isLoading={isLoadingNonstockOptions}
placeholder='Produk' placeholder='Produk'
value={selectedNonstock} value={selectedNonstock}
onChange={nonstockChangeHandler} onChange={nonstockChangeHandler}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
/> />
<SelectInput <SelectInput
isClearable isClearable
@@ -18,6 +18,47 @@ Font.register({
src: 'helvetica', 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({ const pdfStyles = StyleSheet.create({
page: { page: {
fontSize: 10, fontSize: 10,
@@ -136,10 +177,40 @@ const pdfStyles = StyleSheet.create({
backgroundColor: '#F0F0F0', backgroundColor: '#F0F0F0',
fontWeight: 'bold', 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 { interface DebtSupplierExportPDFParams {
data: DebtSupplier[]; data: DebtSupplier[];
params?: {
supplier_name?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
};
} }
const createPDFDocument = (params: DebtSupplierExportPDFParams) => { const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
@@ -157,9 +228,50 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<Text style={pdfStyles.mainTitle}> <Text style={pdfStyles.mainTitle}>
Laporan &gt; Rekapitulasi Hutang ke Supplier Laporan &gt; Rekapitulasi Hutang ke Supplier
</Text> </Text>
<View style={pdfStyles.parameterContainer}>
<View style={pdfStyles.parameterBadge}>
<Text>
Periode:{' '}
{params.params?.start_date
? formatDate(params.params.start_date, 'DD MMM YYYY')
: '-'}{' '}
s.d{' '}
{params.params?.end_date
? formatDate(params.params.end_date, 'DD MMM YYYY')
: '-'}
</Text>
</View>
{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}> <Text style={pdfStyles.supplierTitle}>
{supplierReport.supplier.name} {supplierReport.supplier.name}
</Text> </Text>
<Text style={pdfStyles.supplierInfo}>
{supplierReport.supplier.category}
</Text>
</View> </View>
{/* Table */} {/* Table */}
@@ -193,7 +305,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Jatuh Tempo</Text> <Text>Jatuh Tempo</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 2 }]}>
<Text>Status Jatuh Tempo</Text> <Text>Status Jatuh Tempo</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
@@ -205,7 +317,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Sisa Saldo Hutang (Rp)</Text> <Text>Sisa Saldo Hutang (Rp)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Status</Text> <Text>Status</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
@@ -216,40 +328,40 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
{/* Initial Balance Row */} {/* Initial Balance Row */}
<View style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}> <View style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}> <View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text></Text> <Text></Text> {/* NO */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text> <Text></Text> {/* No. PR */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text> <Text></Text> {/* No. PO */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> <Text></Text> {/* Tgl Terima/Bayar */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> <Text></Text> {/* Tgl PO */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.6 }]}>
<Text></Text> <Text></Text> {/* Aging */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> <Text></Text> {/* Area */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> <Text></Text> {/* Gudang */}
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 1 }]}>
<Text></Text> <Text></Text> {/* Jatuh Tempo */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text></Text> <Text></Text> {/* Status Jatuh Tempo */}
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text> <Text></Text> {/* Nominal Pembelian (Rp) */}
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text></Text> <Text></Text> {/* Pembayaran (Rp) */}
</View> </View>
<View <View
style={[ style={[
@@ -261,14 +373,16 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
]} ]}
> >
<Text> <Text>
{' '}
{/* Sisa Saldo Hutang (Rp) */}
{formatCurrency(supplierReport.initial_balance || 0)} {formatCurrency(supplierReport.initial_balance || 0)}
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text> <Text></Text> {/* Status */}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> <Text></Text> {/* No. Perjalanan */}
</View> </View>
</View> </View>
@@ -328,8 +442,32 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
: '-'} : '-'}
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text>{item.due_status || '-'}</Text> {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>
<View <View
style={[ style={[
@@ -361,8 +499,32 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
> >
<Text>{formatCurrency(item.balance)}</Text> <Text>{formatCurrency(item.balance)}</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{item.status || '-'}</Text> {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>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.travel_number || '-'}</Text> <Text>{item.travel_number || '-'}</Text>
@@ -400,7 +562,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 2 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View <View
@@ -445,7 +607,7 @@ const createPDFDocument = (params: DebtSupplierExportPDFParams) => {
> >
<Text>{formatCurrency(supplierReport.total.debt_price)}</Text> <Text>{formatCurrency(supplierReport.total.debt_price)}</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCellLast, { flex: 1 }]}> <View style={[pdfStyles.tableCellLast, { flex: 1 }]}>
@@ -64,7 +64,7 @@ export const generateDebtSupplierExcel = (
'Status Jatuh Tempo': item.due_status || '', 'Status Jatuh Tempo': item.due_status || '',
'Nominal Pembelian (Rp)': item.total_price || 0, 'Nominal Pembelian (Rp)': item.total_price || 0,
'Pembayaran (Rp)': item.payment_price || 0, 'Pembayaran (Rp)': item.payment_price || 0,
'Sisa Saldo Hutang (Rp)': item.debt_price || 0, 'Sisa Saldo Hutang (Rp)': item.balance || 0,
Status: item.status || '', Status: item.status || '',
'Nomor Perjalanan': item.travel_number || '', 'Nomor Perjalanan': item.travel_number || '',
})), })),
@@ -94,18 +94,18 @@ export const generateDebtSupplierExcel = (
const colWidths = [ const colWidths = [
{ wch: 5 }, // No { wch: 5 }, // No
{ wch: 15 }, // Nomor PR { wch: 10 }, // Nomor PR
{ wch: 15 }, // Nomor PO { wch: 10 }, // Nomor PO
{ wch: 15 }, // Tanggal Terima/Bayar { wch: 20 }, // Tanggal Terima/Bayar
{ wch: 15 }, // Tanggal PO { wch: 10 }, // Tanggal PO
{ wch: 12 }, // Aging { wch: 10 }, // Aging
{ wch: 15 }, // Area { wch: 15 }, // Area
{ wch: 15 }, // Gudang { wch: 15 }, // Gudang
{ wch: 18 }, // Jatuh Tempo { wch: 12 }, // Jatuh Tempo
{ wch: 18 }, // Status Jatuh Tempo { wch: 20 }, // Status Jatuh Tempo
{ wch: 15 }, // Nominal Pembelian (Rp) { wch: 20 }, // Nominal Pembelian (Rp)
{ wch: 15 }, // Pembayaran (Rp) { wch: 15 }, // Pembayaran (Rp)
{ wch: 15 }, // Sisa Saldo Hutang (Rp) { wch: 20 }, // Sisa Saldo Hutang (Rp)
{ wch: 12 }, // Status { wch: 12 }, // Status
{ wch: 15 }, // Nomor Perjalanan { wch: 15 }, // Nomor Perjalanan
]; ];
@@ -55,6 +55,7 @@ const CustomerPaymentTab = () => {
const { const {
options: customerOptions, options: customerOptions,
setInputValue: setCustomerInputValue,
isLoadingOptions: isLoadingCustomers, isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers, loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers, hasMore: hasMoreCustomers,
@@ -62,6 +63,7 @@ const CustomerPaymentTab = () => {
const { const {
options: salesOptions, options: salesOptions,
setInputValue: setSalesInputValue,
isLoadingOptions: isLoadingSales, isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales, loadMore: loadMoreSales,
hasMore: hasMoreSales, hasMore: hasMoreSales,
@@ -654,6 +656,7 @@ const CustomerPaymentTab = () => {
Array.isArray(val) ? val : val ? [val] : [] Array.isArray(val) ? val : val ? [val] : []
); );
}} }}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers} isLoading={isLoadingCustomers}
isClearable isClearable
onMenuScrollToBottom={loadMoreCustomers} onMenuScrollToBottom={loadMoreCustomers}
@@ -670,6 +673,7 @@ const CustomerPaymentTab = () => {
onChange={(val) => { onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []); setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}} }}
onInputChange={setSalesInputValue}
isLoading={isLoadingSales} isLoading={isLoadingSales}
isClearable isClearable
onMenuScrollToBottom={loadMoreSales} onMenuScrollToBottom={loadMoreSales}
@@ -9,9 +9,9 @@ import SelectInput, {
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import Modal, { useModal } from '@/components/Modal'; 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 { 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 { SupplierApi } from '@/services/api/master-data';
import { import {
DebtRow, DebtRow,
@@ -31,8 +31,47 @@ import {
DebtSupplierFilterSchema, DebtSupplierFilterSchema,
DebtSupplierFilterType, DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter'; } from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import ButtonFilter from '@/components/helper/ButtonFilter'; 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 = () => { const DebtSupplierTab = () => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
@@ -51,10 +90,12 @@ const DebtSupplierTab = () => {
const filterModal = useModal(); const filterModal = useModal();
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } = const {
useSelect(SupplierApi.basePath, 'id', 'name', '', { setInputValue: setSupplierInputValue,
limit: 'limit', options: supplierOptions,
}); isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo( const dataTypeOptions = useMemo(
() => [ () => [
@@ -209,7 +250,17 @@ const DebtSupplierTab = () => {
return; 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.'); toast.success('PDF berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.'); toast.error('Gagal membuat PDF. Silakan coba lagi.');
@@ -224,6 +275,7 @@ const DebtSupplierTab = () => {
header: 'No', header: 'No',
enableSorting: false, enableSorting: false,
cell: (props) => props.row.index, cell: (props) => props.row.index,
footer: () => 'Total',
}, },
{ {
id: 'pr_number', id: 'pr_number',
@@ -328,7 +380,7 @@ const DebtSupplierTab = () => {
enableSorting: false, enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.due_status; const value = props.row.original.due_status;
return value || '-'; return value ? (value != '-' ? getPillBadge(value, 'due') : '-') : '-';
}, },
}, },
{ {
@@ -404,7 +456,11 @@ const DebtSupplierTab = () => {
enableSorting: false, enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.status; const value = props.row.original.status;
return value || '-'; return value
? value != '-'
? getPillBadge(value, 'payment')
: '-'
: '-';
}, },
}, },
{ {
@@ -472,9 +528,15 @@ const DebtSupplierTab = () => {
<Card <Card
key={supplierReport.supplier.id} key={supplierReport.supplier.id}
title={supplierReport.supplier.name} 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' variant='bordered'
collapsible={true} collapsible={true}
defaultCollapsed={true}
> >
<Table <Table
data={[ data={[
@@ -488,34 +550,43 @@ const DebtSupplierTab = () => {
renderFooter={supplierReport.rows.length > 0} renderFooter={supplierReport.rows.length > 0}
className={{ className={{
containerClassName: 'w-full', containerClassName: 'w-full',
tableWrapperClassName: 'overflow-x-auto mt-4', tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm', headerColumnClassName: cn(
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', TABLE_DEFAULT_STYLING.headerColumnClassName,
headerColumnClassName: 'whitespace-nowrap'
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200', ),
bodyRowClassName: bodyColumnClassName: cn(
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200', TABLE_DEFAULT_STYLING.bodyColumnClassName,
bodyColumnClassName: 'whitespace-nowrap'
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', ),
tableFooterClassName: footerRowClassName: cn(
'bg-gray-100 font-semibold border border-gray-200', TABLE_DEFAULT_STYLING.footerRowClassName,
footerRowClassName: 'border-t-2 border-gray-300', 'bg-white'
footerColumnClassName: ),
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', footerColumnClassName: cn(
TABLE_DEFAULT_STYLING.footerColumnClassName,
'whitespace-nowrap p-3'
),
paginationClassName: 'hidden', paginationClassName: 'hidden',
}} }}
renderCustomRow={(row) => { renderCustomRow={(row) => {
if (row.index == 0) { if (row.index == 0) {
return ( return (
<tr <tr
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200' className={cn(TABLE_DEFAULT_STYLING.bodyRowClassName)}
key={row.index} key={row.index}
> >
<td <td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap' className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
colSpan={12} colSpan={12}
></td> ></td>
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'> <td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
>
<div <div
className={`text-right ${row.original.balance < 0 ? 'text-red-500' : ''}`} className={`text-right ${row.original.balance < 0 ? 'text-red-500' : ''}`}
> >
@@ -523,7 +594,9 @@ const DebtSupplierTab = () => {
</div> </div>
</td> </td>
<td <td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap' className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
colSpan={2} colSpan={2}
></td> ></td>
</tr> </tr>
@@ -610,7 +683,9 @@ const DebtSupplierTab = () => {
Array.isArray(val) ? val : val ? [val] : null Array.isArray(val) ? val : val ? [val] : null
); );
}} }}
isLoading={isLoadingSuppliers} onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={ isError={
@@ -62,6 +62,7 @@ const ProductionResultContent = () => {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
options: areaOptions, options: areaOptions,
isLoadingOptions: isLoadingAreaOptions, isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name'); } = useSelect<BaseKandang>(AreaApi.basePath, 'id', 'name');
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -78,6 +79,7 @@ const ProductionResultContent = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', { } = useSelect<BaseKandang>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '', area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '',
}); });
@@ -94,6 +96,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockInputValue, setInputValue: setProjectFlockInputValue,
options: projectFlockOptions, options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions, isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<BaseKandang>( } = useSelect<BaseKandang>(
ProjectFlockApi.basePath, ProjectFlockApi.basePath,
'id', 'id',
@@ -120,6 +123,7 @@ const ProductionResultContent = () => {
setInputValue: setProjectFlockKandangInputValue, setInputValue: setProjectFlockKandangInputValue,
options: projectFlockKandangOptions, options: projectFlockKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangOptions, isLoadingOptions: isLoadingProjectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
} = useSelect<BaseKandang>( } = useSelect<BaseKandang>(
ProjectFlockKandangApi.basePath, ProjectFlockKandangApi.basePath,
'id', 'id',
@@ -235,6 +239,7 @@ const ProductionResultContent = () => {
value={selectedArea} value={selectedArea}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4', wrapper: 'col-span-12 sm:col-span-6 lg:col-span-4',
@@ -251,6 +256,7 @@ const ProductionResultContent = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
isDisabled={!selectedArea} isDisabled={!selectedArea}
className={{ className={{
@@ -270,6 +276,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlock} value={selectedProjectFlock}
onChange={projectFlockChangeHandler} onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue} onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable isClearable
isDisabled={!selectedArea || !selectedLocation} isDisabled={!selectedArea || !selectedLocation}
className={{ className={{
@@ -289,6 +296,7 @@ const ProductionResultContent = () => {
value={selectedProjectFlockKandang} value={selectedProjectFlockKandang}
onChange={projectFlockKandangChangeHandler} onChange={projectFlockKandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue} onInputChange={setProjectFlockKandangInputValue}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isClearable isClearable
isDisabled={!selectedProjectFlock} isDisabled={!selectedProjectFlock}
className={{ className={{
@@ -58,18 +58,26 @@ const HppPerKandangTab = () => {
}, },
}); });
const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( const {
AreaApi.basePath, setInputValue: setAreaInputValue,
'id', options: areaOptions,
'name', isLoadingOptions: isLoadingAreas,
'search' loadMore: loadMoreAreas,
); } = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const { options: locationOptions, isLoadingOptions: isLoadingLocations } = const {
useSelect(LocationApi.basePath, 'id', 'name', 'search'); setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } = const {
useSelect(KandangApi.basePath, 'id', 'name', 'search'); setInputValue: setKandangInputValue,
options: kandangOptions,
isLoadingOptions: isLoadingKandangs,
loadMore: loadMoreKandangs,
} = useSelect(KandangApi.basePath, 'id', 'name', 'search');
const showUnrecordedOptions: OptionType[] = [ const showUnrecordedOptions: OptionType[] = [
{ value: 'false', label: 'Sembunyikan' }, { value: 'false', label: 'Sembunyikan' },
@@ -810,6 +818,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value)) .includes(String(opt.value))
)} )}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreas} isLoading={isLoadingAreas}
isClearable isClearable
/> />
@@ -824,6 +834,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value)) .includes(String(opt.value))
)} )}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
isClearable isClearable
/> />
@@ -838,6 +850,8 @@ const HppPerKandangTab = () => {
.includes(String(opt.value)) .includes(String(opt.value))
)} )}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangs} isLoading={isLoadingKandangs}
isClearable isClearable
/> />
+16 -16
View File
@@ -5,22 +5,22 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/dashboard/': ['lti.dashboard.list'], '/dashboard/': ['lti.dashboard.list'],
// Daily Checklist // Daily Checklist
// TODO: use real daily checklist permission name '/daily-checklist/dashboard/': ['lti.daily_checklist.dashboard.list'],
// '/daily-checklist/': ['lti.daily_checklist.list'], '/daily-checklist/daily-checklist/': ['lti.daily_checklist.create'],
// '/daily-checklist/dashboard/': ['lti.daily_checklist.list'], '/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'],
// '/daily-checklist/list-daily-checklist/': ['lti.daily_checklist.list'], '/daily-checklist/list-daily-checklist/detail/': [
// '/daily-checklist/list-daily-checklist/detail/': ['lti.daily_checklist.detail'], 'lti.daily_checklist.detail',
// '/daily-checklist/reports/': ['lti.daily_checklist.reports'], ],
// '/daily-checklist/master-data/employee/': ['lti.dashboard.master_data.employee'], '/daily-checklist/reports/': ['lti.daily_checklist.reports'],
// '/daily-checklist/master-data/activity/': ['lti.dashboard.master_data.activity'], '/daily-checklist/master-data/employee/': [
'/daily-checklist/dashboard/': ['lti.dashboard.list'], 'lti.daily_checklist.master_data.employee',
'/daily-checklist/daily-checklist/': ['lti.dashboard.list'], ],
'/daily-checklist/list-daily-checklist/': ['lti.dashboard.list'], '/daily-checklist/master-data/activity/': [
'/daily-checklist/list-daily-checklist/detail/': ['lti.dashboard.list'], 'lti.daily_checklist.master_data.activity',
'/daily-checklist/reports/': ['lti.dashboard.list'], ],
'/daily-checklist/master-data/employee/': ['lti.dashboard.list'], '/daily-checklist/master-data/configuration/': [
'/daily-checklist/master-data/activity/': ['lti.dashboard.list'], 'lti.daily_checklist.master_data.configuration',
'/daily-checklist/master-data/configuration/': ['lti.dashboard.list'], ],
// Production // Production
// Production - Project Flock // Production - Project Flock
+3 -2
View File
@@ -148,10 +148,11 @@ export class ClosingApiService extends BaseApiService<Closing, null, null> {
} }
async getFinance( async getFinance(
id: number id: number,
kandangId?: number
): Promise<BaseApiResponse<ClosingFinance> | undefined> { ): Promise<BaseApiResponse<ClosingFinance> | undefined> {
try { try {
const path = `${this.basePath}/${id}/keuangan`; const path = `${this.basePath}/${id}${kandangId ? `/${kandangId}` : ''}/keuangan`;
return await httpClient<BaseApiResponse<ClosingFinance>>(path, { return await httpClient<BaseApiResponse<ClosingFinance>>(path, {
method: 'GET', method: 'GET',
}); });
+12 -9
View File
@@ -1,20 +1,20 @@
import { BaseMetadata } from '@/types/api/api-general'; import { BaseMetadata } from '@/types/api/api-general';
import { Uom } from '@/types/api/master-data/uom'; import { Uom } from '@/types/api/master-data/uom';
import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategory } from '@/types/api/master-data/product-category';
import { Supplier } from '@/types/api/master-data/supplier'; import { BaseSupplier, Supplier } from '@/types/api/master-data/supplier';
export type BaseProduct = { export type BaseProduct = {
id: number; id: number;
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
product_price: number; product_price: number;
selling_price?: number; selling_price?: number;
tax?: number; tax?: number;
expiry_period: number; expiry_period?: number;
uom: Uom; uom: Uom;
product_category: ProductCategory; product_category: ProductCategory;
suppliers: Supplier[]; suppliers: (BaseSupplier & { price: number })[];
flags: string[]; flags: string[];
}; };
@@ -23,14 +23,17 @@ export type Product = BaseMetadata & BaseProduct;
export type CreateProductPayload = { export type CreateProductPayload = {
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
uom_id: number; uom_id: number;
product_category_id: number; product_category_id: number;
product_price: number; product_price: number;
selling_price: number; selling_price?: number;
tax: number; tax?: number;
expiry_period: number; expiry_period?: number;
supplier_ids: number[]; suppliers: {
supplier_id: number;
price: number;
}[];
flags: string[]; flags: string[];
}; };