Merge branch 'feat/FE/US-391/TASK-435-436-expense-adjustment' into 'development'

[FEAT/FE][US#391/TASK-435-436] Expense Adjustment

See merge request mbugroup/lti-web-client!122
This commit is contained in:
Rivaldi A N S
2025-12-30 15:21:09 +00:00
13 changed files with 485 additions and 310 deletions
@@ -16,7 +16,7 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
interface ExpenseRealizationContentProps { interface ExpenseRealizationContentProps {
initialValues?: Expense; initialValues?: Expense;
@@ -103,24 +103,32 @@ const ExpenseRealizationContent = ({
initialValues?.realization_docs.length > 0 && ( initialValues?.realization_docs.length > 0 && (
<ul className='list-disc'> <ul className='list-disc'>
{initialValues?.realization_docs.map( {initialValues?.realization_docs.map(
(realizationDocument, realizationDocumentIdx) => ( (realizationDocument, realizationDocumentIdx) => {
<li key={realizationDocumentIdx}> const path = realizationDocument.path.startsWith(
<Link '/'
href={realizationDocument.path} )
target='_blank' ? realizationDocument.path.slice(1)
rel='noopener noreferrer' : realizationDocument.path;
className='text-blue-500 underline' const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
> return (
{realizationDocument.path}{' '} <li key={realizationDocumentIdx}>
<Icon <Link
icon='cuida:open-in-new-tab-outline' href={documentUrl}
width={12} target='_blank'
height={12} rel='noopener noreferrer'
className='inline' className='text-blue-500 underline'
/> >
</Link> {realizationDocument.path}{' '}
</li> <Icon
) icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
);
}
)} )}
</ul> </ul>
)} )}
@@ -211,7 +219,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
@@ -273,7 +281,7 @@ const ExpenseRealizationContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseGrandTotal += item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
@@ -27,7 +27,7 @@ import {
UploadRequestDocumentsFormSchema, UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues, UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line'; import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
@@ -408,9 +408,13 @@ const ExpenseRequestContent = ({
<th>Kandang</th> <th>Kandang</th>
<th>:</th> <th>:</th>
<td> <td>
{initialValues?.kandangs {initialValues?.kandangs &&
.map((item) => item.name) initialValues?.kandangs.some((k) => k.name)
.join(', ')} ? initialValues?.kandangs
.filter((item) => item.name)
.map((item) => item.name)
.join(', ')
: '-'}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -448,7 +452,14 @@ const ExpenseRequestContent = ({
<tr> <tr>
<th>Nominal Biaya</th> <th>Nominal Biaya</th>
<th>:</th> <th>:</th>
<td>{formatCurrency(initialValues?.grand_total ?? 0)}</td> <td>
{formatCurrency(
initialValues?.latest_approval.step_number === 4 ||
initialValues?.latest_approval.step_number === 5
? (initialValues?.total_realisasi ?? 0)
: (initialValues?.total_pengajuan ?? 0)
)}
</td>
</tr> </tr>
<tr> <tr>
<th>Status Pencairan</th> <th>Status Pencairan</th>
@@ -482,24 +493,32 @@ const ExpenseRequestContent = ({
initialValues?.documents.length > 0 && ( initialValues?.documents.length > 0 && (
<ul className='list-disc'> <ul className='list-disc'>
{initialValues?.documents.map( {initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => ( (requestDocument, requestDocumentIdx) => {
<li key={requestDocumentIdx}> const path = requestDocument.path.startsWith(
<Link '/'
href={requestDocument.path} )
target='_blank' ? requestDocument.path.slice(1)
rel='noopener noreferrer' : requestDocument.path;
className='text-blue-500 underline' const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
> return (
{requestDocument.path}{' '} <li key={requestDocumentIdx}>
<Icon <Link
icon='cuida:open-in-new-tab-outline' href={documentUrl}
width={12} target='_blank'
height={12} rel='noopener noreferrer'
className='inline' className='text-blue-500 underline'
/> >
</Link> {requestDocument.path}{' '}
</li> <Icon
) icon='cuida:open-in-new-tab-outline'
width={12}
height={12}
className='inline'
/>
</Link>
</li>
);
}
)} )}
</ul> </ul>
)} )}
@@ -558,7 +577,7 @@ const ExpenseRequestContent = ({
let expenseGrandTotal = 0; let expenseGrandTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseGrandTotal += item.price) (item) => (expenseGrandTotal += item.qty * item.price)
); );
return ( return (
@@ -573,7 +592,9 @@ const ExpenseRequestContent = ({
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} {kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${initialValues?.location.name || 'Umum'}`}
</th> </th>
</tr> </tr>
<tr> <tr>
@@ -20,10 +20,10 @@ interface ExpenseKandangsTableProps {
locationId?: number; locationId?: number;
type: 'add' | 'edit' | 'detail'; type: 'add' | 'edit' | 'detail';
selectedKandangs: { selectedKandangs: {
id: number; id?: number;
name: string; name?: string;
}[]; }[];
onChange: (kandangs: { id: number; name: string }[]) => void; onChange: (kandangs: { id?: number; name?: string }[]) => void;
className?: { className?: {
wrapper?: string; wrapper?: string;
}; };
@@ -67,7 +67,11 @@ const ExpenseKandangsTable = ({
); );
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>( const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id)) convertRowSelectionArrToObj(
selectedKandangs
.map((item) => item.id)
.filter((id): id is number => id !== undefined)
)
); );
const kandangsColumns: ColumnDef<Kandang>[] = [ const kandangsColumns: ColumnDef<Kandang>[] = [
@@ -1,6 +1,7 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
type ExpenseRealizationFormSchemaType = { type ExpenseRealizationFormSchemaType = {
category?: { category?: {
@@ -12,7 +13,7 @@ type ExpenseRealizationFormSchemaType = {
label: string; label: string;
}; };
realization_date?: string; realization_date?: string;
kandangs?: { id: number; name: string }[]; kandangs?: { id?: number; name?: string }[];
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
@@ -20,7 +21,7 @@ type ExpenseRealizationFormSchemaType = {
existing_documents?: { name: string; url: string }[]; existing_documents?: { name: string; url: string }[];
documents?: File[]; documents?: File[];
realizations: { realizations: {
kandang_id: number; kandang_id?: number;
cost_items: { cost_items: {
nonstock?: { nonstock?: {
value: number; value: number;
@@ -49,12 +50,11 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
kandangs: Yup.array() kandangs: Yup.array()
.of( .of(
Yup.object({ Yup.object({
id: Yup.number().required('Kandang wajib dipilih!'), id: Yup.number().optional(),
name: Yup.string().required('Kandang wajib dipilih!'), name: Yup.string().optional(),
}) })
) )
.min(1, 'Kandang wajib dipilih!') .optional(),
.required('Kandang wajib dipilih!'),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -73,7 +73,7 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
realizations: Yup.array() realizations: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(),
cost_items: Yup.array() cost_items: Yup.array()
.of( .of(
Yup.object({ Yup.object({
@@ -86,12 +86,12 @@ export const ExpenseRealizationFormSchema: Yup.ObjectSchema<ExpenseRealizationFo
notes: Yup.string(), notes: Yup.string(),
}) })
) )
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!') .min(1, 'Harus memiliki setidaknya 1 biaya!')
.required('Biaya kandang wajib diisi!'), .required('Biaya wajib diisi!'),
}) })
) )
.min(1, 'Biaya kandang wajib diisi!') .min(1, 'Biaya wajib diisi!')
.required('Biaya kandang wajib diisi!'), .required('Biaya wajib diisi!'),
}); });
export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema; export const UpdateExpenseRealizationFormSchema = ExpenseRealizationFormSchema;
@@ -139,10 +139,13 @@ export const getExpenseRealizationFormInitialValues = (
label: initialValues.supplier.name, label: initialValues.supplier.name,
} }
: undefined, : undefined,
existing_documents: initialValues?.realization_docs?.map((doc) => ({ existing_documents: initialValues?.realization_docs?.map((doc) => {
name: doc.path, const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
url: doc.path, return {
})), name: doc.path,
url: `${S3_PUBLIC_BASE_URL}/${path}`,
};
}),
documents: [], documents: [],
realizations: initialValues?.kandangs realizations: initialValues?.kandangs
? initialValues.kandangs.map((kandangExpense) => { ? initialValues.kandangs.map((kandangExpense) => {
@@ -150,25 +150,10 @@ const ExpenseRealizationForm = ({
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('realizations', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { // Auto-create realization item for location (without kandang)
formik.setFieldTouched('kandangs', true); formik.setFieldValue('realizations', [
formik.setFieldValue('kandangs', kandangs); {
const newRealizations = [...(formik.values.realizations ?? [])];
// add new realizations
kandangs.forEach((kandangItem) => {
const isKandangExistInRealization = newRealizations.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
);
if (isKandangExistInRealization) return;
newRealizations.push({
kandang_id: kandangItem.id,
cost_items: [ cost_items: [
{ {
nonstock: undefined, nonstock: undefined,
@@ -177,25 +162,57 @@ const ExpenseRealizationForm = ({
notes: '', notes: '',
}, },
], ],
},
]);
};
const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[]
) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
// If no kandangs selected, create realization item for location
if (kandangs.length === 0) {
formik.setFieldValue('realizations', [
{
cost_items: [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
return;
}
// Start with empty array when kandangs are selected
const newRealizations: typeof formik.values.realizations = [];
// add new realizations for each kandang
kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return;
const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
);
newRealizations.push({
kandang_id: kandangItem.id,
cost_items: existingRealization?.cost_items || [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
}); });
}); });
// prune realizations
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedRealizationsIdx: number[] = [];
newRealizations.forEach((realization, idx) => {
const isRealizationValid = kandangIds.has(realization.kandang_id);
if (!isRealizationValid) {
deletedRealizationsIdx.push(idx);
}
});
deletedRealizationsIdx.forEach((deletedRealizationIdx) => {
newRealizations.splice(deletedRealizationIdx, 1);
});
formik.setFieldValue('realizations', newRealizations); formik.setFieldValue('realizations', newRealizations);
}; };
@@ -338,7 +355,10 @@ const ExpenseRealizationForm = ({
)} )}
<ExpenseRealizationKandangDetailExpense <ExpenseRealizationKandangDetailExpense
type={type}
formik={formik} formik={formik}
supplierId={formik.values.supplier?.value as number}
location={formik.values.location}
className={{ className={{
wrapper: 'col-span-12', wrapper: 'col-span-12',
}} }}
@@ -18,6 +18,11 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
interface ExpenseRealizationKandangDetailExpenseProps { interface ExpenseRealizationKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRealizationFormValues>; formik: FormikContextType<ExpenseRealizationFormValues>;
supplierId?: number;
location?: {
value: number;
label: string;
};
className?: { className?: {
wrapper?: string; wrapper?: string;
}; };
@@ -25,12 +30,18 @@ interface ExpenseRealizationKandangDetailExpenseProps {
const ExpenseRealizationKandangDetailExpense: React.FC< const ExpenseRealizationKandangDetailExpense: React.FC<
ExpenseRealizationKandangDetailExpenseProps ExpenseRealizationKandangDetailExpenseProps
> = ({ type, formik, className }) => { > = ({ type, formik, supplierId, location, className }) => {
const { const {
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions, isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name'); } = useSelect<Nonstock>(
NonstockApi.basePath,
'id',
'name',
'search',
supplierId ? { supplier_id: String(supplierId) } : undefined
);
const nonstockChangeHandler = ( const nonstockChangeHandler = (
kandangExpenseIdx: number, kandangExpenseIdx: number,
@@ -82,140 +93,159 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
</div> </div>
<div className='w-full flex flex-col gap-6'> <div className='w-full flex flex-col gap-6'>
{formik.values.realizations.length === 0 && ( {!formik.values.supplier?.value && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu! Pilih supplier terlebih dahulu!
</p> </p>
</div> </div>
)} )}
{formik.values.realizations.map((kandangExpense, kandangExpenseIdx) => { {formik.values.realizations.length === 0 &&
const kandangName = formik.values.kandangs?.find( formik.values.supplier?.value && (
(kandang) => kandang.id === kandangExpense.kandang_id <div>
); <p className='text-sm text-gray-400 text-center'>
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
</p>
</div>
)}
return ( {formik.values.realizations.length > 0 &&
kandangName?.name && ( formik.values.supplier?.value &&
<div formik.values.realizations.map(
key={`kandangExpense-${kandangExpenseIdx}`} (kandangExpense, kandangExpenseIdx) => {
className='w-full flex flex-col gap-4' const kandangName = kandangExpense.kandang_id
> ? formik.values.kandangs?.find(
<div> (kandang) => kandang.id === kandangExpense.kandang_id
<h5 className='mb-2 text-lg font-bold text-center'> )
Biaya {kandangName?.name} : null;
</h5>
<div className='overflow-x-auto'> return (
<table className='table'> (kandangName?.name || !kandangExpense.kandang_id) && (
<thead> <div
<tr> key={`kandangExpense-${kandangExpenseIdx}`}
<th>Nonstock</th> className='w-full flex flex-col gap-4'
<th>Total Kuantitas</th> >
<th>Harga Satuan</th> <div>
<th>Catatan</th> <h5 className='mb-2 text-lg font-bold text-center'>
</tr> {kandangName?.name
</thead> ? `Biaya ${kandangName.name}`
: location?.label
? `Biaya ${location.label}`
: 'Biaya Umum'}
</h5>
<tbody> <div className='overflow-x-auto'>
{kandangExpense.cost_items.map( <table className='table'>
(expenseItem, expenseIdx) => ( <thead>
<tr key={`expense-${expenseIdx}`}> <tr>
<td className='p-2'> <th>Nonstock</th>
<SelectInput <th>Total Kuantitas</th>
placeholder='Pilih Nonstock' <th>Harga Satuan</th>
value={expenseItem.nonstock} <th>Catatan</th>
onChange={(val) => {
nonstockChangeHandler(
kandangExpenseIdx,
expenseIdx,
val
);
}}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
isDisabled
/>
</td>
<td className='p-2'>
<NumberInput
required
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Harga Satuan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].price ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
</span>
}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<TextInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].notes ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
</tr> </tr>
) </thead>
)}
</tbody> <tbody>
</table> {kandangExpense.cost_items.map(
(expenseItem, expenseIdx) => (
<tr key={`expense-${expenseIdx}`}>
<td className='p-2'>
<SelectInput
placeholder='Pilih Nonstock'
value={expenseItem.nonstock}
onChange={(val) => {
nonstockChangeHandler(
kandangExpenseIdx,
expenseIdx,
val
);
}}
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
className={{ wrapper: 'min-w-48' }}
isDisabled
/>
</td>
<td className='p-2'>
<NumberInput
required
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].quantity`}
placeholder='Masukkan Total Kuantitas'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].quantity ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'quantity',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<NumberInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].price`}
placeholder='Masukkan Harga Satuan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].price ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'price',
kandangExpenseIdx,
expenseIdx
)}
inputPrefix={
<span className='text-gray-600 font-medium'>
Rp
</span>
}
className={{ wrapper: 'min-w-24' }}
/>
</td>
<td className='p-2'>
<TextInput
name={`realizations[${kandangExpenseIdx}].cost_items[${expenseIdx}].notes`}
placeholder='Tuliskan catatan'
value={
formik.values.realizations[
kandangExpenseIdx
].cost_items[expenseIdx].notes ?? ''
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={isExpenseRepeaterInputError(
'notes',
kandangExpenseIdx,
expenseIdx
)}
className={{ wrapper: 'min-w-24' }}
/>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> )
</div> );
) }
); )}
})}
</div> </div>
</Card> </Card>
); );
@@ -1,6 +1,7 @@
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatDate } from '@/lib/helper'; import { formatDate } from '@/lib/helper';
import { S3_PUBLIC_BASE_URL } from '@/config/constant';
type ExpenseFormSchemaType = { type ExpenseFormSchemaType = {
category?: { category?: {
@@ -11,8 +12,9 @@ type ExpenseFormSchemaType = {
value: number; value: number;
label: string; label: string;
}; };
location_id: number;
transaction_date?: string; transaction_date?: string;
kandangs?: { id: number; name: string }[]; kandangs?: { id?: number; name?: string }[];
supplier?: { supplier?: {
value: number; value: number;
label: string; label: string;
@@ -21,7 +23,7 @@ type ExpenseFormSchemaType = {
deleted_documents?: number[]; deleted_documents?: number[];
documents?: File[]; documents?: File[];
expense_nonstocks: { expense_nonstocks: {
kandang_id: number; kandang_id?: number;
cost_items: { cost_items: {
nonstock?: { nonstock?: {
value: number; value: number;
@@ -46,16 +48,17 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
label: Yup.string().required(), label: Yup.string().required(),
}).required('Lokasi wajib diisi!'), }).required('Lokasi wajib diisi!'),
location_id: Yup.number().min(1).required('Lokasi wajib diisi!'),
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'), transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
kandangs: Yup.array() kandangs: Yup.array()
.of( .of(
Yup.object({ Yup.object({
id: Yup.number().required('Kandang wajib dipilih!'), id: Yup.number().optional(),
name: Yup.string().required('Kandang wajib dipilih!'), name: Yup.string().optional(),
}) })
) )
.min(1, 'Kandang wajib dipilih!') .optional(),
.required('Kandang wajib dipilih!'),
supplier: Yup.object({ supplier: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
@@ -77,7 +80,7 @@ export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
expense_nonstocks: Yup.array() expense_nonstocks: Yup.array()
.of( .of(
Yup.object({ Yup.object({
kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').required(), kandang_id: Yup.number().min(1, 'Wajib memilih kandang!').optional(),
cost_items: Yup.array() cost_items: Yup.array()
.of( .of(
Yup.object({ Yup.object({
@@ -128,6 +131,7 @@ export const getExpenseFormInitialValues = (
label: initialValues.location.name, label: initialValues.location.name,
} }
: undefined, : undefined,
location_id: Number(initialValues?.location.id || 0),
transaction_date: initialValues?.transaction_date transaction_date: initialValues?.transaction_date
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD') ? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
: undefined, : undefined,
@@ -141,11 +145,14 @@ export const getExpenseFormInitialValues = (
label: initialValues.supplier.name, label: initialValues.supplier.name,
} }
: undefined, : undefined,
existing_documents: initialValues?.documents?.map((doc) => ({ existing_documents: initialValues?.documents?.map((doc) => {
id: doc.id, const path = doc.path.startsWith('/') ? doc.path.slice(1) : doc.path;
name: doc.path, return {
url: doc.path, id: doc.id,
})), name: doc.path,
url: `${S3_PUBLIC_BASE_URL}/${path}`,
};
}),
deleted_documents: [], deleted_documents: [],
documents: [], documents: [],
expense_nonstocks: initialValues?.kandangs expense_nonstocks: initialValues?.kandangs
@@ -108,18 +108,24 @@ const ExpenseRequestForm = ({
const expensePayload: CreateExpensePayload = { const expensePayload: CreateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP', category: formik.values.category?.value as 'BOP' | 'NON-BOP',
location_id: values.location_id as number,
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => ({ expense_nonstocks: values.expense_nonstocks.map((expenseNonstock) => {
kandang_id: expenseNonstock.kandang_id, const basePayload = {
cost_items: expenseNonstock.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
})), };
return expenseNonstock.kandang_id
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
: basePayload;
}),
}; };
switch (type) { switch (type) {
@@ -130,19 +136,25 @@ const ExpenseRequestForm = ({
case 'edit': case 'edit':
const expenseUpdatePayload: UpdateExpensePayload = { const expenseUpdatePayload: UpdateExpensePayload = {
category: formik.values.category?.value as 'BOP' | 'NON-BOP', category: formik.values.category?.value as 'BOP' | 'NON-BOP',
location_id: values.location_id as number,
transaction_date: values?.transaction_date as string, transaction_date: values?.transaction_date as string,
supplier_id: values.supplier?.value as number, supplier_id: values.supplier?.value as number,
documents: values.documents as File[], documents: values.documents as File[],
expense_nonstocks: values.expense_nonstocks.map( expense_nonstocks: values.expense_nonstocks.map(
(expenseNonstock) => ({ (expenseNonstock) => {
kandang_id: expenseNonstock.kandang_id, const basePayload = {
cost_items: expenseNonstock.cost_items.map((costItem) => ({ cost_items: expenseNonstock.cost_items.map((costItem) => ({
nonstock_id: costItem.nonstock?.value as number, nonstock_id: costItem.nonstock?.value as number,
quantity: parseFloat(String(costItem.quantity)) as number, quantity: parseFloat(String(costItem.quantity)) as number,
price: parseFloat(String(costItem.price)) as number, price: parseFloat(String(costItem.price)) as number,
notes: costItem.notes ?? '', notes: costItem.notes ?? '',
})), })),
}) };
return expenseNonstock.kandang_id
? { ...basePayload, kandang_id: expenseNonstock.kandang_id }
: basePayload;
}
), ),
}; };
@@ -179,27 +191,14 @@ const ExpenseRequestForm = ({
formik.setFieldTouched('location', true); formik.setFieldTouched('location', true);
formik.setFieldValue('location', val); formik.setFieldValue('location', val);
const locationId = Array.isArray(val) ? val[0]?.value : val?.value;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandangs', []); formik.setFieldValue('kandangs', []);
formik.setFieldValue('expense_nonstocks', []);
};
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => { // Auto-create expense item for location (without kandang)
formik.setFieldTouched('kandangs', true); formik.setFieldValue('expense_nonstocks', [
formik.setFieldValue('kandangs', kandangs); {
const newExpenseNonstocks = [...(formik.values.expense_nonstocks ?? [])];
// add new expense_nonstocks
kandangs.forEach((kandangItem) => {
const isKandangExistInExpenseNonstocks = newExpenseNonstocks.find(
(expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
);
if (isKandangExistInExpenseNonstocks) return;
newExpenseNonstocks.push({
kandang_id: kandangItem.id,
cost_items: [ cost_items: [
{ {
nonstock: undefined, nonstock: undefined,
@@ -208,25 +207,56 @@ const ExpenseRequestForm = ({
notes: '', notes: '',
}, },
], ],
},
]);
};
const kandangsChangeHandler = (
kandangs: { id?: number; name?: string }[]
) => {
formik.setFieldTouched('kandangs', true);
formik.setFieldValue('kandangs', kandangs);
// If no kandangs selected, create expense item for location
if (kandangs.length === 0) {
formik.setFieldValue('expense_nonstocks', [
{
cost_items: [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
},
]);
return;
}
const newExpenseNonstocks: typeof formik.values.expense_nonstocks = [];
kandangs.forEach((kandangItem) => {
if (!kandangItem.id) return;
const existingExpenseNonstock = formik.values.expense_nonstocks?.find(
(expenseNonstockItem) =>
expenseNonstockItem.kandang_id === kandangItem.id
);
newExpenseNonstocks.push({
kandang_id: kandangItem.id,
cost_items: existingExpenseNonstock?.cost_items || [
{
nonstock: undefined,
quantity: undefined,
price: undefined,
notes: '',
},
],
}); });
}); });
// prune expense_nonstocks
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
const deletedExpenseNonstocksIdx: number[] = [];
newExpenseNonstocks.forEach((expenseNonstock, idx) => {
const isExpenseNonstockValid = kandangIds.has(expenseNonstock.kandang_id);
if (!isExpenseNonstockValid) {
deletedExpenseNonstocksIdx.push(idx);
}
});
deletedExpenseNonstocksIdx.forEach((deletedExpenseNonstockIdx) => {
newExpenseNonstocks.splice(deletedExpenseNonstockIdx, 1);
});
formik.setFieldValue('expense_nonstocks', newExpenseNonstocks); formik.setFieldValue('expense_nonstocks', newExpenseNonstocks);
}; };
@@ -454,7 +484,10 @@ const ExpenseRequestForm = ({
)} )}
<ExpenseRequestKandangDetailExpense <ExpenseRequestKandangDetailExpense
type={type}
formik={formik} formik={formik}
supplierId={formik.values.supplier?.value as number}
location={formik.values.location}
className={{ className={{
wrapper: 'col-span-12', wrapper: 'col-span-12',
}} }}
@@ -21,6 +21,11 @@ import { removeArrayItemAndSync } from '@/lib/utils/formik';
interface ExpenseRequestKandangDetailExpenseProps { interface ExpenseRequestKandangDetailExpenseProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
formik: FormikContextType<ExpenseRequestFormValues>; formik: FormikContextType<ExpenseRequestFormValues>;
supplierId?: number;
location?: {
value: number;
label: string;
};
className?: { className?: {
wrapper?: string; wrapper?: string;
}; };
@@ -28,12 +33,18 @@ interface ExpenseRequestKandangDetailExpenseProps {
const ExpenseRequestKandangDetailExpense: React.FC< const ExpenseRequestKandangDetailExpense: React.FC<
ExpenseRequestKandangDetailExpenseProps ExpenseRequestKandangDetailExpenseProps
> = ({ type, formik, className }) => { > = ({ type, formik, supplierId, location, className }) => {
const { const {
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions, isLoadingOptions: isLoadingNonstockOptions,
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name'); } = useSelect<Nonstock>(
NonstockApi.basePath,
'id',
'name',
'search',
supplierId ? { supplier_id: String(supplierId) } : undefined
);
const nonstockChangeHandler = ( const nonstockChangeHandler = (
kandangExpenseIdx: number, kandangExpenseIdx: number,
@@ -113,41 +124,57 @@ const ExpenseRequestKandangDetailExpense: React.FC<
</div> </div>
<div className='w-full flex flex-col gap-6'> <div className='w-full flex flex-col gap-6'>
{(formik.values.expense_nonstocks.length === 0 || {!formik.values.supplier?.value && (
!formik.values.supplier?.value) && (
<div> <div>
<p className='text-sm text-gray-400 text-center'> <p className='text-sm text-gray-400 text-center'>
Pilih kandang terlebih dahulu! Pilih supplier terlebih dahulu!
</p> </p>
</div> </div>
)} )}
{formik.values.expense_nonstocks.length === 0 &&
formik.values.supplier?.value && (
<div>
<p className='text-sm text-gray-400 text-center'>
Belum ada item biaya. Silakan pilih lokasi terlebih dahulu.
</p>
</div>
)}
{formik.values.expense_nonstocks.length > 0 && {formik.values.expense_nonstocks.length > 0 &&
formik.values.supplier?.value && formik.values.supplier?.value &&
formik.values.expense_nonstocks.map( formik.values.expense_nonstocks.map(
(kandangExpense, kandangExpenseIdx) => { (kandangExpense, kandangExpenseIdx) => {
const kandangName = formik.values.kandangs?.find( const kandangName = kandangExpense.kandang_id
(kandang) => kandang.id === kandangExpense.kandang_id ? formik.values.kandangs?.find(
); (kandang) => kandang.id === kandangExpense.kandang_id
)
: null;
return ( return (
kandangName?.name && ( (kandangName?.name || !kandangExpense.kandang_id) && (
<div <div
key={`kandangExpense-${kandangExpenseIdx}`} key={`kandangExpense-${kandangExpenseIdx}`}
className='w-full flex flex-col gap-4' className='w-full flex flex-col gap-4'
> >
<div> <div>
<h5 className='mb-2 text-lg font-bold text-center'> <h5 className='mb-2 text-lg font-bold text-center'>
Biaya {kandangName?.name} Biaya {kandangName?.name || location?.label || 'Umum'}
</h5> </h5>
<div className='overflow-x-auto'> <div className='overflow-x-auto'>
<table className='table'> <table className='table'>
<thead> <thead>
<tr> <tr>
<th>Nonstock</th> <th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
<th>Total Kuantitas</th> Nonstock
<th>Harga Satuan</th> </th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Total Kuantitas
</th>
<th className='after:content-["*"] after:text-red-500 after:ml-0.5'>
Harga Satuan
</th>
<th>Catatan</th> <th>Catatan</th>
{type !== 'detail' && <th>Aksi</th>} {type !== 'detail' && <th>Aksi</th>}
</tr> </tr>
@@ -219,7 +219,13 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Lokasi', value: expense?.location.name }, { label: 'Lokasi', value: expense?.location.name },
{ {
label: 'Kandang', label: 'Kandang',
value: expense?.kandangs.map((item) => item.name).join(', '), value:
expense?.kandangs && expense?.kandangs.some((k) => k.name)
? expense?.kandangs
.filter((item) => item.name)
.map((item) => item.name)
.join(', ')
: '-',
}, },
{ label: 'Vendor', value: expense?.supplier.name }, { label: 'Vendor', value: expense?.supplier.name },
{ {
@@ -235,7 +241,12 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
{ label: 'Nama Pengaju', value: expense?.created_user.name }, { label: 'Nama Pengaju', value: expense?.created_user.name },
{ {
label: 'Nominal Biaya', label: 'Nominal Biaya',
value: formatCurrency(expense?.grand_total ?? 0), value: formatCurrency(
expense?.latest_approval.step_number === 4 ||
expense?.latest_approval.step_number === 5
? (expense?.total_realisasi ?? 0)
: (expense?.total_pengajuan ?? 0)
),
}, },
{ {
label: 'Nominal Pengajuan', label: 'Nominal Pengajuan',
@@ -326,7 +337,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRequestTotal = 0; let expenseRequestTotal = 0;
kandangExpense.pengajuans?.forEach( kandangExpense.pengajuans?.forEach(
(item) => (expenseRequestTotal += item.price) (item) => (expenseRequestTotal += item.qty * item.price)
); );
return ( return (
@@ -335,7 +346,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
style={ExpensePDFStyle.kandangExpenseContainer} style={ExpensePDFStyle.kandangExpenseContainer}
> >
<Text style={ExpensePDFStyle.kandangExpenseTitle}> <Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.name} {kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text> </Text>
<View style={ExpensePDFStyle.kandangExpenseTable}> <View style={ExpensePDFStyle.kandangExpenseTable}>
@@ -484,7 +497,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
let expenseRealizationTotal = 0; let expenseRealizationTotal = 0;
kandangExpense.realisasi?.forEach( kandangExpense.realisasi?.forEach(
(item) => (expenseRealizationTotal += item.price) (item) => (expenseRealizationTotal += item.qty * item.price)
); );
return ( return (
@@ -493,7 +506,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
style={ExpensePDFStyle.kandangExpenseContainer} style={ExpensePDFStyle.kandangExpenseContainer}
> >
<Text style={ExpensePDFStyle.kandangExpenseTitle}> <Text style={ExpensePDFStyle.kandangExpenseTitle}>
{kandangExpense.name} {kandangExpense.kandang_id && kandangExpense.name
? `Biaya ${kandangExpense.name}`
: `Biaya ${expense?.location.name || 'Umum'}`}
</Text> </Text>
<View style={ExpensePDFStyle.kandangExpenseTable}> <View style={ExpensePDFStyle.kandangExpenseTable}>
+3
View File
@@ -352,6 +352,9 @@ export const ACCEPTED_FILE_TYPE = {
}, },
}; };
export const S3_PUBLIC_BASE_URL = process.env
.NEXT_PUBLIC_S3_PUBLIC_BASE_URL as string;
export const FILTER_TYPE_OPTIONS = [ export const FILTER_TYPE_OPTIONS = [
{ {
label: 'Tanggal Realisasi', label: 'Tanggal Realisasi',
+2
View File
@@ -483,6 +483,7 @@ export class ExpenseApiService extends BaseApiService<
const formData = new FormData(); const formData = new FormData();
formData.append('category', payload.category); formData.append('category', payload.category);
formData.append('location_id', String(payload.location_id));
formData.append('transaction_date', payload.transaction_date); formData.append('transaction_date', payload.transaction_date);
formData.append('supplier_id', String(payload.supplier_id)); formData.append('supplier_id', String(payload.supplier_id));
@@ -505,6 +506,7 @@ export class ExpenseApiService extends BaseApiService<
const formData = new FormData(); const formData = new FormData();
formData.append('category', payload.category); formData.append('category', payload.category);
formData.append('location_id', String(payload.location_id));
formData.append('transaction_date', payload.transaction_date); formData.append('transaction_date', payload.transaction_date);
formData.append('supplier_id', String(payload.supplier_id)); formData.append('supplier_id', String(payload.supplier_id));
+4 -2
View File
@@ -57,11 +57,12 @@ export type Expense = BaseMetadata & BaseExpense;
export type CreateExpensePayload = { export type CreateExpensePayload = {
category: 'BOP' | 'NON-BOP'; category: 'BOP' | 'NON-BOP';
location_id: number;
transaction_date: string; transaction_date: string;
supplier_id: number; supplier_id: number;
documents: File[]; documents: File[];
expense_nonstocks: { expense_nonstocks: {
kandang_id: number; kandang_id?: number;
cost_items: { cost_items: {
nonstock_id: number; nonstock_id: number;
quantity: number; quantity: number;
@@ -72,12 +73,13 @@ export type CreateExpensePayload = {
}; };
export type UpdateExpensePayload = { export type UpdateExpensePayload = {
location_id: number;
category: 'BOP' | 'NON-BOP'; category: 'BOP' | 'NON-BOP';
transaction_date: string; transaction_date: string;
supplier_id: number; supplier_id: number;
documents: File[]; documents: File[];
expense_nonstocks: { expense_nonstocks: {
kandang_id: number; kandang_id?: number;
cost_items: { cost_items: {
nonstock_id: number; nonstock_id: number;
quantity: number; quantity: number;