Merge branch 'dev/hotfix/restu' into 'development'

[FIX/FE] Data Refactor and UI Adjustment (Closing (Penjualan), Transfer Stock, Laporan (Kontrol Pembayaran Customer dan HPP Harian Kandang), Biaya, Recording)

See merge request mbugroup/lti-web-client!210
This commit is contained in:
Rivaldi A N S
2026-01-19 09:15:25 +00:00
23 changed files with 970 additions and 574 deletions
@@ -82,12 +82,12 @@ const SalesReportTable = ({
<div className='font-semibold text-gray-900'>Total Penjualan</div>
),
},
{
id: 'age',
accessorKey: 'age',
header: 'Umur',
cell: (props) => props.getValue() || '-',
},
// {
// id: 'age',
// accessorKey: 'age',
// header: 'Umur',
// cell: (props) => props.getValue() || '-',
// },
{
id: 'do_number',
accessorKey: 'do_number',
@@ -43,7 +43,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
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'>
<Button
href='/expense'
@@ -65,7 +65,7 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
tabs={expenseDetailTabs}
variant='lifted'
className={{
wrapper: 'max-w-5xl mx-auto mt-4',
wrapper: 'mx-auto mt-4',
}}
/>
</section>
@@ -68,7 +68,7 @@ const ExpenseRealizationContent = ({
return (
<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'>
<RequirePermission permissions='lti.expense.update.realization'>
<Button
@@ -84,7 +84,7 @@ const ExpenseRealizationContent = ({
</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'>
<tbody>
<tr>
@@ -179,7 +179,7 @@ const ExpenseRealizationContent = ({
</table>
</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'>
<Card variant='bordered' size='sm' className={{ wrapper: 'grow' }}>
<div className='w-full flex flex-col gap-2'>
@@ -216,127 +216,141 @@ const ExpenseRealizationContent = ({
</div>
</div>
<div className='w-full max-w-5xl mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-8 mx-auto grid grid-cols-2 gap-4'>
<div>
<h2 className='font-bold text-xl text-center'>
Rincian Pengajuan Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map(
(kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</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>
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</th>
</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>
);
})}
<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>
)
)}
</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 className='w-full max-w-5xl mt-8 mx-auto'>
<h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional
</h2>
<div>
<h2 className='font-bold text-xl text-center'>
Rincian Realisasi Biaya Operasional
</h2>
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map((kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
<div className='w-full mt-2 flex flex-col gap-4'>
{initialValues?.kandangs.map(
(kandangExpense, kandangExpenseIdx) => {
let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.qty * item.price)
);
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</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>
return (
<div
key={kandangExpenseIdx}
className='overflow-x-auto w-full mx-auto'
>
<table className='table table-sm table-zebra'>
<thead>
<tr>
<th
colSpan={5}
className='font-bold text-center text-base-content text-lg'
>
Biaya {kandangExpense.name}
</th>
</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>
);
})}
<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>
)
)}
</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>
@@ -273,7 +273,7 @@ const ExpenseRequestContent = ({
<>
<div>
{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} />
</div>
)}
@@ -281,7 +281,7 @@ const ExpenseRequestContent = ({
<div className='w-full mt-4 flex flex-col gap-4'>
{/* 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 && (
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
@@ -414,7 +414,7 @@ const ExpenseRequestContent = ({
</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'>
<tbody>
<tr>
@@ -608,7 +608,7 @@ const ExpenseRequestContent = ({
</table>
</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'>
Rincian Pengajuan Biaya Operasional
</h2>
+20 -28
View File
@@ -54,17 +54,19 @@ const RowOptionsMenu = ({
rejectClickHandler: () => void;
deleteClickHandler: () => void;
}) => {
const showEditButton =
props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4);
const showEditButton = props.row.original.latest_approval
? props.row.original.latest_approval.step_number !== 6 &&
(props.row.original.latest_approval.step_number === 1 ||
props.row.original.latest_approval.step_number === 2 ||
props.row.original.latest_approval.step_number === 3 ||
props.row.original.latest_approval.step_number === 4)
: false;
// TODO: apply RBAC
const showRealizationButton =
props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4;
const showRealizationButton = props.row.original.latest_approval
? props.row.original.latest_approval.action !== 'REJECTED' &&
props.row.original.latest_approval.step_number === 4
: false;
return (
<RowOptionsMenuWrapper type={type}>
@@ -278,6 +280,7 @@ const ExpensesTable = () => {
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() ||
!row.original.latest_approval ||
row.original.latest_approval.action === 'REJECTED';
return (
@@ -413,6 +416,8 @@ const ExpensesTable = () => {
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
row
) => {
if (!row.original.latest_approval) return false;
return (
row.original.latest_approval.action !== 'REJECTED' &&
row.original.latest_approval.step_number !== 6
@@ -692,14 +697,6 @@ const ExpensesTable = () => {
</>
)}
</div>
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'sm:max-w-3xs' }}
/>
</div>
<div className='grid grid-cols-12 justify-end gap-2'>
@@ -753,17 +750,12 @@ const ExpensesTable = () => {
}}
/>
<SelectInput
label='Baris'
options={ROWS_OPTIONS}
value={{
label: String(tableFilterState.pageSize),
value: tableFilterState.pageSize,
}}
onChange={pageSizeChangeHandler}
className={{
wrapper: 'col-span-12 max-w-28 justify-self-end',
}}
<DebouncedTextInput
name='search'
placeholder='Cari Biaya Operasional'
value={tableFilterState.search}
onChange={searchChangeHandler}
className={{ wrapper: 'col-span-12 max-w-52 justify-self-end' }}
/>
</div>
</div>
@@ -19,6 +19,7 @@ import { isResponseSuccess } from '@/lib/api-helper';
interface ExpenseKandangsTableProps {
locationId?: number;
type: 'add' | 'edit' | 'detail';
formType?: 'request' | 'realization';
selectedKandangs: {
id?: number;
name?: string;
@@ -31,6 +32,7 @@ interface ExpenseKandangsTableProps {
const ExpenseKandangsTable = ({
type,
formType = 'request',
locationId,
selectedKandangs,
onChange,
@@ -173,68 +175,76 @@ const ExpenseKandangsTable = ({
}, [sorting, updateSortingFilter]);
return (
<Card
className={{
wrapper: className?.wrapper,
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'>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}
<>
{selectedKandangs.length > 0 && selectedKandangs.some((k) => k.id) && (
<Card
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,
}),
wrapper: className?.wrapper,
body: 'p-4 shadow',
}}
/>
</Collapse>
</Card>
>
<Collapse
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')
: undefined,
kandangs: initialValues?.kandangs.map((kandang) => ({
id: kandang.kandang_id,
id: kandang.id,
name: kandang.name,
})),
supplier: initialValues?.supplier
@@ -249,7 +249,7 @@ const ExpenseRealizationForm = ({
}, [formikSetValues, getExpenseRealizationFormInitialValues, initialValues]);
return (
<section className='w-full max-w-5xl'>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
@@ -297,6 +297,7 @@ const ExpenseRealizationForm = ({
<ExpenseKandangsTable
type='detail'
formType='realization'
locationId={formik.values.location?.value}
selectedKandangs={formik.values.kandangs ?? []}
onChange={kandangsChangeHandler}
@@ -41,22 +41,25 @@ type ExpenseFormSchemaType = {
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
Yup.object({
category: Yup.object({
value: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
label: Yup.string().oneOf(['BOP', 'NON-BOP']).required(),
value: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
label: Yup.string()
.oneOf(['BOP', 'NON-BOP'])
.required('Kategori wajib diisi!'),
})
.nullable()
.optional(),
.required('Kategori wajib diisi!')
.typeError('Kategori wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
}).nullable(),
location_id: Yup.number()
.required('Lokasi wajib diisi!')
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!')
.typeError('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
@@ -73,9 +76,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
supplier: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
}).nullable(),
supplier_id: Yup.number()
.required('Vendor wajib diisi!')
@@ -104,9 +105,12 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
.of(
Yup.object({
nonstock: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
value: Yup.number().min(1).required('Nonstock wajib diisi!'),
label: Yup.string().required('Nonstock wajib diisi!'),
})
.nullable()
.required('Nonstock wajib diisi!')
.typeError('Nonstock wajib diisi!'),
nonstock_id: Yup.number()
.required('Nonstock wajib diisi!')
.min(1, 'Nonstock wajib diisi!')
@@ -190,30 +190,18 @@ const ExpenseRequestForm = ({
formik.setFieldValue('category', val);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('location', true);
formik.setFieldValue('location', val);
const locationChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location ? Number(location.value) : 0;
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandangs', []);
// Auto-create expense item for location (without kandang)
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: null,
nonstock_id: 0,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
};
formik.setFieldTouched('location', true);
formik.setFieldValue('location', location);
formik.setFieldTouched('location_id', true);
formik.setFieldValue('location_id', locationId);
},
[]
);
const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[]
@@ -268,6 +256,7 @@ const ExpenseRequestForm = ({
const supplierChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('supplier', true);
formik.setFieldTouched('supplier_id', true);
formik.setFieldValue('supplier', val);
const supplierId = Array.isArray(val) ? val[0]?.value : val?.value;
@@ -360,7 +349,7 @@ const ExpenseRequestForm = ({
return (
<>
<section className='w-full max-w-5xl'>
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
@@ -407,6 +396,16 @@ const ExpenseRequestForm = ({
placeholder='Pilih Kategori'
value={formik.values.category}
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={[
{
value: 'BOP',
@@ -427,8 +426,13 @@ const ExpenseRequestForm = ({
value={formik.values.location}
onChange={locationChangeHandler}
options={locationOptions}
isLoading={isLoadingLocationOptions}
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' }}
/>
@@ -438,6 +442,12 @@ const ExpenseRequestForm = ({
required
value={formik.values.transaction_date}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
Boolean(formik.errors.transaction_date)
}
errorMessage={formik.errors.transaction_date as string}
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
@@ -460,8 +470,12 @@ const ExpenseRequestForm = ({
value={formik.values.supplier}
onChange={supplierChangeHandler}
options={supplierOptions}
isLoading={isLoadingVendorOptions}
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' }}
/>
@@ -55,6 +55,10 @@ const ExpenseRequestKandangDetailExpense: React.FC<
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
true
);
formik.setFieldTouched(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock_id`,
true
);
formik.setFieldValue(
`expense_nonstocks[${kandangExpenseIdx}].cost_items[${expenseIdx}].nonstock`,
val
@@ -96,7 +100,7 @@ const ExpenseRequestKandangDetailExpense: React.FC<
};
const isExpenseRepeaterInputError = (
column: 'nonstock' | 'quantity' | 'price' | 'notes',
column: 'nonstock_id' | 'quantity' | 'price' | 'notes',
kandangExpenseIdx: number,
expenseIdx: number
) => {
@@ -105,11 +109,14 @@ const ExpenseRequestKandangDetailExpense: React.FC<
expenseIdx
]?.[column] &&
Boolean(
formik.errors.expense_nonstocks?.[kandangExpenseIdx] instanceof
Object &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx] ===
'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx
] instanceof Object &&
] &&
typeof formik.errors.expense_nonstocks?.[kandangExpenseIdx]
.cost_items?.[expenseIdx] === 'object' &&
formik.errors.expense_nonstocks?.[kandangExpenseIdx].cost_items?.[
expenseIdx
]?.[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 (
<Card
className={{
@@ -202,10 +235,21 @@ const ExpenseRequestKandangDetailExpense: React.FC<
val
);
}}
isError={isExpenseRepeaterInputError(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'nonstock_id',
kandangExpenseIdx,
expenseIdx
)}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
isClearable={true}
/>
</td>
@@ -226,6 +270,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
@@ -246,6 +295,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
@@ -271,6 +325,11 @@ const ExpenseRequestKandangDetailExpense: React.FC<
kandangExpenseIdx,
expenseIdx
)}
errorMessage={getExpenseRepeaterErrorMessage(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
@@ -110,6 +110,14 @@ const DeliveryProductObjectSchema = Yup.object({
.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({
delivery_cost: Yup.number()
.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_index: Yup.number().optional(),
document: Yup.mixed<File | MovementDocument>()
.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;
}),
document: DeliveryDocumentSchema,
driver_name: Yup.string().required('Nama sopir wajib diisi!'),
vehicle_plate: Yup.string().required('Plat nomor wajib diisi!'),
supplier: Yup.object({
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFormik } from 'formik';
import useSWR from 'swr';
@@ -95,7 +95,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
isLoadingOptions: isLoadingWarehouses,
loadMore: loadMoreWarehouses,
rawData: warehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search', {
flag: 'EKSPEDISI',
});
// ===== SELECT INPUT DATA =====
const {
@@ -261,6 +263,47 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
},
});
const prevSourceWarehouseIdRef = useRef<number | null>(
formik.values.source_warehouse_id
);
// ===== RESET PRODUCTS WHEN SOURCE WAREHOUSE CHANGES =====
useEffect(() => {
const prevSourceWarehouseId = prevSourceWarehouseIdRef.current;
const currentSourceWarehouseId = formik.values.source_warehouse_id;
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,
@@ -347,13 +390,71 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
};
};
const handleTransferDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value);
};
// ===== EVENT HANDLERS =====
// Product Handlers
const addProduct = () => {
const handleTransferDateChange = useCallback(
(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 = [
...(formik.values.products || []),
{
@@ -363,22 +464,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
},
];
formik.setFieldValue('products', newProducts);
};
}, []);
const removeProduct = useCallback(
(i: number) => {
const updatedProducts =
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
const removeProduct = useCallback((i: number) => {
const updatedProducts =
formik.values.products?.reduce((acc: ProductSchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
formik.setFieldValue('products', updatedProducts);
},
[formik]
);
formik.setFieldValue('products', updatedProducts);
}, []);
const bulkRemoveProduct = useCallback(() => {
const updatedProducts =
@@ -387,10 +485,45 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? [];
formik.setFieldValue('products', updatedProducts);
setSelectedProducts([]);
}, [formik, selectedProducts]);
}, [formik, selectedProducts, setSelectedProducts]);
// Delivery Handlers
const addDelivery = () => {
const handleProductChange = useCallback(
(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.values.deliveries || []),
{
@@ -410,25 +543,19 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
],
},
]);
};
}, []);
const removeDelivery = useCallback(
(i: number) => {
const updatedDeliveries =
formik.values.deliveries?.reduce(
(acc: DeliverySchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
},
[]
) ?? [];
const removeDelivery = useCallback((i: number) => {
const updatedDeliveries =
formik.values.deliveries?.reduce((acc: DeliverySchema[], item, index) => {
if (index !== i) {
acc.push(item);
}
return acc;
}, []) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
},
[formik]
);
formik.setFieldValue('deliveries', updatedDeliveries);
}, []);
const bulkRemoveDelivery = useCallback(() => {
const updatedDeliveries =
@@ -437,33 +564,101 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]);
}, [formik, selectedDeliveries]);
}, [formik, selectedDeliveries, setSelectedDeliveries]);
// Cost Calculation Handlers
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
const handleDeliverySelectAllChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map((_, idx) => idx) ?? []
);
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);
}
} else {
setSelectedDeliveries([]);
}
},
[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(
(idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
@@ -482,7 +677,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}
}
},
[formik]
[]
);
const handleDeliveryCostChangeWrapper = useCallback(
@@ -957,43 +1152,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang'
placeholder='Pilih gudang asal...'
value={formik.values.source_warehouse}
onChange={(val) => {
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);
}
}}
onChange={handleSourceWarehouseChange}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
@@ -1057,41 +1216,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
label='Gudang'
placeholder='Pilih gudang tujuan...'
value={formik.values.destination_warehouse}
onChange={(val) => {
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);
}
}}
onChange={handleDestinationWarehouseChange}
options={warehouseOptions}
onInputChange={setWarehouseSelectInputValue}
isLoading={isLoadingWarehouses}
@@ -1165,18 +1290,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedProducts.length &&
formik.values.products?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map((_, idx) => idx) ??
[]
);
} else {
setSelectedProducts([]);
}
}}
onChange={handleProductSelectAllChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1213,17 +1327,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput
name={`product-${idx}`}
checked={selectedProducts.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([...selectedProducts, idx]);
} else {
setSelectedProducts(
selectedProducts.filter((i) => i !== idx)
);
}
}}
onChange={handleProductCheckboxChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1235,24 +1339,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<SelectInput
required
value={product.product ?? undefined}
onChange={(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
);
}}
onChange={(val) => handleProductChange(idx, val)}
options={productWarehouseOptions}
onInputChange={setProductWarehouseSelectInputValue}
onMenuScrollToBottom={loadMoreProductWarehouses}
@@ -1379,19 +1466,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedDeliveries.length &&
formik.values.deliveries?.length > 0
}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
(_, idx) => idx
) ?? []
);
} else {
setSelectedDeliveries([]);
}
}}
onChange={handleDeliverySelectAllChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1474,20 +1549,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<CheckboxInput
name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
idx,
]);
} else {
setSelectedDeliveries(
selectedDeliveries.filter((i) => i !== idx)
);
}
}}
onChange={handleDeliveryCheckboxChange}
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
@@ -1500,24 +1562,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
placeholder='Pilih produk...'
value={delivery.products[0]?.product ?? undefined}
onChange={(val) => {
formik.setFieldTouched(
`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
);
}}
onChange={(val) =>
handleDeliveryProductChange(idx, val)
}
options={getFilteredProductWarehouseOptions()}
isDisabled={type === 'detail'}
isClearable
@@ -1568,24 +1615,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
required
placeholder='Pilih supplier...'
value={delivery.supplier}
onChange={(val) => {
formik.setFieldTouched(
`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
);
}}
onChange={(val) =>
handleDeliverySupplierChange(idx, val)
}
options={supplierOptions}
onInputChange={setSupplierSelectInputValue}
isLoading={isLoadingSuppliers}
@@ -1677,20 +1709,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<FileInput
accept='.pdf,.jpg,.jpeg,.png'
name={`deliveries.${idx}.document`}
onChange={(e) => {
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.${idx}.document`,
file
);
}
}}
onChange={(e) =>
handleDeliveryDocumentChange(idx, e)
}
{...isRepeaterInputError(
'deliveries',
'document',
@@ -686,10 +686,18 @@ const RecordingTable = () => {
1,
},
{
header: 'Nama Project',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
header: 'Flock',
cell: (props) =>
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 || '-',
@@ -722,12 +730,10 @@ const RecordingTable = () => {
},
},
{
accessorKey: 'warehouse.name',
header: 'Gudang',
cell: (props) => props.row.original.warehouse?.name,
},
{
accessorKey: 'record_date',
header: 'Waktu Recording',
cell: (props) =>
formatDate(props.row.original.record_datetime, 'DD MMMM YYYY'),
@@ -300,7 +300,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>Rata-Rata</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text>
<Text>Harga/Unit</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Akhir</Text>
@@ -378,7 +378,7 @@ const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
<Text>{formatNumber(item.average_weight)}</Text>
</View>
<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.2 }]}>
<Text>{formatCurrency(item.final_price)}</Text>
@@ -38,7 +38,7 @@ export const generateCustomerPaymentExcel = (
'Ekor/Qty': formatNumber(item.qty || 0),
'Berat (Kg)': formatNumber(item.weight || 0),
AVG: formatNumber(item.average_weight || 0),
'Harga Awal': formatCurrency(item.price || 0),
'Harga/Unit': formatCurrency(item.unit_price || 0),
'Harga Akhir': formatCurrency(item.final_price || 0),
Total: formatCurrency(item.total_price || 0),
Pembayaran: formatCurrency(item.payment_amount || 0),
@@ -62,7 +62,7 @@ export const generateCustomerPaymentExcel = (
'Ekor/Qty': formatNumber(customerReport.summary.total_qty || 0),
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
AVG: '',
'Harga Awal': '',
'Harga/Unit': '',
'Harga Akhir': formatCurrency(
customerReport.summary.total_final_amount || 0
),
@@ -89,7 +89,7 @@ export const generateCustomerPaymentExcel = (
{ wch: 10 }, // Ekor/Qty
{ wch: 12 }, // Berat
{ wch: 10 }, // AVG
{ wch: 15 }, // Harga Awal
{ wch: 15 }, // Harga/Unit
{ wch: 15 }, // Harga Akhir
{ wch: 15 }, // Total
{ wch: 15 }, // Pembayaran
@@ -106,7 +106,11 @@ const CustomerPaymentTab = () => {
};
const getPaymentStatusText = (notes: string) => {
return notes;
return notes
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// ===== FILTER HANDLERS =====
@@ -159,7 +163,7 @@ const CustomerPaymentTab = () => {
isSubmitted
? () => {
const params = {
customer_id:
customer_ids:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
@@ -180,7 +184,7 @@ const CustomerPaymentTab = () => {
: null,
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.customer_ids,
undefined, // TODO: Change to params.sales_id when BE is ready
undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date,
@@ -203,7 +207,7 @@ const CustomerPaymentTab = () => {
CustomerPaymentReport[] | null
> => {
const params = {
customer_id:
customer_ids:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
@@ -219,7 +223,7 @@ const CustomerPaymentTab = () => {
};
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.customer_ids,
undefined, // TODO: Change to params.sales_id when BE is ready
undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date,
@@ -336,7 +340,9 @@ const CustomerPaymentTab = () => {
const value = props.row.original.aging_day;
return (
<div className='text-center'>
{value && value > 0 ? `${formatNumber(value)} hari` : '-'}
{value !== null && value !== undefined
? `${formatNumber(value)} hari`
: '-'}
</div>
);
},
@@ -405,12 +411,12 @@ const CustomerPaymentTab = () => {
),
},
{
id: 'price',
header: 'Harga Awal',
accessorKey: 'price',
id: 'unit_price',
header: 'Harga/Unit',
accessorKey: 'unit_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.price;
const value = props.row.original.unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
@@ -510,7 +516,7 @@ const CustomerPaymentTab = () => {
status: getPaymentStatusIndicatorColor(value),
}}
>
<span>{getPaymentStatusText(value)}</span>
<span className='capitalize'>{getPaymentStatusText(value)}</span>
</Badge>
);
},
@@ -246,7 +246,12 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<View
style={[
pdfStyles.tableCellHeaderRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>Nominal Sisa</Text>
</View>
</View>
@@ -301,7 +306,12 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(group.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<View
style={[
pdfStyles.tableCellRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>{formatCurrency(group.egg_value_rp)}</Text>
</View>
</View>
@@ -347,7 +357,12 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>HPP Telur (RP/KG)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<View
style={[
pdfStyles.tableCellHeaderRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>Nominal Sisa</Text>
</View>
</View>
@@ -356,12 +371,7 @@ const createPDFDocument = (
{data.rows.map((item: HppPerKandangRow, index: number) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < data.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
style={[pdfStyles.tableRow, pdfStyles.tableBorderBottom]}
>
<View style={[pdfStyles.tableCellCenter, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
@@ -410,11 +420,199 @@ const createPDFDocument = (
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.egg_hpp_rp_per_kg)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<View
style={[
pdfStyles.tableCellRight,
{ flex: 1.2, borderRightWidth: 0 },
]}
>
<Text>{formatCurrency(item.egg_value_rp)}</Text>
</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>
</Page>
@@ -40,6 +40,9 @@ const HppPerKandangTab = () => {
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== VALIDATION STATE =====
const [weightMaxError, setWeightMaxError] = useState<string>('');
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
@@ -127,8 +130,12 @@ const HppPerKandangTab = () => {
const val = e.target.value;
updateFilter('weight_min', val ? String(parseFloat(val) || 0) : '');
setIsSubmitted(false);
if (weightMaxError) {
setWeightMaxError('');
}
},
[updateFilter]
[updateFilter, weightMaxError]
);
const weightMaxChangeHandler = useCallback<
@@ -136,10 +143,22 @@ const HppPerKandangTab = () => {
>(
(e) => {
const val = e.target.value;
updateFilter('weight_max', val ? String(parseFloat(val) || 0) : '');
const weightMax = val ? parseFloat(val) || 0 : 0;
const weightMin = tableFilterState.weight_min
? parseFloat(tableFilterState.weight_min)
: 0;
if (weightMax < weightMin) {
setWeightMaxError('Rentang bobot max tidak boleh lebih kecil dari min');
toast.error('Rentang bobot max tidak boleh lebih kecil dari min');
return;
}
setWeightMaxError('');
updateFilter('weight_max', val ? String(weightMax) : '');
setIsSubmitted(false);
},
[updateFilter]
[updateFilter, tableFilterState.weight_min]
);
const periodChangeHandler = useCallback<ChangeEventHandler<HTMLInputElement>>(
@@ -325,8 +344,53 @@ const HppPerKandangTab = () => {
const allExportData =
allDataForExport.rows as HppPerKandangReport['rows'];
const perWeightRangeSummary =
allDataForExport.summary.per_weight_range || [];
const summaryTotal = allDataForExport.summary.total;
const rekapitulasiData: { [key: string]: string | number }[] =
perWeightRangeSummary.map(
(item: HppPerKandangPerWeightRange, index: number) => ({
No: index + 1,
'Rentang BW': item.label || '',
'Sisa Butir': item.egg_production_pieces || 0,
'Sisa Kg': item.egg_production_kg || 0,
'Rata-Rata Bobot (Kg)': item.avg_weight_kg || 0,
'Feed (Supplier)':
item.feed_suppliers
?.map(
(s: { alias?: string; name: string }) => s.alias || s.name
)
.join(' | ') || '',
'DOC (Supplier)':
item.doc_suppliers
?.map(
(s: { alias?: string; name: string }) => s.alias || s.name
)
.join(' | ') || '',
'Rata-Rata Harga DOC': item.average_doc_price_rp || 0,
'HPP Telur (RP/KG)': item.egg_hpp_rp_per_kg || 0,
'Nominal Sisa': item.egg_value_rp || 0,
})
);
const rekapitulasiWorksheet = XLSX.utils.json_to_sheet(rekapitulasiData);
const rekapitulasiColWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Rentang BW
{ wch: 15 }, // Sisa Butir
{ wch: 12 }, // Sisa Kg
{ wch: 18 }, // Rata-Rata Bobot (Kg)
{ wch: 20 }, // Feed (Supplier)
{ wch: 20 }, // DOC (Supplier)
{ wch: 20 }, // Rata-Rata Harga DOC
{ wch: 18 }, // HPP Telur (RP/KG)
{ wch: 25 }, // Nominal Sisa
];
rekapitulasiWorksheet['!cols'] = rekapitulasiColWidths;
const excelData: { [key: string]: string | number }[] = allExportData.map(
(item: HppPerKandangRow, index: number) => ({
No: index + 1,
@@ -384,7 +448,12 @@ const HppPerKandangTab = () => {
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'HPP Per Kandang');
XLSX.utils.book_append_sheet(
workbook,
rekapitulasiWorksheet,
'Rekapitulasi'
);
XLSX.utils.book_append_sheet(workbook, worksheet, 'Detail Per Kandang');
const filename = `laporan-hpp-harian-kandang-periode-${tableFilterState.period}.xlsx`;
@@ -741,6 +810,8 @@ const HppPerKandangTab = () => {
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreas}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable
/>
<SelectInput
@@ -757,6 +828,8 @@ const HppPerKandangTab = () => {
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocations}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable
/>
<SelectInput
@@ -773,6 +846,8 @@ const HppPerKandangTab = () => {
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangs}
closeMenuOnSelect={false}
hideSelectedOptions={false}
isClearable
/>
</div>
@@ -792,6 +867,8 @@ const HppPerKandangTab = () => {
placeholder='Masukkan bobot maximum'
value={tableFilterState.weight_max}
onChange={weightMaxChangeHandler}
isError={!!weightMaxError}
errorMessage={weightMaxError}
/>
</div>
<DateInput
@@ -818,7 +895,11 @@ const HppPerKandangTab = () => {
</div>
<div className='mt-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
<Button
color='primary'
onClick={handleSubmit}
disabled={!!weightMaxError}
>
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
Cari
</Button>
+1 -17
View File
@@ -74,23 +74,7 @@ export const RECORDING_APPROVAL_LINE: ApprovalLine = [
},
{
step_number: 2,
step_name: 'Approval Head Area',
},
{
step_number: 3,
step_name: 'Approval Business Unit Vice President',
},
{
step_number: 4,
step_name: 'Approval Finance',
},
{
step_number: 5,
step_name: 'Realisasi',
},
{
step_number: 6,
step_name: 'Selesai',
step_name: 'Disetujui',
},
] as const;
+2 -2
View File
@@ -12,7 +12,7 @@ export class FinanceApiService extends BaseApiService<
}
async getCustomerPaymentReport(
customer_id?: string,
customer_ids?: string,
// TODO: Uncomment when BE is ready
// sales_id?: string,
// filter_by?: 'do_date',
@@ -28,7 +28,7 @@ export class FinanceApiService extends BaseApiService<
{
method: 'GET',
params: {
customer_id: customer_id,
customer_ids: customer_ids,
// TODO: Uncomment when BE is ready
// sales_id: sales_id,
// filter_by: filter_by,
+4
View File
@@ -1,6 +1,8 @@
import { BaseApproval, BaseMetadata, User } from '@/types/api/api-general';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Kandang } from '@/types/api/master-data/kandang';
import { Location } from '@/types/api/master-data/location';
export type ProductionStandard = {
id: number;
@@ -87,6 +89,8 @@ export type Recording = BaseMetadata &
approval?: BaseApproval;
created_user: User;
warehouse?: Warehouse;
kandang?: Kandang;
location?: Location;
product_category?: 'GROWING' | 'LAYING';
depletions?: RecordingDepletion[];
stocks?: RecordingStock[];
+1 -1
View File
@@ -11,7 +11,7 @@ export type CustomerPaymentRow = {
qty: number;
weight: number;
average_weight: number;
price: number;
unit_price: number;
final_price: number;
total_price: number;
payment_amount: number;