Merge branch 'development' of https://gitlab.com/mbugroup/lti-web-client into fix/marketing

This commit is contained in:
randy-ar
2026-01-20 10:44:22 +07:00
124 changed files with 6524 additions and 3120 deletions
+7
View File
@@ -17,6 +17,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"html-to-image": "^1.11.13",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
@@ -7380,6 +7381,12 @@
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html2canvas": { "node_modules/html2canvas": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+1
View File
@@ -20,6 +20,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"formik": "^2.4.6", "formik": "^2.4.6",
"html-to-image": "^1.11.13",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^3.0.4", "jspdf": "^3.0.4",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
-2
View File
@@ -24,8 +24,6 @@ const FinanceDetailPage = () => {
); );
} }
console.log(finance);
// if (!finance || isResponseError(finance)) { // if (!finance || isResponseError(finance)) {
// router.replace('/404'); // router.replace('/404');
// return; // return;
+5 -3
View File
@@ -1,5 +1,6 @@
import Button, { ButtonProps } from '@/components/Button'; import Button, { ButtonProps } from '@/components/Button';
import { getFilledFormikValuesCount } from '@/lib/formik-helper'; import { getFilledFormikValuesCount } from '@/lib/formik-helper';
import { cn } from '@/lib/helper';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { FormikValues } from 'formik'; import { FormikValues } from 'formik';
@@ -13,11 +14,12 @@ const ButtonFilter = ({ values, onClick, ...props }: ButtonFilterProps) => {
<Button <Button
{...props} {...props}
onClick={onClick} onClick={onClick}
className={ className={cn(
getFilledFormikValuesCount(values) > 0 getFilledFormikValuesCount(values) > 0
? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200' ? 'bg-gradient-to-t from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: '' : '',
} props.className
)}
> >
<Icon <Icon
icon='heroicons:funnel' icon='heroicons:funnel'
+1 -1
View File
@@ -18,7 +18,7 @@ const AlertErrorList = ({
if (formErrorList.length === 0) return null; if (formErrorList.length === 0) return null;
return ( return (
<Alert color='error' className='w-full flex flex-col gap-2 px-4 m-4'> <Alert color='error' className='w-full flex flex-col gap-2 px-4'>
<div className='flex justify-between items-center gap-2 w-full'> <div className='flex justify-between items-center gap-2 w-full'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon icon='material-symbols:error-outline' width={24} height={24} /> <Icon icon='material-symbols:error-outline' width={24} height={24} />
+18 -2
View File
@@ -113,7 +113,15 @@ const DateInput = ({
}; };
const handleSelectSingle = (selectedDate?: Date) => { const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return; if (!selectedDate) {
setSelected(undefined);
setDisplayValue('');
const syntheticEvent = {
target: { name, value: '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
return;
}
if (minDate && selectedDate < minDate) { if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`); setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return; return;
@@ -136,7 +144,15 @@ const DateInput = ({
}; };
const handleSelectRange = (range?: { from?: Date; to?: Date }) => { const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return; if (!range) {
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: { from: '', to: '' } },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
return;
}
setSelectedRange(range); setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : ''; const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
+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 {
@@ -3,224 +3,82 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatTitleCase } from '@/lib/helper';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { import { HppItem, ProfitLossItem } from '@/types/api/closing';
DataSummarySubTotal, import { useSearchParams } from 'next/navigation';
HppPurchaseData, import { useMemo } from 'react';
ProfitLossDataAmount,
} from '@/types/api/closing';
import useSWR from 'swr'; import useSWR from 'swr';
type HppTableRow =
| (HppPurchaseData & {
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
budgeting?: never;
realization?: never;
}
| {
type: string;
group_name: string;
group_index: number;
isGroupHeader: false;
budgeting?: { rp_per_bird: number; rp_per_kg: number; amount: number };
realization?: { rp_per_bird: number; rp_per_kg: number; amount: number };
};
type ProfitLossTableRow =
| (DataSummarySubTotal & {
type: string;
group_name: string;
group_index: number;
isGroupHeader?: boolean;
})
| {
group_name: string;
group_index: number;
isGroupHeader: true;
type?: never;
rp_per_bird?: never;
rp_per_kg?: never;
amount?: never;
};
const ClosingFinanceTable = ({ const ClosingFinanceTable = ({
projectFlockId, projectFlockId,
}: { }: {
projectFlockId: number; projectFlockId: number;
}) => { }) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: finance, isLoading } = useSWR( const { data: finance, isLoading } = useSWR(
`/closing/finance/${projectFlockId}`, `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`,
() => ClosingApi.getFinance(projectFlockId) () =>
ClosingApi.getFinance(
projectFlockId,
kandangId ? Number(kandangId) : undefined
)
); );
const staticHppRows: Array<{ const hppTableData: HppItem[] = useMemo(() => {
group_name: string; if (isResponseSuccess(finance)) {
type: string; const customItems = {
group_index: number; label: 'HPP dan Pengeluaran',
}> = [ code: 'custom_row',
{ } as HppItem;
group_name: 'HPP dan Pengeluaran', const purchases = finance.data.hpp.items.filter(
type: 'Pembelian PAKAN', (item) => item.category === 'purchase'
group_index: 0, );
}, const totalBudgeting = {
{ label: 'HPP dan Bahan Baku',
group_name: 'HPP dan Pengeluaran', code: 'custom_row',
type: 'Pembelian STARTER', } as HppItem;
group_index: 0, const overheads = finance.data.hpp.items.filter(
}, (item) => item.category === 'overhead'
{ );
group_name: 'HPP dan Pengeluaran', return [customItems, ...purchases, totalBudgeting, ...overheads];
type: 'Pembelian DOC', }
group_index: 0, return [];
}, }, [finance]);
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian PULLET',
group_index: 0,
},
{
group_name: 'HPP dan Pengeluaran',
type: 'Pembelian LAYER',
group_index: 0,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Pengeluaran Overhead',
group_index: 1,
},
{
group_name: 'HPP dan Bahan Baku',
type: 'Beban Ekspedisi',
group_index: 1,
},
];
const hppTableData: HppTableRow[] = [ const profitLossTableData: ProfitLossItem[] = useMemo(() => {
{ if (isResponseSuccess(finance)) {
group_name: 'HPP dan Pengeluaran', const incomes = finance.data.profit_loss.items.filter(
group_index: 0, (item) => item.type === 'income'
isGroupHeader: true as const, );
}, const purchases = finance.data.profit_loss.items.filter(
...staticHppRows (item) => item.type === 'purchase'
.filter((row) => row.group_index === 0) );
.map((staticRow) => { const overheads = finance.data.profit_loss.items.filter(
const apiData = isResponseSuccess(finance) (item) => item.type === 'overhead'
? finance.data.hpp_purchases.hpp );
.find((g) => g.group_name === staticRow.group_name) const grossProfit = {
?.data.find((d) => d.type === staticRow.type) label: 'LABA RUGI BRUTO',
: null; code: 'custom_row',
type: 'gross_profit',
return { rp_per_bird:
group_name: staticRow.group_name, finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0,
group_index: staticRow.group_index, rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0,
type: staticRow.type, amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0,
budgeting: apiData?.budgeting || { } as ProfitLossItem;
rp_per_bird: 0, const subtotal = {
rp_per_kg: 0, label: 'Subtotal',
amount: 0, code: 'custom_row',
}, type: 'subtotal',
realization: apiData?.realization || { rp_per_bird:
rp_per_bird: 0, finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0,
rp_per_kg: 0, rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0,
amount: 0, amount: finance.data.profit_loss.summary.sub_total.amount ?? 0,
}, } as ProfitLossItem;
isGroupHeader: false as const, return [...incomes, ...purchases, grossProfit, ...overheads, subtotal];
}; }
}), return [];
{ }, [finance]);
group_name: 'HPP dan Bahan Baku',
group_index: 1,
isGroupHeader: true as const,
},
...staticHppRows
.filter((row) => row.group_index === 1)
.map((staticRow) => {
const apiData = isResponseSuccess(finance)
? finance.data.hpp_purchases.hpp
.find((g) => g.group_name === staticRow.group_name)
?.data.find((d) => d.type === staticRow.type)
: null;
return {
group_name: staticRow.group_name,
group_index: staticRow.group_index,
type: staticRow.type,
budgeting: apiData?.budgeting || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
realization: apiData?.realization || {
rp_per_bird: 0,
rp_per_kg: 0,
amount: 0,
},
isGroupHeader: false as const,
};
}),
{
group_name: 'HPP',
group_index: 2,
isGroupHeader: true as const,
},
];
const profitLossTableData: ProfitLossTableRow[] = isResponseSuccess(finance)
? [
// Pembelian group
...finance.data.profit_loss.data.pembelian.map((item) => ({
label: 'Pembelian',
group_name: 'Pembelian',
group_index: 1,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.gross_profit.label,
group_name: 'Penjualan',
group_index: 0,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.gross_profit.label,
rp_per_bird:
finance.data.profit_loss.data.summary.gross_profit.rp_per_bird,
rp_per_kg:
finance.data.profit_loss.data.summary.gross_profit.rp_per_kg,
amount: finance.data.profit_loss.data.summary.gross_profit.amount,
},
// Penjualan group
...finance.data.profit_loss.data.penjualan.map((item) => ({
label: 'Penjualan',
group_name: 'Penjualan',
group_index: 0,
type: item.type,
rp_per_bird: item.rp_per_bird,
rp_per_kg: item.rp_per_kg,
amount: item.amount,
isGroupHeader: false as const,
})),
{
label: finance.data.profit_loss.data.summary.sub_total.label,
group_name: 'Pembelian',
group_index: 1,
isGroupHeader: true as const,
type: finance.data.profit_loss.data.summary.sub_total.label,
rp_per_bird:
finance.data.profit_loss.data.summary.sub_total.rp_per_bird,
rp_per_kg: finance.data.profit_loss.data.summary.sub_total.rp_per_kg,
amount: finance.data.profit_loss.data.summary.sub_total.amount,
},
]
: [];
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
@@ -233,35 +91,21 @@ const ClosingFinanceTable = ({
> >
<div className='grid grid-cols-2 gap-6'> <div className='grid grid-cols-2 gap-6'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div> <div>Laba Rugi Brutto</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.gross_profit
.label || '-'
)
: 'Laba Rugi Brutto'}
</div>
<div className='text-lg font-bold'> <div className='text-lg font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.gross_profit.amount finance.data.profit_loss.summary.gross_profit.amount
) )
: '-'} : '-'}
</div> </div>
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<div> <div>Laba Rugi Netto</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit.label ||
'-'
)
: 'Laba Rugi Netto'}
</div>
<div className='text-lg font-bold'> <div className='text-lg font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit.amount finance.data.profit_loss.summary.net_profit.amount
) )
: '-'} : '-'}
</div> </div>
@@ -269,11 +113,7 @@ const ClosingFinanceTable = ({
</div> </div>
</Card> </Card>
<Card <Card
title={ title='HPP Purchases'
isResponseSuccess(finance)
? finance.data.hpp_purchases.title
: 'HPP Purchases'
}
variant='bordered' variant='bordered'
collapsible collapsible
className={{ className={{
@@ -281,17 +121,18 @@ const ClosingFinanceTable = ({
}} }}
> >
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<HppTableRow> <Table<HppItem>
data={hppTableData} data={hppTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'No.', header: 'No.',
enableSorting: false, enableSorting: false,
accessorFn: (item, index) => { accessorFn: (item, index) => {
if (item.isGroupHeader) return '-'; if (item.code === 'custom_row') return '-';
const dataRowsBefore = hppTableData const dataRowsBefore = hppTableData
.slice(0, index) .slice(0, index)
.filter((row) => !row.isGroupHeader).length; .filter((row) => row.code !== 'custom_row').length;
return dataRowsBefore + 1; return dataRowsBefore + 1;
}, },
footer: (props) => { footer: (props) => {
@@ -301,7 +142,7 @@ const ClosingFinanceTable = ({
{ {
header: 'Jenis', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatTitleCase(item.type || '-'), accessorFn: (item) => formatTitleCase(item.label || '-'),
}, },
{ {
header: 'Budgeting', header: 'Budgeting',
@@ -317,7 +158,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_bird' && return props.column.id === 'budgeting_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting
?.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
@@ -333,8 +174,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_rp_per_kg' && return props.column.id === 'budgeting_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting?.rp_per_kg ||
?.rp_per_kg || 0 0
) )
: '-'; : '-';
}, },
@@ -349,8 +190,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'budgeting_amount' && return props.column.id === 'budgeting_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp?.budgeting finance.data.hpp.summary?.budgeting?.amount || 0
?.amount || 0
) )
: '-'; : '-';
}, },
@@ -371,8 +211,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_bird' && return props.column.id === 'realization_rp_per_bird' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization
?.realization?.rp_per_bird || 0 ?.rp_per_bird || 0
) )
: '-'; : '-';
}, },
@@ -387,8 +227,8 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_rp_per_kg' && return props.column.id === 'realization_rp_per_kg' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization
?.realization?.rp_per_kg || 0 ?.rp_per_kg || 0
) )
: '-'; : '-';
}, },
@@ -403,8 +243,7 @@ const ClosingFinanceTable = ({
return props.column.id === 'realization_amount' && return props.column.id === 'realization_amount' &&
isResponseSuccess(finance) isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.hpp_purchases.summary_hpp finance.data.hpp.summary?.realization?.amount || 0
?.realization?.amount || 0
) )
: '-'; : '-';
}, },
@@ -414,7 +253,7 @@ const ClosingFinanceTable = ({
]} ]}
renderCustomRow={(row) => { renderCustomRow={(row) => {
const rowData = row.original; const rowData = row.original;
if (rowData.isGroupHeader) { if (rowData.code === 'custom_row') {
return ( return (
<tr <tr
key={row.id} key={row.id}
@@ -428,7 +267,7 @@ const ClosingFinanceTable = ({
className={TABLE_DEFAULT_STYLING.bodyColumnClassName} className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
> >
<div className='font-bold'> <div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')} {formatTitleCase(rowData.label ?? '-')}
</div> </div>
</td> </td>
</tr> </tr>
@@ -441,11 +280,7 @@ const ClosingFinanceTable = ({
</div> </div>
</Card> </Card>
<Card <Card
title={ title='Profit/Loss'
isResponseSuccess(finance)
? finance.data.profit_loss.title
: 'Profit/Loss'
}
variant='bordered' variant='bordered'
collapsible collapsible
className={{ className={{
@@ -453,38 +288,32 @@ const ClosingFinanceTable = ({
}} }}
> >
<div className='mt-6 p-0 mb-0'> <div className='mt-6 p-0 mb-0'>
<Table<ProfitLossTableRow> <Table<ProfitLossItem>
data={profitLossTableData} data={profitLossTableData}
isLoading={isLoading}
columns={[ columns={[
{ {
header: 'Jenis', header: 'Jenis',
enableSorting: false, enableSorting: false,
accessorFn: (item) => item.type, accessorFn: (item) => item.label,
cell: (item) => ( cell: (item) => (
<div className=''> <div className=''>
{formatTitleCase(item.row.original.type || '-')} {formatTitleCase(item.row.original.label || '-')}
</div> </div>
), ),
footer: (item) => ( footer: () => (
<div className='font-bold uppercase'> <div className='font-bold uppercase'>LABA RUGI NETTO</div>
{isResponseSuccess(finance)
? formatTitleCase(
finance.data.profit_loss.data.summary.net_profit
.label || '-'
)
: '-'}
</div>
), ),
}, },
{ {
header: 'Rp/Ekor', header: 'Rp/Ekor',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), accessorFn: (item) => formatCurrency(item.rp_per_bird || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.rp_per_bird || 0 .rp_per_bird || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -495,11 +324,11 @@ const ClosingFinanceTable = ({
header: 'Rp/Kg', header: 'Rp/Kg',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), accessorFn: (item) => formatCurrency(item.rp_per_kg || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.rp_per_kg || 0 .rp_per_kg || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -510,11 +339,11 @@ const ClosingFinanceTable = ({
header: 'Jumlah (Rp)', header: 'Jumlah (Rp)',
enableSorting: false, enableSorting: false,
accessorFn: (item) => formatCurrency(item.amount || 0), accessorFn: (item) => formatCurrency(item.amount || 0),
footer: (item) => ( footer: () => (
<div className='font-bold'> <div className='font-bold'>
{isResponseSuccess(finance) {isResponseSuccess(finance)
? formatCurrency( ? formatCurrency(
finance.data.profit_loss.data.summary.net_profit finance.data.profit_loss.summary.net_profit
.amount || 0 .amount || 0
) )
: formatCurrency(0)} : formatCurrency(0)}
@@ -524,55 +353,30 @@ const ClosingFinanceTable = ({
]} ]}
renderCustomRow={(row) => { renderCustomRow={(row) => {
const rowData = row.original; const rowData = row.original;
if (rowData.isGroupHeader) { if (rowData.code === 'custom_row') {
if (rowData.amount) {
return (
<tr
key={row.id}
className={TABLE_DEFAULT_STYLING.footerRowClassName}
>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold ps-6 uppercase'>
{formatTitleCase(rowData.label ?? '-')}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_bird ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_kg ?? 0)}
</div>
</td>
<td
className={TABLE_DEFAULT_STYLING.bodyColumnClassName}
>
<div className='font-bold'>
{formatCurrency(rowData.amount ?? 0)}
</div>
</td>
</tr>
);
}
return ( return (
<tr <tr
key={row.id} key={row.id}
className={TABLE_DEFAULT_STYLING.bodyRowClassName} className={TABLE_DEFAULT_STYLING.footerRowClassName}
> >
<td <td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
colSpan={4} <div className='font-bold ps-6 uppercase'>
className={TABLE_DEFAULT_STYLING.bodyColumnClassName} {formatTitleCase(rowData.label ?? '-')}
> </div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'> <div className='font-bold'>
{formatTitleCase(rowData.group_name ?? '-')} {formatCurrency(rowData.rp_per_bird ?? 0)}
</div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'>
{formatCurrency(rowData.rp_per_kg ?? 0)}
</div>
</td>
<td className={TABLE_DEFAULT_STYLING.bodyColumnClassName}>
<div className='font-bold'>
{formatCurrency(rowData.amount ?? 0)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingIncomingSapronakSummary } from '@/types/api/closing';
interface ClosingIncomingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingIncomingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingIncomingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: incomingSapronakSummaries,
isLoading: isLoadingIncomingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const incomingSapronaksColumns: ColumnDef<ClosingIncomingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries.data.length > 0
: false
);
}
}, [incomingSapronakSummaries, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Ringkasan Sapronak Masuk</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<Table<ClosingIncomingSapronakSummary>
data={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.data
: []
}
columns={incomingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(incomingSapronakSummaries)
? incomingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingIncomingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(incomingSapronakSummaries) &&
incomingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingIncomingSapronaksSummaryTable;
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table'; import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingIncomingSapronaksTableProps {
const ClosingIncomingSapronaksTable = ({ const ClosingIncomingSapronaksTable = ({
projectFlockId, projectFlockId,
}: ClosingIncomingSapronaksTableProps) => { }: ClosingIncomingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -43,7 +47,7 @@ const ClosingIncomingSapronaksTable = ({
const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } = const { data: incomingSapronaks, isLoading: isLoadingIncomingSapronaks } =
useSWR( useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming`, `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=incoming&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakFetcher, ClosingApi.getAllIncomingSapronakFetcher,
{ {
keepPreviousData: true, keepPreviousData: true,
@@ -0,0 +1,174 @@
'use client';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import Card from '@/components/Card';
import Collapse from '@/components/Collapse';
import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ClosingApi } from '@/services/api/closing';
import { ClosingOutgoingSapronakSummary } from '@/types/api/closing';
interface ClosingOutgoingSapronaksSummaryTableProps {
projectFlockId: number;
}
const ClosingOutgoingSapronaksSummaryTable = ({
projectFlockId,
}: ClosingOutgoingSapronaksSummaryTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
nameSort: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
nameSort: 'sort_name',
},
});
const {
data: outgoingSapronakSummaries,
isLoading: isLoadingOutgoingSapronakSummaries,
} = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak/summary${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllIncomingSapronakSummaryFetcher,
{
keepPreviousData: true,
}
);
const [open, setOpen] = useState(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const outgoingSapronaksColumns: ColumnDef<ClosingOutgoingSapronakSummary>[] =
[
{
header: '#',
cell: (props) => props.row.index + 1,
},
{
accessorKey: 'category',
header: 'Kategori',
},
{
accessorKey: 'total_qty',
header: 'Total Kuantitas',
cell: (props) =>
`${formatNumber(props.row.original.total_qty)} ${props.row.original.uom.name}`,
},
];
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value);
};
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
useEffect(() => {
if (!open) {
setOpen(
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries.data.length > 0
: false
);
}
}, [outgoingSapronakSummaries, isResponseSuccess]);
return (
<Card
className={{
wrapper: 'w-full',
body: 'p-4 shadow',
}}
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Ringkasan Sapronak Keluar</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<div className='w-full p-0'>
<Table<ClosingOutgoingSapronakSummary>
data={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.data
: []
}
columns={outgoingSapronaksColumns}
pageSize={tableFilterState.pageSize}
onPageSizeChange={setPageSize}
rowOptions={[10, 20, 50, 100]}
page={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.page
: 0
}
totalItems={
isResponseSuccess(outgoingSapronakSummaries)
? outgoingSapronakSummaries?.meta?.total_results
: 0
}
onPageChange={setPage}
isLoading={isLoadingOutgoingSapronakSummaries}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'w-full mb-20':
isResponseSuccess(outgoingSapronakSummaries) &&
outgoingSapronakSummaries?.data?.length === 0,
}),
}}
/>
</div>
</Collapse>
</Card>
);
};
export default ClosingOutgoingSapronaksSummaryTable;
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef, SortingState } from '@tanstack/react-table'; import { ColumnDef, SortingState } from '@tanstack/react-table';
@@ -23,6 +24,9 @@ interface ClosingOutgoingSapronaksTableProps {
const ClosingOutgoingSapronaksTable = ({ const ClosingOutgoingSapronaksTable = ({
projectFlockId, projectFlockId,
}: ClosingOutgoingSapronaksTableProps) => { }: ClosingOutgoingSapronaksTableProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -43,7 +47,7 @@ const ClosingOutgoingSapronaksTable = ({
const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } = const { data: outgoingSapronaks, isLoading: isLoadingOutgoingSapronaks } =
useSWR( useSWR(
`${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing`, `${ClosingApi.basePath}/${projectFlockId}/sapronak${getTableFilterQueryString()}&type=outgoing&kandang_id=${kandangId ? `${kandangId}` : ''}`,
ClosingApi.getAllOutgoingSapronakFetcher, ClosingApi.getAllOutgoingSapronakFetcher,
{ {
keepPreviousData: true, keepPreviousData: true,
@@ -32,101 +32,160 @@ const ClosingOverheadTable = ({
); );
// Helper function to create columns with footer support // Helper function to create columns with footer support
const createColumns = (total?: OverheadTotal): ColumnDef<Overhead>[] => [ const createColumns = (
// Group untuk kolom tanpa footer total?: OverheadTotal,
{ kandangId?: number
header: 'Nama Item', ): ColumnDef<Overhead>[] => {
accessorFn: (props) => props.item_name, const flockColumn: ColumnDef<Overhead>[] = [
footer: 'Total Pengeluaran Overhead', {
}, header: 'Budget Pengajuan',
{ footer: '',
header: 'Satuan', columns: [
accessorFn: (props) => props.uom_name, {
}, id: 'budget_quantity',
{ header: 'Jumlah',
header: 'Budget Pengajuan', accessorFn: (props) =>
footer: '', props.budget_quantity ? formatNumber(props.budget_quantity) : '-',
columns: [ footer: total ? () => formatNumber(total.budget_quantity) : '',
{ },
id: 'budget_quantity', {
header: 'Jumlah', id: 'budget_unit_price',
accessorFn: (props) => header: 'Harga Satuan',
props.budget_quantity ? formatNumber(props.budget_quantity) : '-', accessorFn: (props) =>
footer: total ? () => formatNumber(total.budget_quantity) : '', props.budget_unit_price
}, ? formatCurrency(props.budget_unit_price)
{ : '-',
id: 'budget_unit_price', footer: '',
header: 'Harga Satuan', },
accessorFn: (props) => {
props.budget_unit_price id: 'budget_total_amount',
? formatCurrency(props.budget_unit_price) header: 'Total',
: '-', accessorFn: (props) =>
footer: '', props.budget_total_amount
}, ? formatCurrency(props.budget_total_amount)
{ : '-',
id: 'budget_total_amount', footer: total
header: 'Total', ? () => formatCurrency(total.budget_total_amount)
accessorFn: (props) => : '',
props.budget_total_amount },
? formatCurrency(props.budget_total_amount) ],
: '-', },
footer: total ? () => formatCurrency(total.budget_total_amount) : '', {
}, header: 'Realisasi',
], footer: '',
}, columns: [
{ {
header: 'Realisasi', id: 'actual_date',
footer: '', header: 'Tanggal',
columns: [ accessorFn: (props) =>
{ props.actual_date
id: 'actual_date', ? formatDate(props.actual_date, 'DD MMM, YYYY')
header: 'Tanggal', : '-',
accessorFn: (props) => footer: '',
props.actual_date },
? formatDate(props.actual_date, 'DD MMM, YYYY') {
: '-', id: 'actual_quantity',
footer: '', header: 'Jumlah',
}, accessorFn: (props) =>
{ props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
id: 'actual_quantity', footer: total ? () => formatNumber(total.actual_quantity) : '',
header: 'Jumlah', },
accessorFn: (props) => {
props.actual_quantity ? formatNumber(props.actual_quantity) : '-', id: 'actual_unit_price',
footer: total ? () => formatNumber(total.actual_quantity) : '', header: 'Harga Satuan',
}, accessorFn: (props) =>
{ props.actual_unit_price
id: 'actual_unit_price', ? formatCurrency(props.actual_unit_price)
header: 'Harga Satuan', : '-',
accessorFn: (props) => footer: '',
props.actual_unit_price },
? formatCurrency(props.actual_unit_price) {
: '-', id: 'actual_total_amount',
footer: '', header: 'Total',
}, accessorFn: (props) =>
{ props.actual_total_amount
id: 'actual_total_amount', ? formatCurrency(props.actual_total_amount)
header: 'Total', : '-',
accessorFn: (props) => footer: total
props.actual_total_amount ? () => formatCurrency(total.actual_total_amount)
? formatCurrency(props.actual_total_amount) : '',
: '-', },
footer: total ? () => formatCurrency(total.actual_total_amount) : '', ],
}, },
], ];
},
{ const kandangColumn: ColumnDef<Overhead>[] = [
id: 'cost_per_bird', {
header: 'Rp/Ekor', id: 'actual_date',
accessorFn: (props) => header: 'Tanggal',
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-', accessorFn: (props) =>
footer: total ? () => formatCurrency(total.cost_per_bird) : '', props.actual_date
}, ? formatDate(props.actual_date, 'DD MMM, YYYY')
]; : '-',
footer: '',
},
{
id: 'actual_quantity',
header: 'Jumlah',
accessorFn: (props) =>
props.actual_quantity ? formatNumber(props.actual_quantity) : '-',
footer: total ? () => formatNumber(total.actual_quantity) : '',
},
{
id: 'actual_unit_price',
header: 'Harga Satuan',
accessorFn: (props) =>
props.actual_unit_price
? formatCurrency(props.actual_unit_price)
: '-',
footer: '',
},
{
id: 'actual_total_amount',
header: 'Total',
accessorFn: (props) =>
props.actual_total_amount
? formatCurrency(props.actual_total_amount)
: '-',
footer: total ? () => formatCurrency(total.actual_total_amount) : '',
},
];
const finalColumns: ColumnDef<Overhead>[] = [
// Group untuk kolom tanpa footer
{
header: 'No',
accessorFn: (_, index) => index,
cell: (props) => props.row.index + 1,
},
{
header: 'Nama Item',
accessorFn: (props) => props.item_name,
footer: 'Total Pengeluaran Overhead',
},
{
header: 'Satuan',
accessorFn: (props) => props.uom_name,
},
...(kandangId ? kandangColumn : flockColumn),
{
id: 'cost_per_bird',
header: 'Rp/Ekor',
accessorFn: (props) =>
props.cost_per_bird ? formatCurrency(props.cost_per_bird) : '-',
footer: total ? () => formatCurrency(total.cost_per_bird) : '',
},
];
return finalColumns;
};
const columns = useMemo( const columns = useMemo(
() => () =>
isResponseSuccess(overhead) isResponseSuccess(overhead)
? createColumns(overhead.data?.total) ? createColumns(
overhead.data?.total,
kandangId ? Number(kandangId) : undefined
)
: createColumns(), : createColumns(),
[overhead] [overhead]
); );
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { ClosingApi } from '@/services/api/closing'; import { ClosingApi } from '@/services/api/closing';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -12,9 +13,12 @@ interface ClosingProductionDataTabContentProps {
const ClosingProductionDataTabContent = ({ const ClosingProductionDataTabContent = ({
projectFlockId, projectFlockId,
}: ClosingProductionDataTabContentProps) => { }: ClosingProductionDataTabContentProps) => {
const searchParams = useSearchParams();
const kandangId = searchParams.get('kandangId');
const { data: productionData, isLoading } = useSWR( const { data: productionData, isLoading } = useSWR(
`${ClosingApi.basePath}/${projectFlockId}/production-data`, `${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`,
() => ClosingApi.getProductionData(projectFlockId) () => ClosingApi.getProductionData(projectFlockId, Number(kandangId))
); );
if (isLoading) { if (isLoading) {
@@ -2,6 +2,8 @@
import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable';
import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable';
import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable';
import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable';
interface ClosingSapronakTableProps { interface ClosingSapronakTableProps {
projectFlockId?: number; projectFlockId?: number;
@@ -16,7 +18,15 @@ const ClosingSapronakTabContent = ({
<> <>
<ClosingIncomingSapronaksTable projectFlockId={projectFlockId} /> <ClosingIncomingSapronaksTable projectFlockId={projectFlockId} />
<ClosingIncomingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
<ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} /> <ClosingOutgoingSapronaksTable projectFlockId={projectFlockId} />
<ClosingOutgoingSapronaksSummaryTable
projectFlockId={projectFlockId}
/>
</> </>
)} )}
</div> </div>
@@ -163,6 +163,7 @@ const ClosingsTable = () => {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
@@ -228,6 +229,7 @@ const ClosingsTable = () => {
value={selectedLocation} value={selectedLocation}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-6', wrapper: 'col-span-12 sm:col-span-6',
@@ -82,12 +82,12 @@ const SalesReportTable = ({
<div className='font-semibold text-gray-900'>Total Penjualan</div> <div className='font-semibold text-gray-900'>Total Penjualan</div>
), ),
}, },
{ // {
id: 'age', // id: 'age',
accessorKey: 'age', // accessorKey: 'age',
header: 'Umur', // header: 'Umur',
cell: (props) => props.getValue() || '-', // cell: (props) => props.getValue() || '-',
}, // },
{ {
id: 'do_number', id: 'do_number',
accessorKey: 'do_number', accessorKey: 'do_number',
@@ -8,19 +8,22 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data'; import { KandangApi, LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import { import {
DashboardFilterType, DashboardFilterType,
getDashboardFilterSchema, getDashboardFilterSchema,
} from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema'; } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart'; import DashboardLineChart from '@/components/pages/dashboard/chart/DashboardLineChart';
import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton'; import DashboardLineChartSkeleton from '@/components/pages/dashboard/skeleton/DashboardLineChartSkeleton';
import DashboardAllCharts, {
DashboardAllChartsRef,
} from '@/components/pages/dashboard/chart/DashboardAllCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { import {
DashboardFilter, DashboardFilter,
@@ -30,6 +33,11 @@ import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import { useDashboardStore } from '@/stores/dashboard';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -44,11 +52,22 @@ const normalizeToArray = (
const DashboardProduction = () => { const DashboardProduction = () => {
const filterModal = useModal(); const filterModal = useModal();
// ===== DASHBOARD STORE =====
const { filterValues, setFilterValues, resetFilterValues } =
useDashboardStore();
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
'OVERVIEW' (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
); );
const [endpointUrl, setEndpointUrl] = useState('/dashboards'); const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>([]); const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location)
);
const [exporting, setExporting] = useState(false);
const statsRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
const allChartsRef = useRef<DashboardAllChartsRef>(null);
// ===== FETCH DATA ===== // ===== FETCH DATA =====
const { const {
@@ -64,22 +83,32 @@ const DashboardProduction = () => {
: undefined; : undefined;
// ===== SELECT ===== // ===== SELECT =====
const { options: flockOptions, isLoadingOptions: isLoadingFlockOptions } =
useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const { const {
setInputValue: setInputValueFlock,
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueLocation,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', '', { } = useSelect(LocationApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
const { options: kandangOptions, isLoadingOptions: isLoadingKandangOptions } = const {
useSelect(KandangApi.basePath, 'id', 'name', '', { setInputValue: setInputValueKandang,
limit: 'limit', options: kandangOptions,
location_id: selectedLocationIds ? selectedLocationIds.toString() : '', isLoadingOptions: isLoadingKandangOptions,
}); loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
limit: 'limit',
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [ const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' }, { value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' }, { value: 'FLOCK', label: 'Flock' },
@@ -89,20 +118,21 @@ const DashboardProduction = () => {
// ===== FORMIK ===== // ===== FORMIK =====
const formik = useFormik({ const formik = useFormik({
initialValues: { initialValues: {
startDate: '', startDate: filterValues.startDate || '',
endDate: '', endDate: filterValues.endDate || '',
flock: [] as OptionType[], flock: filterValues.flock || ([] as OptionType[]),
location: [] as OptionType[], location: filterValues.location || ([] as OptionType[]),
kandang: [] as OptionType[], kandang: filterValues.kandang || ([] as OptionType[]),
analysisMode: analysisMode, analysisMode: filterValues.analysisMode || analysisMode,
comparisonType: '', comparisonType: filterValues.comparisonType || '',
lokasiIds: [], locationIds: filterValues.locationIds || [],
flockIds: [], flockIds: filterValues.flockIds || [],
kandangIds: [], kandangIds: filterValues.kandangIds || [],
} as DashboardFilterType, } as DashboardFilterType,
validationSchema: getDashboardFilterSchema(analysisMode), validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => { onSubmit: (values) => {
console.log(values); // Save filter values to store
setFilterValues(values);
handleApplyFilter({ handleApplyFilter({
start_date: values.startDate || '', start_date: values.startDate || '',
@@ -118,13 +148,13 @@ const DashboardProduction = () => {
const handleResetFilter = () => { const handleResetFilter = () => {
formik.resetForm(); formik.resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW'); setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards'); setEndpointUrl('/dashboards');
setSelectedLocationIds([]);
}; };
const handleApplyFilter = (values: DashboardFilter) => { const handleApplyFilter = (values: DashboardFilter) => {
console.log(values);
// Build query params object, only include non-empty values // Build query params object, only include non-empty values
const params: Record<string, string> = {}; const params: Record<string, string> = {};
@@ -140,15 +170,37 @@ const DashboardProduction = () => {
if (values.comparison_type) params.comparison_type = values.comparison_type; if (values.comparison_type) params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`); setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
console.log(endpointUrl);
filterModal.closeModal(); filterModal.closeModal();
refreshDashboardProductionData(); refreshDashboardProductionData();
formik.resetForm();
}; };
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues]);
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
// ===== Export PDF =====
const handleExportPDF = async () => {
await generateDashboardPDF({
filterValues: formik.values,
statsRef,
allChartsRef,
setExporting,
});
};
if (isLoadingDashboardProductionData) { if (isLoadingDashboardProductionData) {
return ( return (
<div className='w-full min-h-screen flex items-center justify-center'> <div className='w-full min-h-screen flex items-center justify-center'>
@@ -156,103 +208,108 @@ const DashboardProduction = () => {
</div> </div>
); );
} }
return ( return (
<> <>
<section className='w-full p-4 space-y-6'> <section className='w-full p-4 space-y-6'>
<div className='flex flex-col sm:flex-row items-center justify-between gap-4'> <div className='flex flex-col sm:flex-row items-center justify-between gap-4'>
<div></div> <div></div>
<div className='flex flex-row justify-end gap-2'> <div className='flex flex-row justify-end gap-2'>
<Button <ButtonFilter
values={{
...formik.values,
analysisMode: undefined,
}}
variant='outline' variant='outline'
className={`min-w-28 rounded-lg ${
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'bg-gradient-to-r from-blue-50 to-blue-100 border-blue-500 text-blue-600 hover:from-blue-100 hover:to-blue-200'
: ''
}`}
onClick={() => filterModal.openModal()}
>
<Icon
icon='heroicons:funnel'
width={20}
height={20}
className={
isResponseSuccess(dashboardProductionResponse) &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters
? 'text-blue-600'
: ''
}
/>
Filter
{isResponseSuccess(dashboardProductionResponse) &&
dashboardProductionResponse.meta &&
(dashboardProductionResponse.meta as unknown as DashboardMeta)
.filters && (
<span className='w-6 h-6 text-white bg-red-500 rounded-lg flex items-center justify-center text-xs'>
{(() => {
const meta =
dashboardProductionResponse.meta as unknown as DashboardMeta;
if (!meta.filters) return 0;
const count =
(meta.filters.location_ids.length > 1
? meta.filters.location_ids.length
: 0) +
(meta.filters.flock_ids.length > 1
? meta.filters.flock_ids.length
: 0) +
(meta.filters.kandang_ids.length > 1
? meta.filters.kandang_ids.length
: 0);
return meta.filters.analysis_mode === 'OVERVIEW'
? 1
: count;
})()}
</span>
)}
</Button>
<Button
variant='outline'
color='neutral'
className='min-w-28 rounded-lg' className='min-w-28 rounded-lg'
onClick={() => filterModal.openModal()}
/>
<Dropdown
trigger={
<Button variant='outline' className='min-w-28 rounded-lg z-50'>
<Icon icon='heroicons:arrow-down-tray' />
Export
<Icon icon='heroicons:chevron-down' />
</Button>
}
className={{
content: 'w-full',
}}
> >
<Icon icon='heroicons:arrow-down-tray' width={20} height={20} /> <Menu className={exporting ? 'hidden' : ''}>
Export <MenuItem title='PDF' onClick={handleExportPDF} />
<Icon icon='heroicons:chevron-down' width={20} height={20} /> </Menu>
</Button> </Dropdown>
</div> </div>
</div> </div>
{/* Dashboard Stats */} {/* Dashboard Stats */}
<DashboardStats data={dashboardProductionData?.statistics_data ?? []} /> <div ref={statsRef}>
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
</div>
{/* Use DashboardLineChart component or skeleton */} {/* Use DashboardLineChart component or skeleton */}
{isLoadingDashboardProductionData ? ( <div ref={chartRef}>
<DashboardLineChartSkeleton /> {isLoadingDashboardProductionData ? (
) : dashboardProductionData && <DashboardLineChartSkeleton />
dashboardProductionData.charts && ) : dashboardProductionData &&
Object.keys(dashboardProductionData.charts).length > 0 ? ( dashboardProductionData.charts &&
<DashboardLineChart Object.keys(dashboardProductionData.charts).length > 0 ? (
analysisMode={ <DashboardLineChart
isResponseSuccess(dashboardProductionResponse) analysisMode={
? dashboardProductionResponse.meta isResponseSuccess(dashboardProductionResponse)
? ( ? dashboardProductionResponse.meta
dashboardProductionResponse.meta as unknown as DashboardMeta ? (
).filters?.analysis_mode dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode : analysisMode
: analysisMode }
} data={dashboardProductionData}
data={dashboardProductionData} selectedKandang={
/> analysisMode === 'OVERVIEW'
) : ( ? (formik.values.kandang as OptionType)
<DashboardLineChartSkeleton : undefined
meta={ }
isResponseSuccess(dashboardProductionResponse) />
? (dashboardProductionResponse.meta as unknown as DashboardMeta) ) : (
: undefined <DashboardLineChartSkeleton
} meta={
/> isResponseSuccess(dashboardProductionResponse)
? (dashboardProductionResponse.meta as unknown as DashboardMeta)
: undefined
}
/>
)}
</div>
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
{dashboardProductionData && (
<div
style={{
position: 'absolute',
left: '-9999px',
top: 0,
width: '1200px', // Fixed width for consistent PDF rendering
}}
>
<DashboardAllCharts
ref={allChartsRef}
data={dashboardProductionData}
analysisMode={
isResponseSuccess(dashboardProductionResponse)
? dashboardProductionResponse.meta
? (
dashboardProductionResponse.meta as unknown as DashboardMeta
).filters?.analysis_mode
: analysisMode
: analysisMode
}
/>
</div>
)} )}
</section> </section>
@@ -287,7 +344,7 @@ const DashboardProduction = () => {
{/* Rentang Waktu */} {/* Rentang Waktu */}
<div className='px-4'> <div className='px-4'>
<label className='flex items-center gap-2 mb-3'>Tanggal</label> <label className='flex items-center gap-2 mb-3'>Tanggal</label>
<div className='flex items-center gap-2'> <div className='flex items-start gap-2'>
<DateInput <DateInput
name='startDate' name='startDate'
placeholder='Tanggal Mulai' placeholder='Tanggal Mulai'
@@ -302,7 +359,7 @@ const DashboardProduction = () => {
Boolean(formik.touched.startDate) Boolean(formik.touched.startDate)
} }
/> />
<span className='hidden md:block text-center'></span> <div className='hidden md:block mt-3 text-center'></div>
<DateInput <DateInput
name='endDate' name='endDate'
placeholder='Tanggal Akhir' placeholder='Tanggal Akhir'
@@ -383,6 +440,8 @@ const DashboardProduction = () => {
<SelectInput <SelectInput
label='Farm' label='Farm'
value={formik.values.location} value={formik.values.location}
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
onChange={(selected) => { onChange={(selected) => {
formik.setFieldValue('location', selected); formik.setFieldValue('location', selected);
// Update selectedLocationIds for kandang filter // Update selectedLocationIds for kandang filter
@@ -422,6 +481,8 @@ const DashboardProduction = () => {
formik.setFieldValue('flock', selected) formik.setFieldValue('flock', selected)
} }
errorMessage={formik.errors.flock as string} errorMessage={formik.errors.flock as string}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
options={flockOptions} options={flockOptions}
isLoading={isLoadingFlockOptions} isLoading={isLoadingFlockOptions}
isMulti={ isMulti={
@@ -450,6 +511,8 @@ const DashboardProduction = () => {
formik.setFieldValue('kandang', selected) formik.setFieldValue('kandang', selected)
} }
errorMessage={formik.errors.kandang as string} errorMessage={formik.errors.kandang as string}
onInputChange={setInputValueKandang}
onMenuScrollToBottom={loadMoreKandang}
options={kandangOptions} options={kandangOptions}
isLoading={isLoadingKandangOptions} isLoading={isLoadingKandangOptions}
isMulti={ isMulti={
@@ -465,7 +528,9 @@ const DashboardProduction = () => {
</div> </div>
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <div className='w-full p-4'>
<AlertErrorList formErrorList={formErrorList} onClose={close} />
</div>
{/* Action Buttons */} {/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'> <div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
@@ -473,7 +538,6 @@ const DashboardProduction = () => {
type='reset' type='reset'
variant='soft' variant='soft'
className='ms-4 min-w-36 rounded-lg' className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilter}
> >
Reset Filter Reset Filter
</Button> </Button>
@@ -0,0 +1,343 @@
import Card from '@/components/Card';
import {
Dashboard,
DashboardOverviewCharts,
DashboardComparisonCharts,
DashboardChartsSeries,
DashboardChartsDataset,
} from '@/types/api/dashboard/dashboard';
import { Icon } from '@iconify/react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
type DashboardAllChartsProps = {
data: Dashboard;
analysisMode: string;
};
export type DashboardAllChartsRef = {
getChartRefs: () => {
key: string;
ref: HTMLDivElement | null;
label: string;
}[];
};
// Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts {
if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
}
// Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts {
if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
}
const lineColors: Record<string, string> = {
body_weight: '#10B981',
std_body_weight: '#10B981',
act_laying: '#1062B9',
std_laying: '#1062B9',
act_egg_weight: '#10B981',
std_egg_weight: '#10B981',
act_feed_intake: '#F52419',
std_feed_intake: '#F52419',
act_uniformity: '#F59E0B',
std_uniformity: '#F59E0B',
act_fcr: '#10B981',
std_fcr: '#10B981',
act_fcr_cum: '#F52419',
std_fcr_cum: '#10B981',
normal: '#10B981',
abnormal: '#F52419',
act_deplesi: '#10B981',
std_deplesi: '#10B981',
};
const defaultLineColors: string[] = [
'#10B981',
'#1062B9',
'#F52419',
'#F59E0B',
'#7F56D9',
];
// Helper function to get line color
const getLineColor = (seriesId: string | number, index: number): string => {
const predefinedColor = lineColors[seriesId];
if (predefinedColor) {
return predefinedColor;
}
return defaultLineColors[index % defaultLineColors.length];
};
// Mapping for chart type labels
const chartTypeLabels: Record<keyof DashboardOverviewCharts, string> = {
body_weight: 'Body Weight',
performance: 'Performance',
fcr: 'FCR',
quality_control: 'Quality Control',
deplesi: 'Deplesi',
};
const DashboardAllCharts = forwardRef<
DashboardAllChartsRef,
DashboardAllChartsProps
>(({ data, analysisMode }, ref) => {
// Create refs for charts - use string keys for flexibility
const chartRefs = useRef<{
[key: string]: HTMLDivElement | null;
}>({});
// Determine chart keys and labels based on analysis mode
const getChartConfig = () => {
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
const overviewKeys: (keyof DashboardOverviewCharts)[] = [
'body_weight',
'performance',
'fcr',
'quality_control',
'deplesi',
];
return overviewKeys.map((key) => ({
key,
label: chartTypeLabels[key],
chartData: (data.charts as DashboardOverviewCharts)[key],
}));
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
// For comparison mode, find which comparison type has data
const comparisonKey = data.charts.farm
? 'farm'
: data.charts.flock
? 'flock'
: 'kandang';
const comparisonLabels: Record<string, string> = {
farm: 'Farm Comparison',
flock: 'Flock Comparison',
kandang: 'Kandang Comparison',
};
return [
{
key: comparisonKey,
label: comparisonLabels[comparisonKey],
chartData: data.charts[comparisonKey],
},
];
}
return [];
};
const chartConfig = getChartConfig();
// Expose method to get all chart refs
useImperativeHandle(ref, () => ({
getChartRefs: () => {
return chartConfig
.map(({ key, label }) => ({
key,
ref: chartRefs.current[key] || null,
label,
}))
.filter((item) => item.ref !== null);
},
}));
return (
<div className='space-y-6'>
{chartConfig.map(({ key, label, chartData }) => {
if (
!chartData ||
!chartData.dataset ||
chartData.dataset.length === 0
) {
return null;
}
const seriesData: DashboardChartsSeries[] = chartData.series || [];
const dataset: DashboardChartsDataset[] = chartData.dataset || [];
return (
<div
key={key}
ref={(el: HTMLDivElement | null) => {
chartRefs.current[key] = el;
}}
>
<Card
className={{
wrapper: 'w-full rounded-lg',
}}
variant='bordered'
>
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6'>
<div className='text-lg font-semibold'>
{label}{' '}
<Icon
icon='heroicons:information-circle'
width={20}
height={20}
className='inline text-neutral-500'
/>
</div>
</div>
{/* Legend */}
<div className='flex flex-wrap gap-3 mb-6'>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
return (
<div
key={series.id}
className='flex items-center gap-2 px-3 py-2 rounded-lg border border-neutral-400 bg-neutral-50'
>
<div
className={`w-6 h-0.5 ${
isStandard ? 'border-t-2 border-dashed' : ''
}`}
style={{
backgroundColor: isStandard
? 'transparent'
: getLineColor(series.id, index),
borderColor: isStandard
? getLineColor(series.id, index)
: 'transparent',
}}
></div>
<span className='text-sm text-neutral-900 font-medium'>
{series.label}
</span>
<Icon
icon='heroicons:information-circle'
width={16}
height={16}
className='text-neutral-400'
/>
</div>
);
})}
</div>
{/* Chart */}
<ResponsiveContainer width='100%' height={350}>
<LineChart
data={dataset}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
seriesData.forEach((series) => {
const value = item[series.id];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(
Math.max(0, minValue - padding)
);
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
/>
{seriesData.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(series.id, index),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</Card>
</div>
);
})}
</div>
);
});
DashboardAllCharts.displayName = 'DashboardAllCharts';
export default DashboardAllCharts;
@@ -1,8 +1,10 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu'; import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem'; import MenuItem from '@/components/menu/MenuItem';
import { formatNumber } from '@/lib/helper';
import { import {
Dashboard, Dashboard,
DashboardOverviewCharts, DashboardOverviewCharts,
@@ -25,20 +27,29 @@ import {
type DashboardLineChartProps = { type DashboardLineChartProps = {
analysisMode: 'OVERVIEW' | 'COMPARISON'; analysisMode: 'OVERVIEW' | 'COMPARISON';
data: Dashboard; data: Dashboard;
selectedKandang?: OptionType;
}; };
// Type guard to check if charts is DashboardOverviewCharts // Type guard to check if charts is DashboardOverviewCharts
function isOverviewCharts( function isOverviewCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardOverviewCharts { ): charts is DashboardOverviewCharts {
return 'deplesi' in charts; if (!charts) return false;
return (
'deplesi' in charts ||
'body_weight' in charts ||
'fcr' in charts ||
'performance' in charts ||
'quality_control' in charts
);
} }
// Type guard to check if charts is DashboardComparisonCharts // Type guard to check if charts is DashboardComparisonCharts
function isComparisonCharts( function isComparisonCharts(
charts: DashboardOverviewCharts | DashboardComparisonCharts charts: DashboardOverviewCharts | DashboardComparisonCharts | undefined
): charts is DashboardComparisonCharts { ): charts is DashboardComparisonCharts {
return 'location' in charts || 'flock' in charts || 'kandang' in charts; if (!charts) return false;
return 'farm' in charts || 'flock' in charts || 'kandang' in charts;
} }
const lineColors: Record<string, string> = { const lineColors: Record<string, string> = {
@@ -94,6 +105,7 @@ const getLineColor = (
const DashboardLineChart = ({ const DashboardLineChart = ({
analysisMode, analysisMode,
data, data,
selectedKandang,
}: DashboardLineChartProps) => { }: DashboardLineChartProps) => {
const [chartData, setChartData] = const [chartData, setChartData] =
useState<keyof DashboardOverviewCharts>('body_weight'); useState<keyof DashboardOverviewCharts>('body_weight');
@@ -123,7 +135,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts) isComparisonCharts(data.charts)
) { ) {
const comparisonChart = const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang; data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || []; seriesData = comparisonChart?.series || [];
} }
@@ -224,7 +236,7 @@ const DashboardLineChart = ({
isComparisonCharts(data.charts) isComparisonCharts(data.charts)
) { ) {
const comparisonChart = const comparisonChart =
data.charts.location || data.charts.flock || data.charts.kandang; data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || []; seriesData = comparisonChart?.series || [];
} }
@@ -283,261 +295,382 @@ const DashboardLineChart = ({
})()} })()}
</div> </div>
{/* Chart */} {/* Chart Container with Empty State Overlay */}
<ResponsiveContainer width='100%' height={350}> <div className='relative'>
<LineChart {/* Chart */}
data={(() => { <ResponsiveContainer width='100%' height={350}>
// Transform data based on analysisMode <LineChart
if (analysisMode === 'OVERVIEW') { data={(() => {
// For OVERVIEW mode, use the selected chart data // Transform data based on analysisMode
if (isOverviewCharts(data.charts)) { if (analysisMode === 'OVERVIEW') {
const selectedChartData = data.charts[chartData]; // For OVERVIEW mode, use the selected chart data
if (!selectedChartData || !selectedChartData.dataset) return []; if (isOverviewCharts(data.charts)) {
return selectedChartData.dataset; const selectedChartData = data.charts[chartData];
if (!selectedChartData || !selectedChartData.dataset)
return [];
return selectedChartData.dataset;
}
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
} }
return [];
} else {
// For COMPARISON mode, use the first available comparison chart
if (isComparisonCharts(data.charts)) {
const chartData =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
if (!chartData || !chartData.dataset) return [];
return chartData.dataset;
}
return [];
}
})()}
margin={{
top: 5,
right: 10,
left: 0,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
<XAxis
dataKey='week'
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
// Get all values from visible series
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()} })()}
ticks={(() => { margin={{
// Calculate dynamic ticks based on domain top: 5,
let seriesData: DashboardChartsSeries[] = []; right: 10,
let dataset: DashboardChartsDataset[] = []; left: 0,
bottom: 5,
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}} }}
labelStyle={{ color: 'white', marginBottom: '4px' }} >
itemStyle={{ color: 'white', fontSize: '12px' }} <CartesianGrid strokeDasharray='3 3' stroke='#e5e7eb' />
labelFormatter={(value) => `Week ${value}`} <XAxis
formatter={( dataKey='week'
value: number | undefined, tick={{ fontSize: 11, fill: '#9ca3af' }}
name: string | undefined tickLine={false}
) => { axisLine={{ stroke: '#e5e7eb' }}
if (value === undefined || name === undefined) return ['', '']; label={{
value: 'Weeks',
position: 'insideBottom',
offset: -5,
style: { fontSize: 12, fill: '#9ca3af' },
}}
/>
<YAxis
tick={{ fontSize: 11, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
domain={(() => {
// Calculate dynamic domain based on visible data
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
// Get series data to find the unit if (
let seriesData: DashboardChartsSeries[] = []; analysisMode === 'OVERVIEW' &&
if ( isOverviewCharts(data.charts)
analysisMode === 'OVERVIEW' && ) {
isOverviewCharts(data.charts) seriesData = data.charts[chartData]?.series || [];
) { dataset = data.charts[chartData]?.dataset || [];
seriesData = data.charts[chartData]?.series || []; } else if (
} else if ( analysisMode === 'COMPARISON' &&
analysisMode === 'COMPARISON' && isComparisonCharts(data.charts)
isComparisonCharts(data.charts) ) {
) { const comparisonChart =
const comparisonChart = data.charts.farm ||
data.charts.location || data.charts.flock ||
data.charts.flock || data.charts.kandang;
data.charts.kandang; seriesData = comparisonChart?.series || [];
seriesData = comparisonChart?.series || []; dataset = comparisonChart?.dataset || [];
} }
// Find the series that matches this line's name // Get all values from visible series
const series = seriesData.find((s) => s.label === name); const visibleSeriesIds = Array.from(visibleSeries);
const unit = series?.unit || ''; const allValues: number[] = [];
return [`${value} ${unit}`, name]; dataset.forEach((item: DashboardChartsDataset) => {
}} visibleSeriesIds.forEach((seriesId) => {
/> const value = item[seriesId];
{/* Dynamic Line rendering based on visible series */} if (typeof value === 'number') {
{(() => { allValues.push(value);
let seriesData: DashboardChartsSeries[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.location ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
} }
activeDot={isStandard ? undefined : { r: 5 }} });
/> });
if (allValues.length === 0) return [0, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
// Add padding (10% on each side)
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
return [domainMin, domainMax];
})()}
ticks={(() => {
// Calculate dynamic ticks based on domain
let seriesData: DashboardChartsSeries[] = [];
let dataset: DashboardChartsDataset[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
dataset = comparisonChart?.dataset || [];
}
const visibleSeriesIds = Array.from(visibleSeries);
const allValues: number[] = [];
dataset.forEach((item: DashboardChartsDataset) => {
visibleSeriesIds.forEach((seriesId) => {
const value = item[seriesId];
if (typeof value === 'number') {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [0, 25, 50, 75, 100];
const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const padding = (maxValue - minValue) * 0.1;
const domainMin = Math.floor(Math.max(0, minValue - padding));
const domainMax = Math.ceil(maxValue + padding);
// Generate 5 evenly spaced ticks
const range = domainMax - domainMin;
const step = range / 4;
return [
domainMin,
Math.round(domainMin + step),
Math.round(domainMin + step * 2),
Math.round(domainMin + step * 3),
domainMax,
];
})()}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
color: 'white',
}}
labelStyle={{ color: 'white', marginBottom: '4px' }}
itemStyle={{ color: 'white', fontSize: '12px' }}
labelFormatter={(value) => `Week ${value}`}
content={(props) => {
return (
<div className='flex flex-col gap-2 rounded-lg bg-neutral-950 p-4 text-white'>
<p className='text-neutral-300 text-xs font-semibold text-start'>
{analysisMode === 'OVERVIEW'
? selectedKandang
? selectedKandang.label || 'Overview Performance'
: 'Overview Performance'
: 'Comparison Performance'}
</p>
<ul className='flex flex-col gap-1'>
{props.payload.map((item, index) => {
if (item.name.startsWith('STD. ')) return null;
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find(
(s) => s.label === item.name
);
const color = series?.id
? getLineColor(series.id, index, analysisMode)
: '#9ca3af';
const unit = series?.unit;
return (
<li
key={item.name}
className='flex w-full justify-between items-center flex-row gap-6 p-0'
>
<span className='flex flex-row gap-1 items-center'>
<div
className='h-4 w-4 m-0 rounded-md'
style={{
backgroundColor: color,
}}
></div>
<div className='m-0'>
{formatNumber(item.value)}
{unit}
</div>
</span>
<span className='m-0'>{item.name}</span>
</li>
);
})}
</ul>
<p className='text-neutral-300 text-xs text-start'>
Week {props.label}
</p>
</div>
); );
}); }}
})()} formatter={(
</LineChart> value: number | undefined,
</ResponsiveContainer> name: string | undefined
) => {
if (
value === undefined ||
name === undefined ||
name.startsWith('STD. ')
)
return [undefined, undefined];
// Get series data to find the unit
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm ||
data.charts.flock ||
data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
// Find the series that matches this line's name
const series = seriesData.find((s) => s.label === name);
const id = series?.id || '';
return [value, id];
}}
/>
{/* Dynamic Line rendering based on visible series */}
{(() => {
let seriesData: DashboardChartsSeries[] = [];
if (
analysisMode === 'OVERVIEW' &&
isOverviewCharts(data.charts)
) {
seriesData = data.charts[chartData]?.series || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
seriesData = comparisonChart?.series || [];
}
return seriesData
.filter((series) => visibleSeries.has(series.id))
.map((series, index) => {
const isStandard = series.id
.toString()
.toLowerCase()
.includes('std');
// Use series.id directly as dataKey to match dataset fields
const dataKey = series.id.toString();
return (
<Line
key={series.id}
type='monotone'
dataKey={dataKey}
name={series.label}
stroke={getLineColor(series.id, index, analysisMode)}
opacity={isStandard ? 0.5 : 1}
strokeWidth={2}
strokeDasharray={isStandard ? '5 5' : undefined}
dot={
isStandard
? false
: {
r: 3,
fill: '#fff',
stroke: getLineColor(
series.id,
index,
analysisMode
),
strokeWidth: 2,
}
}
activeDot={isStandard ? undefined : { r: 5 }}
/>
);
});
})()}
</LineChart>
</ResponsiveContainer>
{/* Empty State Overlay */}
{(() => {
// Get current dataset
let dataset: DashboardChartsDataset[] = [];
if (analysisMode === 'OVERVIEW' && isOverviewCharts(data.charts)) {
dataset = data.charts[chartData]?.dataset || [];
} else if (
analysisMode === 'COMPARISON' &&
isComparisonCharts(data.charts)
) {
const comparisonChart =
data.charts.farm || data.charts.flock || data.charts.kandang;
dataset = comparisonChart?.dataset || [];
}
// Show empty state if dataset is empty
if (dataset.length === 0) {
return (
<div className='absolute inset-x-0 inset-y-15 z-10 flex flex-col items-center justify-center rounded-lg'>
{/* Chart icon */}
<div className='w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center mb-4'>
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={24}
height={24}
/>
</div>
{/* Empty state text */}
<h3 className='text-gray-900 font-semibold text-base mb-2'>
Data Not Yet Available
</h3>
<p className='text-gray-500 text-sm text-center max-w-xs'>
Please change your filters to get the data.
</p>
</div>
);
}
return null;
})()}
</div>
</Card> </Card>
); );
}; };
@@ -0,0 +1,262 @@
import jsPDF from 'jspdf';
import { toPng } from 'html-to-image';
import toast from 'react-hot-toast';
import { formatDate } from '@/lib/helper';
import { DashboardFilterType } from '@/components/pages/dashboard/filter/DashboardProductionFilter.schema';
import { DashboardAllChartsRef } from '@/components/pages/dashboard/chart/DashboardAllCharts';
interface DashboardPDFExportParams {
filterValues: DashboardFilterType;
statsRef: React.RefObject<HTMLDivElement | null>;
allChartsRef: React.RefObject<DashboardAllChartsRef | null>;
setExporting: (value: boolean) => void;
}
export const generateDashboardPDF = async ({
filterValues,
statsRef,
allChartsRef,
setExporting,
}: DashboardPDFExportParams): Promise<void> => {
try {
setExporting(true);
toast.loading('Generating PDF...', { id: 'export-pdf' });
// Wait for DOM to update
await new Promise((resolve) => setTimeout(resolve, 200));
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
let yPosition = margin;
// Add title
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text('Dashboard Produksi', margin, yPosition);
yPosition += 10;
// Add filter information (horizontal layout)
pdf.setFontSize(6);
pdf.setFont('helvetica', 'normal');
const filterItems: string[] = [];
// Period
if (filterValues.startDate || filterValues.endDate) {
const periodText = `Periode: ${
filterValues.startDate
? formatDate(filterValues.startDate, 'DD MMM YYYY')
: '-'
} s.d ${
filterValues.endDate
? formatDate(filterValues.endDate, 'DD MMM YYYY')
: '-'
}`;
filterItems.push(periodText);
}
// Analysis Mode
const analysisModeText = `Analysis Mode: ${
filterValues.analysisMode === 'OVERVIEW'
? 'Performance Overview'
: 'Performance Comparison'
}`;
filterItems.push(analysisModeText);
// Comparison Type (only for COMPARISON mode)
if (
filterValues.analysisMode === 'COMPARISON' &&
filterValues.comparisonType
) {
const comparisonTypeLabel =
filterValues.comparisonType === 'FARM'
? 'Farm'
: filterValues.comparisonType === 'FLOCK'
? 'Flock'
: filterValues.comparisonType === 'KANDANG'
? 'Kandang'
: filterValues.comparisonType;
filterItems.push(`Compared By: ${comparisonTypeLabel}`);
}
// Farm
if (filterValues.location) {
const locationText = Array.isArray(filterValues.location)
? filterValues.location.map((loc) => loc.label).join(', ')
: filterValues.location.label;
filterItems.push(`Farm: ${locationText || '-'}`);
}
// Flock
if (
filterValues.flock &&
(Array.isArray(filterValues.flock)
? filterValues.flock.length > 0
: filterValues.flock)
) {
const flockText = Array.isArray(filterValues.flock)
? filterValues.flock.map((f) => f.label).join(', ')
: filterValues.flock.label;
filterItems.push(`Flock: ${flockText || '-'}`);
}
// Kandang
if (
filterValues.kandang &&
(Array.isArray(filterValues.kandang)
? filterValues.kandang.length > 0
: filterValues.kandang)
) {
const kandangText = Array.isArray(filterValues.kandang)
? filterValues.kandang.map((k) => k.label).join(', ')
: filterValues.kandang.label;
filterItems.push(`Kandang: ${kandangText || '-'}`);
}
// Generated timestamp
filterItems.push(`Dicetak: ${formatDate(new Date(), 'DD MMM YYYY HH:mm')}`);
// Render filter items horizontally with word wrap and gray background
const maxWidth = pageWidth - 2 * margin;
let currentLine = '';
const lines: string[] = [];
// First pass: calculate all lines
filterItems.forEach((item, index) => {
const separator = index > 0 ? ' | ' : '';
const testLine = currentLine + separator + item;
const testWidth = pdf.getTextWidth(testLine);
if (testWidth > maxWidth && currentLine !== '') {
lines.push(currentLine);
currentLine = item;
} else {
currentLine = testLine;
}
});
// Add last line
if (currentLine) {
lines.push(currentLine);
}
// Calculate background dimensions
const lineHeight = 3;
const padding = 1;
const backgroundHeight = lines.length * lineHeight + padding * 2;
// Draw gray background
pdf.setFillColor(240, 240, 240); // Light gray (RGB: 240, 240, 240)
pdf.rect(
margin - padding,
yPosition - padding - 2,
pageWidth - 2 * margin + padding * 2,
backgroundHeight,
'F'
);
// Render text on top of background
lines.forEach((line, index) => {
pdf.text(line, margin, yPosition);
if (index < lines.length - 1) {
yPosition += lineHeight;
}
});
yPosition += 10;
// Capture and add stats if available
if (statsRef.current) {
const statsImage = await toPng(statsRef.current, {
quality: 1,
pixelRatio: 2,
});
const statsImgProps = pdf.getImageProperties(statsImage);
const statsWidth = pageWidth - 2 * margin;
const statsHeight =
(statsImgProps.height * statsWidth) / statsImgProps.width;
// Check if we need a new page
if (yPosition + statsHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
pdf.addImage(
statsImage,
'PNG',
margin,
yPosition,
statsWidth,
statsHeight
);
yPosition += statsHeight + 10;
}
if (allChartsRef.current) {
// Get all individual chart refs
const chartRefs = allChartsRef.current.getChartRefs();
// Capture each chart separately and add to PDF
for (let i = 0; i < chartRefs.length; i++) {
const { ref: chartElement, label } = chartRefs[i];
if (chartElement) {
// Add chart title
pdf.setFontSize(12);
pdf.setFont('helvetica', 'bold');
const chartImage = await toPng(chartElement, {
quality: 1,
pixelRatio: 2,
});
const chartImgProps = pdf.getImageProperties(chartImage);
const chartWidth = pageWidth - 2 * margin;
const chartHeight =
(chartImgProps.height * chartWidth) / chartImgProps.width;
// Calculate total height needed (title + spacing + chart)
const titleHeight = 10;
const totalHeight = titleHeight + chartHeight;
// Check if chart fits on current page
if (yPosition + totalHeight > pageHeight - margin) {
pdf.addPage();
yPosition = margin;
}
// Add title
pdf.text(label, margin, yPosition);
yPosition += titleHeight;
// Add chart image
pdf.addImage(
chartImage,
'PNG',
margin,
yPosition,
chartWidth,
chartHeight
);
// Update yPosition for next chart (add spacing between charts)
yPosition += chartHeight + 10;
}
}
}
// Save the PDF
const fileName = `dashboard-production-${new Date().toISOString().split('T')[0]}.pdf`;
pdf.save(fileName);
toast.success('PDF exported successfully!', { id: 'export-pdf' });
} catch (error) {
toast.error('Failed to export PDF. Please try again.', {
id: 'export-pdf',
});
} finally {
setExporting(false);
}
};
@@ -7,7 +7,7 @@ export type DashboardFilterType = {
analysisMode: string; analysisMode: string;
comparisonType: string | undefined; comparisonType: string | undefined;
location: OptionType | OptionType[]; location: OptionType | OptionType[];
lokasiIds: number[] | undefined; locationIds: number[] | undefined;
flock: OptionType | OptionType[] | undefined; flock: OptionType | OptionType[] | undefined;
flockIds: number[] | undefined; flockIds: number[] | undefined;
kandang: OptionType | OptionType[] | undefined; kandang: OptionType | OptionType[] | undefined;
@@ -25,7 +25,7 @@ export const DashboardFilterOverviewSchema: yup.ObjectSchema<DashboardFilterType
then: (schema) => schema.required('Compared by is required'), then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(), otherwise: (schema) => schema.optional(),
}), }),
lokasiIds: yup.array().optional(), locationIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup location: yup
@@ -68,7 +68,7 @@ export const DashboardFilterComparisonSchema: yup.ObjectSchema<DashboardFilterTy
then: (schema) => schema.required('Compared by is required'), then: (schema) => schema.required('Compared by is required'),
otherwise: (schema) => schema.optional(), otherwise: (schema) => schema.optional(),
}), }),
lokasiIds: yup.array().optional(), locationIds: yup.array().optional(),
flockIds: yup.array().optional(), flockIds: yup.array().optional(),
kandangIds: yup.array().optional(), kandangIds: yup.array().optional(),
location: yup location: yup
@@ -43,7 +43,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
return ( return (
<> <>
<section className='w-full max-w-7xl pb-16'> <section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -65,7 +65,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
tabs={expenseDetailTabs} tabs={expenseDetailTabs}
variant='lifted' variant='lifted'
className={{ className={{
wrapper: 'max-w-5xl mx-auto mt-4', wrapper: 'mx-auto mt-4',
}} }}
/> />
</section> </section>
@@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({
return ( return (
<div> <div>
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
<RequirePermission permissions='lti.expense.update.realization'> <RequirePermission permissions='lti.expense.update.realization'>
<Button <Button
@@ -84,7 +84,7 @@ const ExpenseRealizationContent = ({
</div> </div>
</div> </div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'> <div className='overflow-x-auto w-full mx-auto'>
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<tbody> <tbody>
<tr> <tr>
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
</table> </table>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto'>
<div className='flex flex-row gap-4'> <div className='flex flex-row gap-4'>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}> <Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'> <div className='w-full flex flex-col gap-2'>
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
</div> </div>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto grid grid-cols-2 gap-4'>
<h2 className='font-bold text-xl text-center'> <div>
Rincian Pengajuan Biaya Operasional <h2 className='font-bold text-xl text-center'>
</h2> Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'> <div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { {initialValues?.kandangs.map(
let expenseGrandTotal = 0; (kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
<div <div
key={kandangExpenseIdx} key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto' className='overflow-x-auto w-full mx-auto'
> >
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<thead> <thead>
<tr> <tr>
<th <th
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
Biaya {kandangExpense.name} Biaya {kandangExpense.name}
</th> </th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.pengajuans?.map(
(pengajuanItem, pengajuanIdx) => (
<tr key={pengajuanIdx}>
<td>{pengajuanItem.nonstock.name}</td>
<td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'>{pengajuanItem.note ?? '-'}</td>
</tr> </tr>
) <tr>
)} <th>Nonstock</th>
</tbody> <th>Total Kuantitas</th>
<tfoot> <th>Total Biaya</th>
<tr className='border-y'> <th>Catatan</th>
<th colSpan={2} className='text-right'> </tr>
Total Biaya Keseluruhan: </thead>
</th> <tbody>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th> {kandangExpense.pengajuans?.map(
</tr> (pengajuanItem, pengajuanIdx) => (
</tfoot> <tr key={pengajuanIdx}>
</table> <td>{pengajuanItem.nonstock.name}</td>
</div> <td>{pengajuanItem.qty}</td>
); <td>{formatCurrency(pengajuanItem.price)}</td>
})} <td className='w-xs'>
{pengajuanItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div> </div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div>
<h2 className='font-bold text-xl text-center'> <h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional Rincian Realisasi Biaya Operasional
</h2> </h2>
<div className='w-full mt-2 flex flex-col gap-4'> <div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => { {initialValues?.kandangs.map(
let expenseGrandTotal = 0; (kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
<div <div
key={kandangExpenseIdx} key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto' className='overflow-x-auto w-full mx-auto'
> >
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<thead> <thead>
<tr> <tr>
<th <th
colSpan={5} colSpan={5}
className='font-bold text-center text-base-content text-lg' className='font-bold text-center text-base-content text-lg'
> >
Biaya {kandangExpense.name} Biaya {kandangExpense.name}
</th> </th>
</tr>
<tr>
<th>Nonstock</th>
<th>Total Kuantitas</th>
<th>Total Biaya</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
{kandangExpense.realisasi?.map(
(realisasiItem, realisasiIdx) => (
<tr key={realisasiIdx}>
<td>{realisasiItem.nonstock.name}</td>
<td>{realisasiItem.qty}</td>
<td>{formatCurrency(realisasiItem.price)}</td>
<td className='w-xs'>{realisasiItem.note ?? '-'}</td>
</tr> </tr>
) <tr>
)} <th>Nonstock</th>
</tbody> <th>Total Kuantitas</th>
<tfoot> <th>Total Biaya</th>
<tr className='border-y'> <th>Catatan</th>
<th colSpan={2} className='text-right'> </tr>
Total Biaya Keseluruhan: </thead>
</th> <tbody>
<th colSpan={2}>{formatCurrency(expenseGrandTotal)}</th> {kandangExpense.realisasi?.map(
</tr> (realisasiItem, realisasiIdx) => (
</tfoot> <tr key={realisasiIdx}>
</table> <td>{realisasiItem.nonstock.name}</td>
</div> <td>{realisasiItem.qty}</td>
); <td>{formatCurrency(realisasiItem.price)}</td>
})} <td className='w-xs'>
{realisasiItem.notes ?? '-'}
</td>
</tr>
)
)}
</tbody>
<tfoot>
<tr className='border-y'>
<th colSpan={2} className='text-right'>
Total Biaya Keseluruhan:
</th>
<th colSpan={2}>
{formatCurrency(expenseGrandTotal)}
</th>
</tr>
</tfoot>
</table>
</div>
);
}
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -273,7 +273,7 @@ const ExpenseRequestContent = ({
<> <>
<div> <div>
{initialValues && !isLoadingApprovalHistory && approvalHistory && ( {initialValues && !isLoadingApprovalHistory && approvalHistory && (
<div className='w-full max-w-5xl my-4 mx-auto'> <div className='w-full my-4 mx-auto'>
<ApprovalSteps approvals={approvalHistory} /> <ApprovalSteps approvals={approvalHistory} />
</div> </div>
)} )}
@@ -281,7 +281,7 @@ const ExpenseRequestContent = ({
<div className='w-full mt-4 flex flex-col gap-4'> <div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */} {/* TODO: apply RBAC */}
<div className='w-full max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && ( {isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.head_area'>
<Button <Button
@@ -414,7 +414,7 @@ const ExpenseRequestContent = ({
</div> </div>
</div> </div>
<div className='overflow-x-auto w-full max-w-5xl mx-auto'> <div className='overflow-x-auto w-full mx-auto'>
<table className='table table-sm table-zebra'> <table className='table table-sm table-zebra'>
<tbody> <tbody>
<tr> <tr>
@@ -608,7 +608,7 @@ const ExpenseRequestContent = ({
</table> </table>
</div> </div>
</div> </div>
<div className='w-full max-w-5xl mt-8 mx-auto'> <div className='w-full mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'> <h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional Rincian Pengajuan Biaya Operasional
</h2> </h2>
@@ -654,7 +654,7 @@ const ExpenseRequestContent = ({
<td>{pengajuanItem.qty}</td> <td>{pengajuanItem.qty}</td>
<td>{formatCurrency(pengajuanItem.price)}</td> <td>{formatCurrency(pengajuanItem.price)}</td>
<td className='w-xs'> <td className='w-xs'>
{pengajuanItem.note ?? '-'} {pengajuanItem.notes ?? '-'}
</td> </td>
</tr> </tr>
) )
+20 -28
View File
@@ -54,17 +54,19 @@ const RowOptionsMenu = ({
rejectClickHandler: () => void; rejectClickHandler: () => void;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = const showEditButton = props.row.original.latest_approval
props.row.original.latest_approval.step_number !== 6 && ? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 || (props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 || props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 || props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4); props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC // TODO: apply RBAC
const showRealizationButton = const showRealizationButton = props.row.original.latest_approval
props.row.original.latest_approval.action !== 'REJECTED' && ? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4; props.row.original.latest_approval.step_number === 4
: false;
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
@@ -278,6 +280,7 @@ const ExpensesTable = () => {
cell: ({ row }) => { cell: ({ row }) => {
const isCheckboxDisabled = const isCheckboxDisabled =
!row.getCanSelect() || !row.getCanSelect() ||
!row.original.latest_approval ||
row.original.latest_approval.action === 'REJECTED'; row.original.latest_approval.action === 'REJECTED';
return ( return (
@@ -413,6 +416,8 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = ( const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row row
) => { ) => {
if (!row.original.latest_approval) return false;
return ( return (
row.original.latest_approval.action !== 'REJECTED' && row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6 row.original.latest_approval.step_number !== 6
@@ -692,14 +697,6 @@ const ExpensesTable = () => {
</> </>
)} )}
</div> </div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div> </div>
<div className='grid grid-cols-12 justify-end gap-2'> <div className='grid grid-cols-12 justify-end gap-2'>
@@ -753,17 +750,12 @@ const ExpensesTable = () => {
}} }}
/> />
<SelectInput <DebouncedTextInput
label='Baris' name='search'
options={ROWS_OPTIONS} placeholder='Cari Biaya Operasional'
value={{ value={tableFilterState.search}
label: String(tableFilterState.pageSize), onChange={searchChangeHandler}
value: tableFilterState.pageSize, className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
/> />
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper';
interface ExpenseKandangsTableProps { interface ExpenseKandangsTableProps {
locationId?: number; locationId?: number;
type: 'add' | 'edit' | 'detail'; type: 'add' | 'edit' | 'detail';
formType?: 'request' | 'realization';
selectedKandangs: { selectedKandangs: {
id?: number; id?: number;
name?: string; name?: string;
@@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps {
const ExpenseKandangsTable = ({ const ExpenseKandangsTable = ({
type, type,
formType = 'request',
locationId, locationId,
selectedKandangs, selectedKandangs,
onChange, onChange,
@@ -172,69 +174,84 @@ const ExpenseKandangsTable = ({
updateSortingFilter('picSort', picSortFilter); updateSortingFilter('picSort', picSortFilter);
}, [sorting, updateSortingFilter]); }, [sorting, updateSortingFilter]);
return ( // Tampilkan tabel jika:
<Card // 1. Mode request pertama kali (type='add' dan formType='request')
className={{ // 2. Atau sudah ada kandang yang dipilih
wrapper: className?.wrapper, const shouldShowTable =
body: 'p-4 shadow', (type === 'add' && formType === 'request') ||
}} (selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id));
>
<Collapse
open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>Pilih Kandang</div>
<Icon return (
icon='material-symbols:keyboard-arrow-down' <>
width={24} {shouldShowTable && (
height={24} <Card
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{ className={{
containerClassName: cn({ wrapper: className?.wrapper,
'mb-20': body: 'p-4 shadow',
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
}} }}
/> >
</Collapse> <Collapse
</Card> open={open}
onOpenChange={setOpen}
title={
<div className='card-actions p-4 justify-between items-center w-full'>
<div className='card-title'>
{formType === 'realization'
? 'Kandang yang Direalisasikan'
: 'Pilih Kandang'}
</div>
<Icon
icon='material-symbols:keyboard-arrow-down'
width={24}
height={24}
className={cn('text-primary transition-transform', {
'-rotate-180': open,
})}
/>
</div>
}
className='w-full!'
titleClassName='w-full p-0!'
>
<Table<Kandang>
data={isResponseSuccess(kandangs) ? kandangs?.data : []}
columns={kandangsColumns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(kandangs) ? kandangs?.meta?.page : 0}
totalItems={
isResponseSuccess(kandangs) ? kandangs?.meta?.total_results : 0
}
onPageChange={setPage}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(kandangs) && kandangs?.data?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 first:flex first:flex-row first:justify-start',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 first:flex first:flex-row first:justify-start',
paginationClassName: cn({
hidden:
isResponseSuccess(kandangs) &&
kandangs?.meta?.total_pages === 1,
}),
}}
/>
</Collapse>
</Card>
)}
</>
); );
}; };
@@ -130,7 +130,7 @@ export const getExpenseRealizationFormInitialValues = (
? formatDate(initialValues?.realization_date, 'YYYY-MM-DD') ? formatDate(initialValues?.realization_date, 'YYYY-MM-DD')
: undefined, : undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({ kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id, id: kandang.id,
name: kandang.name, name: kandang.name,
})), })),
supplier: initialValues?.supplier supplier: initialValues?.supplier
@@ -159,7 +159,7 @@ export const getExpenseRealizationFormInitialValues = (
}, },
quantity: realisasiItem.qty, quantity: realisasiItem.qty,
price: realisasiItem.price, price: realisasiItem.price,
notes: realisasiItem.note, notes: realisasiItem.notes,
}; };
}) })
: kandangExpense.pengajuans : kandangExpense.pengajuans
@@ -170,7 +170,7 @@ export const getExpenseRealizationFormInitialValues = (
}, },
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.notes,
})) }))
: []; : [];
@@ -249,7 +249,7 @@ const ExpenseRealizationForm = ({
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]); }, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
return ( return (
<section className='w-full max-w-5xl'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -297,6 +297,7 @@ const ExpenseRealizationForm = ({
<ExpenseKandangsTable <ExpenseKandangsTable
type='detail' type='detail'
formType='realization'
locationId={formik.values.location?.value} locationId={formik.values.location?.value}
selectedKandangs={formik.values.kandangs ?? []} selectedKandangs={formik.values.kandangs ?? []}
onChange={kandangsChangeHandler} onChange={kandangsChangeHandler}
@@ -41,22 +41,25 @@ type ExpenseFormSchemaType = {
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> = export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({ Yup.object({
category: Yup.object({ category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), value: Yup.string()
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(), .oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
label: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
}) })
.nullable() .nullable()
.optional(), .required('Kategori wajib diisi!')
.typeError('Kategori wajib diisi!'),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).nullable(),
.nullable()
.optional(),
location_id: Yup.number() location_id: Yup.number()
.required('Lokasi wajib diisi!')
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'), .typeError('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'), transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
@@ -73,9 +76,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}) }).nullable(),
.nullable()
.optional(),
supplier_id: Yup.number() supplier_id: Yup.number()
.required('Vendor wajib diisi!') .required('Vendor wajib diisi!')
@@ -104,9 +105,12 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
.of( .of(
Yup.object({ Yup.object({
nonstock: Yup.object({ nonstock: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required('Nonstock wajib diisi!'),
label: Yup.string().required(), label: Yup.string().required('Nonstock wajib diisi!'),
}).nullable(), })
.nullable()
.required('Nonstock wajib diisi!')
.typeError('Nonstock wajib diisi!'),
nonstock_id: Yup.number() nonstock_id: Yup.number()
.required('Nonstock wajib diisi!') .required('Nonstock wajib diisi!')
.min(1, 'Nonstock wajib diisi!') .min(1, 'Nonstock wajib diisi!')
@@ -204,7 +208,7 @@ export const getExpenseFormInitialValues = (
nonstock_id: expenseItem.nonstock.id, nonstock_id: expenseItem.nonstock.id,
quantity: expenseItem.qty, quantity: expenseItem.qty,
price: expenseItem.price, price: expenseItem.price,
notes: expenseItem.note, notes: expenseItem.notes,
})) }))
: [], : [],
})) }))
@@ -190,30 +190,18 @@ const ExpenseRequestForm = ({
formik.setFieldValue('category', val); formik.setFieldValue('category', val);
}; };
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = useCallback(
formik.setFieldTouched('location', true); (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('location', val); const location = val as OptionType | null;
const locationId = location ? Number(location.value) : 0;
const locationId = Array.isArray(val) ? val[0]?.value : val?.value; formik.setFieldTouched('location', true);
formik.setFieldValue('location_id', locationId); formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('kandangs', []); formik.setFieldValue('location_id', locationId);
},
// Auto-create expense item for location (without kandang) []
formik.setFieldValue('expense_nonstocks', [ );
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
};
const kandangsChangeHandler = ( const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[] kandangs: { id?: number; name?: string }[]
@@ -268,6 +256,7 @@ const ExpenseRequestForm = ({
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => { const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('supplier', true); formik.setFieldTouched('supplier', true);
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier', val); formik.setFieldValue('supplier', val);
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value; const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
@@ -360,7 +349,7 @@ const ExpenseRequestForm = ({
return ( return (
<> <>
<section className='w-full max-w-5xl'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense' href='/expense'
@@ -407,6 +396,16 @@ const ExpenseRequestForm = ({
placeholder='Pilih Kategori' placeholder='Pilih Kategori'
value={formik.values.category} value={formik.values.category}
onChange={categoryChangeHandler} onChange={categoryChangeHandler}
isError={
formik.touched.category && Boolean(formik.errors.category)
}
errorMessage={
formik.touched.category && formik.errors.category
? typeof formik.errors.category === 'object'
? 'Kategori wajib diisi!'
: (formik.errors.category as string)
: undefined
}
options={[ options={[
{ {
value: 'BOP', value: 'BOP',
@@ -427,8 +426,13 @@ const ExpenseRequestForm = ({
value={formik.values.location} value={formik.values.location}
onChange={locationChangeHandler} onChange={locationChangeHandler}
options={locationOptions} options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'col-span-12 sm:col-span-4' }} className={{ wrapper: 'col-span-12 sm:col-span-4' }}
/> />
@@ -438,6 +442,12 @@ const ExpenseRequestForm = ({
required required
value={formik.values.transaction_date} value={formik.values.transaction_date}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
Boolean(formik.errors.transaction_date)
}
errorMessage={formik.errors.transaction_date as string}
className={{ className={{
wrapper: 'col-span-12 sm:col-span-4', wrapper: 'col-span-12 sm:col-span-4',
}} }}
@@ -460,8 +470,12 @@ const ExpenseRequestForm = ({
value={formik.values.supplier} value={formik.values.supplier}
onChange={supplierChangeHandler} onChange={supplierChangeHandler}
options={supplierOptions} options={supplierOptions}
isLoading={isLoadingVendorOptions}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
errorMessage={formik.errors.supplier_id as string}
className={{ wrapper: 'col-span-12' }} className={{ wrapper: 'col-span-12' }}
/> />
@@ -55,6 +55,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true true
); );
formik.setFieldTouched(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
true
);
formik.setFieldValue( formik.setFieldValue(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`, `expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val val
@@ -96,7 +100,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
}; };
const isExpenseRepeaterInputError = ( const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'price' | 'notes', column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number, kandangExpenseIdx: number,
expenseIdx: number expenseIdx: number
) => { ) => {
@@ -105,11 +109,14 @@ const ExpenseRequestKandangDetailExpense: React.FC<
expenseIdx expenseIdx
]?.[column] && ]?.[column] &&
Boolean( Boolean(
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof formik.errors.expense_nonstocks?.[kandangExpenseIdx] &&
Object && typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx] ===
'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
] instanceof Object && ] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx]
.cost_items?.[expenseIdx] === 'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[ formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx expenseIdx
]?.[column] ]?.[column]
@@ -117,6 +124,32 @@ const ExpenseRequestKandangDetailExpense: React.FC<
); );
}; };
const getExpenseRepeaterErrorMessage = (
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
): string => {
const kandangError = formik.errors.expense_nonstocks?.[kandangExpenseIdx];
if (!kandangError || typeof kandangError !== 'object') return '';
if (!('cost_items' in kandangError)) return '';
const costItemsError = kandangError.cost_items?.[expenseIdx];
if (!costItemsError || typeof costItemsError !== 'object') return '';
const fieldError = costItemsError[column as keyof typeof costItemsError];
if (!fieldError) return '';
if (typeof fieldError === 'object' && fieldError !== null) {
return 'Nonstock wajib diisi!';
}
return String(fieldError);
};
return ( return (
<Card <Card
className={{ className={{
@@ -202,10 +235,21 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val val
); );
}} }}
isError={isExpenseRepeaterInputError(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
options={nonstockOptions} options={nonstockOptions}
isLoading={isLoadingNonstockOptions} isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue} onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }} className={{ wrapper: 'min-w-48' }}
isClearable={true}
/> />
</td> </td>
@@ -226,6 +270,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }} className={{ wrapper: 'min-w-24' }}
/> />
</td> </td>
@@ -246,6 +295,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={ inputPrefix={
<span className='text-gray-600 font-medium'> <span className='text-gray-600 font-medium'>
Rp Rp
@@ -271,6 +325,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx, kandangExpenseIdx,
expenseIdx expenseIdx
)} )}
errorMessage={getExpenseRepeaterErrorMessage(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }} className={{ wrapper: 'min-w-24' }}
/> />
</td> </td>
@@ -447,7 +447,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.note} {pengajuan.notes}
</Text> </Text>
</View> </View>
</View> </View>
@@ -607,7 +607,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
]} ]}
> >
<Text style={ExpensePDFStyle.kandangExpenseLabelText}> <Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.note} {realisasi.notes}
</Text> </Text>
</View> </View>
</View> </View>
+19 -22
View File
@@ -34,7 +34,7 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Pihak', label: 'Pihak',
value: finance.party.id ? finance.party.name : '-', value: finance.party?.id ? finance.party?.name : '-',
}, },
{ {
label: 'Tanggal', label: 'Tanggal',
@@ -56,25 +56,21 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
}, },
{ {
label: 'Nomor Rekening', label: 'Nomor Rekening',
value: `${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, value: `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`,
}, },
{ {
label: `Rekening ${formatTitleCase(finance.party.type)}`, label: `Rekening ${formatTitleCase(finance.party?.type)}`,
value: finance.party.account_number, value: finance.party?.account_number,
}, },
{ {
label: 'Nominal', label: 'Nominal',
value: formatCurrency(finance.expense_amount), value: formatCurrency(finance.nominal),
},
{
label: 'Sisa',
value: formatCurrency(finance.income_amount),
}, },
].filter((item) => { ].filter((item) => {
// Hide party account number row if transaction type is INJECTION // Hide party account number row if transaction type is INJECTION
if ( if (
FINANCE_INJECTION_STATUS.includes(finance.transaction_type) && FINANCE_INJECTION_STATUS.includes(finance.transaction_type) &&
item.label === `Rekening ${formatTitleCase(finance.party.type)}` item.label === `Rekening ${formatTitleCase(finance.party?.type)}`
) { ) {
return false; return false;
} }
@@ -148,18 +144,19 @@ const FinanceDetail = ({ finance }: { finance: Finance }) => {
</Card> </Card>
<div className='flex flex-row gap-2 justify-end'> <div className='flex flex-row gap-2 justify-end'>
{FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) && ( {FINANCE_TRANSACTION_STATUS.includes(finance.transaction_type) &&
<RequirePermission permissions='lti.finance.payments.update'> finance.party?.type !== 'SUPPLIER' && (
<Button <RequirePermission permissions='lti.finance.payments.update'>
color='warning' <Button
className='min-w-24' color='warning'
href={`/finance/detail/edit?financeId=${finance.id}`} className='min-w-24'
> href={`/finance/detail/edit?financeId=${finance.id}`}
<Icon icon='mdi:pencil-outline' /> >
Edit <Icon icon='mdi:pencil-outline' />
</Button> Edit
</RequirePermission> </Button>
)} </RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && ( {FINANCE_INITIAL_BALANCE_STATUS.includes(finance.transaction_type) && (
<RequirePermission permissions='lti.finance.initial_balances.update'> <RequirePermission permissions='lti.finance.initial_balances.update'>
<Button <Button
+67 -54
View File
@@ -1,21 +1,17 @@
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useMemo, useState } from 'react';
import { CellContext, Row } from '@tanstack/react-table'; import { CellContext } from '@tanstack/react-table';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/dropdown/Dropdown';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Tooltip from '@/components/Tooltip';
import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; import { formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Finance } from '@/types/api/finance/finance'; import { Finance } from '@/types/api/finance/finance';
@@ -23,7 +19,6 @@ import {
FINANCE_INITIAL_BALANCE_STATUS, FINANCE_INITIAL_BALANCE_STATUS,
FINANCE_INJECTION_STATUS, FINANCE_INJECTION_STATUS,
FINANCE_TRANSACTION_STATUS, FINANCE_TRANSACTION_STATUS,
ROWS_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
@@ -70,19 +65,24 @@ const RowOptionsMenu = ({
{FINANCE_TRANSACTION_STATUS.includes( {FINANCE_TRANSACTION_STATUS.includes(
props.row.original.transaction_type props.row.original.transaction_type
) && ( ) &&
<RequirePermission permissions='lti.finance.payments.update'> props.row.original.party?.type !== 'SUPPLIER' && (
<Button <RequirePermission permissions='lti.finance.payments.update'>
href={`/finance/detail/edit?financeId=${props.row.original.id}`} <Button
variant='ghost' href={`/finance/detail/edit?financeId=${props.row.original.id}`}
color='warning' variant='ghost'
className='justify-start text-sm' color='warning'
> className='justify-start text-sm'
<Icon icon='material-symbols:edit-outline' width={16} height={16} /> >
Edit <Icon
</Button> icon='material-symbols:edit-outline'
</RequirePermission> width={16}
)} height={16}
/>
Edit
</Button>
</RequirePermission>
)}
{FINANCE_INITIAL_BALANCE_STATUS.includes( {FINANCE_INITIAL_BALANCE_STATUS.includes(
props.row.original.transaction_type props.row.original.transaction_type
@@ -199,35 +199,37 @@ const FinanceTable = () => {
// ===== Options ===== // ===== Options =====
const transactionTypeOptions = useMemo(() => { const transactionTypeOptions = useMemo(() => {
return [
{ label: 'Transfer', value: 'TRANSFER' },
{ label: 'Cash', value: 'CASH' },
{ label: 'Card', value: 'CARD' },
{ label: 'Cheque', value: 'CHEQUE' },
{ label: 'Saldo', value: 'SALDO' },
];
}, []);
const partyTypeOptions = useMemo(() => {
return [ return [
{ label: 'Customer', value: 'CUSTOMER' }, { label: 'Customer', value: 'CUSTOMER' },
{ label: 'Supplier', value: 'SUPPLIER' }, { label: 'Supplier', value: 'SUPPLIER' },
]; ];
}, []); }, []);
const {
options: partyTypeOptions,
isLoadingOptions: partyTypeIsLoadingOptions,
setInputValue: partyTypeInputValue,
loadMore: partyTypeLoadMore,
} = useSelect(
selectedTransactionType
? selectedTransactionType.value === 'CUSTOMER'
? CustomerApi.basePath
: SupplierApi.basePath
: '',
'id',
'name'
);
const sortByOptions = useMemo(() => { const sortByOptions = useMemo(() => {
return [ return [
{ label: 'Tanggal Pembayaran', value: 'payment_date' }, { label: 'Tanggal Pembayaran', value: 'payment_date' },
{ label: 'Tanggal Dibuat', value: 'created_at' }, { label: 'Tanggal Dibuat', value: 'created_at' },
]; ];
}, []); }, []);
const { options: bankOptions, rawData: bankRawData } = useSelect<Bank>( const {
BankApi.basePath, options: bankOptions,
'id', rawData: bankRawData,
'alias', setInputValue: bankInputValue,
'', loadMore: bankLoadMore,
{ } = useSelect<Bank>(BankApi.basePath, 'id', 'alias');
limit: 'limit',
}
);
// ===== Handler ===== // ===== Handler =====
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
@@ -344,10 +346,10 @@ const FinanceTable = () => {
}, },
{ {
header: 'Pihak', header: 'Pihak',
accessorFn: (finance: Finance) => finance.party.name, accessorFn: (finance: Finance) => finance.party?.name,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party.id) { if (props.row.original.party?.id) {
return <span>{props.row.original.party.name}</span>; return <span>{props.row.original.party?.name}</span>;
} }
return <span>{'-'}</span>; return <span>{'-'}</span>;
}, },
@@ -368,12 +370,12 @@ const FinanceTable = () => {
{ {
header: 'Bank', header: 'Bank',
accessorFn: (finance: Finance) => accessorFn: (finance: Finance) =>
`${finance.bank.alias} - ${finance.bank.account_number} - ${finance.bank.owner}`, `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`,
}, },
{ {
header: 'Pengeluaran (Rp)', header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) => accessorFn: (finance: Finance) =>
formatCurrency(finance.expense_amount), formatCurrency(Math.abs(finance.expense_amount)),
}, },
{ {
header: 'Pemasukan (Rp)', header: 'Pemasukan (Rp)',
@@ -476,38 +478,49 @@ const FinanceTable = () => {
<div className='grid grid-cols-4 gap-6'> <div className='grid grid-cols-4 gap-6'>
<SelectInput <SelectInput
options={transactionTypeOptions} options={transactionTypeOptions}
label='Jenis Transaksi' label='Tipe Transaksi'
value={selectedTransactionType} value={selectedTransactionType}
onChange={transactionTypeChangeHandler} onChange={transactionTypeChangeHandler}
isClearable isClearable
/> />
<SelectInput
options={partyTypeOptions}
label={
selectedTransactionType
? selectedTransactionType.value === 'CUSTOMER'
? 'Pelanggan'
: 'Supplier'
: 'Pihak'
}
value={selectedPartyType}
onChange={partyTypeChangeHandler}
onInputChange={partyTypeInputValue}
onMenuScrollToBottom={partyTypeLoadMore}
isLoading={partyTypeIsLoadingOptions}
isClearable
/>
<SelectInput <SelectInput
options={ options={
isResponseSuccess(bankRawData) isResponseSuccess(bankRawData)
? bankOptions.map((bank) => ({ ? bankOptions.map((bank) => ({
label: label:
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.alias + ?.alias +
' - ' + ' - ' +
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.account_number + ?.account_number +
' - ' + ' - ' +
bankRawData.data.find((data) => data.id === bank.value) bankRawData.data.find((data) => data.id === bank?.value)
?.owner, ?.owner,
value: bank.value, value: bank?.value,
})) }))
: [] : []
} }
label='Bank' label='Bank'
value={selectedBank} value={selectedBank}
onChange={bankChangeHandler} onChange={bankChangeHandler}
isClearable onInputChange={bankInputValue}
/> onMenuScrollToBottom={bankLoadMore}
<SelectInput
options={partyTypeOptions}
label='Pihak'
value={selectedPartyType}
onChange={partyTypeChangeHandler}
isClearable isClearable
/> />
<DebouncedTextInput <DebouncedTextInput
@@ -32,8 +32,10 @@ import {
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceAddProps { interface FormFinanceAddProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -51,18 +53,22 @@ const FormFinanceAdd = ({
initialValues, initialValues,
}: FormFinanceAddProps) => { }: FormFinanceAddProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
const [isSupplier, setIsSupplier] = useState(
initialValues?.party?.type === 'SUPPLIER'
);
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): FinanceFormValues => { const formikInitialValues = useMemo((): FinanceFormValues => {
return { return {
party_type_option: party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find( FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type (option) => option.value === initialValues?.party?.type
) || null, ) || null,
party_id_option: initialValues?.party party_id_option: initialValues?.party
? { ? {
label: initialValues?.party.name || '', label: initialValues?.party?.name || '',
value: initialValues?.party.id || 0, value: initialValues?.party?.id || 0,
} }
: null, : null,
payment_date: initialValues?.payment_date || '', payment_date: initialValues?.payment_date || '',
@@ -72,11 +78,11 @@ const FormFinanceAdd = ({
) || null, ) || null,
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues?.bank?.name,
value: initialValues.bank.id, value: initialValues?.bank?.id,
} }
: null, : null,
party_account_number: initialValues?.party.account_number || '', party_account_number: initialValues?.party?.account_number || '',
reference_number: initialValues?.reference_number || '', reference_number: initialValues?.reference_number || '',
nominal: initialValues?.nominal.toString() || '', nominal: initialValues?.nominal.toString() || '',
notes: initialValues?.notes || '', notes: initialValues?.notes || '',
@@ -113,20 +119,22 @@ const FormFinanceAdd = ({
options: partyOptions, options: partyOptions,
isLoadingOptions: isLoadingPartyOptions, isLoadingOptions: isLoadingPartyOptions,
rawData: partyRawData, rawData: partyRawData,
setInputValue: setPartyInputValue,
loadMore: loadMorePartyOptions,
} = useSelect<PartyCommonProps>( } = useSelect<PartyCommonProps>(
formik.values.party_type_option?.value === 'CUSTOMER' formik.values.party_type_option?.value === 'CUSTOMER'
? CustomerApi.basePath ? CustomerApi.basePath
: SupplierApi.basePath, : SupplierApi.basePath,
'id', 'id',
'name', 'name'
'',
{ limit: 'limit' }
); );
const { const {
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -151,6 +159,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -166,6 +175,7 @@ const FormFinanceAdd = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -205,6 +215,7 @@ const FormFinanceAdd = ({
? formik.errors.party_type_option ? formik.errors.party_type_option
: '' : ''
} }
isDisabled={type === 'edit' || isSupplier}
required required
isClearable isClearable
/> />
@@ -219,6 +230,8 @@ const FormFinanceAdd = ({
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`} placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis transaksi dahulu'}`}
options={partyOptions} options={partyOptions}
value={formik.values.party_id_option} value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_id_option', value); formik.setFieldValue('party_id_option', value);
if (isResponseSuccess(partyRawData) && value) { if (isResponseSuccess(partyRawData) && value) {
@@ -241,7 +254,7 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={!formik.values.party_type_option?.value} isDisabled={!formik.values.party_type_option?.value || isSupplier}
/> />
<DateInput <DateInput
label='Tanggal' label='Tanggal'
@@ -259,6 +272,7 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<SelectInput <SelectInput
label='Metode Pembayaran' label='Metode Pembayaran'
@@ -280,6 +294,7 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={isSupplier}
/> />
<SelectInput <SelectInput
label='Bank' label='Bank'
@@ -304,6 +319,8 @@ const FormFinanceAdd = ({
: [] : []
} }
value={formik.values.bank_id_option} value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('bank_id_option', value); formik.setFieldValue('bank_id_option', value);
}} }}
@@ -318,6 +335,7 @@ const FormFinanceAdd = ({
} }
required required
isClearable isClearable
isDisabled={isSupplier}
/> />
<TextInput <TextInput
label={`Nomor Rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'Pihak'}`} label={`Nomor Rekening ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'Pihak'}`}
@@ -338,6 +356,7 @@ const FormFinanceAdd = ({
} }
required required
readOnly readOnly
disabled={isSupplier}
/> />
<TextInput <TextInput
label='Nomor Referensi' label='Nomor Referensi'
@@ -357,6 +376,7 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<NumberInput <NumberInput
label='Nominal' label='Nominal'
@@ -372,6 +392,7 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<TextArea <TextArea
label='Catatan' label='Catatan'
@@ -387,8 +408,18 @@ const FormFinanceAdd = ({
: '' : ''
} }
required required
disabled={isSupplier}
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -27,13 +27,7 @@ export const InitialBalanceFormSchema = Yup.object().shape({
'Pihak wajib diisi', 'Pihak wajib diisi',
(value) => value !== null && value !== undefined (value) => value !== null && value !== undefined
), ),
bank_id_option: Yup.mixed() bank_id_option: Yup.mixed().nullable(),
.nullable()
.test(
'is-valid-option',
'Bank wajib diisi',
(value) => value !== null && value !== undefined
),
reference_number: Yup.string().required('Nomor referensi wajib diisi'), reference_number: Yup.string().required('Nomor referensi wajib diisi'),
initial_balance_type_option: Yup.mixed() initial_balance_type_option: Yup.mixed()
.nullable() .nullable()
@@ -29,8 +29,9 @@ import { Bank } from '@/types/api/master-data/bank';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FormFinanceAddInitialBalanceProps { interface FormFinanceAddInitialBalanceProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -42,6 +43,7 @@ const FormFinanceAddInitialBalance = ({
initialValues, initialValues,
}: FormFinanceAddInitialBalanceProps) => { }: FormFinanceAddInitialBalanceProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): InitialBalanceFormValues => { const formikInitialValues = useMemo((): InitialBalanceFormValues => {
@@ -53,18 +55,18 @@ const FormFinanceAddInitialBalance = ({
return { return {
party_type_option: party_type_option:
FINANCE_PARTY_TYPE_OPTIONS.find( FINANCE_PARTY_TYPE_OPTIONS.find(
(option) => option.value === initialValues?.party.type (option) => option.value === initialValues?.party?.type
) || null, ) || null,
party_id_option: initialValues?.party party_id_option: initialValues?.party
? { ? {
label: initialValues.party.name, label: initialValues.party?.name,
value: initialValues.party.id, value: initialValues.party?.id,
} }
: null, : null,
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues.bank?.name,
value: initialValues.bank.id, value: initialValues.bank?.id,
} }
: null, : null,
reference_number: initialValues?.reference_number || '', reference_number: initialValues?.reference_number || '',
@@ -104,21 +106,25 @@ const FormFinanceAddInitialBalance = ({
}); });
// ===== Options ===== // ===== Options =====
const { options: partyOptions, isLoadingOptions: isLoadingPartyOptions } = const {
useSelect( options: partyOptions,
formik.values.party_type_option?.value === 'CUSTOMER' isLoadingOptions: isLoadingPartyOptions,
? CustomerApi.basePath setInputValue: setPartyInputValue,
: SupplierApi.basePath, loadMore: loadMorePartyOptions,
'id', } = useSelect(
'name', formik.values.party_type_option?.value === 'CUSTOMER'
'', ? CustomerApi.basePath
{ limit: 'limit' } : SupplierApi.basePath,
); 'id',
'name'
);
const { const {
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -143,6 +149,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -162,6 +169,7 @@ const FormFinanceAddInitialBalance = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -189,6 +197,8 @@ const FormFinanceAddInitialBalance = ({
placeholder='Pilih jenis pihak' placeholder='Pilih jenis pihak'
options={FINANCE_PARTY_TYPE_OPTIONS} options={FINANCE_PARTY_TYPE_OPTIONS}
value={formik.values.party_type_option} value={formik.values.party_type_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_type_option', value); formik.setFieldValue('party_type_option', value);
formik.setFieldValue('party_id_option', null); formik.setFieldValue('party_id_option', null);
@@ -205,6 +215,7 @@ const FormFinanceAddInitialBalance = ({
: '' : ''
} }
required required
isDisabled={type === 'edit'}
isClearable isClearable
/> />
<SelectInput <SelectInput
@@ -218,6 +229,8 @@ const FormFinanceAddInitialBalance = ({
placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`} placeholder={`Pilih ${formik.values.party_type_option?.value ? formatTitleCase(formik.values.party_type_option.value as string) : 'jenis pihak dahulu'}`}
options={partyOptions} options={partyOptions}
value={formik.values.party_id_option} value={formik.values.party_id_option}
onInputChange={setPartyInputValue}
onMenuScrollToBottom={loadMorePartyOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('party_id_option', value); formik.setFieldValue('party_id_option', value);
}} }}
@@ -269,7 +282,6 @@ const FormFinanceAddInitialBalance = ({
? formik.errors.bank_id_option ? formik.errors.bank_id_option
: '' : ''
} }
required
isClearable isClearable
/> />
<TextInput <TextInput
@@ -354,7 +366,18 @@ const FormFinanceAddInitialBalance = ({
} }
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -24,8 +24,10 @@ import {
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Alert from '@/components/Alert';
import { Icon } from '@iconify/react';
interface FormFinanceInjectionProps { interface FormFinanceInjectionProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -37,14 +39,15 @@ const FormFinanceInjection = ({
initialValues, initialValues,
}: FormFinanceInjectionProps) => { }: FormFinanceInjectionProps) => {
const router = useRouter(); const router = useRouter();
const [serverErrorMessage, setServerErrorMessage] = useState('');
// ===== Formik ===== // ===== Formik =====
const formikInitialValues = useMemo((): InjectionFormValues => { const formikInitialValues = useMemo((): InjectionFormValues => {
return { return {
bank_id_option: initialValues?.bank bank_id_option: initialValues?.bank
? { ? {
label: initialValues.bank.name, label: initialValues.bank?.name,
value: initialValues.bank.id, value: initialValues.bank?.id,
} }
: null, : null,
adjustment_date: initialValues?.payment_date || '', adjustment_date: initialValues?.payment_date || '',
@@ -80,7 +83,9 @@ const FormFinanceInjection = ({
options: bankOptions, options: bankOptions,
rawData: bankRawData, rawData: bankRawData,
isLoadingOptions: isLoadingBankOptions, isLoadingOptions: isLoadingBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name', '', { limit: 'limit' }); setInputValue: setBankInputValue,
loadMore: loadMoreBankOptions,
} = useSelect<Bank>(BankApi.basePath, 'id', 'name');
// ===== Helper Functions ===== // ===== Helper Functions =====
const transformFormValuesToPayload = ( const transformFormValuesToPayload = (
@@ -101,6 +106,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -117,6 +123,7 @@ const FormFinanceInjection = ({
if (isResponseError(response)) { if (isResponseError(response)) {
toast.error(response.message); toast.error(response.message);
setServerErrorMessage(response.message);
return; return;
} }
@@ -162,6 +169,8 @@ const FormFinanceInjection = ({
: [] : []
} }
value={formik.values.bank_id_option} value={formik.values.bank_id_option}
onInputChange={setBankInputValue}
onMenuScrollToBottom={loadMoreBankOptions}
onChange={(value) => { onChange={(value) => {
formik.setFieldValue('bank_id_option', value); formik.setFieldValue('bank_id_option', value);
}} }}
@@ -226,6 +235,15 @@ const FormFinanceInjection = ({
required required
/> />
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{serverErrorMessage && (
<Alert color='error'>
<Icon icon='mdi:alert' />
{serverErrorMessage}
<Button color='error' onClick={() => setServerErrorMessage('')}>
<Icon icon='mdi:close' />
</Button>
</Alert>
)}
<div className='flex justify-center gap-4'> <div className='flex justify-center gap-4'>
<Button <Button
type='reset' type='reset'
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError } from '@/lib/api-helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { import {
CreateInventoryAdjustmentPayload, CreateInventoryAdjustmentPayload,
@@ -22,12 +22,18 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import TextInput from '@/components/input/TextInput'; import TextInput from '@/components/input/TextInput';
import { RadioGroup } from '@/components/input/RadioInput'; import { RadioGroup } from '@/components/input/RadioInput';
import TextArea from '@/components/input/TextArea'; import TextArea from '@/components/input/TextArea';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface InventoryAdjustmentFormProps { interface InventoryAdjustmentFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -44,10 +50,7 @@ const InventoryAdjustmentForm = ({
InventoryAdjustmentFormErrorMessage, InventoryAdjustmentFormErrorMessage,
setInventoryAdjustmentFormErrorMessage, setInventoryAdjustmentFormErrorMessage,
] = useState(''); ] = useState('');
const [selectedProductCategories, setSelectedProductCategories] =
useState('');
const [disabledProduct, setDisabledProduct] = useState(true); const [disabledProduct, setDisabledProduct] = useState(true);
const [optionsProduct, setOptionsProduct] = useState<OptionType[]>([]);
const [quantityLabel, setQuantityLabel] = useState('Tambah Stok'); const [quantityLabel, setQuantityLabel] = useState('Tambah Stok');
// Submit Handler // Submit Handler
@@ -108,45 +111,30 @@ const InventoryAdjustmentForm = ({
}); });
// Fetch Data // Fetch Data
const productCategoriesUrl = `${ const {
ProductCategoryApi.basePath setInputValue: setProductCategoryInputValue,
}?${new URLSearchParams({ options: productCategoryOptions,
search: '', isLoadingOptions: isLoadingProductCategoryOptions,
}).toString()}`; loadMore: loadMoreProductCategories,
const { data: productCategories, isLoading: isLoadingProductCategories } = } = useSelect<ProductCategory>(ProductCategoryApi.basePath, 'id', 'name');
useSWR(productCategoriesUrl, ProductCategoryApi.getAllFetcher);
const productUrl = `${ProductApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setProductInputValue,
product_category_id: selectedProductCategories, options: productOptions,
}).toString()}`; isLoadingOptions: isLoadingProductOptions,
const { data: products, isLoading: isLoadingProducts } = useSWR( loadMore: loadMoreProducts,
productUrl, } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
ProductApi.getAllFetcher product_category_id: formik.values.product_category_id
); ? String(formik.values.product_category_id)
: '',
});
const warehouseUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ const {
search: '', setInputValue: setWarehouseInputValue,
limit: '100', options: warehouseOptions,
}).toString()}`; isLoadingOptions: isLoadingWarehouseOptions,
const { data: warehouses, isLoading: isLoadingWarehouses } = useSWR( loadMore: loadMoreWarehouses,
warehouseUrl, } = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
WarehouseApi.getAllFetcher
);
// Map Data to Options
const optionsProductCategory = isResponseSuccess(productCategories)
? productCategories?.data.map((productCategory) => ({
value: productCategory.id,
label: productCategory.name,
}))
: [];
const optionsWarehouse = isResponseSuccess(warehouses)
? warehouses?.data.map((warehouse) => ({
value: warehouse.id,
label: warehouse.name,
}))
: [];
// Options Handler // Options Handler
const productCategoryChangeHandler = ( const productCategoryChangeHandler = (
@@ -157,7 +145,6 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category', val); formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null; const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled); setDisabledProduct(disabled);
formik.setFieldValue('product_id', 0); formik.setFieldValue('product_id', 0);
@@ -193,9 +180,6 @@ const InventoryAdjustmentForm = ({
// Effect // Effect
useEffect(() => { useEffect(() => {
if (initialValues?.product_warehouse?.product?.id) { if (initialValues?.product_warehouse?.product?.id) {
setSelectedProductCategories(
String(initialValues.product_warehouse.product.id)
);
setDisabledProduct(false); setDisabledProduct(false);
formik.setFieldValue( formik.setFieldValue(
'product_id', 'product_id',
@@ -219,25 +203,10 @@ const InventoryAdjustmentForm = ({
); );
formik.setFieldValue('note', initialValues.note); formik.setFieldValue('note', initialValues.note);
} }
}, [ }, [formik, initialValues, setQuantityLabel, setDisabledProduct]);
formik,
initialValues,
setQuantityLabel,
setDisabledProduct,
setSelectedProductCategories,
]);
useEffect(() => { useEffect(() => {
formikSetValues(formikInitialValues as InventoryAdjustmentFormValues); formikSetValues(formikInitialValues as InventoryAdjustmentFormValues);
}, [formikSetValues, formikInitialValues]); }, [formikSetValues, formikInitialValues]);
useEffect(() => {
if (isResponseSuccess(products)) {
const options = products.data.map((p) => ({
value: p.id,
label: p.name,
}));
setOptionsProduct(options);
}
}, [products]);
// Utils Function // Utils Function
const formatNumber = (value: string) => { const formatNumber = (value: string) => {
@@ -282,9 +251,10 @@ const InventoryAdjustmentForm = ({
label='Kategori Produk' label='Kategori Produk'
value={formik.values.product_category as OptionType} value={formik.values.product_category as OptionType}
onChange={productCategoryChangeHandler} onChange={productCategoryChangeHandler}
onInputChange={setSelectedProductCategories} onInputChange={setProductCategoryInputValue}
options={optionsProductCategory} options={productCategoryOptions}
isLoading={isLoadingProductCategories} onMenuScrollToBottom={loadMoreProductCategories}
isLoading={isLoadingProductCategoryOptions}
isError={ isError={
formik.touched.product_category && formik.touched.product_category &&
Boolean(formik.errors.product_category) Boolean(formik.errors.product_category)
@@ -300,8 +270,10 @@ const InventoryAdjustmentForm = ({
label='Produk' label='Produk'
value={formik.values.product as OptionType} value={formik.values.product as OptionType}
onChange={productChangeHandler} onChange={productChangeHandler}
options={optionsProduct} onInputChange={setProductInputValue}
isLoading={isLoadingProducts} options={productOptions}
onMenuScrollToBottom={loadMoreProducts}
isLoading={isLoadingProductOptions}
isError={formik.touched.product && Boolean(formik.errors.product)} isError={formik.touched.product && Boolean(formik.errors.product)}
errorMessage={formik.errors.product as string} errorMessage={formik.errors.product as string}
isDisabled={type === 'detail' || disabledProduct} isDisabled={type === 'detail' || disabledProduct}
@@ -314,8 +286,10 @@ const InventoryAdjustmentForm = ({
label='Warehouse' label='Warehouse'
value={formik.values.warehouse as OptionType} value={formik.values.warehouse as OptionType}
onChange={warehouseChangeHandler} onChange={warehouseChangeHandler}
options={optionsWarehouse} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouses} options={warehouseOptions}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouseOptions}
isError={ isError={
formik.touched.warehouse && Boolean(formik.errors.warehouse) formik.touched.warehouse && Boolean(formik.errors.warehouse)
} }
@@ -110,6 +110,14 @@ const DeliveryProductObjectSchema = Yup.object({
.typeError('Qty harus berupa angka!'), .typeError('Qty harus berupa angka!'),
}); });
const DeliveryDocumentSchema = Yup.mixed<File | MovementDocument>()
.nullable()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value): boolean => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
});
const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
delivery_cost: Yup.number() delivery_cost: Yup.number()
.transform((value) => (isNaN(value) || value === 0 ? undefined : value)) .transform((value) => (isNaN(value) || value === 0 ? undefined : value))
@@ -135,13 +143,7 @@ const DeliveryObjectSchema: Yup.ObjectSchema<DeliverySchema> = Yup.object({
}), }),
document_path: Yup.string().nullable().optional(), document_path: Yup.string().nullable().optional(),
document_index: Yup.number().optional(), document_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>() document: DeliveryDocumentSchema,
.nullable()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true;
}),
driver_name: Yup.string().required('Nama sopir wajib diisi!'), driver_name: Yup.string().required('Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'), vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
supplier: Yup.object({ supplier: Yup.object({
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -38,6 +38,8 @@ import Card from '@/components/Card';
import { S3_PUBLIC_BASE_URL } from '@/config/constant'; import { S3_PUBLIC_BASE_URL } from '@/config/constant';
import { getUniqueFormikErrors } from '@/lib/formik-helper'; import { getUniqueFormikErrors } from '@/lib/formik-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
interface MovementFormProps { interface MovementFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -49,10 +51,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [movementFormErrorMessage, setMovementFormErrorMessage] = useState(''); const [movementFormErrorMessage, setMovementFormErrorMessage] = useState('');
const [
productWarehouseSelectInputValue,
setProductWarehouseSelectInputValue,
] = useState('');
const [selectedProducts, setSelectedProducts] = useState<number[]>([]); const [selectedProducts, setSelectedProducts] = useState<number[]>([]);
const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]); const [selectedDeliveries, setSelectedDeliveries] = useState<number[]>([]);
const [formErrorList, setFormErrorList] = useState<string[]>([]); const [formErrorList, setFormErrorList] = useState<string[]>([]);
@@ -93,10 +91,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
const { const {
inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
isLoadingOptions: isLoadingWarehouses, isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
flag: 'EKSPEDISI',
});
// ===== SELECT INPUT DATA ===== // ===== SELECT INPUT DATA =====
const { const {
@@ -107,12 +108,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
category: 'BOP', category: 'BOP',
}); });
const warehousesUrl = `${WarehouseApi.basePath}?${new URLSearchParams({ search: warehouseSelectInputValue }).toString()}`;
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
// ===== DATA PROCESSING ===== // ===== DATA PROCESSING =====
const warehouseStockMap = useMemo(() => { const warehouseStockMap = useMemo(() => {
if (!isResponseSuccess(allProductWarehouses)) return new Map(); if (!isResponseSuccess(allProductWarehouses)) return new Map();
@@ -268,26 +263,64 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, },
}); });
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) ===== const prevSourceWarehouseIdRef = useRef<number | null>(
const getProductWarehousesUrl = useCallback(() => { formik.values.source_warehouse_id
const productWarehouseParams = new URLSearchParams({ );
search: productWarehouseSelectInputValue,
});
if (formik.values.source_warehouse_id) {
productWarehouseParams.append(
'warehouse_id',
formik.values.source_warehouse_id.toString()
);
}
return `${ProductWarehouseApi.basePath}?${productWarehouseParams.toString()}`;
}, [formik.values.source_warehouse_id, productWarehouseSelectInputValue]);
const productWarehousesUrl = getProductWarehousesUrl(); // ===== RESET PRODUCTS WHEN SOURCE WAREHOUSE CHANGES =====
const { data: productWarehouses, isLoading: isLoadingProductWarehouses } = useEffect(() => {
useSWR( const prevSourceWarehouseId = prevSourceWarehouseIdRef.current;
formik.values.source_warehouse_id ? productWarehousesUrl : null, const currentSourceWarehouseId = formik.values.source_warehouse_id;
ProductWarehouseApi.getAllFetcher
); if (
prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null
) {
formik.setFieldValue('products', [
{
product: null,
product_id: 0,
product_qty: '',
},
]);
formik.setFieldTouched('products', false);
const updatedDeliveries = formik.values.deliveries.map(
(delivery: DeliverySchema) => ({
...delivery,
products: [
{
product: null,
product_id: 0,
product_qty: '',
},
],
})
);
formik.setFieldValue('deliveries', updatedDeliveries);
formik.setFieldTouched('deliveries', false);
}
prevSourceWarehouseIdRef.current = currentSourceWarehouseId;
}, [formik.values.source_warehouse_id, formik.values.deliveries]);
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const {
setInputValue: setProductWarehouseSelectInputValue,
isLoadingOptions: isLoadingProductWarehouses,
loadMore: loadMoreProductWarehouses,
rawData: productWarehouses,
} = useSelect<ProductWarehouse>(
formik.values.source_warehouse_id ? ProductWarehouseApi.basePath : null,
'id',
'name',
'search',
{
warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString()
: '',
}
);
const productWarehouseOptions = isResponseSuccess(productWarehouses) const productWarehouseOptions = isResponseSuccess(productWarehouses)
? productWarehouses?.data.map((pw) => ({ ? productWarehouses?.data.map((pw) => ({
@@ -357,13 +390,71 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}; };
}; };
const handleTransferDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value);
};
// ===== EVENT HANDLERS ===== // ===== EVENT HANDLERS =====
// Product Handlers const handleTransferDateChange = useCallback(
const addProduct = () => { (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value);
},
[]
);
const handleSourceWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const newSourceWarehouseId = (val as WarehouseOptionType)?.value;
if (
newSourceWarehouseId &&
newSourceWarehouseId === formik.values.destination_warehouse_id
) {
const destinationWarehouseName =
(formik.values.destination_warehouse as WarehouseOptionType)?.label ||
'gudang tujuan';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
);
return;
}
formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue('source_warehouse_id', newSourceWarehouseId);
},
[
formik.values.destination_warehouse_id,
formik.values.destination_warehouse,
]
);
const handleDestinationWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const newDestinationWarehouseId = (val as WarehouseOptionType)?.value;
if (
newDestinationWarehouseId &&
newDestinationWarehouseId === formik.values.source_warehouse_id
) {
const sourceWarehouseName =
(formik.values.source_warehouse as WarehouseOptionType)?.label ||
'gudang asal';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
);
return;
}
formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
newDestinationWarehouseId
);
},
[formik.values.source_warehouse_id, formik.values.source_warehouse]
);
const addProduct = useCallback(() => {
const newProducts = [ const newProducts = [
...(formik.values.products || []), ...(formik.values.products || []),
{ {
@@ -373,22 +464,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, },
]; ];
formik.setFieldValue('products', newProducts); formik.setFieldValue('products', newProducts);
}; }, []);
const removeProduct = useCallback( const removeProduct = useCallback((i: number) => {
(i: number) => { const updatedProducts =
const updatedProducts = formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
formik.values.products?.reduce((acc: ProductSchema[], item, index) => { if (index !== i) {
if (index !== i) { acc.push(item);
acc.push(item); }
} return acc;
return acc; }, []) ?? [];
}, []) ?? [];
formik.setFieldValue('products', updatedProducts); formik.setFieldValue('products', updatedProducts);
}, }, []);
[formik]
);
const bulkRemoveProduct = useCallback(() => { const bulkRemoveProduct = useCallback(() => {
const updatedProducts = const updatedProducts =
@@ -397,10 +485,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? []; ) ?? [];
formik.setFieldValue('products', updatedProducts); formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]); setSelectedProducts([]);
}, [formik, selectedProducts]); }, [formik, selectedProducts, setSelectedProducts]);
// Delivery Handlers const handleProductChange = useCallback(
const addDelivery = () => { (idx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(`products.${idx}.product`, true);
formik.setFieldValue(`products.${idx}.product`, val);
formik.setFieldTouched(`products.${idx}.product_id`, true);
formik.setFieldValue(
`products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value
);
},
[]
);
const handleProductSelectAllChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedProducts(formik.values.products?.map((_, idx) => idx) ?? []);
} else {
setSelectedProducts([]);
}
},
[formik.values.products, setSelectedProducts]
);
const handleProductCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.name.replace('product-', ''));
if (e.target.checked) {
setSelectedProducts((prev) => [...prev, idx]);
} else {
setSelectedProducts((prev) => prev.filter((i) => i !== idx));
}
},
[setSelectedProducts]
);
const addDelivery = useCallback(() => {
formik.setFieldValue('deliveries', [ formik.setFieldValue('deliveries', [
...(formik.values.deliveries || []), ...(formik.values.deliveries || []),
{ {
@@ -420,25 +543,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
], ],
}, },
]); ]);
}; }, []);
const removeDelivery = useCallback( const removeDelivery = useCallback((i: number) => {
(i: number) => { const updatedDeliveries =
const updatedDeliveries = formik.values.deliveries?.reduce((acc: DeliverySchema[], item, index) => {
formik.values.deliveries?.reduce( if (index !== i) {
(acc: DeliverySchema[], item, index) => { acc.push(item);
if (index !== i) { }
acc.push(item); return acc;
} }, []) ?? [];
return acc;
},
[]
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries); formik.setFieldValue('deliveries', updatedDeliveries);
}, }, []);
[formik]
);
const bulkRemoveDelivery = useCallback(() => { const bulkRemoveDelivery = useCallback(() => {
const updatedDeliveries = const updatedDeliveries =
@@ -447,33 +564,101 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? []; ) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries); formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]); setSelectedDeliveries([]);
}, [formik, selectedDeliveries]); }, [formik, selectedDeliveries, setSelectedDeliveries]);
// Cost Calculation Handlers const handleDeliverySelectAllChange = useCallback(
const handleDeliveryCostChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => {
(idx: number, value: number) => { if (e.target.checked) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value); setSelectedDeliveries(
formik.values.deliveries?.map((_, idx) => idx) ?? []
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
const productQty = delivery.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
); );
if (productQty > 0 && value > 0) { } else {
const perItem = value / productQty; setSelectedDeliveries([]);
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
} }
}, },
[formik] [formik.values.deliveries, setSelectedDeliveries]
); );
const handleDeliveryCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const idx = Number(e.target.name.replace('delivery-', ''));
if (e.target.checked) {
setSelectedDeliveries((prev) => [...prev, idx]);
} else {
setSelectedDeliveries((prev) => prev.filter((i) => i !== idx));
}
},
[setSelectedDeliveries]
);
const handleDeliveryProductChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product`,
true
);
formik.setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product_id`,
true
);
formik.setFieldValue(
`deliveries.${deliveryIdx}.products.0.product_id`,
(val as OptionType)?.value
);
},
[]
);
const handleDeliverySupplierChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true);
formik.setFieldValue(`deliveries.${deliveryIdx}.supplier`, val);
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true);
formik.setFieldValue(
`deliveries.${deliveryIdx}.supplier_id`,
(val as OptionType)?.value
);
},
[]
);
const handleDeliveryDocumentChange = useCallback(
(deliveryIdx: number, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = '';
return;
}
formik.setFieldValue(`deliveries.${deliveryIdx}.document`, file);
}
},
[]
);
const handleDeliveryCostChange = useCallback((idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value);
const delivery = formik.values.deliveries?.[idx];
if (delivery) {
const productQty = delivery.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
);
if (productQty > 0 && value > 0) {
const perItem = value / productQty;
formik.setFieldValue(
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
}
}
}, []);
const handleDeliveryCostPerItemChange = useCallback( const handleDeliveryCostPerItemChange = useCallback(
(idx: number, value: number) => { (idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value); formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
@@ -492,7 +677,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
} }
}, },
[formik] []
); );
const handleDeliveryCostChangeWrapper = useCallback( const handleDeliveryCostChangeWrapper = useCallback(
@@ -967,45 +1152,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang' label='Gudang'
placeholder='Pilih gudang asal...' placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse} value={formik.values.source_warehouse}
onChange={(val) => { onChange={handleSourceWarehouseChange}
const newSourceWarehouseId = (val as WarehouseOptionType)
?.value;
if (newSourceWarehouseId) {
if (
newSourceWarehouseId ===
formik.values.destination_warehouse_id
) {
const destinationWarehouseName =
(
formik.values
.destination_warehouse as WarehouseOptionType
)?.label || 'gudang tujuan';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang asal tidak boleh sama dengan ${destinationWarehouseName}.`
);
return;
}
}
formik.setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true);
formik.setFieldValue(
'source_warehouse_id',
newSourceWarehouseId
);
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}}
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
isError={ isError={
formik.touched.source_warehouse_id && formik.touched.source_warehouse_id &&
@@ -1066,44 +1216,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang' label='Gudang'
placeholder='Pilih gudang tujuan...' placeholder='Pilih gudang tujuan...'
value={formik.values.destination_warehouse} value={formik.values.destination_warehouse}
onChange={(val) => { onChange={handleDestinationWarehouseChange}
const newDestinationWarehouseId = (val as WarehouseOptionType)
?.value;
if (newDestinationWarehouseId) {
if (
newDestinationWarehouseId ===
formik.values.source_warehouse_id
) {
const sourceWarehouseName =
(formik.values.source_warehouse as WarehouseOptionType)
?.label || 'gudang asal';
toast.error(
`Tidak bisa memilih gudang yang sama. Gudang tujuan tidak boleh sama dengan ${sourceWarehouseName}.`
);
return;
}
}
formik.setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue(
'destination_warehouse_id',
newDestinationWarehouseId
);
if (
formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!'
) {
formik.setFieldError('destination_warehouse_id', undefined);
}
}}
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
formik.touched.destination_warehouse_id && formik.touched.destination_warehouse_id &&
Boolean(formik.errors.destination_warehouse_id) Boolean(formik.errors.destination_warehouse_id)
@@ -1173,18 +1290,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedProducts.length && selectedProducts.length &&
formik.values.products?.length > 0 formik.values.products?.length > 0
} }
onChange={( onChange={handleProductSelectAllChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map((_, idx) => idx) ??
[]
);
} else {
setSelectedProducts([]);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1221,17 +1327,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput <CheckboxInput
name={`product-${idx}`} name={`product-${idx}`}
checked={selectedProducts.includes(idx)} checked={selectedProducts.includes(idx)}
onChange={( onChange={handleProductCheckboxChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([...selectedProducts, idx]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1243,26 +1339,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput <SelectInput
required required
value={product.product ?? undefined} value={product.product ?? undefined}
onChange={(val) => { onChange={(val) => handleProductChange(idx, val)}
formik.setFieldTouched(
`products.${idx}.product`,
true
);
formik.setFieldValue(
`products.${idx}.product`,
val
);
formik.setFieldTouched(
`products.${idx}.product_id`,
true
);
formik.setFieldValue(
`products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value
);
}}
options={productWarehouseOptions} options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue} onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
isLoading={isLoadingProductWarehouses} isLoading={isLoadingProductWarehouses}
isDisabled={ isDisabled={
type === 'detail' || type === 'detail' ||
@@ -1386,19 +1466,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedDeliveries.length && selectedDeliveries.length &&
formik.values.deliveries?.length > 0 formik.values.deliveries?.length > 0
} }
onChange={( onChange={handleDeliverySelectAllChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedDeliveries([]);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1481,20 +1549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput <CheckboxInput
name={`delivery-${idx}`} name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)} checked={selectedDeliveries.includes(idx)}
onChange={( onChange={handleDeliveryCheckboxChange}
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter((i) => i !== idx)
);
}
}}
classNames={{ classNames={{
wrapper: 'flex justify-center', wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm', checkbox: 'checkbox checkbox-sm',
@@ -1507,24 +1562,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required required
placeholder='Pilih produk...' placeholder='Pilih produk...'
value={delivery.products[0]?.product ?? undefined} value={delivery.products[0]?.product ?? undefined}
onChange={(val) => { onChange={(val) =>
formik.setFieldTouched( handleDeliveryProductChange(idx, val)
`deliveries.${idx}.products.0.product`, }
true
);
formik.setFieldValue(
`deliveries.${idx}.products.0.product`,
val
);
formik.setFieldTouched(
`deliveries.${idx}.products.0.product_id`,
true
);
formik.setFieldValue(
`deliveries.${idx}.products.0.product_id`,
(val as OptionType)?.value
);
}}
options={getFilteredProductWarehouseOptions()} options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
@@ -1575,24 +1615,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required required
placeholder='Pilih supplier...' placeholder='Pilih supplier...'
value={delivery.supplier} value={delivery.supplier}
onChange={(val) => { onChange={(val) =>
formik.setFieldTouched( handleDeliverySupplierChange(idx, val)
`deliveries.${idx}.supplier`, }
true
);
formik.setFieldValue(
`deliveries.${idx}.supplier`,
val
);
formik.setFieldTouched(
`deliveries.${idx}.supplier_id`,
true
);
formik.setFieldValue(
`deliveries.${idx}.supplier_id`,
(val as OptionType)?.value
);
}}
options={supplierOptions} options={supplierOptions}
onInputChange={setSupplierSelectInputValue} onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers} isLoading={isLoadingSuppliers}
@@ -1684,20 +1709,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<FileInput <FileInput
accept='.pdf,.jpg,.jpeg,.png' accept='.pdf,.jpg,.jpeg,.png'
name={`deliveries.${idx}.document`} name={`deliveries.${idx}.document`}
onChange={(e) => { onChange={(e) =>
const file = e.target.files?.[0]; handleDeliveryDocumentChange(idx, e)
if (file) { }
if (file.size > 5 * 1024 * 1024) {
toast.error('Ukuran dokumen maksimal 5 MB!');
e.target.value = '';
return;
}
formik.setFieldValue(
`deliveries.${idx}.document`,
file
);
}
}}
{...isRepeaterInputError( {...isRepeaterInputError(
'deliveries', 'deliveries',
'document', 'document',
@@ -91,7 +91,7 @@ const InventoryProductDetail = ({
<td>:</td> <td>:</td>
<td> <td>
{inventoryProduct?.tax {inventoryProduct?.tax
? formatCurrency(inventoryProduct?.tax) ? formatNumber(inventoryProduct?.tax) + '%'
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -16,7 +16,7 @@ import { TableRowSizeSelector } from '@/components/table/TableRowSizeSelector';
import { TableToolbar } from '@/components/table/TableToolbar'; import { TableToolbar } from '@/components/table/TableToolbar';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { import {
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
@@ -33,6 +33,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { useAuth } from '@/services/hooks/useAuth'; import { useAuth } from '@/services/hooks/useAuth';
import { CustomerApi, ProductApi } from '@/services/api/master-data'; import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line'; import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import Badge from '@/components/Badge';
const RowsOptionsMenu = ({ const RowsOptionsMenu = ({
type = 'dropdown', type = 'dropdown',
@@ -184,12 +185,16 @@ const MarketingTable = () => {
const { const {
options: productsOptions, options: productsOptions,
isLoadingOptions: isLoadingProductsOptions, isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect(ProductApi.basePath, 'id', 'name', '', { } = useSelect(ProductApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
const { const {
options: customersOptions, options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions, isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', '', { } = useSelect(CustomerApi.basePath, 'id', 'name', '', {
limit: 'limit', limit: 'limit',
}); });
@@ -400,6 +405,8 @@ const MarketingTable = () => {
.join(',') || '' .join(',') || ''
) )
} }
onInputChange={setProductsInputValue}
onMenuScrollToBottom={loadMoreProducts}
isMulti isMulti
/> />
{/* select status */} {/* select status */}
@@ -444,6 +451,8 @@ const MarketingTable = () => {
(value as OptionType)?.value.toString() || '' (value as OptionType)?.value.toString() || ''
) )
} }
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/> />
</TableRowSizeSelector> </TableRowSizeSelector>
</div> </div>
@@ -512,8 +521,53 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'latest_approval.step_name', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return (
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-full flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
);
},
}, },
{ {
accessorKey: 'customer.name', accessorKey: 'customer.name',
@@ -16,6 +16,7 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
formatNumber, formatNumber,
formatTitleCase,
formatVechicleNumber, formatVechicleNumber,
} from '@/lib/helper'; } from '@/lib/helper';
import { import {
@@ -34,6 +35,7 @@ import toast from 'react-hot-toast';
import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport'; import SalesOrderExport from '@/components/pages/marketing/pdf/SalesOrderExport';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport'; import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import Badge from '@/components/Badge';
const MarketingDetail = ({ const MarketingDetail = ({
initialValues, initialValues,
@@ -121,6 +123,10 @@ const MarketingDetail = ({
); );
}; };
const approval = initialValues?.latest_approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return ( return (
<> <>
<div className='flex flex-col w-full gap-4'> <div className='flex flex-col w-full gap-4'>
@@ -230,7 +236,46 @@ const MarketingDetail = ({
<tr> <tr>
<td className='font-semibold'>Status</td> <td className='font-semibold'>Status</td>
<td>:</td> <td>:</td>
<td>{initialValues?.latest_approval?.step_name}</td> <td>
<Badge
variant='soft'
className={{
badge:
'rounded-lg px-2 w-fit flex flex-row justify-start whitespace-nowrap',
}}
color={
isRejected
? 'error'
: isApproved
? approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
}
>
<Icon
icon='mdi:circle'
width={12}
height={12}
color={
approval?.step_number == 1
? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
}
/>
{isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge>
</td>
</tr> </tr>
<tr> <tr>
<td className='font-semibold'>Tanggal Penjualan</td> <td className='font-semibold'>Tanggal Penjualan</td>
@@ -633,7 +633,9 @@ const MarketingForm = ({
isClearable isClearable
placeholder='Pilih Pelanggan' placeholder='Pilih Pelanggan'
isDisabled={ isDisabled={
formType === 'add_deliver' || formType === 'edit_deliver' formType === 'add_deliver' ||
formType === 'edit_deliver' ||
formType === 'edit'
} }
/> />
<DateInput <DateInput
@@ -7,6 +7,7 @@ import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import { format } from 'path'; import { format } from 'path';
import { date } from 'yup'; import { date } from 'yup';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
interface DeliveryOrderExportProps { interface DeliveryOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -23,7 +24,7 @@ const DeliveryOrderExport = ({
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!salesData) { if (!salesData) {
alert('No sales order data available'); toast.error('No sales order data available');
return; return;
} }
setIsGeneratingPDF(true); setIsGeneratingPDF(true);
@@ -40,8 +41,7 @@ const DeliveryOrderExport = ({
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error('Error generating PDF:', error); toast.error('Failed to generate PDF. Please try again.');
alert('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
} }
@@ -5,6 +5,7 @@ import { Document, Image, Page, pdf, Text, View } from '@react-pdf/renderer';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast';
interface SalesOrderExportProps { interface SalesOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -17,7 +18,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!salesData) { if (!salesData) {
alert('No sales order data available'); toast.error('No sales order data available');
return; return;
} }
setIsGeneratingPDF(true); setIsGeneratingPDF(true);
@@ -32,8 +33,7 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error('Error generating PDF:', error); toast.error('Failed to generate PDF. Please try again.');
alert('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
} }
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Area } from '@/types/api/master-data/area'; import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const AreasTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await AreaApi.delete(selectedArea?.id as number); const deleteResponse = await AreaApi.delete(selectedArea?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshAreas(); refreshAreas();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data'; import { BankApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -177,7 +177,14 @@ const BanksTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await BankApi.delete(selectedBank?.id as number); const deleteResponse = await BankApi.delete(selectedBank?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshBanks(); refreshBanks();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -186,7 +186,16 @@ const CustomersTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await CustomerApi.delete(selectedCustomer?.id as number); const deleteResponse = await CustomerApi.delete(
selectedCustomer?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshCustomers(); refreshCustomers();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Fcr } from '@/types/api/master-data/fcr'; import { Fcr } from '@/types/api/master-data/fcr';
import { FcrApi } from '@/services/api/master-data'; import { FcrApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const FcrsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await FcrApi.delete(selectedFcr?.id as number); const deleteResponse = await FcrApi.delete(selectedFcr?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFcrs(); refreshFcrs();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -19,7 +19,7 @@ import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
const RowsOptions = ({ const RowsOptions = ({
@@ -33,22 +33,6 @@ const RowsOptions = ({
}) => { }) => {
return ( return (
<RowOptionsMenuWrapper type={type}> <RowOptionsMenuWrapper type={type}>
<RequirePermission permissions='lti.master.flocks.update'>
<Button
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.detail'> <RequirePermission permissions='lti.master.flocks.detail'>
<Button <Button
href={`/master-data/flock/detail/?flockId=${props.row.original.id}`} href={`/master-data/flock/detail/?flockId=${props.row.original.id}`}
@@ -65,6 +49,22 @@ const RowsOptions = ({
Detail Detail
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.master.flocks.update'>
<Button
href={`/master-data/flock/detail/edit/?flockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon
icon='material-symbols:edit-outline'
width={16}
height={16}
className='justify-start text-sm'
/>
Edit
</Button>
</RequirePermission>
<RequirePermission permissions='lti.master.flocks.delete'> <RequirePermission permissions='lti.master.flocks.delete'>
<Button <Button
onClick={deleteClickHandler} onClick={deleteClickHandler}
@@ -182,7 +182,14 @@ const FlockTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await FlockApi.delete(selectedFlock?.id as number); const deleteResponse = await FlockApi.delete(selectedFlock?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshFlocks(); refreshFlocks();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -19,6 +19,8 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import { toast } from 'react-hot-toast';
import Alert from '@/components/Alert';
interface FlockCustomProps { interface FlockCustomProps {
formType?: 'add' | 'edit' | 'detail'; formType?: 'add' | 'edit' | 'detail';
@@ -37,7 +39,13 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await FlockApi.delete(initialValues?.id as number); const deleteFlockRes = await FlockApi.delete(initialValues?.id as number);
if (deleteFlockRes?.status === 'error') {
setFlockFormErrorMessage(deleteFlockRes.message);
return;
}
toast.success(deleteFlockRes?.message as string);
deleteModal.closeModal(); deleteModal.closeModal();
setIsDeleteLoading(false); setIsDeleteLoading(false);
@@ -68,12 +76,29 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
// cek type form yang disubmit // cek type form yang disubmit
switch (formType) { switch (formType) {
case 'add': case 'add': {
await FlockApi.create(payload); const createFlockRes = await FlockApi.create(payload);
if (createFlockRes?.status === 'error') {
setFlockFormErrorMessage(createFlockRes.message);
return;
}
toast.success(createFlockRes?.message as string);
break; break;
case 'edit': }
await FlockApi.update(initialValues?.id as number, payload); case 'edit': {
const updateFlockRes = await FlockApi.update(
initialValues?.id as number,
payload
);
if (updateFlockRes?.status === 'error') {
setFlockFormErrorMessage(updateFlockRes.message);
return;
}
toast.success(updateFlockRes?.message as string);
break; break;
}
default: default:
break; break;
} }
@@ -174,6 +199,24 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
)} )}
<AlertErrorList formErrorList={formErrorList} onClose={close} /> <AlertErrorList formErrorList={formErrorList} onClose={close} />
{flockFormErrorMessage && (
<Alert color='error' className='w-full'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
{flockFormErrorMessage}
<Button
onClick={() => setFlockFormErrorMessage('')}
variant='link'
className='ml-auto p-0 w-fit text-white'
color='none'
>
<Icon icon='material-symbols:close' width={24} height={24} />
</Button>
</Alert>
)}
{formType !== 'detail' && ( {formType !== 'detail' && (
<div <div
@@ -197,17 +240,6 @@ const FlockForm = ({ formType = 'add', initialValues }: FlockCustomProps) => {
</div> </div>
)} )}
</div> </div>
{flockFormErrorMessage && (
<div role='alert' className='alert alert-error'>
<Icon
icon='material-symbols:error-outline'
width={24}
height={24}
/>
<span>{flockFormErrorMessage}</span>
</div>
)}
</form> </form>
</section> </section>
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { KandangApi } from '@/services/api/master-data'; import { KandangApi } from '@/services/api/master-data';
import { cn, formatNumber } from '@/lib/helper'; import { cn, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -199,7 +199,16 @@ const KandangsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await KandangApi.delete(selectedKandang?.id as number); const deleteResponse = await KandangApi.delete(
selectedKandang?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshKandangs(); refreshKandangs();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -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}
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -186,7 +186,16 @@ const LocationsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await LocationApi.delete(selectedLocation?.id as number); const deleteResponse = await LocationApi.delete(
selectedLocation?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshLocations(); refreshLocations();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Nonstock } from '@/types/api/master-data/nonstock'; import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data'; import { NonstockApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -198,7 +198,16 @@ const NonstocksTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await NonstockApi.delete(selectedNonstock?.id as number); const deleteResponse = await NonstockApi.delete(
selectedNonstock?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshNonstocks(); refreshNonstocks();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -83,7 +83,7 @@ const NonstockForm = ({ type = 'add', initialValues }: NonstockFormProps) => {
const formikInitialValues = useMemo<NonstockFormValues>(() => { const formikInitialValues = useMemo<NonstockFormValues>(() => {
return { return {
name: initialValues?.name ?? '', name: initialValues?.name ?? '',
uomId: initialValues?.uom_id ?? 0, uomId: initialValues?.uom?.id ?? 0,
uom: initialValues?.uom uom: initialValues?.uom
? { ? {
value: initialValues?.uom?.id, value: initialValues?.uom?.id,
@@ -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}
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -170,7 +170,16 @@ const ProductCategoryTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ProductCategoryApi.delete(selectedProductCategory?.id as number); const deleteResponse = await ProductCategoryApi.delete(
selectedProductCategory?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductCategories(); refreshProductCategories();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { ProductApi } from '@/services/api/master-data'; import { ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -230,8 +230,19 @@ const ProductsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ProductApi.delete(selectedProduct?.id as number);
const deleteResponse = await ProductApi.delete(
selectedProduct?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProducts(); refreshProducts();
deleteModal.closeModal(); deleteModal.closeModal();
toast.success('Successfully delete Product!'); toast.success('Successfully delete Product!');
setIsDeleteLoading(false); setIsDeleteLoading(false);
@@ -3,7 +3,7 @@ import * as Yup from 'yup';
type ProductFormSchemaType = { type ProductFormSchemaType = {
name: string; name: string;
brand: string; brand: string;
sku: string; sku?: string;
uom?: { uom?: {
value: number; value: number;
label: string; label: string;
@@ -15,10 +15,16 @@ type ProductFormSchemaType = {
} | null; } | null;
product_category_id: number; product_category_id: number;
product_price: number | string; product_price: number | string;
selling_price: number | string; selling_price?: number | string;
tax: number | string; tax?: number | string;
expiry_period: number | string; expiry_period?: number | string;
supplier_ids: number[]; suppliers: {
supplier: {
value: number;
label: string;
} | null;
price: number;
}[];
flags: string[]; flags: string[];
}; };
@@ -26,7 +32,7 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
Yup.object({ Yup.object({
name: Yup.string().required('Nama wajib diisi!'), name: Yup.string().required('Nama wajib diisi!'),
brand: Yup.string().required('Merek wajib diisi!'), brand: Yup.string().required('Merek wajib diisi!'),
sku: Yup.string().required('SKU wajib diisi!'), sku: Yup.string(),
uom: Yup.object({ uom: Yup.object({
value: Yup.number() value: Yup.number()
@@ -58,24 +64,34 @@ export const ProductFormSchema: Yup.ObjectSchema<ProductFormSchemaType> =
.min(1, 'Harga produk tidak boleh kurang dari 1!'), .min(1, 'Harga produk tidak boleh kurang dari 1!'),
selling_price: Yup.number() selling_price: Yup.number()
.required('Harga jual wajib diisi!') .typeError('Harga hanya boleh angka!')
.typeError('Harga jual wajib diisi!')
.min(1, 'Harga jual tidak boleh kurang dari 1!'), .min(1, 'Harga jual tidak boleh kurang dari 1!'),
tax: Yup.number() tax: Yup.number()
.required('Pajak wajib diisi!') .typeError('Pajak hanya boleh angka!')
.typeError('Pajak wajib diisi!')
.min(0, 'Pajak tidak boleh kurang dari 0!') .min(0, 'Pajak tidak boleh kurang dari 0!')
.max(100, 'Pajak tidak boleh lebih dari 100%!'), .max(100, 'Pajak tidak boleh lebih dari 100%!'),
expiry_period: Yup.number() expiry_period: Yup.number()
.required('Periode kadaluarsa wajib diisi!') .typeError('Periode kadaluarsa hanya boleh angka!')
.typeError('Periode kadaluarsa wajib diisi!')
.min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'), .min(1, 'Periode kadaluarsa tidak boleh kurang dari 1 hari!'),
supplier_ids: Yup.array() suppliers: Yup.array()
.of(Yup.number().required().typeError('Supplier tidak valid!')) .of(
.min(1, 'Minimal harus ada 1 supplier!') Yup.object({
supplier: Yup.object({
value: Yup.number()
.min(1, 'Supplier wajib dipilih!')
.required('Supplier wajib dipilih!')
.typeError('Supplier wajib dipilih!'),
label: Yup.string().required('Supplier wajib dipilih!'),
}).required('Supplier wajib dipilih!'),
price: Yup.number()
.min(1, 'Harga tidak boleh kurang dari 1!')
.required('Harga wajib diisi!')
.typeError('Harga wajib diisi!'),
})
)
.required('Supplier wajib diisi!'), .required('Supplier wajib diisi!'),
flags: Yup.array() flags: Yup.array()
@@ -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' && (
@@ -7,7 +7,7 @@ import { ProductionStandard } from '@/types/api/master-data/production-standard'
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import useSWR from 'swr'; import useSWR from 'swr';
import { ProductionStandardApi } from '@/services/api/master-data'; import { ProductionStandardApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import { CellContext } from '@tanstack/react-table'; import { CellContext } from '@tanstack/react-table';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
@@ -94,9 +94,16 @@ const ProductionStandardTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await ProductionStandardApi.delete( const deleteResponse = await ProductionStandardApi.delete(
selectedProductionStandard?.id as number selectedProductionStandard?.id as number
); );
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshProductionStandards(); refreshProductionStandards();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -2,34 +2,30 @@ import * as Yup from 'yup';
// Schema for LAYING category (production_standard_details is required) // Schema for LAYING category (production_standard_details is required)
const LayingRepeaterFormSchema = Yup.object({ const LayingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'), week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({ production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'), target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'), max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'), min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'), feed_intake: Yup.number().required('Wajib diisi!'),
}), }),
production_standard_details: Yup.object({ production_standard_details: Yup.object({
target_hen_day_production: Yup.number().required( target_hen_day_production: Yup.number().required('Wajib diisi!'),
'Produksi telur per hari wajib diisi!' target_hen_house_production: Yup.number().required('Wajib diisi!'),
), target_egg_weight: Yup.number().required('Wajib diisi!'),
target_hen_house_production: Yup.number().required( target_egg_mass: Yup.number().required('Wajib diisi!'),
'Produksi telur per kandang wajib diisi!' standard_fcr: Yup.number().required('Wajib diisi!'),
),
target_egg_weight: Yup.number().required('Berat telur wajib diisi!'),
target_egg_mass: Yup.number().required('Massa telur wajib diisi!'),
standard_fcr: Yup.number().required('FCR wajib diisi!'),
}).required(), }).required(),
}); });
// Schema for GROWING category (production_standard_details is optional) // Schema for GROWING category (production_standard_details is optional)
const GrowingRepeaterFormSchema = Yup.object({ const GrowingRepeaterFormSchema = Yup.object({
week: Yup.number().required('Minggu wajib diisi!'), week: Yup.number().required('Wajib diisi!'),
production_standard_uniformity_details: Yup.object({ production_standard_uniformity_details: Yup.object({
target_mean_bw: Yup.number().required('Berat rata-rata wajib diisi!'), target_mean_bw: Yup.number().required('Wajib diisi!'),
max_depletion: Yup.number().required('Maksimal depletion wajib diisi!'), max_depletion: Yup.number().required('Wajib diisi!'),
min_uniformity: Yup.number().required('Minimal uniformitas wajib diisi!'), min_uniformity: Yup.number().required('Wajib diisi!'),
feed_intake: Yup.number().required('Pengambilan makanan wajib diisi!'), feed_intake: Yup.number().required('Wajib diisi!'),
}), }),
production_standard_details: Yup.object({ production_standard_details: Yup.object({
target_hen_day_production: Yup.number().optional(), target_hen_day_production: Yup.number().optional(),
@@ -344,7 +344,7 @@ const ProductionStandardForm = ({
const columns = useMemo<ColumnDef<TableRowsType>[]>(() => { const columns = useMemo<ColumnDef<TableRowsType>[]>(() => {
const baseColumns: ColumnDef<TableRowsType>[] = [ const baseColumns: ColumnDef<TableRowsType>[] = [
{ {
header: 'Minggu', header: 'Week',
accessorKey: 'week', accessorKey: 'week',
enableSorting: false, enableSorting: false,
}, },
@@ -358,30 +358,40 @@ const ProductionStandardForm = ({
header: 'Hen Day', header: 'Hen Day',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_hen_day_production, row.production_standard_details?.target_hen_day_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_day_production}%`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Hen House', header: 'Hen House',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_hen_house_production, row.production_standard_details?.target_hen_house_production,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_hen_house_production} pc`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Egg Weight', header: 'Egg Weight',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_egg_weight, row.production_standard_details?.target_egg_weight,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_weight} g`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Egg Mass', header: 'Egg Mass',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.target_egg_mass, row.production_standard_details?.target_egg_mass,
cell: ({ row }) =>
`${row.original.production_standard_details?.target_egg_mass} g`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'FCR', header: 'FCR',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_details?.standard_fcr, row.production_standard_details?.standard_fcr,
cell: ({ row }) =>
`${row.original.production_standard_details?.standard_fcr} g`,
enableSorting: false, enableSorting: false,
}, },
] ]
@@ -393,24 +403,32 @@ const ProductionStandardForm = ({
header: 'Mean BW', header: 'Mean BW',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.target_mean_bw, row.production_standard_uniformity_details?.target_mean_bw,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.target_mean_bw} g`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Max Depletion', header: 'Max Depletion',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.max_depletion, row.production_standard_uniformity_details?.max_depletion,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.max_depletion}%`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Min Uniformity', header: 'Min Uniformity',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.min_uniformity, row.production_standard_uniformity_details?.min_uniformity,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.min_uniformity}%`,
enableSorting: false, enableSorting: false,
}, },
{ {
header: 'Feed Intake', header: 'Feed Intake',
accessorFn: (row) => accessorFn: (row) =>
row.production_standard_uniformity_details?.feed_intake, row.production_standard_uniformity_details?.feed_intake,
cell: ({ row }) =>
`${row.original.production_standard_uniformity_details?.feed_intake} g`,
enableSorting: false, enableSorting: false,
}, },
]; ];
@@ -728,7 +746,52 @@ const ProductionStandardForm = ({
}; };
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
formik,
{
onBeforeSubmit: (e) => {
e.preventDefault();
// For GROWING category, clear production_standard_details errors and set default values
if (formik.values.project_category === 'GROWING') {
// Set default values for production_standard_details
formik.values.details?.forEach((detail) => {
detail.production_standard_details = {
target_hen_day_production: 0,
target_hen_house_production: 0,
target_egg_weight: 0,
target_egg_mass: 0,
standard_fcr: 0,
};
});
// Clear any errors related to production_standard_details
const currentErrors = { ...formik.errors };
if (currentErrors.details && Array.isArray(currentErrors.details)) {
const cleanedDetails = currentErrors.details
.map((detailError) => {
if (detailError && typeof detailError === 'object') {
const { production_standard_details, ...rest } = detailError;
return Object.keys(rest).length > 0 ? rest : undefined;
}
return detailError;
})
.filter(
(error): error is Exclude<typeof error, undefined> =>
error !== undefined
);
currentErrors.details = (
cleanedDetails.length > 0 ? cleanedDetails : undefined
) as typeof currentErrors.details;
}
formik.setErrors(currentErrors);
}
return true;
},
}
);
return ( return (
<> <>
@@ -821,19 +884,20 @@ const ProductionStandardForm = ({
key={`row-${row.index}`} key={`row-${row.index}`}
className='sticky bottom-0 bg-base-100 shadow-lg' className='sticky bottom-0 bg-base-100 shadow-lg'
> >
<td colSpan={colSpan} className='p-6'> <td colSpan={colSpan} className='p-2'>
<form <form
className='h-full w-full flex flex-col justify-end' className='h-full w-full flex flex-col justify-end'
onSubmit={repeaterFormik.handleSubmit} onSubmit={repeaterFormik.handleSubmit}
onReset={repeaterFormik.handleReset} onReset={repeaterFormik.handleReset}
> >
<div <div
className={cn( className='grid gap-2 items-start w-full'
'grid gap-4 items-start', style={{
formik.values.project_category === 'LAYING' gridTemplateColumns:
? 'grid-cols-10' formik.values.project_category === 'LAYING'
: 'grid-cols-5' ? 'repeat(10, minmax(auto, 1fr)) minmax(auto, auto)'
)} : 'repeat(4, minmax(auto, 1fr)) minmax(auto, auto)',
}}
> >
<NumberInput <NumberInput
name='week' name='week'
@@ -862,7 +926,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />} bottomLabel='Persen (%)'
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -894,11 +958,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Butir (pc)'
<div className='w-full h-full flex items-center justify-center'>
Butir
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -930,11 +990,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram (g)'
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -959,17 +1015,13 @@ const ProductionStandardForm = ({
name='production_standard_details.target_egg_mass' name='production_standard_details.target_egg_mass'
label='Egg Mass' label='Egg Mass'
placeholder='1' placeholder='1'
bottomLabel='Gram (g)'
value={ value={
repeaterFormik.values repeaterFormik.values
.production_standard_details?.target_egg_mass .production_standard_details?.target_egg_mass
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -1000,11 +1052,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram (g)'
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={getProductionDetailsError( errorMessage={getProductionDetailsError(
repeaterFormik.errors repeaterFormik.errors
.production_standard_details, .production_standard_details,
@@ -1038,11 +1086,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram (g)'
<div className='w-full h-full flex items-center justify-center'>
gr
</div>
}
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1072,7 +1116,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />} bottomLabel='Persen (%)'
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1102,7 +1146,7 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={<Icon icon='mdi:percent' />} bottomLabel='Persen (%)'
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1132,11 +1176,8 @@ const ProductionStandardForm = ({
} }
onChange={repeaterFormik.handleChange} onChange={repeaterFormik.handleChange}
onBlur={repeaterFormik.handleBlur} onBlur={repeaterFormik.handleBlur}
endAdornment={ bottomLabel='Gram/Ekor (g)'
<div className='w-full h-full flex items-center justify-center'> endAdornment
gr/ekor
</div>
}
errorMessage={ errorMessage={
repeaterFormik.errors repeaterFormik.errors
.production_standard_uniformity_details .production_standard_uniformity_details
@@ -1162,7 +1203,7 @@ const ProductionStandardForm = ({
type='button' type='button'
color='error' color='error'
variant='outline' variant='outline'
className='min-w-24' className='min-w-xs'
onClick={handleCancelEdit} onClick={handleCancelEdit}
> >
<Icon icon='mdi:close' /> Batal <Icon icon='mdi:close' /> Batal
@@ -1178,7 +1219,7 @@ const ProductionStandardForm = ({
<Button <Button
type='submit' type='submit'
color={editMode ? 'warning' : 'success'} color={editMode ? 'warning' : 'success'}
className='min-w-24' className='min-w-xs'
disabled={ disabled={
isAddingRow || isAddingRow ||
formik.values.project_category === '' formik.values.project_category === ''
@@ -1195,7 +1236,7 @@ const ProductionStandardForm = ({
variant='outline' variant='outline'
color='primary' color='primary'
onClick={toggleTableHeight} onClick={toggleTableHeight}
className='absolute bottom-6 right-6' className='absolute bottom-2 right-2'
> >
<Icon <Icon
icon={ icon={
@@ -11,7 +11,7 @@ import RowDropdownOptions from '@/components/table/RowDropdownOptions';
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -205,7 +205,16 @@ const SuppliersTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await SupplierApi.delete(selectedSupplier?.id as number); const deleteResponse = await SupplierApi.delete(
selectedSupplier?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshSuppliers(); refreshSuppliers();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -20,7 +20,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Uom } from '@/types/api/master-data/uom'; import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data'; import { UomApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -164,7 +164,14 @@ const UomsTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await UomApi.delete(selectedUom?.id as number); const deleteResponse = await UomApi.delete(selectedUom?.id as number);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshUoms(); refreshUoms();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -25,7 +25,7 @@ import RequirePermission from '@/components/helper/RequirePermission';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi } from '@/services/api/master-data'; import { WarehouseApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
@@ -220,7 +220,16 @@ const WarehousesTable = () => {
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
await WarehouseApi.delete(selectedWarehouse?.id as number); const deleteResponse = await WarehouseApi.delete(
selectedWarehouse?.id as number
);
if (isResponseError(deleteResponse)) {
toast.error(deleteResponse.message);
setIsDeleteLoading(false);
return;
}
refreshWarehouses(); refreshWarehouses();
deleteModal.closeModal(); deleteModal.closeModal();
@@ -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}
@@ -75,12 +75,12 @@ const ChickinFormKandang = ({
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Badge <Badge
variant='soft' variant='soft'
color='success' color='primary'
className={{ className={{
badge: 'rounded-lg px-2', badge: 'rounded-lg px-2',
}} }}
> >
<Icon icon='mdi:circle' width={12} height={12} color='success' />{' '} <Icon icon='mdi:circle' width={12} height={12} color='primary' />{' '}
Aktif Aktif
</Badge> </Badge>
<div className='divider divider-horizontal p-0 m-0'></div> <div className='divider divider-horizontal p-0 m-0'></div>
@@ -5,14 +5,17 @@ import Button from '@/components/Button';
import FloatingActionsButton from '@/components/FloatingActionsButton'; import FloatingActionsButton from '@/components/FloatingActionsButton';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput'; import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ROWS_OPTIONS } from '@/config/constant'; import { ROWS_OPTIONS } from '@/config/constant';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate, formatTitleCase } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production/project-flock'; import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -59,9 +62,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const selectedRowIds = Object.keys(rowSelection) const selectedRowIds = Object.keys(rowSelection)
.filter((id) => rowSelection[id]) .filter((id) => rowSelection[id])
.map((id) => parseInt(id)); .map((id) => parseInt(id));
const [locationSelectInputValue, setLocationSelectInputValue] = useState('');
const [areaSelectInputValue, setAreaSelectInputValue] = useState('');
const [kandangSelectInputValue, setKandangSelectInputValue] = useState('');
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null); const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null null
@@ -90,55 +90,25 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
{ revalidateOnMount: true } { revalidateOnMount: true }
); );
const areaUrl = `${AreaApi.basePath}?${new URLSearchParams({ // ===== Fetch Data Select =====
search: areaSelectInputValue, const {
limit: '100', options: optionsArea,
}).toString()}`; isLoadingOptions: isLoadingArea,
const { data: areas, isLoading: isLoadingAreas } = useSWR( setInputValue: setAreaSelectInputValue,
areaUrl, loadMore: loadMoreArea,
AreaApi.getAllFetcher } = useSelect(AreaApi.basePath, 'id', 'name');
); const {
options: optionsLocation,
const locationUrl = `${LocationApi.basePath}?${new URLSearchParams({ isLoadingOptions: isLoadingLocation,
search: locationSelectInputValue, setInputValue: setLocationSelectInputValue,
area_id: selectedArea != null ? selectedArea.value.toString() : '', loadMore: loadMoreLocation,
limit: '100', } = useSelect(LocationApi.basePath, 'id', 'name');
}).toString()}`; const {
const { data: locations, isLoading: isLoadingLocations } = useSWR( options: optionsKandang,
locationUrl, isLoadingOptions: isLoadingKandang,
LocationApi.getAllFetcher setInputValue: setKandangSelectInputValue,
); loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name');
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: kandangSelectInputValue,
location_id:
selectedLocation != null ? selectedLocation.value.toString() : '',
limit: '100',
}).toString()}`;
const { data: kandangs, isLoading: isLoadingKandang } = useSWR(
kandangUrl,
KandangApi.getAllFetcher
);
// ===== Data to Options Mapping ======
const optionsArea = isResponseSuccess(areas)
? areas?.data.map((area) => ({
value: area.id,
label: area.name,
}))
: [];
const optionsKandang = isResponseSuccess(kandangs)
? kandangs?.data.map((kandang) => ({
value: kandang.id,
label: kandang.name,
}))
: [];
const optionsLocation = isResponseSuccess(locations)
? locations?.data.map((location) => ({
value: location.id,
label: location.name,
}))
: [];
// ====== HANDLER ====== // ====== HANDLER ======
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -284,7 +254,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
header: 'Status', header: 'Status',
cell: (props) => { cell: (props) => {
const approval = props.row.original.approval; const approval = props.row.original.approval;
const isRejected = approval?.action == 'REJECTED';
const isApproved = approval?.action == 'APPROVED';
return ( return (
<Badge <Badge
variant='soft' variant='soft'
@@ -292,11 +263,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
badge: 'rounded-lg px-2 w-full flex flex-row justify-start', badge: 'rounded-lg px-2 w-full flex flex-row justify-start',
}} }}
color={ color={
approval?.step_number == 1 isRejected
? 'neutral' ? 'error'
: approval?.step_number == 2 : isApproved
? 'success' ? approval?.step_number == 1
: 'error' ? 'neutral'
: approval?.step_number == 2
? 'primary'
: approval?.step_number == 3
? 'success'
: 'neutral'
: 'neutral'
} }
> >
<Icon <Icon
@@ -307,11 +284,15 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
approval?.step_number == 1 approval?.step_number == 1
? 'neutral' ? 'neutral'
: approval?.step_number == 2 : approval?.step_number == 2
? 'success' ? 'primary'
: 'error' : approval?.step_number == 3
? 'success'
: 'neutral'
} }
/> />
{approval?.step_name} {isRejected
? 'Ditolak'
: formatTitleCase(approval?.step_name || '')}
</Badge> </Badge>
); );
}, },
@@ -385,7 +366,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<SelectInput <SelectInput
label='Area' label='Area'
options={optionsArea} options={optionsArea}
isLoading={isLoadingAreas} isLoading={isLoadingArea}
value={selectedArea} value={selectedArea}
onChange={(val) => { onChange={(val) => {
setSelectedArea(val as OptionType); setSelectedArea(val as OptionType);
@@ -395,12 +376,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}} }}
onInputChange={setAreaSelectInputValue} onInputChange={setAreaSelectInputValue}
onMenuScrollToBottom={loadMoreArea}
isClearable isClearable
/> />
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
options={optionsLocation} options={optionsLocation}
isLoading={isLoadingLocations} isLoading={isLoadingLocation}
value={selectedLocation} value={selectedLocation}
onChange={(val) => { onChange={(val) => {
setSelectedLocation(val as OptionType); setSelectedLocation(val as OptionType);
@@ -410,6 +392,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}} }}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
onMenuScrollToBottom={loadMoreLocation}
isClearable isClearable
/> />
<SelectInput <SelectInput
@@ -425,6 +408,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}} }}
onInputChange={setKandangSelectInputValue} onInputChange={setKandangSelectInputValue}
onMenuScrollToBottom={loadMoreKandang}
isClearable isClearable
/> />
<DebouncedTextInput <DebouncedTextInput
@@ -156,9 +156,9 @@ const ProjectFlockDetail = ({
projectFlock.approval?.step_number == 1 projectFlock.approval?.step_number == 1
? 'neutral' ? 'neutral'
: projectFlock.approval?.step_number == 2 : projectFlock.approval?.step_number == 2
? 'success' ? 'primary'
: projectFlock.approval?.step_number >= 3 : projectFlock.approval?.step_number == 3
? 'error' ? 'success'
: undefined : undefined
} }
className={{ className={{
@@ -173,9 +173,9 @@ const ProjectFlockDetail = ({
projectFlock.approval?.step_number == 1 projectFlock.approval?.step_number == 1
? 'neutral' ? 'neutral'
: projectFlock.approval?.step_number == 2 : projectFlock.approval?.step_number == 2
? 'success' ? 'primary'
: projectFlock.approval?.step_number >= 3 : projectFlock.approval?.step_number == 3
? 'error' ? 'success'
: undefined : undefined
} }
/>{' '} />{' '}
@@ -273,7 +273,7 @@ const ProjectFlockDetail = ({
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
<Badge <Badge
variant='soft' variant='soft'
color={'success'} color={'primary'}
className={{ className={{
badge: 'rounded-lg px-2', badge: 'rounded-lg px-2',
}} }}
@@ -282,7 +282,7 @@ const ProjectFlockDetail = ({
icon='mdi:circle' icon='mdi:circle'
width={12} width={12}
height={12} height={12}
color={'success'} color={'primary'}
/>{' '} />{' '}
Kandang Aktif ({projectFlock.kandangs?.length}) Kandang Aktif ({projectFlock.kandangs?.length})
</Badge> </Badge>
@@ -102,41 +102,54 @@ const ProjectFlockForm = ({
); );
// Fetch Data // Fetch Data
const { isLoadingOptions: isLoadingFlocks, options: optionsFlock } = const {
useSelect(FlockApi.basePath, 'id', 'name'); setInputValue: setInputValueFlock,
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
});
const { options: optionsArea, isLoadingOptions: isLoadingAreas } = useSelect( const {
AreaApi.basePath, setInputValue: setInputValueArea,
'id', options: optionsArea,
'name' isLoadingOptions: isLoadingAreas,
); loadMore: loadMoreArea,
} = useSelect(AreaApi.basePath, 'id', 'name');
const { options: optionsLocation, isLoadingOptions: isLoadingLocations } = const {
useSelect(LocationApi.basePath, 'id', 'name', '', { options: optionsLocation,
area_id: isLoadingOptions: isLoadingLocations,
selectedArea != '' setInputValue: setInputValueLocation,
? selectedArea loadMore: loadMoreLocation,
: ((initialValues?.area?.id ?? '') as string), } = useSelect(LocationApi.basePath, 'id', 'name', '', {
}); area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const { options: optionsFcr, isLoadingOptions: isLoadingFcrs } = useSelect( const {
FcrApi.basePath, options: optionsFcr,
'id', isLoadingOptions: isLoadingFcrs,
'name' setInputValue: setInputValueFcr,
); loadMore: loadMoreFcr,
} = useSelect(FcrApi.basePath, 'id', 'name');
const { const {
options: optionsProductionStandards, options: optionsProductionStandards,
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', { } = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
search: '',
project_category: selectedCategory, project_category: selectedCategory,
}); });
const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({ const kandangUrl = `${KandangApi.basePath}?${new URLSearchParams({
search: '', search: '',
location_id: selectedLocation == '' ? '0' : selectedLocation, location_id: selectedLocation == '' ? '0' : selectedLocation,
limit: 'limit', limit: '500',
}).toString()}`; }).toString()}`;
const { const {
data: kandang, data: kandang,
@@ -153,6 +166,8 @@ const ProjectFlockForm = ({
options: optionsNonstock, options: optionsNonstock,
rawData: nonstocks, rawData: nonstocks,
isLoadingOptions: isLoadingNonstocks, isLoadingOptions: isLoadingNonstocks,
setInputValue: setInputValueNonstock,
loadMore: loadMoreNonstock,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name'); } = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
useEffect(() => { useEffect(() => {
@@ -542,15 +557,12 @@ const ProjectFlockForm = ({
}; };
const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => { const onDeleteBudgetRowHandler = (nonstock_id: number, index?: number) => {
console.log(`nonstock_id: ${nonstock_id}, index: ${index}`);
if (!nonstock_id) { if (!nonstock_id) {
const updatedBudgets = formik.values.project_budgets const updatedBudgets = formik.values.project_budgets
.map((budget, i) => { .map((budget, i) => {
if (i == index) { if (i == index) {
console.log(`buget: ${null}, index: ${index}, i: ${i}`);
return null; return null;
} else { } else {
console.log(`buget: ${budget}, index: ${index}, i: ${i}`);
return budget; return budget;
} }
}) })
@@ -722,6 +734,8 @@ const ProjectFlockForm = ({
formik.touched.area_id && Boolean(formik.errors.area_id) formik.touched.area_id && Boolean(formik.errors.area_id)
} }
errorMessage={formik.errors.area_id as string} errorMessage={formik.errors.area_id as string}
onInputChange={setInputValueArea}
onMenuScrollToBottom={loadMoreArea}
isClearable isClearable
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
@@ -740,6 +754,8 @@ const ProjectFlockForm = ({
formik.touched.location_id && formik.touched.location_id &&
Boolean(formik.errors.location_id) Boolean(formik.errors.location_id)
} }
onInputChange={setInputValueLocation}
onMenuScrollToBottom={loadMoreLocation}
errorMessage={formik.errors.location_id as string} errorMessage={formik.errors.location_id as string}
isClearable isClearable
isDisabled={formType != 'add' || disabledLocation} isDisabled={formType != 'add' || disabledLocation}
@@ -766,6 +782,8 @@ const ProjectFlockForm = ({
); );
}} }}
options={optionsFlock} options={optionsFlock}
onInputChange={setInputValueFlock}
onMenuScrollToBottom={loadMoreFlock}
isLoading={isLoadingFlocks} isLoading={isLoadingFlocks}
isError={ isError={
formik.touched.flock_name && Boolean(formik.errors.flock_name) formik.touched.flock_name && Boolean(formik.errors.flock_name)
@@ -781,6 +799,8 @@ const ProjectFlockForm = ({
onChange={(val) => { onChange={(val) => {
optionChangeHandler(val, 'fcr'); optionChangeHandler(val, 'fcr');
}} }}
onInputChange={setInputValueFcr}
onMenuScrollToBottom={loadMoreFcr}
options={optionsFcr} options={optionsFcr}
isLoading={isLoadingFcrs} isLoading={isLoadingFcrs}
isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)} isError={formik.touched.fcr_id && Boolean(formik.errors.fcr_id)}
@@ -808,6 +828,8 @@ const ProjectFlockForm = ({
onChange={(val) => { onChange={(val) => {
optionChangeHandler(val, 'production_standard'); optionChangeHandler(val, 'production_standard');
}} }}
onInputChange={setInputValueProductionStandard}
onMenuScrollToBottom={loadMoreProductionStandard}
options={optionsProductionStandards} options={optionsProductionStandards}
isLoading={isLoadingProductionStandards} isLoading={isLoadingProductionStandards}
isError={ isError={
@@ -892,6 +914,8 @@ const ProjectFlockForm = ({
isLoading={isLoadingNonstocks} isLoading={isLoadingNonstocks}
placeholder='Pilih barang non stock' placeholder='Pilih barang non stock'
value={formik.values.project_budgets[index].nonstock} value={formik.values.project_budgets[index].nonstock}
onInputChange={setInputValueNonstock}
onMenuScrollToBottom={loadMoreNonstock}
onChange={(val) => { onChange={(val) => {
const updatedBudgets = [ const updatedBudgets = [
...formik.values.project_budgets, ...formik.values.project_budgets,
@@ -5,7 +5,7 @@ import { RefObject } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { SortingState, CellContext } from '@tanstack/react-table'; import { SortingState, CellContext } from '@tanstack/react-table';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate, formatNumber } from '@/lib/helper';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
@@ -656,30 +656,52 @@ const RecordingTable = () => {
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const recording = row.original;
const isDisabled = isRecordingApproved(recording);
const handleToggleSelection = (e: unknown) => {
if (!isDisabled) {
row.getToggleSelectedHandler()(e);
}
};
return ( return (
<div> <div className={cn({ 'opacity-50': isDisabled })}>
<CheckboxInput <CheckboxInput
name='row' name='row'
checked={row.getIsSelected()} checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected()} indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()} onChange={handleToggleSelection}
disabled={isDisabled}
/> />
</div> </div>
); );
}, },
}, },
{ {
header: '#', header: 'No',
cell: (props) => cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) + tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index + props.row.index +
1, 1,
}, },
{ {
header: 'Nama Project', header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
header: 'Flock',
cell: (props) => cell: (props) =>
props.row.original.project_flock?.flock_name || '-', props.row.original.project_flock?.flock_name || '-',
}, },
{
header: 'Kandang',
cell: (props) => props.row.original.kandang?.name || '-',
},
{
header: 'Periode',
cell: (props) => props.row.original.project_flock?.period || '-',
},
{ {
header: 'Kategori', header: 'Kategori',
cell: (props) => { cell: (props) => {
@@ -696,19 +718,280 @@ const RecordingTable = () => {
}, },
{ {
header: 'Umur (hari)', header: 'Umur (hari)',
cell: (props) => props.row.original.day, cell: (props) => {
return (
<>
<span>
{props.row.original.day} (Minggu ke-
{props.row.original.project_flock.production_standart.week})
</span>
</>
);
},
}, },
{ {
accessorKey: 'record_date',
header: 'Waktu Recording', header: 'Waktu Recording',
cell: (props) => cell: (props) =>
formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'), formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'),
}, },
{ {
header: 'Populasi Awal', header: 'Populasi Akhir',
cell: (props) => cell: (props) =>
props.row.original.project_flock?.total_chick_qty?.toLocaleString() || props.row.original.project_flock?.total_chick_qty != null
'-', ? formatNumber(props.row.original.project_flock.total_chick_qty)
: '-',
},
{
id: 'fcr',
header: 'FCR',
columns: [
{
id: 'fcr_actual',
header: 'Actual',
cell: (props) => {
const value = props.row.original.fcr_value;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'fcr_standard',
header: 'Standard',
cell: (props) => {
const value = props.row.original.project_flock?.fcr?.fcr_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'feed_intake',
header: 'Feed Intake (KG)',
columns: [
{
id: 'feed_intake_actual',
header: 'Actual',
cell: (props) => {
const value = props.row.original.feed_intake;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'feed_intake_standard',
header: 'Standard',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.feed_intake_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'mortality',
header: 'Mortality',
columns: [
{
id: 'cum_depletion_rate_actual',
header: 'Cum Depletion Rate',
cell: (props) => {
const value = props.row.original.cum_depletion_rate;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'max_depletion_std',
header: 'Max Depletion Std',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.max_depletion_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'total_depletion',
header: 'Total Depletion',
cell: (props) => {
const value = props.row.original.total_depletion_qty;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'egg_production',
header: 'Egg Production',
columns: [
{
id: 'egg_mass_actual',
header: 'Egg Mass Actual',
cell: (props) => {
const value = props.row.original.egg_mass;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'egg_mass_standard',
header: 'Egg Mass Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.egg_mass_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'egg_weight_actual',
header: 'Egg Weight Actual',
cell: (props) => {
const value = props.row.original.egg_weight;
return (
<div className='text-center'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
{
id: 'egg_weight_standard',
header: 'Egg Weight Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.egg_weight_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? formatNumber(value)
: '-'}
</div>
);
},
},
],
},
{
id: 'hen_performance',
header: 'Hen Performance',
columns: [
{
id: 'hen_day_actual',
header: 'Hen Day Actual',
cell: (props) => {
const value = props.row.original.hen_day;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'hen_day_standard',
header: 'Hen Day Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.hen_day_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'hen_house_actual',
header: 'Hen House Actual',
cell: (props) => {
const value = props.row.original.hen_house;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
{
id: 'hen_house_standard',
header: 'Hen House Standar',
cell: (props) => {
const value =
props.row.original.project_flock?.production_standart
?.hen_house_std;
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)}%`
: '-'}
</div>
);
},
},
],
}, },
{ {
header: 'Status Approval', header: 'Status Approval',
@@ -730,21 +1013,6 @@ const RecordingTable = () => {
approvalHistoryModal.openModal(); approvalHistoryModal.openModal();
}; };
const getStatusText = (action: string) => {
switch (action) {
case 'APPROVED':
return 'Disetujui';
case 'REJECTED':
return 'Ditolak';
case 'CREATED':
return 'Dibuat';
case 'UPDATED':
return 'Diperbarui';
default:
return action;
}
};
return ( return (
<Badge <Badge
variant='soft' variant='soft'
@@ -755,7 +1023,7 @@ const RecordingTable = () => {
}} }}
onClick={openApprovalHistory} onClick={openApprovalHistory}
> >
{getStatusText(approval.action)} {approval.step_name || approval.action}
</Badge> </Badge>
); );
}, },
@@ -874,14 +1142,15 @@ const RecordingTable = () => {
'mb-20': 'mb-20':
isResponseSuccess(recordings) && recordings?.data?.length === 0, isResponseSuccess(recordings) && recordings?.data?.length === 0,
}), }),
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName: headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', 'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0',
bodyRowClassName: 'border-b border-b-gray-200', bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200',
bodyColumnClassName: bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end', 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}} }}
/> />
@@ -7,6 +7,22 @@ import {
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
type RecordingGrowingFormSchemaType = { type RecordingGrowingFormSchemaType = {
record_date: string;
location?: {
value: number;
label: string;
} | null;
location_id: number;
project_flock?: {
value: number;
label: string;
} | null;
project_flock_id: number;
kandang?: {
value: number;
label: string;
} | null;
kandang_id: number;
project_flock_kandang: { project_flock_kandang: {
value: number; value: number;
label: string; label: string;
@@ -17,16 +33,16 @@ type RecordingGrowingFormSchemaType = {
qty: number | string; qty: number | string;
}[]; }[];
depletions: { depletions: {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
}[]; }[];
}; };
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: { eggs: {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
weight: number | string; weight?: number | string;
}[]; }[];
}; };
@@ -36,14 +52,14 @@ export type StockSchema = {
}; };
export type DepletionSchema = { export type DepletionSchema = {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
}; };
export type EggSchema = { export type EggSchema = {
product_warehouse_id: number; product_warehouse_id?: number;
qty: number | string; qty?: number | string;
weight: number | string; weight?: number | string;
}; };
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
@@ -59,32 +75,51 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.required('Produk depletions wajib diisi!') .optional()
.min(1, 'Produk depletions wajib diisi!') .typeError('Depletions harus berupa angka!'),
.typeError('Produk depletions harus berupa angka!'),
qty: Yup.number() qty: Yup.number()
.required('Jumlah depletions wajib diisi!') .optional()
.min(1, 'Jumlah depletions minimal 1!')
.typeError('Jumlah depletions harus berupa angka!'), .typeError('Jumlah depletions harus berupa angka!'),
}); });
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({ const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.number() product_warehouse_id: Yup.number()
.required('Kondisi telur wajib diisi!') .optional()
.min(1, 'Kondisi telur wajib diisi!')
.typeError('Kondisi telur harus berupa angka!'), .typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number() qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
.required('Jumlah telur wajib diisi!') weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
.min(1, 'Jumlah telur tidak boleh 0!')
.typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number()
.required('Berat telur wajib diisi!')
.min(1, 'Berat telur minimal 1 gram!')
.typeError('Berat telur harus berupa angka!'),
}); });
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> = export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
Yup.object({ Yup.object({
record_date: Yup.string()
.required('Tanggal recording wajib diisi!')
.min(1, 'Tanggal recording wajib diisi!')
.typeError('Tanggal recording wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
location_id: Yup.number()
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
project_flock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
project_flock_id: Yup.number()
.min(1, 'Project flock wajib diisi!')
.required('Project flock wajib diisi!')
.typeError('Project flock wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!')
.typeError('Kandang wajib diisi!'),
project_flock_kandang: Yup.object({ project_flock_kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -100,7 +135,7 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
.required('Project Flock Kandang wajib diisi!') .required('Project Flock Kandang wajib diisi!')
.test( .test(
'not-already-recorded', 'not-already-recorded',
'Project Flock ini sudah direcord hari ini!', 'Project Flock ini sudah direcord pada tanggal tersebut!',
function (value) { function (value) {
const recordedProjectFlockIds = this.options.context const recordedProjectFlockIds = this.options.context
?.recordedProjectFlockIds as Set<number>; ?.recordedProjectFlockIds as Set<number>;
@@ -119,18 +154,12 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
.of(StockObjectSchema) .of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!') .min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'), .required('Data stok wajib diisi!'),
depletions: Yup.array() depletions: Yup.array().of(DepletionObjectSchema).default([]),
.of(DepletionObjectSchema)
.min(1, 'Minimal harus ada 1 data depletions!')
.required('Data depletions wajib diisi!'),
}); });
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> = export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema.shape({ RecordingGrowingFormSchema.shape({
eggs: Yup.array() eggs: Yup.array().of(EggObjectSchema).default([]),
.of(EggObjectSchema)
.min(1, 'Minimal harus ada 1 data telur!')
.required('Data telur wajib diisi!'),
}); });
export const UpdateRecordingGrowingFormSchema = export const UpdateRecordingGrowingFormSchema =
@@ -179,6 +208,15 @@ type RecordingFormData = Partial<Recording> & {
export const getRecordingGrowingFormInitialValues = ( export const getRecordingGrowingFormInitialValues = (
initialValues?: RecordingFormData initialValues?: RecordingFormData
): RecordingGrowingFormValues => ({ ): RecordingGrowingFormValues => ({
record_date: initialValues?.record_datetime
? new Date(initialValues.record_datetime).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
location: null,
location_id: 0,
project_flock: null,
project_flock_id: 0,
kandang: null,
kandang_id: 0,
project_flock_kandang: initialValues?.project_flock_kandang_id project_flock_kandang: initialValues?.project_flock_kandang_id
? { ? {
value: initialValues.project_flock_kandang_id, value: initialValues.project_flock_kandang_id,
File diff suppressed because it is too large Load Diff
@@ -179,12 +179,16 @@ const TransferToLayingsTable = () => {
setInputValue: setFlockSourceInputValue, setInputValue: setFlockSourceInputValue,
options: flockSourceOptions, options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions, isLoadingOptions: isLoadingFlockSourceOptions,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name'); } = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
const { const {
setInputValue: setFlockDestinationInputValue, setInputValue: setFlockDestinationInputValue,
options: flockDestinationOptions, options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions, isLoadingOptions: isLoadingFlockDestinationOptions,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<Flock>(FlockApi.basePath, 'id', 'name'); } = useSelect<Flock>(FlockApi.basePath, 'id', 'name');
// Flocks value // Flocks value
@@ -595,6 +599,7 @@ const TransferToLayingsTable = () => {
value={selectedFlockSource} value={selectedFlockSource}
onChange={flockSourceChangeHandler} onChange={flockSourceChangeHandler}
onInputChange={setFlockSourceInputValue} onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-3', wrapper: 'col-span-12 sm:col-span-3',
@@ -608,6 +613,7 @@ const TransferToLayingsTable = () => {
value={selectedFlockDestination} value={selectedFlockDestination}
onChange={flockDestinationChangeHandler} onChange={flockDestinationChangeHandler}
onInputChange={setFlockDestinationInputValue} onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isClearable isClearable
className={{ className={{
wrapper: 'col-span-12 sm:col-span-3', wrapper: 'col-span-12 sm:col-span-3',
@@ -270,6 +270,8 @@ const TransferToLayingForm = ({
options: flockSourceOptions, options: flockSourceOptions,
isLoadingOptions: isLoadingFlockSourceOptions, isLoadingOptions: isLoadingFlockSourceOptions,
rawData: flockSources, rawData: flockSources,
loadMore: loadMoreFlockSource,
hasMore: hasMoreFlockSource,
} = useSelect<ProjectFlock>( } = useSelect<ProjectFlock>(
'/production/project-flocks', '/production/project-flocks',
'id', 'id',
@@ -360,6 +362,8 @@ const TransferToLayingForm = ({
options: flockDestinationOptions, options: flockDestinationOptions,
isLoadingOptions: isLoadingFlockDestinationOptions, isLoadingOptions: isLoadingFlockDestinationOptions,
rawData: flockDestinations, rawData: flockDestinations,
loadMore: loadMoreFlockDestination,
hasMore: hasMoreFlockDestination,
} = useSelect<ProjectFlock>( } = useSelect<ProjectFlock>(
'/production/project-flocks', '/production/project-flocks',
'id', 'id',
@@ -573,6 +577,7 @@ const TransferToLayingForm = ({
onChange={flockSourceChangeHandler} onChange={flockSourceChangeHandler}
isLoading={isLoadingFlockSourceOptions} isLoading={isLoadingFlockSourceOptions}
onInputChange={setFlockSourceInputValue} onInputChange={setFlockSourceInputValue}
onMenuScrollToBottom={loadMoreFlockSource}
isError={ isError={
formik.touched.flockSource && formik.touched.flockSource &&
Boolean(typeof formik.errors.flockSource === 'string') Boolean(typeof formik.errors.flockSource === 'string')
@@ -591,6 +596,7 @@ const TransferToLayingForm = ({
onChange={flockDestinationChangeHandler} onChange={flockDestinationChangeHandler}
isLoading={isLoadingFlockDestinationOptions} isLoading={isLoadingFlockDestinationOptions}
onInputChange={setFlockDestinationInputValue} onInputChange={setFlockDestinationInputValue}
onMenuScrollToBottom={loadMoreFlockDestination}
isError={ isError={
formik.touched.flockDestination && formik.touched.flockDestination &&
Boolean(typeof formik.errors.flockDestination === 'string') Boolean(typeof formik.errors.flockDestination === 'string')
@@ -37,7 +37,10 @@ import DateInput from '@/components/input/DateInput';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { import {
getStatusColor, getStatusColor,
getStatusIndicatorColor, getStatusIndicatorColor,
@@ -229,63 +232,37 @@ const UniformityTable = () => {
useState<number | undefined>(undefined); useState<number | undefined>(undefined);
const [filterStartDate, setFilterStartDate] = useState(''); const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState(''); const [filterEndDate, setFilterEndDate] = useState('');
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({}); const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const { const {
setInputValue: setFilterLocationInputValue, setInputValue: setFilterLocationInputValue,
options: filterLocationOptions, options: filterLocationOptions,
isLoadingOptions: isLoadingFilterLocations, isLoadingOptions: isLoadingFilterLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', { loadMore: loadMoreFilterLocations,
limit: '100', hasMore: hasMoreFilterLocations,
}); } = useSelect(LocationApi.basePath, 'id', 'name', 'search');
// ===== FETCH PROJECT FLOCKS DATA FOR FILTER ===== // ===== FETCH PROJECT FLOCKS DATA FOR FILTER =====
const filterProjectFlocksUrl = useMemo(() => {
const params = new URLSearchParams({
search: projectFlockSearchValue || '',
limit: '100',
});
if (filterLocation) {
params.append('location_id', filterLocation.value.toString());
}
return `${ProjectFlockApi.basePath}?${params.toString()}`;
}, [projectFlockSearchValue, filterLocation]);
const { const {
data: filterProjectFlocksData, setInputValue: setFilterProjectFlockSearchValue,
isLoading: isLoadingFilterProjectFlocks, options: filterProjectFlockOptions,
} = useSWR(filterProjectFlocksUrl, ProjectFlockApi.getAllFetcher); rawData: filterProjectFlocksRawData,
isLoadingOptions: isLoadingFilterProjectFlocks,
const filterProjectFlocksDataList = useMemo( loadMore: loadMoreFilterProjectFlocks,
() => hasMore: hasMoreFilterProjectFlocks,
isResponseSuccess(filterProjectFlocksData) } = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
? filterProjectFlocksData.data location_id: filterProjectFlockLocationId,
: undefined, });
[filterProjectFlocksData]
);
const filterProjectFlockOptions = useMemo(() => {
let options: OptionType[] = [];
if (isResponseSuccess(filterProjectFlocksData)) {
const flockOptions =
filterProjectFlocksData?.data.map((projectFlock) => ({
value: projectFlock.id,
label: projectFlock.flock_name || '',
})) || [];
options = options.concat(flockOptions);
}
return options;
}, [filterProjectFlocksData]);
// ===== KANDANG OPTIONS FOR FILTER ===== // ===== KANDANG OPTIONS FOR FILTER =====
const filterKandangOptions = useMemo(() => { const filterKandangOptions = useMemo(() => {
let options: OptionType[] = []; let options: OptionType[] = [];
if (filterProjectFlock && filterProjectFlocksDataList) { if (filterProjectFlock && isResponseSuccess(filterProjectFlocksRawData)) {
const selectedProjectFlockData = filterProjectFlocksDataList.find( const data = filterProjectFlocksRawData.data as unknown as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === filterProjectFlock.value (pf) => pf.id === filterProjectFlock.value
); );
@@ -301,7 +278,7 @@ const UniformityTable = () => {
} }
return options; return options;
}, [filterProjectFlock, filterProjectFlocksDataList]); }, [filterProjectFlock, filterProjectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP ===== // ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => { const projectFlockKandangLookupUrl = useMemo(() => {
@@ -394,9 +371,13 @@ const UniformityTable = () => {
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterLocationChange = useCallback( const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
setFilterLocation(val as OptionType | null); const location = val as OptionType | null;
setFilterLocation(location);
setFilterProjectFlock(null); setFilterProjectFlock(null);
setFilterKandang(null); setFilterKandang(null);
setFilterProjectFlockLocationId(
location ? location.value.toString() : ''
);
}, },
[] []
); );
@@ -1206,6 +1187,7 @@ const UniformityTable = () => {
options={filterLocationOptions} options={filterLocationOptions}
onInputChange={setFilterLocationInputValue} onInputChange={setFilterLocationInputValue}
isLoading={isLoadingFilterLocations} isLoading={isLoadingFilterLocations}
onMenuScrollToBottom={loadMoreFilterLocations}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
{filterErrors.location && ( {filterErrors.location && (
@@ -1225,8 +1207,9 @@ const UniformityTable = () => {
setFilterErrors((prev) => ({ ...prev, project_flock: '' })); setFilterErrors((prev) => ({ ...prev, project_flock: '' }));
}} }}
options={filterProjectFlockOptions} options={filterProjectFlockOptions}
onInputChange={setProjectFlockSearchValue} onInputChange={setFilterProjectFlockSearchValue}
isLoading={isLoadingFilterProjectFlocks} isLoading={isLoadingFilterProjectFlocks}
onMenuScrollToBottom={loadMoreFilterProjectFlocks}
isDisabled={!filterLocation} isDisabled={!filterLocation}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
@@ -1,4 +1,4 @@
import Badge from '../../../../Badge'; import Badge from '@/components/Badge';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
@@ -36,7 +36,10 @@ import {
VerifyUniformityPayload, VerifyUniformityPayload,
} from '@/types/api/production/uniformity'; } from '@/types/api/production/uniformity';
import { type BaseApiResponse } from '@/types/api/api-general'; import { type BaseApiResponse } from '@/types/api/api-general';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm'; import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm'; import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -88,7 +91,9 @@ const UniformityForm = ({
null null
); );
const [projectFlockSearchValue, setProjectFlockSearchValue] = useState(''); const [selectedProjectFlockLocationId, setSelectedProjectFlockLocationId] =
useState<string>('');
const [selectedProjectFlock, setSelectedProjectFlock] = const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null); useState<OptionType | null>(null);
@@ -100,50 +105,21 @@ const UniformityForm = ({
setInputValue: setLocationSelectInputValue, setInputValue: setLocationSelectInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocations, isLoadingOptions: isLoadingLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', { loadMore: loadMoreLocations,
page: '1', hasMore: hasMoreLocations,
limit: '100', } = useSelect(LocationApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setProjectFlockSearchValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlocks,
loadMore: loadMoreProjectFlocks,
hasMore: hasMoreProjectFlocks,
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', 'search', {
location_id: selectedProjectFlockLocationId,
}); });
// ===== FETCH PROJECT FLOCKS DATA =====
const projectFlocksUrl = useMemo(() => {
const params = new URLSearchParams({
search: projectFlockSearchValue || '',
page: '1',
limit: '100',
});
if (selectedLocation) {
params.append('location_id', selectedLocation.value.toString());
}
return `${ProjectFlockApi.basePath}?${params.toString()}`;
}, [projectFlockSearchValue, selectedLocation]);
const { data: projectFlocksData, isLoading: isLoadingProjectFlocks } = useSWR(
projectFlocksUrl,
ProjectFlockApi.getAllFetcher
);
const projectFlocksDataList =
projectFlocksData?.status === 'success'
? projectFlocksData.data
: undefined;
// ===== PROJECT FLOCK OPTIONS =====
const projectFlockOptions = useMemo(() => {
let options: OptionType[] = [];
if (isResponseSuccess(projectFlocksData)) {
const flockOptions =
projectFlocksData?.data.map((projectFlock) => ({
value: projectFlock.id,
label: projectFlock.flock_name || '',
})) || [];
options = options.concat(flockOptions);
}
return options;
}, [projectFlocksData]);
// ===== APPROVED PROJECT FLOCK KANDANGS ===== // ===== APPROVED PROJECT FLOCK KANDANGS =====
const approvedProjectFlockKandangsUrl = useMemo(() => { const approvedProjectFlockKandangsUrl = useMemo(() => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -168,8 +144,9 @@ const UniformityForm = ({
const kandangOptions = useMemo(() => { const kandangOptions = useMemo(() => {
let options: OptionType[] = []; let options: OptionType[] = [];
if (selectedProjectFlock && projectFlocksDataList) { if (selectedProjectFlock && isResponseSuccess(projectFlocksRawData)) {
const selectedProjectFlockData = projectFlocksDataList.find( const data = projectFlocksRawData.data as unknown as ProjectFlock[];
const selectedProjectFlockData = data.find(
(pf) => pf.id === selectedProjectFlock.value (pf) => pf.id === selectedProjectFlock.value
); );
@@ -196,7 +173,7 @@ const UniformityForm = ({
return options; return options;
}, [ }, [
selectedProjectFlock, selectedProjectFlock,
projectFlocksDataList, projectFlocksRawData,
approvedProjectFlockKandangs, approvedProjectFlockKandangs,
formType, formType,
]); ]);
@@ -313,6 +290,10 @@ const UniformityForm = ({
formik.setFieldValue('location_id', locationId); formik.setFieldValue('location_id', locationId);
setSelectedLocation(location); setSelectedLocation(location);
setSelectedProjectFlock(null);
setSelectedProjectFlockLocationId(
location ? location.value.toString() : ''
);
}, },
[] []
); );
@@ -513,6 +494,7 @@ const UniformityForm = ({
options={locationOptions} options={locationOptions}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
onMenuScrollToBottom={loadMoreLocations}
isError={ isError={
formik.touched.location_id && Boolean(formik.errors.location_id) formik.touched.location_id && Boolean(formik.errors.location_id)
} }
@@ -530,6 +512,7 @@ const UniformityForm = ({
options={projectFlockOptions} options={projectFlockOptions}
onInputChange={setProjectFlockSearchValue} onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks} isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id} isDisabled={!formik.values.location_id}
isError={ isError={
formik.touched.project_flock_id && formik.touched.project_flock_id &&
@@ -156,8 +156,11 @@ const PurchaseOrderAcceptApprovalForm = ({
setInputValue: setExpeditionsSelectInputValue, setInputValue: setExpeditionsSelectInputValue,
options: expeditionVendors, options: expeditionVendors,
isLoadingOptions: isLoadingExpeditions, isLoadingOptions: isLoadingExpeditions,
loadMore: loadMoreExpeditions,
hasMore: hasMoreExpeditions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', { } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search', {
category: 'BOP', category: 'BOP',
flag: 'EKSPEDISI',
}); });
// ===== FORM CONFIGURATION ===== // ===== FORM CONFIGURATION =====
@@ -183,8 +186,8 @@ const PurchaseOrderAcceptApprovalForm = ({
purchase_item_id: formItem.purchase_item_id || 0, purchase_item_id: formItem.purchase_item_id || 0,
received_date: formItem.received_date || '', received_date: formItem.received_date || '',
travel_number: formItem.travel_number || '', travel_number: formItem.travel_number || '',
vehicle_number: formItem.vehicle_number || '', vehicle_number: formItem.vehicle_number || null,
expedition_vendor_id: formItem.expedition_vendor_id || 0, expedition_vendor_id: formItem.expedition_vendor_id || null,
received_qty: received_qty:
typeof formItem.received_qty === 'string' typeof formItem.received_qty === 'string'
? parseFloat(formItem.received_qty) || 0 ? parseFloat(formItem.received_qty) || 0
@@ -192,10 +195,13 @@ const PurchaseOrderAcceptApprovalForm = ({
transport_per_item: transport_per_item:
typeof formItem.transport_per_item === 'string' typeof formItem.transport_per_item === 'string'
? parseFloat(formItem.transport_per_item) || 0 ? parseFloat(formItem.transport_per_item) || 0
: formItem.transport_per_item || 0, : formItem.transport_per_item || null,
}; };
}) || [], }) || [],
travel_documents: values.travel_documents || [], travel_documents:
values.travel_documents
?.filter((file): file is File => file instanceof File)
.filter(Boolean) || undefined,
}; };
switch (type) { switch (type) {
@@ -403,22 +409,13 @@ const PurchaseOrderAcceptApprovalForm = ({
Dokumen Surat Jalan Dokumen Surat Jalan
<span className='text-error'>*</span> <span className='text-error'>*</span>
</th> </th>
<th> <th>Nomor Kendaraan</th>
Nomor Kendaraan <th>Vendor Ekspedisi</th>
<span className='text-error'>*</span>
</th>
<th>
Vendor Ekspedisi
<span className='text-error'>*</span>
</th>
<th> <th>
Jumlah Diterima Jumlah Diterima
<span className='text-error'>*</span> <span className='text-error'>*</span>
</th> </th>
<th> <th>Transport/Item</th>
Transport/Item
<span className='text-error'>*</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -536,7 +533,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td> </td>
<td> <td>
<TextInput <TextInput
required
name={`items.${idx}.vehicle_number`} name={`items.${idx}.vehicle_number`}
type='text' type='text'
value={formItem?.vehicle_number || ''} value={formItem?.vehicle_number || ''}
@@ -562,7 +558,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td> </td>
<td> <td>
<SelectInput <SelectInput
required
isClearable={true} isClearable={true}
value={formItem?.expedition_vendor} value={formItem?.expedition_vendor}
key={`expedition-vendor-${idx}`} key={`expedition-vendor-${idx}`}
@@ -570,6 +565,8 @@ const PurchaseOrderAcceptApprovalForm = ({
expeditionVendorChangeHandler(idx, val) expeditionVendorChangeHandler(idx, val)
} }
options={getExpeditionVendorOptions()} options={getExpeditionVendorOptions()}
isLoading={isLoadingExpeditions}
onMenuScrollToBottom={loadMoreExpeditions}
isError={ isError={
isRepeaterInputError(idx, 'expedition_vendor_id') isRepeaterInputError(idx, 'expedition_vendor_id')
.isError .isError
@@ -629,7 +626,6 @@ const PurchaseOrderAcceptApprovalForm = ({
</td> </td>
<td> <td>
<NumberInput <NumberInput
required
name={`items.${idx}.transport_per_item`} name={`items.${idx}.transport_per_item`}
value={formItem?.transport_per_item || ''} value={formItem?.transport_per_item || ''}
onChange={(e) => onChange={(e) =>
@@ -680,7 +676,6 @@ const PurchaseOrderAcceptApprovalForm = ({
<div className={'col-span-2 my-2'}> <div className={'col-span-2 my-2'}>
<FileInput <FileInput
required
name='travel_documents' name='travel_documents'
label='Dokumen Surat Jalan' label='Dokumen Surat Jalan'
accept='.pdf,.jpg,.jpeg,.png' accept='.pdf,.jpg,.jpeg,.png'
@@ -38,16 +38,16 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
vehicle_number: string; vehicle_number?: string | null;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
expedition_vendor_id: number; expedition_vendor_id?: number | null;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item?: number | string | null;
}[]; }[];
travel_documents: File[]; travel_documents?: (File | null | undefined)[] | null;
}; };
export type PurchaseStaffApprovalItemSchema = { export type PurchaseStaffApprovalItemSchema = {
@@ -75,14 +75,14 @@ export type PurchaseAcceptApprovalItemSchema = {
purchase_item_id: number; purchase_item_id: number;
received_date: string; received_date: string;
travel_number: string; travel_number: string;
vehicle_number: string; vehicle_number?: string | null;
expedition_vendor?: { expedition_vendor?: {
value: number; value: number;
label: string; label: string;
} | null; } | null;
expedition_vendor_id: number; expedition_vendor_id?: number | null;
received_qty: number | string; received_qty: number | string;
transport_per_item: number | string; transport_per_item?: number | string | null;
}; };
export type PurchaseDeleteItemsSchema = { export type PurchaseDeleteItemsSchema = {
@@ -184,24 +184,19 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.required('No. Surat jalan wajib diisi!') .required('No. Surat jalan wajib diisi!')
.typeError('No. Surat jalan wajib diisi!'), .typeError('No. Surat jalan wajib diisi!'),
vehicle_number: Yup.string() vehicle_number: Yup.string()
.required('Nomor kendaraan wajib diisi!') .nullable()
.typeError('Nomor kendaraan wajib diisi!'), .optional()
.typeError('Nomor kendaraan harus berupa plat nomor!'),
expedition_vendor: Yup.object({ expedition_vendor: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), })
.nullable()
.optional(),
expedition_vendor_id: Yup.number() expedition_vendor_id: Yup.number()
.min(1, 'Vendor ekspedisi wajib diisi!') .nullable()
.required('Vendor ekspedisi wajib diisi!') .optional()
.test( .typeError('Vendor ekspedisi harus berupa angka!'),
'is-valid-expedition-vendor',
'Vendor ekspedisi harus dipilih!',
function (value) {
if (!this.parent.expedition_vendor) return true;
return Boolean(value && value > 0);
}
)
.typeError('Vendor ekspedisi harus dipilih!'),
received_qty: Yup.mixed<string | number>() received_qty: Yup.mixed<string | number>()
.required('Jumlah diterima wajib diisi!') .required('Jumlah diterima wajib diisi!')
.test( .test(
@@ -217,13 +212,14 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
) )
.typeError('Jumlah diterima harus berupa angka!'), .typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>() transport_per_item: Yup.mixed<string | number>()
.required('Biaya transport per item wajib diisi!') .nullable()
.optional()
.test( .test(
'is-valid-transport-per-item', 'is-valid-transport-per-item',
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!', 'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
function (value) { function (value) {
if (value === '' || value === null || value === undefined) if (value === '' || value === null || value === undefined)
return false; return true;
const numValue = const numValue =
typeof value === 'string' ? parseFloat(value) : value; typeof value === 'string' ? parseFloat(value) : value;
return !isNaN(numValue) && numValue >= 0; return !isNaN(numValue) && numValue >= 0;
@@ -389,16 +385,17 @@ export const PurchaseRequestAcceptApprovalFormSchema: Yup.ObjectSchema<PurchaseR
travel_documents: Yup.array() travel_documents: Yup.array()
.of( .of(
Yup.mixed<File>() Yup.mixed<File>()
.required('Dokumen surat jalan wajib diupload!') .nullable()
.optional()
.test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => { .test('fileSize', 'Ukuran dokumen maksimal 5 MB', (value) => {
if (!value) return true; if (!value) return true;
if (value instanceof File) return value.size <= 5 * 1024 * 1024; if (value instanceof File) return value.size <= 5 * 1024 * 1024;
return true; return true;
}) })
) )
.required('Dokumen surat jalan wajib diupload!') .nullable()
.min(1, 'Minimal upload 1 dokumen surat jalan!') .optional()
.typeError('Dokumen surat jalan wajib diupload!'), .typeError('Dokumen surat jalan harus berupa array!'),
}); });
export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType = export const PurchaseRequestAcceptApprovalFormInitialValues: PurchaseRequestAcceptApprovalFormSchemaType =
@@ -633,8 +633,18 @@ const PurchaseOrderStaffApprovalForm = ({
formik.setFieldValue(`items.${idx}.qty`, numValue); formik.setFieldValue(`items.${idx}.qty`, numValue);
formik.setFieldValue(`items.${idx}.price`, ''); if (
formik.setFieldValue(`items.${idx}.total_price`, ''); formItem.price !== '' &&
formItem.price !== undefined &&
formItem.price !== null &&
numValue !== '' &&
numValue > 0
) {
const calculatedTotal = Number(formItem.price) * Number(numValue);
formik.setFieldValue(`items.${idx}.total_price`, calculatedTotal);
} else if (numValue === '') {
formik.setFieldValue(`items.${idx}.total_price`, '');
}
} }
if (field === 'price' || field === 'total_price') { if (field === 'price' || field === 'total_price') {
@@ -1184,8 +1194,10 @@ const PurchaseOrderStaffApprovalForm = ({
color='warning' color='warning'
className='px-4' className='px-4'
onClick={() => { onClick={() => {
formik.setValues(formikInitialValues); if (type === 'add') {
formik.resetForm(); formik.setValues(formikInitialValues);
formik.resetForm();
}
setPurchaseOrderFormErrorMessage(''); setPurchaseOrderFormErrorMessage('');
onCancel?.(); onCancel?.();
onModalClose?.(); onModalClose?.();
@@ -63,11 +63,9 @@ const PurchaseRequestForm = ({
useState(''); useState('');
const [formErrorList, setFormErrorList] = useState<string[]>([]); const [formErrorList, setFormErrorList] = useState<string[]>([]);
// ===== TYPE DEFINITIONS ===== const [selectedArea, setSelectedArea] = useState('');
interface ProductOptionType { const [selectedLocation, setSelectedLocation] = useState('');
value: number; const [disabledLocation, setDisabledLocation] = useState(true);
label: string;
}
// ===== UTILITY FUNCTIONS ===== // ===== UTILITY FUNCTIONS =====
const isRepeaterInputError = ( const isRepeaterInputError = (
@@ -160,11 +158,35 @@ const PurchaseRequestForm = ({
isLoadingOptions: isLoadingAreas, isLoadingOptions: isLoadingAreas,
} = useSelect(AreaApi.basePath, 'id', 'name', 'search'); } = useSelect(AreaApi.basePath, 'id', 'name', 'search');
const {
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
hasMore: hasMoreLocations,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
});
const { const {
inputValue: warehouseSelectInputValue, inputValue: warehouseSelectInputValue,
setInputValue: setWarehouseSelectInputValue, setInputValue: setWarehouseSelectInputValue,
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouses, isLoadingOptions: isLoadingWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); loadMore: loadMoreWarehouses,
hasMore: hasMoreWarehouses,
} = useSelect(WarehouseApi.basePath, 'id', 'name', 'search', {
area_id:
selectedArea != ''
? selectedArea
: ((initialValues?.area?.id ?? '') as string),
location_id:
selectedLocation != ''
? selectedLocation
: ((initialValues?.location?.id ?? '') as string),
});
// ===== FORM CONFIGURATION ===== // ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<PurchaseRequestFormValues>( const formikInitialValues = useMemo<PurchaseRequestFormValues>(
@@ -267,70 +289,6 @@ const PurchaseRequestForm = ({
return data; return data;
}, [supplierData]); }, [supplierData]);
const locationsUrl = useMemo(() => {
const params = new URLSearchParams({
search: locationSelectInputValue,
...(formik.values.area_id && formik.values.area_id > 0
? { area_id: formik.values.area_id.toString() }
: {}),
});
return `${LocationApi.basePath}?${params.toString()}`;
}, [locationSelectInputValue, formik.values.area_id]);
const { data: locations, isLoading: isLoadingLocations } = useSWR(
locationsUrl,
LocationApi.getAllFetcher
);
const locationOptions = useMemo(() => {
if (!isResponseSuccess(locations)) return [];
return (
locations?.data.map((location) => ({
value: location.id,
label: location.name,
})) || []
);
}, [locations]);
const warehousesUrl = useMemo(() => {
const params = new URLSearchParams({ search: warehouseSelectInputValue });
if (formik.values.area_id && formik.values.area_id > 0) {
params.append('area_id', formik.values.area_id.toString());
}
if (formik.values.location_id && formik.values.location_id > 0) {
params.append('location_id', formik.values.location_id.toString());
}
return `${WarehouseApi.basePath}?${params.toString()}`;
}, [
warehouseSelectInputValue,
formik.values.area_id,
formik.values.location_id,
]);
const { data: warehouses } = useSWR(
warehousesUrl,
WarehouseApi.getAllFetcher
);
const warehouseOptions = useMemo(() => {
if (!isResponseSuccess(warehouses)) return [];
return (
warehouses?.data.map((w) => ({
value: w.id,
label: w.name,
area: w.area?.name,
location:
'type' in w && (w.type === 'LOKASI' || w.type === 'KANDANG')
? w.location?.name
: undefined,
})) || []
);
}, [warehouses]);
const addPurchaseItem = () => { const addPurchaseItem = () => {
const newItems = [ const newItems = [
...(formik.values.items || []), ...(formik.values.items || []),
@@ -407,6 +365,18 @@ const PurchaseRequestForm = ({
} }
}, [formik.values.supplier_id]); }, [formik.values.supplier_id]);
useEffect(() => {
if (type !== 'add' && initialValues) {
if (initialValues.area?.id) {
setSelectedArea(initialValues.area.id.toString());
setDisabledLocation(false);
}
if (initialValues.location?.id) {
setSelectedLocation(initialValues.location.id.toString());
}
}
}, [type, initialValues]);
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const handleSupplierChange = useCallback( const handleSupplierChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
@@ -445,6 +415,16 @@ const PurchaseRequestForm = ({
formik.setFieldValue('area_id', (area as OptionType)?.value || 0); formik.setFieldValue('area_id', (area as OptionType)?.value || 0);
formik.setFieldTouched('area', true); formik.setFieldTouched('area', true);
formik.setFieldValue('area', area); formik.setFieldValue('area', area);
setSelectedArea((area as OptionType)?.value as string);
setSelectedLocation('');
const disabled = (area as OptionType)?.value == null;
setDisabledLocation(disabled);
formik.setFieldTouched('location_id', false);
formik.setFieldValue('location_id', 0);
formik.setFieldTouched('location', false);
formik.setFieldValue('location', null);
}, },
[] []
); );
@@ -456,6 +436,8 @@ const PurchaseRequestForm = ({
formik.setFieldValue('location_id', (location as OptionType)?.value || 0); formik.setFieldValue('location_id', (location as OptionType)?.value || 0);
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
formik.setFieldValue('location', location); formik.setFieldValue('location', location);
setSelectedLocation((location as OptionType)?.value as string);
}, },
[] []
); );
@@ -596,10 +578,15 @@ const PurchaseRequestForm = ({
placeholder='Pilih Lokasi...' placeholder='Pilih Lokasi...'
value={formik.values.location} value={formik.values.location}
onChange={handleLocationChange} onChange={handleLocationChange}
options={locationOptions} options={
selectedArea != '' || initialValues?.area?.id
? locationOptions
: []
}
onInputChange={setLocationSelectInputValue} onInputChange={setLocationSelectInputValue}
isLoading={isLoadingLocations} isLoading={isLoadingLocations}
isDisabled={type === 'detail'} onMenuScrollToBottom={loadMoreLocations}
isDisabled={type === 'detail' || disabledLocation}
isClearable={type !== 'detail'} isClearable={type !== 'detail'}
/> />
@@ -713,6 +700,7 @@ const PurchaseRequestForm = ({
options={warehouseOptions} options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue} onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses} isLoading={isLoadingWarehouses}
onMenuScrollToBottom={loadMoreWarehouses}
isError={ isError={
isRepeaterInputError(idx, 'warehouse_id').isError isRepeaterInputError(idx, 'warehouse_id').isError
} }
@@ -732,9 +720,9 @@ const PurchaseRequestForm = ({
required required
value={item.product ?? undefined} value={item.product ?? undefined}
onChange={(val) => { onChange={(val) => {
const product = val as ProductOptionType | null; const product = val as OptionType | null;
const productId = const productId =
(product as ProductOptionType)?.value || 0; (product as OptionType)?.value || 0;
formik.setFieldTouched( formik.setFieldTouched(
`items.${idx}.product`, `items.${idx}.product`,
@@ -540,31 +540,6 @@ const PurchaseOrderDetail = ({
accessorKey: 'travel_number', accessorKey: 'travel_number',
cell: (props) => props.row.original.travel_number || '-', cell: (props) => props.row.original.travel_number || '-',
}, },
{
header: 'Dokumen Surat Jalan',
accessorKey: 'travel_document_path',
cell: (props) => {
const documentPath = props.row.original.travel_document_path;
return documentPath ? (
<Button
color='primary'
className='w-fit min-w-32 flex items-center justify-start gap-1 px-2 py-1 text-sm'
href={documentPath}
target='_blank'
rel='noopener noreferrer'
>
<Icon
icon='material-symbols:file-open-outline'
width={16}
height={16}
/>
Lihat Dokumen
</Button>
) : (
'-'
);
},
},
{ {
header: 'No. Armada Pengangkut', header: 'No. Armada Pengangkut',
accessorKey: 'vehicle_number', accessorKey: 'vehicle_number',
@@ -588,7 +563,10 @@ const PurchaseOrderDetail = ({
{ {
header: 'Transport /Item', header: 'Transport /Item',
accessorKey: 'transport_per_item', accessorKey: 'transport_per_item',
cell: (props) => formatCurrency(props.getValue() as number), cell: (props) => {
const value = props.row.original.transport_per_item;
return value ? formatCurrency(value) : formatCurrency(0);
},
}, },
]; ];
@@ -723,8 +701,8 @@ const PurchaseOrderDetail = ({
</span> </span>
<span className='text-gray-900 ml-3 break-all'> <span className='text-gray-900 ml-3 break-all'>
:{' '} :{' '}
{purchaseData.items?.[0]?.warehouse?.type === 'LOKASI' && {purchaseData.items?.[0]?.warehouse &&
purchaseData.items?.[0]?.warehouse?.location?.name 'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.name ? purchaseData.items[0].warehouse.location.name
: '-'} : '-'}
</span> </span>
@@ -905,11 +883,29 @@ const PurchaseOrderDetail = ({
Informasi Penerimaan Barang Informasi Penerimaan Barang
</h3> </h3>
{canShowPenerimaanBarang && ( {canShowPenerimaanBarang && (
<RowDropdownOptions isLast2Rows> <div className='flex items-center gap-2'>
<PenerimaanBarangDropdown {goodsReceiptItems[0]?.travel_document_path && (
onEdit={penerimaanBarangModal.openModal} <Button
/> color='primary'
</RowDropdownOptions> className='w-fit min-w-32 flex items-center justify-start gap-1 p-1.5 text-sm'
href={goodsReceiptItems[0].travel_document_path}
target='_blank'
rel='noopener noreferrer'
>
<Icon
icon='material-symbols:file-open-outline'
width={16}
height={16}
/>
Lihat Dokumen
</Button>
)}
<RowDropdownOptions isLast2Rows>
<PenerimaanBarangDropdown
onEdit={penerimaanBarangModal.openModal}
/>
</RowDropdownOptions>
</div>
)} )}
</div> </div>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
@@ -324,12 +324,14 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
PT LUMBUNG TELUR INDONESIA PT LUMBUNG TELUR INDONESIA
</Text> </Text>
<Text> <Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI' {purchaseData?.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.name ? purchaseData.items[0].warehouse.location.name
: '-'} : '-'}
</Text> </Text>
<Text> <Text>
{purchaseData?.items?.[0]?.warehouse.type === 'LOKASI' {purchaseData?.items?.[0]?.warehouse &&
'location' in purchaseData.items[0].warehouse
? purchaseData.items[0].warehouse.location.address ? purchaseData.items[0].warehouse.location.address
: '-'} : '-'}
</Text> </Text>
@@ -434,7 +436,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
</View> </View>
<View style={pdfStyles.tableCell}> <View style={pdfStyles.tableCell}>
<Text> <Text>
{item.warehouse?.type === 'LOKASI' {item.warehouse && 'location' in item.warehouse
? item.warehouse.location.address ? item.warehouse.location.address
: '-'} : '-'}
</Text> </Text>
@@ -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',
@@ -168,7 +168,7 @@ const DailyMarketingsTable = ({
]; ];
useEffect(() => { useEffect(() => {
console.log({ sorting }); // console.log({ sorting });
if (sorting.length === 1) { if (sorting.length === 1) {
onFilterByChange(sorting[0].id); onFilterByChange(sorting[0].id);
@@ -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
@@ -177,10 +177,12 @@ interface CustomerPaymentExportPDFParams {
data: CustomerPaymentReport[]; data: CustomerPaymentReport[];
params?: { params?: {
customer_name?: string; customer_name?: string;
sales?: string; // TODO: Uncomment when BE is ready
// sales?: string;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
filter_by?: string; // TODO: Uncomment when BE is ready
// filter_by?: string;
}; };
} }
@@ -195,9 +197,10 @@ const getParameterText = (
paramsText.push('Semua Customer'); paramsText.push('Semua Customer');
} }
if (params?.sales) { // TODO: Uncomment when BE is ready
paramsText.push(`Sales: ${params.sales}`); // if (params?.sales) {
} // paramsText.push(`Sales: ${params.sales}`);
// }
if (params?.start_date && params?.end_date) { if (params?.start_date && params?.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY'); const startDate = formatDate(params.start_date, 'DD MMM YYYY');
@@ -242,9 +245,10 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
: '-'} : '-'}
</Text> </Text>
</View> </View>
<View style={pdfStyles.parameterBadge}> {/* TODO: Uncomment when BE is ready */}
{/* <View style={pdfStyles.parameterBadge}>
<Text>Filter Tanggal: Tanggal DO</Text> <Text>Filter Tanggal: Tanggal DO</Text>
</View> </View> */}
<View style={pdfStyles.parameterBadge}> <View style={pdfStyles.parameterBadge}>
<Text> <Text>
Customer: {params.params?.customer_name || 'Semua Customer'} Customer: {params.params?.customer_name || 'Semua Customer'}
@@ -280,7 +284,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
<Text>Aging</Text> <Text>Aging</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Referensi</Text> <Text>Referensi</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
@@ -296,17 +300,11 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Rata-Rata</Text> <Text>Rata-Rata</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text> <Text>Harga/Unit</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>CN</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Akhir</Text> <Text>Harga Akhir</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Pajak</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Total</Text> <Text>Total</Text>
</View> </View>
@@ -343,13 +341,15 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text> <Text>
{item.do_date ? formatDate(item.do_date, 'DD MMM YY') : '-'} {item.trans_date
? formatDate(item.trans_date, 'DD MMM YY')
: '-'}
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text> <Text>
{item.realization_date {item.delivery_date
? formatDate(item.realization_date, 'DD MMM YY') ? formatDate(item.delivery_date, 'DD MMM YY')
: '-'} : '-'}
</Text> </Text>
</View> </View>
@@ -358,11 +358,15 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
{item.aging_day ? formatNumber(item.aging_day) : '-'} hari {item.aging_day ? formatNumber(item.aging_day) : '-'} hari
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.reference || '-'}</Text> <Text>{item.reference || '-'}</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}> <View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{item.vehicle_plate || '-'}</Text> <Text>
{Array.isArray(item.vehicle_numbers)
? item.vehicle_numbers.join(', ')
: item.vehicle_numbers || '-'}
</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty)}</Text> <Text>{formatNumber(item.qty)}</Text>
@@ -374,22 +378,16 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>{formatNumber(item.average_weight)}</Text> <Text>{formatNumber(item.average_weight)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.price)}</Text> <Text>{formatCurrency(item.unit_price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.credit_note)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.final_price)}</Text> <Text>{formatCurrency(item.final_price)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(item.ppn)}%</Text> <Text>{formatCurrency(item.total_price)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.total)}</Text> <Text>{formatCurrency(item.payment_amount)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.payment)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text style={pdfStyles.textError}> <Text style={pdfStyles.textError}>
@@ -397,30 +395,32 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
{item.notes ? ( {item.status ? (
<Text>{item.notes}</Text>
) : (
<View <View
style={[ style={[
pdfStyles.badge, pdfStyles.badge,
item.accounts_receivable === 0 item.status === 'LUNAS'
? pdfStyles.badgeLunas ? pdfStyles.badgeLunas
: pdfStyles.badgeBelumLunas, : pdfStyles.badgeBelumLunas,
]} ]}
> >
<Text> <Text>
{item.accounts_receivable === 0 {item.status === 'LUNAS' ? 'Lunas' : 'Belum Lunas'}
? 'Lunas'
: 'Belum Lunas'}
</Text> </Text>
</View> </View>
) : (
<Text>-</Text>
)} )}
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pickup_info || '-'}</Text> <Text>
{Array.isArray(item.pickup_info)
? item.pickup_info.join(', ')
: item.pickup_info || '-'}
</Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.sales_marketing || '-'}</Text> <Text>{item.sales_person || '-'}</Text>
</View> </View>
</View> </View>
))} ))}
@@ -440,7 +440,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<View style={[pdfStyles.tableCell, { flex: 0.8 }]}> <View style={[pdfStyles.tableCell, { flex: 0.8 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}> <View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
@@ -458,25 +458,13 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text></Text> <Text></Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text> <Text></Text>
{formatCurrency(
customerReport.summary.total_initial_amount
)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatCurrency(customerReport.summary.total_credit_note)}
</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text> <Text>
{formatCurrency(customerReport.summary.total_final_amount)} {formatCurrency(customerReport.summary.total_final_amount)}
</Text> </Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text> <Text>
{formatCurrency(customerReport.summary.total_grand_amount)} {formatCurrency(customerReport.summary.total_grand_amount)}
@@ -24,30 +24,30 @@ export const generateCustomerPaymentExcel = (
const excelData: { [key: string]: string | number }[] = customerData.map( const excelData: { [key: string]: string | number }[] = customerData.map(
(item, index) => ({ (item, index) => ({
No: index + 1, No: index + 1,
'Tanggal DO/Bayar': item.do_date 'Tanggal DO/Bayar': item.trans_date
? formatDate(item.do_date, 'DD MMM YYYY') ? formatDate(item.trans_date, 'DD MMM YYYY')
: '', : '',
'Tanggal Realisasi': item.realization_date 'Tanggal Realisasi': item.delivery_date
? formatDate(item.realization_date, 'DD MMM YYYY') ? formatDate(item.delivery_date, 'DD MMM YYYY')
: '', : '',
Aging: formatNumber(item.aging_day || 0), Aging: formatNumber(item.aging_day || 0),
Referensi: item.reference || '', Referensi: item.reference || '',
'Nomor Polisi': Array.isArray(item.vehicle_plate) 'Nomor Polisi': Array.isArray(item.vehicle_numbers)
? item.vehicle_plate.join(', ') ? item.vehicle_numbers.join(', ')
: '', : '',
'Ekor/Qty': formatNumber(item.qty || 0), 'Ekor/Qty': formatNumber(item.qty || 0),
'Berat (Kg)': formatNumber(item.weight || 0), 'Berat (Kg)': formatNumber(item.weight || 0),
AVG: formatNumber(item.average_weight || 0), AVG: formatNumber(item.average_weight || 0),
'Harga Awal': formatCurrency(item.price || 0), 'Harga/Unit': formatCurrency(item.unit_price || 0),
CN: formatCurrency(item.credit_note || 0),
'Harga Akhir': formatCurrency(item.final_price || 0), 'Harga Akhir': formatCurrency(item.final_price || 0),
'PPN (%)': formatNumber(item.ppn || 0), Total: formatCurrency(item.total_price || 0),
Total: formatCurrency(item.total || 0), Pembayaran: formatCurrency(item.payment_amount || 0),
Pembayaran: formatCurrency(item.payment || 0),
'Saldo Piutang': formatCurrency(item.accounts_receivable || 0), 'Saldo Piutang': formatCurrency(item.accounts_receivable || 0),
Keterangan: item.notes || '', Keterangan: item.status || '',
Pengambilan: item.pickup_info || '', Pengambilan: Array.isArray(item.pickup_info)
'Sales/Marketing': item.sales_marketing || '', ? item.pickup_info.join(', ')
: '',
'Sales/Marketing': item.sales_person || '',
}) })
); );
@@ -62,14 +62,10 @@ export const generateCustomerPaymentExcel = (
'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0), 'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0),
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0), 'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
AVG: '', AVG: '',
'Harga Awal': formatCurrency( 'Harga/Unit': '',
customerReport.summary.total_initial_amount || 0
),
CN: formatCurrency(customerReport.summary.total_credit_note || 0),
'Harga Akhir': formatCurrency( 'Harga Akhir': formatCurrency(
customerReport.summary.total_final_amount || 0 customerReport.summary.total_final_amount || 0
), ),
'PPN (%)': '',
Total: formatCurrency(customerReport.summary.total_grand_amount || 0), Total: formatCurrency(customerReport.summary.total_grand_amount || 0),
Pembayaran: formatCurrency(customerReport.summary.total_payment || 0), Pembayaran: formatCurrency(customerReport.summary.total_payment || 0),
'Saldo Piutang': formatCurrency( 'Saldo Piutang': formatCurrency(
@@ -93,10 +89,8 @@ export const generateCustomerPaymentExcel = (
{ wch: 10 }, // Ekor/Qty { wch: 10 }, // Ekor/Qty
{ wch: 12 }, // Berat { wch: 12 }, // Berat
{ wch: 10 }, // AVG { wch: 10 }, // AVG
{ wch: 15 }, // Harga Awal { wch: 15 }, // Harga/Unit
{ wch: 10 }, // CN
{ wch: 15 }, // Harga Akhir { wch: 15 }, // Harga Akhir
{ wch: 10 }, // PPN
{ wch: 15 }, // Total { wch: 15 }, // Total
{ wch: 15 }, // Pembayaran { wch: 15 }, // Pembayaran
{ wch: 15 }, // Saldo Piutang { wch: 15 }, // Saldo Piutang
@@ -187,10 +187,30 @@ const pdfStyles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
whiteSpace: 'nowrap', 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) => {
@@ -208,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 */}
@@ -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
]; ];
@@ -47,6 +47,8 @@ const CustomerPaymentTab = () => {
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>( const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
[] []
); );
// TODO: Uncomment when BE is ready
// const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterSales, setFilterSales] = useState<typeof salesOptions>([]); const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState(''); const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState(''); const [filterEndDate, setFilterEndDate] = useState('');
@@ -55,13 +57,16 @@ const CustomerPaymentTab = () => {
const { const {
options: customerOptions, options: customerOptions,
setInputValue: setCustomerInputValue,
isLoadingOptions: isLoadingCustomers, isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers, loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers, hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search'); } = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// TODO: Uncomment when BE is ready
const { const {
options: salesOptions, options: salesOptions,
setInputValue: setSalesInputValue,
isLoadingOptions: isLoadingSales, isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales, loadMore: loadMoreSales,
hasMore: hasMoreSales, hasMore: hasMoreSales,
@@ -101,7 +106,11 @@ const CustomerPaymentTab = () => {
}; };
const getPaymentStatusText = (notes: string) => { const getPaymentStatusText = (notes: string) => {
return notes; return notes
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}; };
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
@@ -133,23 +142,18 @@ const CustomerPaymentTab = () => {
count += 1; count += 1;
} }
// Sales filter // TODO: Uncomment when BE is ready
if (filterSales.length > 0) { // // Sales filter
count += 1; // if (filterSales.length > 0) {
} // count += 1;
// }
// Filter by (always count if submitted)
if (isSubmitted) {
count += 1;
}
return count; return count;
}, [ }, [
filterStartDate, filterStartDate,
filterEndDate, filterEndDate,
filterCustomer, filterCustomer,
filterSales, // filterSales,
isSubmitted,
]); ]);
const hasFilters = activeFiltersCount > 0; const hasFilters = activeFiltersCount > 0;
@@ -159,15 +163,16 @@ const CustomerPaymentTab = () => {
isSubmitted isSubmitted
? () => { ? () => {
const params = { const params = {
customer_id: customer_ids:
filterCustomer.length > 0 filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',') ? filterCustomer.map((v) => String(v.value)).join(',')
: undefined, : undefined,
sales_id: // TODO: Uncomment when BE is ready
filterSales.length > 0 // sales_id:
? filterSales.map((v) => String(v.value)).join(',') // filterSales.length > 0
: undefined, // ? filterSales.map((v) => String(v.value)).join(',')
filter_by: 'do_date' as const, // : undefined,
// filter_by: 'do_date' as const,
start_date: filterStartDate || undefined, start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined, end_date: filterEndDate || undefined,
page: currentPage, page: currentPage,
@@ -179,9 +184,9 @@ const CustomerPaymentTab = () => {
: null, : null,
([, params]) => ([, params]) =>
FinanceApi.getCustomerPaymentReport( FinanceApi.getCustomerPaymentReport(
params.customer_id, params.customer_ids,
params.sales_id, undefined, // TODO: Change to params.sales_id when BE is ready
params.filter_by, undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date, params.start_date,
params.end_date, params.end_date,
params.page, params.page,
@@ -202,15 +207,15 @@ const CustomerPaymentTab = () => {
CustomerPaymentReport[] | null CustomerPaymentReport[] | null
> => { > => {
const params = { const params = {
customer_id: customer_ids:
filterCustomer.length > 0 filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',') ? filterCustomer.map((v) => String(v.value)).join(',')
: undefined, : undefined,
sales_id: // TODO: Uncomment when BE is ready
filterSales.length > 0 // sales_id:
? filterSales.map((v) => String(v.value)).join(',') // filterSales.length > 0
: undefined, // ? filterSales.map((v) => String(v.value)).join(',')
filter_by: 'do_date' as const, // : undefined,
start_date: filterStartDate || undefined, start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined, end_date: filterEndDate || undefined,
limit: 100, limit: 100,
@@ -218,9 +223,9 @@ const CustomerPaymentTab = () => {
}; };
const response = await FinanceApi.getCustomerPaymentReport( const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id, params.customer_ids,
params.sales_id, undefined, // TODO: Change to params.sales_id when BE is ready
params.filter_by, undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date, params.start_date,
params.end_date, params.end_date,
params.page, params.page,
@@ -277,13 +282,15 @@ const CustomerPaymentTab = () => {
filterCustomer.length > 0 filterCustomer.length > 0
? filterCustomer.map((c) => c.label).join(', ') ? filterCustomer.map((c) => c.label).join(', ')
: undefined, : undefined,
sales: // TODO: Uncomment when BE is ready
filterSales.length > 0 // sales:
? filterSales.map((s) => s.label).join(', ') // filterSales.length > 0
: undefined, // ? filterSales.map((s) => s.label).join(', ')
// : undefined,
start_date: filterStartDate || undefined, start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined, end_date: filterEndDate || undefined,
filter_by: 'do_date', // TODO: Uncomment when BE is ready
// filter_by: 'do_date' as const,
}, },
}); });
toast.success('PDF berhasil dibuat dan diunduh.'); toast.success('PDF berhasil dibuat dan diunduh.');
@@ -301,36 +308,41 @@ const CustomerPaymentTab = () => {
{ {
id: 'no', id: 'no',
header: 'No', header: 'No',
cell: (props) => props.row.index + 1, cell: (props) => props.row.index,
footer: () => <div className='font-semibold text-gray-900'>Total</div>, footer: () => <div className='font-semibold text-gray-900'>Total</div>,
}, },
{ {
id: 'do_date_or_payment_date', id: 'do_date_or_payment_date',
header: 'Tanggal DO/Bayar', header: 'Tanggal Jual/Bayar',
accessorKey: 'do_date', accessorKey: 'trans_date',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.do_date; const value = props.row.original.trans_date;
return formatDate(value, 'DD MMM YYYY'); return value ? formatDate(value, 'DD MMM YYYY') : '-';
}, },
}, },
{ {
id: 'realization_date', id: 'realization_date',
header: 'Tanggal Realisasi', header: 'Tanggal Realisasi',
accessorKey: 'realization_date', accessorKey: 'delivery_date',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.realization_date; const value = props.row.original.delivery_date;
return formatDate(value, 'DD MMM YYYY'); return value ? formatDate(value, 'DD MMM YYYY') : '-';
}, },
}, },
{ {
id: 'aging', id: 'aging',
header: 'Aging', header: 'Aging',
accessorKey: 'aging_day', accessorKey: 'aging_day',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.aging_day; const value = props.row.original.aging_day;
return ( return (
<div className='text-center'> <div className='text-center'>
{value ? formatNumber(value) : '-'} hari {value !== null && value !== undefined
? `${formatNumber(value)} hari`
: '-'}
</div> </div>
); );
}, },
@@ -339,6 +351,7 @@ const CustomerPaymentTab = () => {
id: 'reference', id: 'reference',
header: 'Referensi', header: 'Referensi',
accessorKey: 'reference', accessorKey: 'reference',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.reference; const value = props.row.original.reference;
return value || '-'; return value || '-';
@@ -347,16 +360,18 @@ const CustomerPaymentTab = () => {
{ {
id: 'vehicle_plate', id: 'vehicle_plate',
header: 'Nomor Polisi', header: 'Nomor Polisi',
accessorKey: 'vehicle_plate', accessorKey: 'vehicle_numbers',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.vehicle_plate; const value = props.row.original.vehicle_numbers;
return value || '-'; return Array.isArray(value) ? value.join(', ') : value || '-';
}, },
}, },
{ {
id: 'qty', id: 'qty',
header: 'Ekor/Qty', header: 'Qty',
accessorKey: 'qty', accessorKey: 'qty',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.qty; const value = props.row.original.qty;
return <div className='text-right'>{formatNumber(value)}</div>; return <div className='text-right'>{formatNumber(value)}</div>;
@@ -371,6 +386,7 @@ const CustomerPaymentTab = () => {
id: 'weight', id: 'weight',
header: 'Berat (Kg)', header: 'Berat (Kg)',
accessorKey: 'weight', accessorKey: 'weight',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.weight; const value = props.row.original.weight;
return <div className='text-right'>{formatNumber(value)}</div>; return <div className='text-right'>{formatNumber(value)}</div>;
@@ -385,6 +401,7 @@ const CustomerPaymentTab = () => {
id: 'average_weight', id: 'average_weight',
header: 'AVG', header: 'AVG',
accessorKey: 'average_weight', accessorKey: 'average_weight',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.average_weight; const value = props.row.original.average_weight;
return <div className='text-right'>{formatNumber(value)}</div>; return <div className='text-right'>{formatNumber(value)}</div>;
@@ -394,37 +411,23 @@ const CustomerPaymentTab = () => {
), ),
}, },
{ {
id: 'price', id: 'unit_price',
header: 'Harga Awal', header: 'Harga/Unit',
accessorKey: 'price', accessorKey: 'unit_price',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.price; const value = props.row.original.unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => ( footer: () => (
<div className='text-right font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>-</div>
{formatCurrency(summary.total_initial_amount) || '-'}
</div>
),
},
{
id: 'credit_note',
header: 'CN',
accessorKey: 'credit_note',
cell: (props) => {
const value = props.row.original.credit_note;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_credit_note) || '-'}
</div>
), ),
}, },
{ {
id: 'final_price', id: 'final_price',
header: 'Harga Akhir', header: 'Harga Akhir',
accessorKey: 'final_price', accessorKey: 'final_price',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.final_price; const value = props.row.original.final_price;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
@@ -435,24 +438,13 @@ const CustomerPaymentTab = () => {
</div> </div>
), ),
}, },
{
id: 'ppn',
header: 'PPN (%)',
accessorKey: 'ppn',
cell: (props) => {
const value = props.row.original.ppn;
return <div className='text-right'>{formatNumber(value)}%</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{ {
id: 'total', id: 'total',
header: 'Total', header: 'Total',
accessorKey: 'total', accessorKey: 'total_price',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.total; const value = props.row.original.total_price;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => ( footer: () => (
@@ -464,9 +456,10 @@ const CustomerPaymentTab = () => {
{ {
id: 'payment', id: 'payment',
header: 'Pembayaran', header: 'Pembayaran',
accessorKey: 'payment', accessorKey: 'payment_amount',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.payment; const value = props.row.original.payment_amount;
return <div className='text-right'>{formatCurrency(value)}</div>; return <div className='text-right'>{formatCurrency(value)}</div>;
}, },
footer: () => ( footer: () => (
@@ -479,14 +472,25 @@ const CustomerPaymentTab = () => {
id: 'accounts_receivable', id: 'accounts_receivable',
header: 'Saldo Piutang', header: 'Saldo Piutang',
accessorKey: 'accounts_receivable', accessorKey: 'accounts_receivable',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.accounts_receivable; const value = props.row.original.accounts_receivable;
return ( return (
<div className='text-right text-error'>{formatCurrency(value)}</div> <div
className={`text-right font-semibold ${
value < 0 ? 'text-error' : ''
}`}
>
{formatCurrency(value)}
</div>
); );
}, },
footer: () => ( footer: () => (
<div className='text-right font-semibold text-gray-900'> <div
className={`text-right font-semibold ${
summary.total_accounts_receivable < 0 ? 'text-error' : ''
}`}
>
{formatCurrency(summary.total_accounts_receivable) || '-'} {formatCurrency(summary.total_accounts_receivable) || '-'}
</div> </div>
), ),
@@ -494,9 +498,10 @@ const CustomerPaymentTab = () => {
{ {
id: 'notes', id: 'notes',
header: 'Keterangan', header: 'Keterangan',
accessorKey: 'notes', accessorKey: 'status',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.notes; const value = props.row.original.status;
if (!value) { if (!value) {
return '-'; return '-';
@@ -511,7 +516,7 @@ const CustomerPaymentTab = () => {
status: getPaymentStatusIndicatorColor(value), status: getPaymentStatusIndicatorColor(value),
}} }}
> >
{getPaymentStatusText(value)} <span className='capitalize'>{getPaymentStatusText(value)}</span>
</Badge> </Badge>
); );
}, },
@@ -520,17 +525,19 @@ const CustomerPaymentTab = () => {
id: 'pickup_info', id: 'pickup_info',
header: 'Pengambilan', header: 'Pengambilan',
accessorKey: 'pickup_info', accessorKey: 'pickup_info',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.pickup_info; const value = props.row.original.pickup_info;
return value || '-'; return Array.isArray(value) ? value.join(', ') : value || '-';
}, },
}, },
{ {
id: 'sales_marketing', id: 'sales_marketing',
header: 'Sales/Marketing', header: 'Sales/Marketing',
accessorKey: 'sales_marketing', accessorKey: 'sales_person',
enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.sales_marketing; const value = props.row.original.sales_person;
return value || '-'; return value || '-';
}, },
}, },
@@ -654,6 +661,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}
@@ -661,7 +669,8 @@ const CustomerPaymentTab = () => {
/> />
</div> </div>
<div> {/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInputCheckbox <SelectInputCheckbox
label='Sales' label='Sales'
placeholder='Pilih Sales' placeholder='Pilih Sales'
@@ -670,14 +679,16 @@ 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}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div> */}
<div> {/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInput <SelectInput
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
@@ -686,7 +697,7 @@ const CustomerPaymentTab = () => {
isDisabled={true} isDisabled={true}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div> */}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
@@ -726,10 +737,7 @@ const CustomerPaymentTab = () => {
const summary = customerReport.summary || { const summary = customerReport.summary || {
total_qty: 0, total_qty: 0,
total_weight: 0, total_weight: 0,
total_initial_amount: 0,
total_credit_note: 0,
total_final_amount: 0, total_final_amount: 0,
total_ppn: 0,
total_grand_amount: 0, total_grand_amount: 0,
total_payment: 0, total_payment: 0,
total_accounts_receivable: 0, total_accounts_receivable: 0,
@@ -741,19 +749,27 @@ const CustomerPaymentTab = () => {
<Card <Card
key={customerReport.customer.id} key={customerReport.customer.id}
title={customerReport.customer.name} title={customerReport.customer.name}
subtitle={`(${customerReport.customer.address})`}
className={{ className={{
wrapper: 'w-full rounded-2xl', wrapper: 'w-full rounded-2xl',
body: 'p-0', body: 'p-0',
title: title:
'py-1.5 px-3 bg-[#0069E0] text-white text-lg font-normal', 'py-1.5 px-3 bg-[#0069E0] text-white text-lg font-normal',
subtitle:
'px-3 pb-1 bg-[#0069E0] text-white text-sm font-normal',
}} }}
variant='bordered' variant='bordered'
collapsible={true} collapsible={true}
> >
<Table <Table
data={customerReport.rows} data={[
{
accounts_receivable: customerReport.initial_balance,
} as CustomerPaymentReport['rows'][0],
...customerReport.rows,
]}
columns={tableColumns} columns={tableColumns}
pageSize={10} pageSize={customerReport.rows.length + 1}
renderFooter={customerReport.rows.length > 0} renderFooter={customerReport.rows.length > 0}
className={{ className={{
containerClassName: 'w-full', containerClassName: 'w-full',
@@ -773,6 +789,36 @@ const CustomerPaymentTab = () => {
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden', 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'
key={row.index}
>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={13}
></td>
<td className='px-4 py-3 text-xs whitespace-nowrap'>
<div
className={`text-right ${
row.original.accounts_receivable < 0
? 'text-error'
: ''
}`}
>
{formatCurrency(row.original.accounts_receivable)}
</div>
</td>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={4}
></td>
</tr>
);
}
}}
/> />
</Card> </Card>
); );
@@ -34,6 +34,7 @@ import {
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
const dueStatus: Record<string, Color> = { const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error', 'Sudah Jatuh Tempo': 'error',
@@ -89,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(
() => [ () => [
@@ -247,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.');
@@ -670,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={
@@ -21,10 +21,18 @@ import {
ProjectFlockApi, ProjectFlockApi,
ProjectFlockKandangApi, ProjectFlockKandangApi,
} from '@/services/api/production'; } from '@/services/api/production';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; import {
import { isResponseError } from '@/lib/api-helper'; BaseProjectFlockKandang,
ProjectFlockKandang,
} from '@/types/api/production/project-flock-kandang';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import { ProductionResultReportApi } from '@/services/api/report/production-result'; import { ProductionResultReportApi } from '@/services/api/report/production-result';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import { ProductionResult } from '@/types/api/report/production-result';
import ProductionResultReportPDF from './ProductionResultReportPDF';
import { pdf } from '@react-pdf/renderer';
const ProductionResultContent = () => { const ProductionResultContent = () => {
const [projectFlockKandangs, setProjectFlockKandangs] = useState< const [projectFlockKandangs, setProjectFlockKandangs] = useState<
@@ -49,6 +57,8 @@ const ProductionResultContent = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false); useState(false);
const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false);
const [selectedArea, setSelectedArea] = useState<OptionType | null>(null); const [selectedArea, setSelectedArea] = useState<OptionType | null>(null);
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>( const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
null null
@@ -62,6 +72,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 +89,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 +106,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 +133,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',
@@ -154,6 +168,87 @@ const ProductionResultContent = () => {
setIsLoadingExportingToExcel(false); setIsLoadingExportingToExcel(false);
}; };
const exportToPdfHandler = async () => {
setIsLoadingExportingToPdf(true);
try {
let projectFlockKandangsData: BaseProjectFlockKandang[] = [];
if (selectedProjectFlockKandang) {
const projectFlockKandangResponse =
await ProjectFlockKandangApi.getSingle(
selectedProjectFlockKandang?.value as number
);
projectFlockKandangsData = isResponseSuccess(
projectFlockKandangResponse
)
? [projectFlockKandangResponse.data]
: [];
} else {
const projectFlockKandangsResponse =
await ProjectFlockKandangApi.getAll({
area_id: selectedArea?.value,
project_flock_id: selectedProjectFlock?.value,
});
projectFlockKandangsData = isResponseSuccess(
projectFlockKandangsResponse
)
? projectFlockKandangsResponse.data
: [];
}
const mappedProductionResults: {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
}[] = await Promise.all(
projectFlockKandangsData.map(async (projectFlockKandang) => {
const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`;
const getProductionResultRes = await httpClient<
BaseApiResponse<ProductionResult[]>
>(getProductionResultPath);
return {
projectFlockKandang,
productionResult: isResponseSuccess(getProductionResultRes)
? getProductionResultRes.data
: null,
};
})
);
if (mappedProductionResults.length === 0) {
toast.error('Tidak ada data untuk diexport.');
setIsLoadingExportingToPdf(false);
return;
}
const openPdf = async () => {
const productionResultPdfBlob = await pdf(
<ProductionResultReportPDF
mappedProductionResults={mappedProductionResults}
/>
).toBlob();
const productionResultReportPdfUrl = URL.createObjectURL(
productionResultPdfBlob
);
window.open(productionResultReportPdfUrl, '_blank');
};
await openPdf();
} catch (error) {
console.error(error);
toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.');
}
// await ProductionResultReportApi.exportProductionResultToPdf(
// projectFlockKandangs
// );
setIsLoadingExportingToPdf(false);
};
const searchHandler = async () => { const searchHandler = async () => {
setProjectFlockKandangs(null); setProjectFlockKandangs(null);
setIsLoadingSearch(true); setIsLoadingSearch(true);
@@ -235,6 +330,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 +347,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 +367,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 +387,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={{
@@ -347,6 +446,13 @@ const ProductionResultContent = () => {
onClick={exportToExcelHandler} onClick={exportToExcelHandler}
className='text-nowrap' className='text-nowrap'
/> />
<MenuItem
title='Export to PDF'
icon='icon-park-outline:file-pdf-one'
isLoading={isLoadingExportingToPdf}
onClick={exportToPdfHandler}
className='text-nowrap'
/>
</Menu> </Menu>
</Dropdown> </Dropdown>
</div> </div>
@@ -0,0 +1,388 @@
'use client';
import React from 'react';
import {
Document,
Page,
StyleSheet,
Text,
View,
Image,
} from '@react-pdf/renderer';
import { formatDate, formatNumber } from '@/lib/helper';
import { BaseProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { ProductionResult } from '@/types/api/report/production-result';
type MappedProductionResultsItem = {
projectFlockKandang: BaseProjectFlockKandang;
productionResult: ProductionResult[] | null;
};
interface ProductionResultReportPDFProps {
mappedProductionResults?: MappedProductionResultsItem[];
}
const styles = StyleSheet.create({
page: {
paddingTop: 24,
paddingBottom: 52,
paddingHorizontal: 16,
},
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
companyLogo: {
width: 64,
height: 'auto',
},
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 10,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 420,
marginBottom: 10,
},
doubleDivider: {
width: '100%',
height: 6,
borderTopWidth: 2,
borderTopColor: '#000',
borderBottomWidth: 2,
borderBottomColor: '#000',
},
title: {
marginTop: 14,
fontSize: 14,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
position: 'absolute',
fontSize: 8,
bottom: 22,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
section: {
marginTop: 12,
borderWidth: 1,
borderColor: '#000',
padding: 8,
},
sectionHeader: {
marginBottom: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
},
sectionTitle: {
fontSize: 10,
fontWeight: 'bold',
},
sectionSubtitle: {
fontSize: 8,
color: '#444',
},
// Simple grid table (label/value pairs)
grid: {
width: '100%',
borderWidth: 1,
borderColor: '#000',
},
gridRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000',
},
gridRowLast: {
borderBottomWidth: 0,
},
gridCellLabel: {
width: '40%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
borderRightWidth: 1,
borderRightColor: '#000',
fontWeight: 'bold',
},
gridCellValue: {
width: '60%',
paddingVertical: 3,
paddingHorizontal: 6,
fontSize: 8,
textAlign: 'right',
},
// Subsection headings
groupTitle: {
marginTop: 8,
marginBottom: 4,
fontSize: 9,
fontWeight: 'bold',
},
emptyText: {
fontSize: 8,
color: '#666',
fontStyle: 'italic',
},
});
function safeNum(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function valueText(v: unknown) {
if (v === null || v === undefined) return '-';
if (typeof v === 'number') return formatNumber(v);
return String(v);
}
/**
* Render label/value table for one ProductionResult.
* Uses a compact grid to keep page readable.
*/
function ProductionResultGrid({ pr }: { pr: ProductionResult }) {
const rows: Array<[string, string]> = [
['WOA', valueText(pr.woa)],
// BW
['BW', valueText(pr.bw)],
['Std BW', valueText(pr.std_bw)],
['Uniformity', valueText(pr.uniformity)],
['Std Uniformity', valueText(pr.std_uniformity)],
// Dep
['Dep Kum', valueText(pr.dep_kum)],
['Dep Std', valueText(pr.dep_std)],
// Butiran
['Butiran Utuh', valueText(pr.butiran_utuh)],
['Butiran Putih', valueText(pr.butiran_putih)],
['Butiran Retak', valueText(pr.butiran_retak)],
['Butiran Pecah', valueText(pr.butiran_pecah)],
['Butiran Jumlah', valueText(pr.butiran_jumlah)],
['Total Butir', valueText(pr.total_butir)],
// Kg
['Kg Utuh', valueText(pr.kg_utuh)],
['Kg Putih', valueText(pr.kg_putih)],
['Kg Retak', valueText(pr.kg_retak)],
['Kg Pecah', valueText(pr.kg_pecah)],
['Kg Jumlah', valueText(pr.kg_jumlah)],
['Total Kg', valueText(pr.total_kg)],
// %
['% Utuh', valueText(pr.persen_utuh)],
['% Putih', valueText(pr.persen_putih)],
['% Retak', valueText(pr.persen_retak)],
['% Pecah', valueText(pr.persen_pecah)],
// Produksi
['HD', valueText(pr.hd)],
['HD Std', valueText(pr.hd_std)],
['FI', valueText(pr.fi)],
['FI Std', valueText(pr.fi_std)],
['EM', valueText(pr.em)],
['EM Std', valueText(pr.em_std)],
['EW', valueText(pr.ew)],
['EW Std', valueText(pr.ew_std)],
['FCR', valueText(pr.fcr)],
['FCR Std', valueText(pr.fcr_std)],
['HH', valueText(pr.hh)],
['HH Std', valueText(pr.hh_std)],
];
return (
<View style={styles.grid}>
{rows.map(([label, value], idx) => {
const isLast = idx === rows.length - 1;
return (
<View
key={label}
style={[styles.gridRow, ...(isLast ? [styles.gridRowLast] : [])]}
>
<Text style={styles.gridCellLabel}>{label}</Text>
<Text style={styles.gridCellValue}>{value}</Text>
</View>
);
})}
</View>
);
}
/**
* If there are multiple ProductionResult entries for a kandang,
* we show them sequentially with a small header per result.
*
* You can later change this to render only the latest WOA, or group by week.
*/
function ProductionResultList({
productionResults,
}: {
productionResults: ProductionResult[];
}) {
return (
<View>
{productionResults.map((pr, idx) => {
const kandangName =
pr.project_flock?.kandang?.name ||
pr.project_flock?.kandang?.id?.toString() ||
'';
// Optional: show a compact subheader
const headerLeft = `Data #${idx + 1}`;
const headerRight =
kandangName && pr.woa !== undefined
? `${kandangName} • WOA ${safeNum(pr.woa)}`
: pr.woa !== undefined
? `WOA ${safeNum(pr.woa)}`
: '';
return (
<View
key={`${pr.project_flock?.id ?? 'pf'}-${idx}`}
style={{ marginTop: idx === 0 ? 0 : 10 }}
wrap={false}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{headerLeft}</Text>
<Text style={styles.sectionSubtitle}>{headerRight}</Text>
</View>
<ProductionResultGrid pr={pr} />
</View>
);
})}
</View>
);
}
/**
* Main PDF Component
*/
const ProductionResultReportPDF = ({
mappedProductionResults = [],
}: ProductionResultReportPDFProps) => {
return (
<Document>
<Page style={styles.page} size='A4'>
{/* Header */}
<View>
<View style={styles.companyInfoHeader}>
<Image style={styles.companyLogo} src='/assets/img/lti-logo.png' />
<Text style={styles.companyInfoHeaderDate}>
{formatDate(Date.now(), 'DD MMMM YYYY')}
</Text>
</View>
<View>
<Text style={styles.companyName}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={styles.companyAddress}>
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={styles.doubleDivider} />
</View>
</View>
<Text style={styles.title}>Laporan Production Result</Text>
{/* Sections per ProjectFlockKandang */}
{mappedProductionResults.length === 0 ? (
<View style={{ marginTop: 16 }}>
<Text style={styles.emptyText}>Tidak ada data.</Text>
</View>
) : (
mappedProductionResults.map((item, idx) => {
const pfk = item.projectFlockKandang;
// Try to display meaningful identifiers.
// Adjust these fields based on your real BaseProjectFlockKandang structure.
const kandangName =
pfk?.kandang?.name ?? `Kandang #${pfk?.kandang_id ?? idx + 1}`;
const projectName = pfk?.project_flock?.name ?? '';
const locationName = pfk?.project_flock?.location?.name ?? '';
const areaName = pfk?.project_flock?.area?.name ?? '';
return (
<View
key={`pfk-${pfk?.id ?? idx}`}
style={styles.section}
break={idx > 0} // each kandang starts on a new page for clarity
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{projectName
? `${projectName}${kandangName}`
: kandangName}
</Text>
<Text style={styles.sectionSubtitle}>
{[areaName, locationName].filter(Boolean).join(' • ')}
</Text>
</View>
{item.productionResult && item.productionResult.length > 0 ? (
<ProductionResultList
productionResults={item.productionResult}
/>
) : (
<Text style={styles.emptyText}>
Tidak ada production result untuk kandang ini.
</Text>
)}
</View>
);
})
)}
{/* Footer */}
<View style={styles.footer} fixed>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
};
export default ProductionResultReportPDF;
@@ -226,7 +226,7 @@ const createPDFDocument = (
<Text>Rentang BW</Text> <Text>Rentang BW</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Ekor</Text> <Text>Sisa Butir</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Sisa Kg</Text> <Text>Sisa Kg</Text>
@@ -234,12 +234,6 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Bobot (Kg)</Text> <Text>Rata-Rata Bobot (Kg)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Produksi Telur (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Feed (Supplier)</Text> <Text>Feed (Supplier)</Text>
</View> </View>
@@ -249,16 +243,15 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text> <Text>Rata-Rata Harga DOC</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>HPP Telur (RP/KG)</Text> <Text>HPP Telur (RP/KG)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View
style={[
pdfStyles.tableCellHeaderRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>Nominal Sisa</Text> <Text>Nominal Sisa</Text>
</View> </View>
</View> </View>
@@ -278,23 +271,15 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>{group.label}</Text> <Text>{group.label}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(group.remaining_chicken_weight_kg)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_pieces)}</Text> <Text>{formatNumber(group.egg_production_pieces)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(group.egg_production_kg)}</Text> <Text>{formatNumber(group.egg_production_kg)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(group.avg_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}> <View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text> <Text>
{group.feed_suppliers {group.feed_suppliers
@@ -318,17 +303,16 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.average_doc_price_rp)}</Text> <Text>{formatCurrency(group.average_doc_price_rp)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(group.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text> <Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View
<Text>{formatCurrency(group.remaining_value_rp)}</Text> style={[
pdfStyles.tableCellRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View> </View>
</View> </View>
) )
@@ -356,16 +340,10 @@ const createPDFDocument = (
<Text>Rata-Rata Bobot (Kg)</Text> <Text>Rata-Rata Bobot (Kg)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Ekor</Text> <Text>Sisa Butir</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Sisa Kg (Ayam)</Text> <Text>Sisa Kg (Telur)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Butir)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Produksi Telur (Kg)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Feed (Supplier)</Text> <Text>Feed (Supplier)</Text>
@@ -376,16 +354,15 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Rata-Rata Harga DOC</Text> <Text>Rata-Rata Harga DOC</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Nilai Nominal Telur</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>HPP Ayam</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Telur (RP/KG)</Text> <Text>HPP Telur (RP/KG)</Text>
</View> </View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}> <View
style={[
pdfStyles.tableCellHeaderRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>Nominal Sisa</Text> <Text>Nominal Sisa</Text>
</View> </View>
</View> </View>
@@ -394,12 +371,7 @@ const createPDFDocument = (
{data.rows.map((item: HppPerKandangRow, index: number) => ( {data.rows.map((item: HppPerKandangRow, index: number) => (
<View <View
key={index} key={index}
style={[ style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}
pdfStyles.tableRow,
index < data.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
> >
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}> <View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text> <Text>{index + 1}</Text>
@@ -416,12 +388,6 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.avg_weight_kg)}</Text> <Text>{formatNumber(item.avg_weight_kg)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_birds)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.remaining_chicken_weight_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}> <View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.egg_production_pieces)}</Text> <Text>{formatNumber(item.egg_production_pieces)}</Text>
</View> </View>
@@ -451,20 +417,202 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.average_doc_price_rp)}</Text> <Text>{formatCurrency(item.average_doc_price_rp)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatCurrency(item.hpp_rp)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}> <View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text> <Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text>
</View> </View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}> <View
<Text>{formatCurrency(item.remaining_value_rp)}</Text> style={[
pdfStyles.tableCellRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</View> </View>
</View> </View>
))} ))}
{/* TOTAL Row */}
{data.summary?.total && (
<View style={pdfStyles.tableRow}>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 0.5,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>TOTAL</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1.5,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>ALL</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>-</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatNumber(data.summary.total.average_weight_kg)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 0.8,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatNumber(
data.summary.total.total_egg_production_pieces
)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 0.8,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatNumber(data.summary.total.total_egg_production_kg)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1.2,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{data.rows
.flatMap((row: HppPerKandangRow) =>
row.feed_suppliers?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
)
.filter(
(v: string, i: number, a: string[]) =>
a.indexOf(v) === i
)
.join(' | ') || '-'}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeader,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{data.rows
.flatMap((row: HppPerKandangRow) =>
row.doc_suppliers?.map(
(s: { alias?: string; name: string }) =>
s.alias || s.name
)
)
.filter(
(v: string, i: number, a: string[]) =>
a.indexOf(v) === i
)
.join(' | ') || '-'}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1.2,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatCurrency(
data.summary.total.total_average_doc_price_rp
)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
},
]}
>
<Text>
{formatCurrency(
data.summary.total.average_egg_hpp_rp_per_kg
)}
</Text>
</View>
<View
style={[
pdfStyles.tableCellHeaderRight,
{
flex: 1.2,
backgroundColor: '#F5F5F5',
borderBottomWidth: 0,
borderRightWidth: 0,
},
]}
>
<Text>
{formatCurrency(data.summary.total.total_egg_value_rp)}
</Text>
</View>
</View>
)}
</View> </View>
</View> </View>
</Page> </Page>

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