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