mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
fix(resolve): fix resolve MR
This commit is contained in:
Generated
+39
@@ -19,6 +19,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-number-format": "^5.4.4",
|
"react-number-format": "^5.4.4",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
@@ -2699,6 +2700,15 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/attr-accept": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||||
|
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -4013,6 +4023,18 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-selector": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -6190,6 +6212,23 @@
|
|||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dropzone": {
|
||||||
|
"version": "14.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||||
|
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"attr-accept": "^2.2.4",
|
||||||
|
"file-selector": "^2.1.0",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.13"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.8 || 18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-number-format": "^5.4.4",
|
"react-number-format": "^5.4.4",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm';
|
||||||
|
|
||||||
|
const AddExpense = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
<ExpenseRequestForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddExpense;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm';
|
||||||
|
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ExpenseEditPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const expenseId = searchParams.get('expenseId');
|
||||||
|
|
||||||
|
const { data: expense, isLoading: isLoadingExpense } = useSWR(
|
||||||
|
expenseId,
|
||||||
|
(id: number) => ExpenseApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!expenseId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingExpense && (!expense || isResponseError(expense))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpenseRejectedOrApproved =
|
||||||
|
!isLoadingExpense &&
|
||||||
|
isResponseSuccess(expense) &&
|
||||||
|
(expense.data.approval.action === 'REJECTED' ||
|
||||||
|
expense.data.approval.step_number === 5);
|
||||||
|
|
||||||
|
if (isExpenseRejectedOrApproved) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingExpense && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoadingExpense && isResponseSuccess(expense) && (
|
||||||
|
<ExpenseRequestForm type='edit' initialValues={expense.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseEditPage;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||||
|
|
||||||
|
const Layout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import ExpenseDetail from '@/components/pages/expense/ExpenseDetail';
|
||||||
|
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
const ExpenseDetailPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const expenseId = searchParams.get('expenseId');
|
||||||
|
|
||||||
|
const { data: expense, isLoading: isLoadingExpense } = useSWR(
|
||||||
|
expenseId,
|
||||||
|
(id: number) => ExpenseApi.getSingle(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!expenseId) {
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoadingExpense && (!expense || isResponseError(expense))) {
|
||||||
|
router.replace('/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full p-4 flex flex-row justify-center'>
|
||||||
|
{isLoadingExpense && (
|
||||||
|
<span className='loading loading-spinner loading-xl' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoadingExpense && isResponseSuccess(expense) && (
|
||||||
|
<ExpenseDetail initialValues={expense.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseDetailPage;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ExpensesTable from '@/components/pages/expense/ExpensesTable';
|
||||||
|
|
||||||
|
const Expense = () => {
|
||||||
|
return (
|
||||||
|
<section className='w-full p-4'>
|
||||||
|
<ExpensesTable />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Expense;
|
||||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
|||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { Color } from '@/types/theme';
|
import { Color } from '@/types/theme';
|
||||||
|
|
||||||
interface ButtonProps extends react.ComponentProps<'button'> {
|
export interface ButtonProps extends react.ComponentProps<'button'> {
|
||||||
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
|
variant?: 'soft' | 'outline' | 'dash' | 'ghost' | 'link' | 'active';
|
||||||
color?: Color;
|
color?: Color;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ const DateInput = ({
|
|||||||
{label}
|
{label}
|
||||||
{required && (
|
{required && (
|
||||||
<span className='text-error' title='required'>
|
<span className='text-error' title='required'>
|
||||||
|
{' '}
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useDropzone, type Accept } from 'react-dropzone';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
|
||||||
|
interface DropFileInputProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
caption?: string;
|
||||||
|
values?: File[];
|
||||||
|
accept?: Accept;
|
||||||
|
required?: boolean;
|
||||||
|
maxFiles?: number; // defaults to 1
|
||||||
|
maxSize?: number; // defaults to 2097152 (2 MB)
|
||||||
|
isError?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (files: File[]) => void;
|
||||||
|
onDelete?: (index: number) => void;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
inputContainer?: string;
|
||||||
|
label?: string;
|
||||||
|
inputWrapper?: string;
|
||||||
|
caption?: string;
|
||||||
|
bottomLabel?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
fileItemContainer?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropFileInput: React.FC<DropFileInputProps> = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
bottomLabel,
|
||||||
|
caption = 'Seret atau Pilih Dokumen',
|
||||||
|
values,
|
||||||
|
accept,
|
||||||
|
required,
|
||||||
|
maxFiles = Infinity,
|
||||||
|
maxSize,
|
||||||
|
isError,
|
||||||
|
errorMessage,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const isDisabled =
|
||||||
|
Boolean(values && maxFiles && values.length >= maxFiles) || disabled;
|
||||||
|
|
||||||
|
const {
|
||||||
|
acceptedFiles,
|
||||||
|
getRootProps,
|
||||||
|
getInputProps,
|
||||||
|
isFocused,
|
||||||
|
isDragAccept,
|
||||||
|
isDragReject,
|
||||||
|
} = useDropzone({
|
||||||
|
maxSize,
|
||||||
|
maxFiles,
|
||||||
|
accept: accept,
|
||||||
|
disabled: isDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values && maxFiles && values.length <= maxFiles) {
|
||||||
|
onChange?.([...values, ...acceptedFiles]);
|
||||||
|
}
|
||||||
|
}, [acceptedFiles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className?.wrapper)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full flex flex-col gap-2 text-start',
|
||||||
|
className?.inputContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-sm font-normal leading-5',
|
||||||
|
className?.label
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<span className='tooltip tooltip-error' data-tip='required'>
|
||||||
|
<span className='text-error'>*</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...getRootProps({
|
||||||
|
'aria-disabled': isDisabled,
|
||||||
|
className: cn(
|
||||||
|
'dropzone w-full px-4 py-2 border border-dashed border-gray-300 rounded cursor-pointer transition-all',
|
||||||
|
'hover:border-primary hover:bg-primary/10',
|
||||||
|
{
|
||||||
|
'border-success bg-success/10': isDragAccept,
|
||||||
|
'border-error bg-error/10': isDragReject || isError,
|
||||||
|
'border-primary bg-primary/10': isFocused,
|
||||||
|
'bg-gray-200/20 cursor-not-allowed': isDisabled,
|
||||||
|
},
|
||||||
|
className?.inputWrapper
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...getInputProps({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
disabled: isDisabled,
|
||||||
|
'aria-disabled': isDisabled,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{caption && (
|
||||||
|
<p className={cn('text-gray-500 text-sm', className?.caption)}>
|
||||||
|
{caption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isError && bottomLabel && (
|
||||||
|
<p
|
||||||
|
className={cn('w-full text-sm opacity-60', className?.bottomLabel)}
|
||||||
|
>
|
||||||
|
{bottomLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<p
|
||||||
|
className={cn('w-full text-sm text-error', className?.errorMessage)}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values && values.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full mt-1.5 flex flex-col gap-1.5',
|
||||||
|
className?.fileItemContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{values.map((file, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn('w-full flex flex-row items-center gap-2')}
|
||||||
|
>
|
||||||
|
<div className='p-2 rounded-full bg-primary/10'>
|
||||||
|
<Icon
|
||||||
|
icon='basil:file-solid'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='text-blue-500'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='w-full text-sm'>
|
||||||
|
<p>{file.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
onClick={() => {
|
||||||
|
onDelete?.(idx);
|
||||||
|
}}
|
||||||
|
className='rounded-full text-error focus-visible:text-error-content hover:text-error-content'
|
||||||
|
>
|
||||||
|
<Icon icon='fluent:delete-12-regular' width={24} height={24} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropFileInput;
|
||||||
@@ -179,9 +179,12 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
|||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && (
|
{required && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
<span className='tooltip tooltip-error' data-tip='required'>
|
<span className='tooltip tooltip-error' data-tip='required'>
|
||||||
<span className='text-error'>*</span>
|
<span className='text-error'>*</span>
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RefObject } from 'react';
|
import { MouseEventHandler, RefObject, useState } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import Button from '@/components/Button';
|
import Button, { ButtonProps } from '@/components/Button';
|
||||||
|
|
||||||
import { cn } from '@/lib/helper';
|
import { cn } from '@/lib/helper';
|
||||||
import { Color } from '@/types/theme';
|
|
||||||
|
|
||||||
export interface ConfirmationModalProps {
|
export interface ConfirmationModalProps {
|
||||||
ref: RefObject<HTMLDialogElement | null>;
|
ref: RefObject<HTMLDialogElement | null>;
|
||||||
type?: 'info' | 'success' | 'error';
|
type?: 'info' | 'success' | 'error';
|
||||||
text?: string;
|
text?: string;
|
||||||
closeOnBackdrop?: boolean;
|
closeOnBackdrop?: boolean;
|
||||||
primaryButton?: {
|
primaryButton?: ButtonProps & {
|
||||||
text?: string;
|
text?: string;
|
||||||
color?: Color;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
secondaryButton?: {
|
secondaryButton?: ButtonProps & {
|
||||||
text?: string;
|
text?: string;
|
||||||
color?: Color;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
};
|
};
|
||||||
className?: {
|
className?: {
|
||||||
modal?: string;
|
modal?: string;
|
||||||
@@ -43,10 +36,22 @@ const ConfirmationModal = ({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: ConfirmationModalProps) => {
|
}: ConfirmationModalProps) => {
|
||||||
|
const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
|
||||||
|
|
||||||
const closeModalHandler = () => {
|
const closeModalHandler = () => {
|
||||||
ref.current?.close();
|
ref.current?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const primaryButtonClickHandler: MouseEventHandler<
|
||||||
|
HTMLButtonElement
|
||||||
|
> = async (event) => {
|
||||||
|
setIsPrimaryButtonLoading(true);
|
||||||
|
|
||||||
|
await primaryButton?.onClick?.(event);
|
||||||
|
|
||||||
|
setIsPrimaryButtonLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
<Modal ref={ref} closeOnBackdrop={closeOnBackdrop} className={className}>
|
||||||
<div className='w-full flex flex-col gap-4'>
|
<div className='w-full flex flex-col gap-4'>
|
||||||
@@ -97,10 +102,15 @@ const ConfirmationModal = ({
|
|||||||
<div className='w-full flex flex-row gap-2'>
|
<div className='w-full flex flex-row gap-2'>
|
||||||
{secondaryButton && secondaryButton.text && (
|
{secondaryButton && secondaryButton.text && (
|
||||||
<Button
|
<Button
|
||||||
|
{...secondaryButton}
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
color={secondaryButton?.color ?? 'none'}
|
color={secondaryButton?.color}
|
||||||
isLoading={secondaryButton?.isLoading}
|
isLoading={secondaryButton?.isLoading}
|
||||||
disabled={secondaryButton?.isLoading}
|
disabled={
|
||||||
|
secondaryButton?.isLoading !== undefined
|
||||||
|
? secondaryButton?.isLoading
|
||||||
|
: isPrimaryButtonLoading
|
||||||
|
}
|
||||||
onClick={closeModalHandler}
|
onClick={closeModalHandler}
|
||||||
className='grow'
|
className='grow'
|
||||||
>
|
>
|
||||||
@@ -110,10 +120,19 @@ const ConfirmationModal = ({
|
|||||||
|
|
||||||
{primaryButton && primaryButton.text && (
|
{primaryButton && primaryButton.text && (
|
||||||
<Button
|
<Button
|
||||||
|
{...primaryButton}
|
||||||
color={primaryButton?.color ?? 'info'}
|
color={primaryButton?.color ?? 'info'}
|
||||||
onClick={primaryButton?.onClick}
|
onClick={primaryButtonClickHandler}
|
||||||
isLoading={primaryButton?.isLoading}
|
isLoading={
|
||||||
disabled={primaryButton?.isLoading}
|
primaryButton?.isLoading !== undefined
|
||||||
|
? primaryButton?.isLoading
|
||||||
|
: isPrimaryButtonLoading
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
primaryButton?.isLoading !== undefined
|
||||||
|
? primaryButton?.isLoading
|
||||||
|
: isPrimaryButtonLoading
|
||||||
|
}
|
||||||
className='grow'
|
className='grow'
|
||||||
>
|
>
|
||||||
{primaryButton?.text ?? 'Ya'}
|
{primaryButton?.text ?? 'Ya'}
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||||
|
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||||
|
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||||
|
import DropFileInput from '@/components/input/DropFileInput';
|
||||||
|
|
||||||
|
import { Expense } from '@/types/api/expense';
|
||||||
|
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||||
|
import {
|
||||||
|
UploadRequestDocumentsFormSchema,
|
||||||
|
UploadRequestDocumentsFormValues,
|
||||||
|
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||||
|
|
||||||
|
interface ExpenseDetailProps {
|
||||||
|
initialValues?: Expense;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Modal hooks
|
||||||
|
const deleteModal = useModal();
|
||||||
|
const approveModal = useModal();
|
||||||
|
const rejectModal = useModal();
|
||||||
|
|
||||||
|
// Modal loading state
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||||
|
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||||
|
|
||||||
|
const isLatestApprovalRejectedOrDone =
|
||||||
|
initialValues?.approval &&
|
||||||
|
(initialValues.approval.action === 'REJECTED' ||
|
||||||
|
initialValues.approval.step_number === 5);
|
||||||
|
|
||||||
|
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||||
|
initialValues: {
|
||||||
|
request_documents: [],
|
||||||
|
},
|
||||||
|
validationSchema: UploadRequestDocumentsFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
const addRequestDocumentsRes = await ExpenseApi.uploadRequestDocuments(
|
||||||
|
initialValues?.id as number,
|
||||||
|
values.request_documents
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(addRequestDocumentsRes)) {
|
||||||
|
toast.success(addRequestDocumentsRes.message);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
toast.error(String(addRequestDocumentsRes?.message));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteExpenseClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveClickHandler = () => {
|
||||||
|
approveModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectClickHandler = () => {
|
||||||
|
rejectModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal confirm click handler
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ExpenseApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
toast.success('Berhasil menghapus data biaya operasional!');
|
||||||
|
router.push('/expense');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Gagal menghapus data biaya operasional!');
|
||||||
|
} finally {
|
||||||
|
deleteModal.closeModal();
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalApproveClickHandler = async (notes: string) => {
|
||||||
|
setIsApproveLoading(true);
|
||||||
|
|
||||||
|
const approveResponse = await ExpenseApi.approve(
|
||||||
|
initialValues?.id as number,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(approveResponse)) {
|
||||||
|
approveModal.closeModal();
|
||||||
|
|
||||||
|
toast.success('Berhasil approve pengajuan biaya operasional!');
|
||||||
|
router.push('/expense');
|
||||||
|
} else {
|
||||||
|
approveModal.closeModal();
|
||||||
|
|
||||||
|
toast.error('Gagal approve pengajuan biaya operasional!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApproveLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalRejectClickHandler = async (notes: string) => {
|
||||||
|
setIsRejectLoading(true);
|
||||||
|
|
||||||
|
const rejectResponse = await ExpenseApi.reject(
|
||||||
|
initialValues?.id as number,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(rejectResponse)) {
|
||||||
|
rejectModal.closeModal();
|
||||||
|
|
||||||
|
toast.success('Berhasil reject pengajuan biaya operasional!');
|
||||||
|
router.push('/expense');
|
||||||
|
} else {
|
||||||
|
rejectModal.closeModal();
|
||||||
|
|
||||||
|
toast.error('Gagal reject pengajuan biaya operasional!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRejectLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDocumentsChangeHandler = (val: File[]) => {
|
||||||
|
formik.setFieldTouched('request_documents', true);
|
||||||
|
formik.setFieldValue('request_documents', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDocumentsDeleteHandler = (deletedFileIdx: number) => {
|
||||||
|
const newRequestDocuments = formik.values.request_documents;
|
||||||
|
|
||||||
|
newRequestDocuments?.splice(deletedFileIdx, 1);
|
||||||
|
|
||||||
|
formik.setFieldValue('request_documents', newRequestDocuments);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-7xl pb-16'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/expense'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
Detail Biaya Operasional
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className='w-full mt-4 flex flex-col gap-4'>
|
||||||
|
{/* TODO: apply RBAC */}
|
||||||
|
{!isLatestApprovalRejectedOrDone && (
|
||||||
|
<div className='w-full max-w-3xl mx-auto flex flex-row justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='success'
|
||||||
|
onClick={approveClickHandler}
|
||||||
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:check' width={24} height={24} />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='error'
|
||||||
|
onClick={rejectClickHandler}
|
||||||
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||||
|
className='px-4 ml-2'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteExpenseClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: add and integrate ApprovalSteps component with API */}
|
||||||
|
|
||||||
|
<div className='overflow-x-auto w-full max-w-3xl mx-auto'>
|
||||||
|
<table className='table table-sm table-zebra'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Nomor PO</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.po_number ?? '-'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nomor Referensi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.reference_number}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Lokasi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.location.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Kandang</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
{initialValues?.kandangs
|
||||||
|
.map((item) => item.name)
|
||||||
|
.join(', ')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Vendor</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.vendor.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tanggal Transaksi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
{formatDate(
|
||||||
|
initialValues?.transaction_date,
|
||||||
|
'DD MMMM YYYY'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Tanggal Realisasi</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
{initialValues?.realization_date
|
||||||
|
? formatDate(
|
||||||
|
initialValues?.realization_date,
|
||||||
|
'DD MMMM YYYY'
|
||||||
|
)
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nama Pengaju</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{initialValues?.created_user.name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nominal Biaya</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{formatCurrency(initialValues?.nominal ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nominal Sudah Bayar</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{formatCurrency(initialValues?.paid ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nominal Sisa Bayar</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>{formatCurrency(initialValues?.remaining_cost ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status Pencairan</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
<RealizationStatusBadge
|
||||||
|
approval={initialValues?.approval}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status Biaya</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
<ExpenseStatusBadge approval={initialValues?.approval} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Dokumen Pengajuan</th>
|
||||||
|
<th>:</th>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
{initialValues?.request_documents.length === 0 && '-'}
|
||||||
|
|
||||||
|
{initialValues?.request_documents &&
|
||||||
|
initialValues?.request_documents.length > 0 && (
|
||||||
|
<ul className='list-disc'>
|
||||||
|
{initialValues?.request_documents.map(
|
||||||
|
(requestDocument, requestDocumentIdx) => (
|
||||||
|
<li key={requestDocumentIdx}>
|
||||||
|
<Link
|
||||||
|
href={requestDocument.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-blue-500 underline'
|
||||||
|
>
|
||||||
|
{requestDocument.name}{' '}
|
||||||
|
<Icon
|
||||||
|
icon='cuida:open-in-new-tab-outline'
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
className='inline'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
<DropFileInput
|
||||||
|
name='request_documents'
|
||||||
|
values={formik.values.request_documents}
|
||||||
|
onChange={requestDocumentsChangeHandler}
|
||||||
|
onDelete={requestDocumentsDeleteHandler}
|
||||||
|
accept={{
|
||||||
|
...ACCEPTED_FILE_TYPE.PDF,
|
||||||
|
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||||
|
}}
|
||||||
|
maxFiles={10}
|
||||||
|
className={{
|
||||||
|
wrapper: 'mt-2',
|
||||||
|
inputWrapper: 'flex items-center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formik.values.request_documents &&
|
||||||
|
formik.values.request_documents.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={formik.submitForm}
|
||||||
|
disabled={formik.isSubmitting}
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
className='w-fit self-end'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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-2 flex flex-col gap-4'>
|
||||||
|
{initialValues?.kandang_expenses.map(
|
||||||
|
(kandangExpense, kandangExpenseIdx) => {
|
||||||
|
let expenseGrandTotal = 0;
|
||||||
|
|
||||||
|
kandangExpense.expenses.forEach(
|
||||||
|
(item) => (expenseGrandTotal += item.total_expense)
|
||||||
|
);
|
||||||
|
|
||||||
|
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.kandang.name}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Nonstock</th>
|
||||||
|
<th>Total Kuantitas</th>
|
||||||
|
<th>Total Biaya</th>
|
||||||
|
<th>Catatan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kandangExpense.expenses.map(
|
||||||
|
(expenseItem, expenseIdx) => (
|
||||||
|
<tr key={expenseIdx}>
|
||||||
|
<td>{expenseItem.nonstock.name}</td>
|
||||||
|
<td>{expenseItem.total_quantity}</td>
|
||||||
|
<td>
|
||||||
|
{formatCurrency(expenseItem.total_expense)}
|
||||||
|
</td>
|
||||||
|
<td className='w-xs'>
|
||||||
|
{expenseItem.notes ?? '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className='border-y'>
|
||||||
|
<th colSpan={2} className='text-right'>
|
||||||
|
Total Biaya Keseluruhan:
|
||||||
|
</th>
|
||||||
|
<th colSpan={2}>
|
||||||
|
{formatCurrency(expenseGrandTotal)}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin menghapus data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={approveModal.ref}
|
||||||
|
type='success'
|
||||||
|
text='Apakah anda yakin ingin approve data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'success',
|
||||||
|
isLoading: isApproveLoading,
|
||||||
|
onClick: confirmationModalApproveClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={rejectModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin reject data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isRejectLoading,
|
||||||
|
onClick: confirmationModalRejectClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseDetail;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import PillBadge from '@/components/PillBadge';
|
||||||
|
|
||||||
|
import { BaseApproval } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
interface ExpenseStatusBadgeProps {
|
||||||
|
approval?: BaseApproval;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseStatusBadge = ({ approval }: ExpenseStatusBadgeProps) => {
|
||||||
|
const isLatestApprovalRejected = approval?.action === 'REJECTED';
|
||||||
|
|
||||||
|
const latestApprovalStepNumber = approval?.step_number;
|
||||||
|
|
||||||
|
let expenseStatusPillBadgeColor:
|
||||||
|
| 'yellow'
|
||||||
|
| 'green'
|
||||||
|
| 'gray'
|
||||||
|
| 'red'
|
||||||
|
| 'purple'
|
||||||
|
| 'blue' = 'gray';
|
||||||
|
|
||||||
|
switch (latestApprovalStepNumber) {
|
||||||
|
case 1:
|
||||||
|
expenseStatusPillBadgeColor = 'yellow';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
expenseStatusPillBadgeColor = 'purple';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
expenseStatusPillBadgeColor = 'blue';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
expenseStatusPillBadgeColor = 'red';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
expenseStatusPillBadgeColor = 'green';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLatestApprovalRejected) {
|
||||||
|
expenseStatusPillBadgeColor = 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PillBadge
|
||||||
|
content={isLatestApprovalRejected ? 'Ditolak' : approval?.step_name}
|
||||||
|
color={expenseStatusPillBadgeColor}
|
||||||
|
className='text-xs'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseStatusBadge;
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import {
|
||||||
|
CellContext,
|
||||||
|
ColumnDef,
|
||||||
|
Row,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, {
|
||||||
|
OptionType,
|
||||||
|
useSelect,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
|
import RowDropdownOptions from '@/components/table/RowDropdownOptions';
|
||||||
|
import RowCollapseOptions from '@/components/table/RowCollapseOptions';
|
||||||
|
import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper';
|
||||||
|
import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge';
|
||||||
|
import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge';
|
||||||
|
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||||
|
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
|
||||||
|
import { Expense } from '@/types/api/expense';
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { cn, formatCurrency } from '@/lib/helper';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { ROWS_OPTIONS } from '@/config/constant';
|
||||||
|
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { Location } from '@/types/api/master-data/location';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
|
||||||
|
const RowOptionsMenu = ({
|
||||||
|
type = 'dropdown',
|
||||||
|
props,
|
||||||
|
approveClickHandler,
|
||||||
|
rejectClickHandler,
|
||||||
|
deleteClickHandler,
|
||||||
|
}: {
|
||||||
|
type: 'dropdown' | 'collapse';
|
||||||
|
props: CellContext<Expense, unknown>;
|
||||||
|
approveClickHandler: () => void;
|
||||||
|
rejectClickHandler: () => void;
|
||||||
|
deleteClickHandler: () => void;
|
||||||
|
}) => {
|
||||||
|
const showEditButton =
|
||||||
|
props.row.original.approval.action !== 'REJECTED' &&
|
||||||
|
props.row.original.approval.step_number !== 5 &&
|
||||||
|
props.row.original.approval.action !== 'APPROVED';
|
||||||
|
|
||||||
|
const showDeleteButton = showEditButton;
|
||||||
|
|
||||||
|
// TODO: apply RBAC
|
||||||
|
const showApproveButton = showEditButton;
|
||||||
|
const showRejectButton = showEditButton;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RowOptionsMenuWrapper type={type}>
|
||||||
|
<Button
|
||||||
|
href={`/expense/detail/?expenseId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='primary'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='mdi:eye-outline' width={16} height={16} />
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showEditButton && (
|
||||||
|
<Button
|
||||||
|
href={`/expense/detail/edit/?expenseId=${props.row.original.id}`}
|
||||||
|
variant='ghost'
|
||||||
|
color='warning'
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:edit-outline' width={16} height={16} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: apply RBAC */}
|
||||||
|
{showApproveButton && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='success'
|
||||||
|
onClick={approveClickHandler}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:check' width={24} height={24} />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRejectButton && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
onClick={rejectClickHandler}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
>
|
||||||
|
<Icon icon='material-symbols:close' width={24} height={24} />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteButton && (
|
||||||
|
<Button
|
||||||
|
onClick={deleteClickHandler}
|
||||||
|
variant='ghost'
|
||||||
|
color='error'
|
||||||
|
className='justify-start text-sm text-error focus-visible:text-error-content hover:text-error-content'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</RowOptionsMenuWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpensesTable = () => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
search: '',
|
||||||
|
nameSort: '',
|
||||||
|
transactionDate: '',
|
||||||
|
realizationDate: '',
|
||||||
|
locationId: '',
|
||||||
|
vendorId: '',
|
||||||
|
userId: '',
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
nameSort: 'sort_name',
|
||||||
|
transactionDate: 'transaction_date',
|
||||||
|
realizationDate: 'realization_date',
|
||||||
|
locationId: 'location_id',
|
||||||
|
vendorId: 'vendor_id',
|
||||||
|
userId: 'user_id',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: expenses,
|
||||||
|
isLoading,
|
||||||
|
mutate: refreshExpenses,
|
||||||
|
} = useSWR(
|
||||||
|
`${ExpenseApi.basePath}${getTableFilterQueryString()}`,
|
||||||
|
ExpenseApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModal = useModal();
|
||||||
|
const approveModal = useModal();
|
||||||
|
const rejectModal = useModal();
|
||||||
|
|
||||||
|
const [selectedExpense, setSelectedExpense] = useState<Expense | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||||
|
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||||
|
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
const selectedRowIds = Object.keys(rowSelection).map((item) =>
|
||||||
|
parseInt(item)
|
||||||
|
);
|
||||||
|
|
||||||
|
const expensesColumns: ColumnDef<Expense>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<div className='w-full flex flex-row justify-center'>
|
||||||
|
<CheckboxInput
|
||||||
|
name='allRow'
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomeRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isCheckboxDisabled =
|
||||||
|
!row.getCanSelect() || row.original.approval.action === 'REJECTED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CheckboxInput
|
||||||
|
name='row'
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
disabled={isCheckboxDisabled}
|
||||||
|
indeterminate={row.getIsSomeSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'transaction_date',
|
||||||
|
header: 'Tanggal Pengajuan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'realization_date',
|
||||||
|
header: 'Tanggal Realisasi',
|
||||||
|
cell: (props) => props.getValue() ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Lokasi',
|
||||||
|
cell: (props) => props.row.original.location.name ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.created_user.name ?? '-',
|
||||||
|
header: 'Nama Pengaju',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.vendor.name ?? '-',
|
||||||
|
header: 'Vendor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'nominal',
|
||||||
|
header: 'Nominal',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.nominal
|
||||||
|
? `Rp${formatCurrency(props.row.original.nominal)}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'paid',
|
||||||
|
header: 'Sudah Bayar',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.paid
|
||||||
|
? `Rp${formatCurrency(props.row.original.paid)}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'remaining_cost',
|
||||||
|
header: 'Sisa Bayar',
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.remaining_cost
|
||||||
|
? `Rp${formatCurrency(props.row.original.remaining_cost)}`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status Pencairan',
|
||||||
|
cell: (props) => (
|
||||||
|
<RealizationStatusBadge approval={props.row.original.approval} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status BOP',
|
||||||
|
cell: (props) => (
|
||||||
|
<ExpenseStatusBadge approval={props.row.original.approval} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Aksi',
|
||||||
|
cell: (props) => {
|
||||||
|
const currentPageSize = props.table.getPaginationRowModel().rows.length;
|
||||||
|
const currentPageRows = props.table.getPaginationRowModel().flatRows;
|
||||||
|
const currentRowRelativeIndex =
|
||||||
|
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
|
||||||
|
|
||||||
|
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
|
||||||
|
|
||||||
|
const approveClickHandler = () => {
|
||||||
|
setSelectedExpense(props.row.original);
|
||||||
|
|
||||||
|
// Set row selection
|
||||||
|
setRowSelection({
|
||||||
|
[String(props.row.original.id)]: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
approveModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectClickHandler = () => {
|
||||||
|
setSelectedExpense(props.row.original);
|
||||||
|
|
||||||
|
// Set row selection
|
||||||
|
setRowSelection({
|
||||||
|
[String(props.row.original.id)]: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
rejectModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteClickHandler = () => {
|
||||||
|
setSelectedExpense(props.row.original);
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentPageSize > 2 && (
|
||||||
|
<RowDropdownOptions isLast2Rows={isLast2Rows}>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
approveClickHandler={approveClickHandler}
|
||||||
|
rejectClickHandler={rejectClickHandler}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowDropdownOptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPageSize <= 2 && (
|
||||||
|
<RowCollapseOptions>
|
||||||
|
<RowOptionsMenu
|
||||||
|
type='dropdown'
|
||||||
|
props={props}
|
||||||
|
approveClickHandler={approveClickHandler}
|
||||||
|
rejectClickHandler={rejectClickHandler}
|
||||||
|
deleteClickHandler={deleteClickHandler}
|
||||||
|
/>
|
||||||
|
</RowCollapseOptions>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableEnableRowSelectionHandler: (row: Row<Expense>) => boolean = (
|
||||||
|
row
|
||||||
|
) => {
|
||||||
|
return row.original.approval.action !== 'REJECTED';
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkApproveClickHandler = () => {
|
||||||
|
approveModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkRejectClickHandler = () => {
|
||||||
|
rejectModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
setIsDeleteLoading(true);
|
||||||
|
|
||||||
|
await ExpenseApi.delete(selectedExpense?.id as number);
|
||||||
|
refreshExpenses();
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Berhasil menghapus biaya operasional!');
|
||||||
|
setIsDeleteLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalApproveClickHandler = async (notes: string) => {
|
||||||
|
setIsApproveLoading(true);
|
||||||
|
|
||||||
|
const bulkApproveResponse = await ExpenseApi.bulkApprove(
|
||||||
|
selectedRowIds,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(bulkApproveResponse)) {
|
||||||
|
refreshExpenses();
|
||||||
|
approveModal.closeModal();
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Berhasil approve ${selectedRowIds.length} data transfer ke laying!`
|
||||||
|
);
|
||||||
|
|
||||||
|
setRowSelection({});
|
||||||
|
} else {
|
||||||
|
approveModal.closeModal();
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
`Gagal approve ${selectedRowIds.length} data transfer ke laying!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApproveLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalRejectClickHandler = async (notes: string) => {
|
||||||
|
setIsRejectLoading(true);
|
||||||
|
|
||||||
|
const bulkRejectResponse = await ExpenseApi.bulkReject(
|
||||||
|
selectedRowIds,
|
||||||
|
notes
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseSuccess(bulkRejectResponse)) {
|
||||||
|
refreshExpenses();
|
||||||
|
rejectModal.closeModal();
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Berhasil reject ${selectedRowIds.length} data transfer ke laying!`
|
||||||
|
);
|
||||||
|
setRowSelection({});
|
||||||
|
} else {
|
||||||
|
rejectModal.closeModal();
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
`Gagal reject ${selectedRowIds.length} data transfer ke laying!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRejectLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocationOptions,
|
||||||
|
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<OptionType | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
setSelectedLocation(val as OptionType);
|
||||||
|
updateFilter(
|
||||||
|
'locationId',
|
||||||
|
val ? ((val as OptionType).value as string) : ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setVendorInputValue,
|
||||||
|
options: vendorOptions,
|
||||||
|
isLoadingOptions: isLoadingVendorOptions,
|
||||||
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState<OptionType | null>(null);
|
||||||
|
|
||||||
|
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
setSelectedVendor(val as OptionType);
|
||||||
|
updateFilter('vendorId', val ? ((val as OptionType).value as string) : '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
updateFilter('search', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transactionDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
||||||
|
e
|
||||||
|
) => {
|
||||||
|
updateFilter('transactionDate', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const realizationDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
||||||
|
e
|
||||||
|
) => {
|
||||||
|
updateFilter('realizationDate', e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSizeChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
const newVal = val as OptionType;
|
||||||
|
|
||||||
|
setPageSize(newVal.value as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
|
||||||
|
if (!isNameSorted) {
|
||||||
|
updateFilter('nameSort', '');
|
||||||
|
} else {
|
||||||
|
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
}, [sorting, updateFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='w-full p-0 sm:p-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='flex flex-col gap-2 mb-4'>
|
||||||
|
<div className='w-full flex flex-col sm:flex-row justify-between items-end sm:items-center gap-4'>
|
||||||
|
<div className='w-full sm:w-fit flex flex-col sm:flex-row self-start gap-2'>
|
||||||
|
<Button
|
||||||
|
href='/expense/add'
|
||||||
|
variant='outline'
|
||||||
|
color='primary'
|
||||||
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{selectedRowIds.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* TODO: apply RBAC */}
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='success'
|
||||||
|
onClick={bulkApproveClickHandler}
|
||||||
|
disabled={selectedRowIds.length === 0}
|
||||||
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:check'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='error'
|
||||||
|
onClick={bulkRejectClickHandler}
|
||||||
|
disabled={selectedRowIds.length === 0}
|
||||||
|
className='w-full sm:w-fit'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:close'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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'>
|
||||||
|
<DateInput
|
||||||
|
required
|
||||||
|
label='Tanggal Transaksi'
|
||||||
|
name='transaction_date'
|
||||||
|
placeholder='Masukkan tanggal transaksi'
|
||||||
|
value={tableFilterState.transactionDate}
|
||||||
|
onChange={transactionDateChangeHandler}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12 sm:col-span-3',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
required
|
||||||
|
label='Tanggal Realisasi'
|
||||||
|
name='realization_date'
|
||||||
|
placeholder='Masukkan tanggal realisasi'
|
||||||
|
value={tableFilterState.realizationDate}
|
||||||
|
onChange={realizationDateChangeHandler}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12 sm:col-span-3',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
value={selectedLocation}
|
||||||
|
onChange={locationChangeHandler}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
isClearable
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12 sm:col-span-3',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Vendor'
|
||||||
|
options={vendorOptions}
|
||||||
|
isLoading={isLoadingVendorOptions}
|
||||||
|
value={selectedVendor}
|
||||||
|
onChange={vendorChangeHandler}
|
||||||
|
onInputChange={setVendorInputValue}
|
||||||
|
isClearable
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12 sm:col-span-3',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<Expense>
|
||||||
|
data={isResponseSuccess(expenses) ? expenses?.data : []}
|
||||||
|
columns={expensesColumns}
|
||||||
|
pageSize={tableFilterState.pageSize}
|
||||||
|
page={isResponseSuccess(expenses) ? expenses?.meta?.page : 0}
|
||||||
|
totalItems={
|
||||||
|
isResponseSuccess(expenses) ? expenses?.meta?.total_results : 0
|
||||||
|
}
|
||||||
|
onPageChange={setPage}
|
||||||
|
isLoading={isLoading}
|
||||||
|
sorting={sorting}
|
||||||
|
setSorting={setSorting}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
setRowSelection={setRowSelection}
|
||||||
|
enableRowSelection={tableEnableRowSelectionHandler}
|
||||||
|
className={{
|
||||||
|
containerClassName: cn({
|
||||||
|
'mb-20':
|
||||||
|
isResponseSuccess(expenses) && expenses?.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 last:flex last:flex-row last:justify-end',
|
||||||
|
bodyRowClassName: 'border-b border-b-gray-200',
|
||||||
|
bodyColumnClassName:
|
||||||
|
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin menghapus data biaya operasional ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isDeleteLoading,
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={approveModal.ref}
|
||||||
|
type='success'
|
||||||
|
text={`Apakah anda yakin ingin approve data biaya operasional ini (${selectedRowIds.length} data)?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'success',
|
||||||
|
isLoading: isApproveLoading,
|
||||||
|
onClick: confirmationModalApproveClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModalWithNotes
|
||||||
|
ref={rejectModal.ref}
|
||||||
|
type='error'
|
||||||
|
text={`Apakah anda yakin ingin reject data biaya operasional ini (${selectedRowIds.length} data)?`}
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
isLoading: isRejectLoading,
|
||||||
|
onClick: confirmationModalRejectClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpensesTable;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import PillBadge from '@/components/PillBadge';
|
||||||
|
|
||||||
|
import { BaseApproval } from '@/types/api/api-general';
|
||||||
|
|
||||||
|
interface RealizationStatusBadgeProps {
|
||||||
|
approval?: BaseApproval;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RealizationStatusBadge = ({ approval }: RealizationStatusBadgeProps) => {
|
||||||
|
const isLatestApprovalRejected = approval?.action === 'REJECTED';
|
||||||
|
|
||||||
|
const isExpenseRealized = approval?.step_number && approval.step_number >= 4;
|
||||||
|
|
||||||
|
const realizationStatus = isExpenseRealized
|
||||||
|
? 'Sudah Realisasi'
|
||||||
|
: 'Belum Realisasi';
|
||||||
|
|
||||||
|
let realizationStatusPillBadgeColor:
|
||||||
|
| 'yellow'
|
||||||
|
| 'green'
|
||||||
|
| 'gray'
|
||||||
|
| 'red'
|
||||||
|
| 'purple'
|
||||||
|
| 'blue' = isExpenseRealized ? 'green' : 'yellow';
|
||||||
|
|
||||||
|
if (isLatestApprovalRejected) {
|
||||||
|
realizationStatusPillBadgeColor = 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PillBadge
|
||||||
|
content={isLatestApprovalRejected ? 'Ditolak' : realizationStatus}
|
||||||
|
color={realizationStatusPillBadgeColor}
|
||||||
|
className='text-xs'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RealizationStatusBadge;
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Collapse from '@/components/Collapse';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import Table from '@/components/Table';
|
||||||
|
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||||
|
|
||||||
|
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
|
||||||
|
import { cn, convertRowSelectionArrToObj } from '@/lib/helper';
|
||||||
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||||
|
import { KandangApi } from '@/services/api/master-data';
|
||||||
|
import { isResponseSuccess } from '@/lib/api-helper';
|
||||||
|
|
||||||
|
interface ExpenseKandangsTableProps {
|
||||||
|
locationId?: number;
|
||||||
|
type: 'add' | 'edit' | 'detail';
|
||||||
|
selectedKandangs: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
onChange: (kandangs: { id: number; name: string }[]) => void;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseKandangsTable = ({
|
||||||
|
type,
|
||||||
|
locationId,
|
||||||
|
selectedKandangs,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: ExpenseKandangsTableProps) => {
|
||||||
|
const {
|
||||||
|
state: tableFilterState,
|
||||||
|
updateFilter,
|
||||||
|
setPage,
|
||||||
|
toQueryString: getTableFilterQueryString,
|
||||||
|
} = useTableFilter({
|
||||||
|
initial: {
|
||||||
|
search: '',
|
||||||
|
nameSort: '',
|
||||||
|
picSort: '',
|
||||||
|
locationId,
|
||||||
|
},
|
||||||
|
paramMap: {
|
||||||
|
page: 'page',
|
||||||
|
pageSize: 'limit',
|
||||||
|
nameSort: 'sort_name',
|
||||||
|
picSort: 'sort_pic',
|
||||||
|
locationId: 'location_id',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: kandangs, isLoading } = useSWR(
|
||||||
|
locationId ? `${KandangApi.basePath}${getTableFilterQueryString()}` : null,
|
||||||
|
KandangApi.getAllFetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(
|
||||||
|
isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false
|
||||||
|
);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>(
|
||||||
|
convertRowSelectionArrToObj(selectedKandangs.map((item) => item.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const kandangsColumns: ColumnDef<Kandang>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<div className='w-full flex flex-row justify-center'>
|
||||||
|
<CheckboxInput
|
||||||
|
name='allRow'
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
indeterminate={table.getIsSomePageRowsSelected()}
|
||||||
|
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||||
|
disabled={type === 'detail'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<CheckboxInput
|
||||||
|
name='row'
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
disabled={!row.getCanSelect() || type === 'detail'}
|
||||||
|
indeterminate={row.getIsSomeSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Nama',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'pic',
|
||||||
|
header: 'PIC',
|
||||||
|
cell: (props) => props.row.original.pic.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateSortingFilter = useCallback(
|
||||||
|
(
|
||||||
|
sortName: Exclude<keyof typeof tableFilterState, 'page' | 'pageSize'>,
|
||||||
|
sortFilter: ColumnSort | undefined
|
||||||
|
) => {
|
||||||
|
if (!sortFilter) {
|
||||||
|
updateFilter(sortName, '');
|
||||||
|
} else {
|
||||||
|
updateFilter(sortName, sortFilter.desc ? 'desc' : 'asc');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locationId) updateFilter('locationId', locationId);
|
||||||
|
}, [locationId, updateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(isResponseSuccess(kandangs) ? kandangs.data.length > 0 : false);
|
||||||
|
}, [kandangs, isResponseSuccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(rowSelection).length !== 0 && isResponseSuccess(kandangs)) {
|
||||||
|
const formattedSelectedKandangs = Object.keys(rowSelection).map(
|
||||||
|
(item) => {
|
||||||
|
const selectedKandang = kandangs.data.find(
|
||||||
|
(kandang) => kandang.id === parseInt(item)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: parseInt(item),
|
||||||
|
name: selectedKandang?.name ?? 'Kandang tidak ditemukan!',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onChange(formattedSelectedKandangs);
|
||||||
|
} else {
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}, [rowSelection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedKandangs.length === 0 &&
|
||||||
|
Object.keys(rowSelection).length !== 0
|
||||||
|
) {
|
||||||
|
setRowSelection({});
|
||||||
|
}
|
||||||
|
}, [selectedKandangs]);
|
||||||
|
|
||||||
|
// track sorting
|
||||||
|
useEffect(() => {
|
||||||
|
const nameSortFilter = sorting.find((sortItem) => sortItem.id === 'name');
|
||||||
|
const picSortFilter = sorting.find((sortItem) => sortItem.id === 'pic');
|
||||||
|
|
||||||
|
updateSortingFilter('nameSort', nameSortFilter);
|
||||||
|
updateSortingFilter('picSort', picSortFilter);
|
||||||
|
}, [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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseKandangsTable;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import * as Yup from 'yup';
|
||||||
|
import { Expense } from '@/types/api/expense';
|
||||||
|
import { formatDate } from '@/lib/helper';
|
||||||
|
|
||||||
|
type ExpenseFormSchemaType = {
|
||||||
|
location?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
transaction_date?: string;
|
||||||
|
kandangs?: { id: number; name: string }[];
|
||||||
|
vendor?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
existing_documents?: { name: string; url: string }[];
|
||||||
|
request_documents?: File[];
|
||||||
|
kandangExpenses: {
|
||||||
|
kandangId: number;
|
||||||
|
expenses: {
|
||||||
|
nonstock?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
totalQuantity?: number;
|
||||||
|
totalExpense?: number;
|
||||||
|
notes?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExpenseRequestFormSchema: Yup.ObjectSchema<ExpenseFormSchemaType> =
|
||||||
|
Yup.object({
|
||||||
|
location: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Lokasi wajib diisi!'),
|
||||||
|
|
||||||
|
transaction_date: Yup.string().required('Tanggal transaksi wajib diisi!'),
|
||||||
|
kandangs: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
id: Yup.number().required('Kandang wajib dipilih!'),
|
||||||
|
name: Yup.string().required('Kandang wajib dipilih!'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'Kandang wajib dipilih!')
|
||||||
|
.required('Kandang wajib dipilih!'),
|
||||||
|
|
||||||
|
vendor: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Vendor wajib diisi!'),
|
||||||
|
|
||||||
|
existing_documents: Yup.array().of(
|
||||||
|
Yup.object({
|
||||||
|
name: Yup.string().required(),
|
||||||
|
url: Yup.string().required(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
request_documents: Yup.array().of(Yup.mixed<File>().required()).optional(),
|
||||||
|
|
||||||
|
kandangExpenses: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
kandangId: Yup.number().min(1, 'Wajib memilih kandang!').required(),
|
||||||
|
expenses: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
nonstock: Yup.object({
|
||||||
|
value: Yup.number().min(1).required(),
|
||||||
|
label: Yup.string().required(),
|
||||||
|
}).required('Nonstock wajib diisi!'),
|
||||||
|
totalQuantity: Yup.number().required(
|
||||||
|
'Total kuantitas wajib diisi!'
|
||||||
|
),
|
||||||
|
totalExpense: Yup.number().required('Total biaya wajib diisi!'),
|
||||||
|
notes: Yup.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'Kandang harus memiliki setidaknya 1 biaya!')
|
||||||
|
.required('Biaya kandang wajib diisi!'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'Biaya kandang wajib diisi!')
|
||||||
|
.required('Biaya kandang wajib diisi!'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateExpenseRequestFormSchema = ExpenseRequestFormSchema;
|
||||||
|
|
||||||
|
export const UploadRequestDocumentsFormSchema = Yup.object({
|
||||||
|
request_documents: Yup.array().of(Yup.mixed<File>().required()).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExpenseRequestFormValues = Yup.InferType<
|
||||||
|
typeof ExpenseRequestFormSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type UploadRequestDocumentsFormValues = Yup.InferType<
|
||||||
|
typeof UploadRequestDocumentsFormSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const getExpenseFormInitialValues = (
|
||||||
|
initialValues?: Expense
|
||||||
|
): ExpenseRequestFormValues => {
|
||||||
|
return {
|
||||||
|
location: initialValues?.location
|
||||||
|
? {
|
||||||
|
value: initialValues.location.id,
|
||||||
|
label: initialValues.location.name,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
transaction_date: initialValues?.transaction_date
|
||||||
|
? formatDate(initialValues.transaction_date, 'YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
kandangs: initialValues?.kandangs.map((kandang) => ({
|
||||||
|
id: kandang.id,
|
||||||
|
name: kandang.name,
|
||||||
|
})),
|
||||||
|
vendor: initialValues?.vendor
|
||||||
|
? {
|
||||||
|
value: initialValues.vendor.id,
|
||||||
|
label: initialValues.vendor.name,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
existing_documents: initialValues?.request_documents,
|
||||||
|
request_documents: [],
|
||||||
|
kandangExpenses: initialValues?.kandang_expenses
|
||||||
|
? initialValues.kandang_expenses.map((kandangExpense) => ({
|
||||||
|
kandangId: kandangExpense.kandang.id,
|
||||||
|
expenses: kandangExpense.expenses.map((expenseItem) => ({
|
||||||
|
nonstock: {
|
||||||
|
value: expenseItem.nonstock.id,
|
||||||
|
label: expenseItem.nonstock.name,
|
||||||
|
},
|
||||||
|
totalQuantity: expenseItem.total_quantity,
|
||||||
|
totalExpense: expenseItem.total_expense,
|
||||||
|
notes: expenseItem.notes,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useFormik } from 'formik';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { useModal } from '@/components/Modal';
|
||||||
|
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||||
|
import SelectInput, {
|
||||||
|
OptionType,
|
||||||
|
useSelect,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
|
import DateInput from '@/components/input/DateInput';
|
||||||
|
import ExpenseKandangsTable from '@/components/pages/expense/form/ExpenseKandangsTable';
|
||||||
|
import DropFileInput from '@/components/input/DropFileInput';
|
||||||
|
import ExpenseRequestKandangDetailExpense from '@/components/pages/expense/form/ExpenseRequestKandangDetailExpense';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ExpenseRequestFormSchema,
|
||||||
|
ExpenseRequestFormValues,
|
||||||
|
getExpenseFormInitialValues,
|
||||||
|
UpdateExpenseRequestFormSchema,
|
||||||
|
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||||
|
import { isResponseError } from '@/lib/api-helper';
|
||||||
|
import {
|
||||||
|
Expense,
|
||||||
|
CreateExpensePayload,
|
||||||
|
UpdateExpensePayload,
|
||||||
|
} from '@/types/api/expense';
|
||||||
|
import { ExpenseApi } from '@/services/api/expense';
|
||||||
|
import { cn, sleep } from '@/lib/helper';
|
||||||
|
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||||
|
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
|
||||||
|
interface ExpenseFormProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
initialValues?: Expense;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: integrate this with real API
|
||||||
|
const ExpenseRequestForm = ({
|
||||||
|
type = 'add',
|
||||||
|
initialValues,
|
||||||
|
}: ExpenseFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Modal hooks
|
||||||
|
const deleteModal = useModal();
|
||||||
|
const approveModal = useModal();
|
||||||
|
const rejectModal = useModal();
|
||||||
|
|
||||||
|
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const createExpenseHandler = useCallback(
|
||||||
|
async (payload: CreateExpensePayload) => {
|
||||||
|
const createExpenseRes = await ExpenseApi.create(
|
||||||
|
ExpenseApi.convertPayloadToFormData(payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isResponseError(createExpenseRes)) {
|
||||||
|
setExpenseFormErrorMessage(createExpenseRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(createExpenseRes?.message as string);
|
||||||
|
router.push('/expense');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateExpenseHandler = useCallback(
|
||||||
|
async (expenseId: number, payload: UpdateExpensePayload) => {
|
||||||
|
const updateExpenseRes = await ExpenseApi.update(
|
||||||
|
expenseId,
|
||||||
|
ExpenseApi.convertPayloadToFormData(payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateExpenseRes?.status === 'error') {
|
||||||
|
setExpenseFormErrorMessage(updateExpenseRes.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(updateExpenseRes?.message as string);
|
||||||
|
router.refresh();
|
||||||
|
router.push('/expense');
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formik = useFormik<ExpenseRequestFormValues>({
|
||||||
|
initialValues: getExpenseFormInitialValues(initialValues),
|
||||||
|
validationSchema:
|
||||||
|
type === 'edit'
|
||||||
|
? UpdateExpenseRequestFormSchema
|
||||||
|
: ExpenseRequestFormSchema,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
setExpenseFormErrorMessage('');
|
||||||
|
|
||||||
|
const expensePayload: CreateExpensePayload = {
|
||||||
|
locationId: values.location?.value as number,
|
||||||
|
kandangIds: values.kandangs
|
||||||
|
? values.kandangs.map((item) => item.id)
|
||||||
|
: [],
|
||||||
|
transaction_date: values.transaction_date as string,
|
||||||
|
vendorId: values.vendor?.value as number,
|
||||||
|
request_documents: values.request_documents as File[],
|
||||||
|
kandang_expenses: values.kandangExpenses.map((kandangExpense) => ({
|
||||||
|
kandangId: kandangExpense.kandangId,
|
||||||
|
expenses: kandangExpense.expenses.map((expenseItem) => ({
|
||||||
|
nonstockId: expenseItem.nonstock?.value as number,
|
||||||
|
total_quantity: expenseItem.totalQuantity as number,
|
||||||
|
total_expense: expenseItem.totalExpense as number,
|
||||||
|
notes: expenseItem.notes,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'add':
|
||||||
|
await createExpenseHandler(expensePayload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
await updateExpenseHandler(
|
||||||
|
initialValues?.id as number,
|
||||||
|
expensePayload
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setValues: formikSetValues } = formik;
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setLocationInputValue,
|
||||||
|
options: locationOptions,
|
||||||
|
isLoadingOptions: isLoadingLocationOptions,
|
||||||
|
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setInputValue: setVendorInputValue,
|
||||||
|
options: vendorOptions,
|
||||||
|
isLoadingOptions: isLoadingVendorOptions,
|
||||||
|
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldTouched('location', true);
|
||||||
|
formik.setFieldValue('location', val);
|
||||||
|
|
||||||
|
formik.setFieldValue('kandangs', []);
|
||||||
|
formik.setFieldValue('kandangExpenses', []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kandangsChangeHandler = (kandangs: { id: number; name: string }[]) => {
|
||||||
|
formik.setFieldTouched('kandangs', true);
|
||||||
|
formik.setFieldValue('kandangs', kandangs);
|
||||||
|
|
||||||
|
const newKandangExpenses = [...(formik.values.kandangExpenses ?? [])];
|
||||||
|
|
||||||
|
// add new kandangExpenses
|
||||||
|
kandangs.forEach((kandangItem) => {
|
||||||
|
const isKandangExistInKandangExpense = newKandangExpenses.find(
|
||||||
|
(kandangExpenseItem) => kandangExpenseItem.kandangId === kandangItem.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isKandangExistInKandangExpense) return;
|
||||||
|
|
||||||
|
newKandangExpenses.push({
|
||||||
|
kandangId: kandangItem.id,
|
||||||
|
expenses: [
|
||||||
|
{
|
||||||
|
nonstock: undefined,
|
||||||
|
totalExpense: undefined,
|
||||||
|
totalQuantity: undefined,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// prune kandangExpenses
|
||||||
|
const kandangIds = new Set(kandangs.map((kandang) => kandang.id));
|
||||||
|
const deletedKandangExpensesIdx: number[] = [];
|
||||||
|
|
||||||
|
newKandangExpenses.forEach((kandangExpense, idx) => {
|
||||||
|
const isKandangExpenseValid = kandangIds.has(kandangExpense.kandangId);
|
||||||
|
|
||||||
|
if (!isKandangExpenseValid) {
|
||||||
|
deletedKandangExpensesIdx.push(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deletedKandangExpensesIdx.forEach((deletedKandangExpenseIdx) => {
|
||||||
|
newKandangExpenses.splice(deletedKandangExpenseIdx, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
formik.setFieldValue('kandangExpenses', newKandangExpenses);
|
||||||
|
};
|
||||||
|
|
||||||
|
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||||
|
formik.setFieldTouched('vendor', true);
|
||||||
|
formik.setFieldValue('vendor', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDocumentsChangeHandler = (val: File[]) => {
|
||||||
|
formik.setFieldTouched('request_documents', true);
|
||||||
|
formik.setFieldValue('request_documents', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteExpenseClickHandler = () => {
|
||||||
|
deleteModal.openModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalRejectClickHandler = async () => {
|
||||||
|
await sleep(750);
|
||||||
|
|
||||||
|
rejectModal.closeModal();
|
||||||
|
toast.success('Berhasil melakukan reject biaya operasional!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalApproveClickHandler = async () => {
|
||||||
|
await sleep(750);
|
||||||
|
|
||||||
|
approveModal.closeModal();
|
||||||
|
toast.success('Berhasil melakukan approve biaya operasional!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmationModalDeleteClickHandler = async () => {
|
||||||
|
await ExpenseApi.delete(initialValues?.id as number);
|
||||||
|
|
||||||
|
deleteModal.closeModal();
|
||||||
|
toast.success('Successfully delete Expense!');
|
||||||
|
router.push('/expense');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formikSetValues(getExpenseFormInitialValues(initialValues));
|
||||||
|
}, [formikSetValues, getExpenseFormInitialValues, initialValues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className='w-full max-w-5xl'>
|
||||||
|
<header className='flex flex-col gap-4'>
|
||||||
|
<Button
|
||||||
|
href='/expense'
|
||||||
|
variant='link'
|
||||||
|
className='w-fit p-0 text-primary'
|
||||||
|
>
|
||||||
|
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className='text-2xl font-bold text-center'>
|
||||||
|
{type === 'add' && 'Tambah Biaya Operasional'}
|
||||||
|
{type === 'edit' && 'Edit Biaya Operasional'}
|
||||||
|
{type === 'detail' && 'Detail Biaya Operasional'}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onReset={formik.handleReset}
|
||||||
|
className='w-full mt-8 flex flex-col gap-6'
|
||||||
|
>
|
||||||
|
<div className='grid grid-cols-12 gap-4'>
|
||||||
|
<SelectInput
|
||||||
|
label='Lokasi'
|
||||||
|
required
|
||||||
|
placeholder='Pilih Lokasi'
|
||||||
|
value={formik.values.location}
|
||||||
|
onChange={locationChangeHandler}
|
||||||
|
options={locationOptions}
|
||||||
|
isLoading={isLoadingLocationOptions}
|
||||||
|
onInputChange={setLocationInputValue}
|
||||||
|
className={{ wrapper: 'col-span-12 sm:col-span-6' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
name='transaction_date'
|
||||||
|
label='Tanggal Transaksi'
|
||||||
|
required
|
||||||
|
value={formik.values.transaction_date}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12 sm:col-span-6',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExpenseKandangsTable
|
||||||
|
type={type}
|
||||||
|
locationId={formik.values.location?.value}
|
||||||
|
selectedKandangs={formik.values.kandangs ?? []}
|
||||||
|
onChange={kandangsChangeHandler}
|
||||||
|
className={{
|
||||||
|
wrapper: 'w-full col-span-12',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectInput
|
||||||
|
label='Vendor'
|
||||||
|
required
|
||||||
|
placeholder='Pilih Vendor'
|
||||||
|
value={formik.values.vendor}
|
||||||
|
onChange={vendorChangeHandler}
|
||||||
|
options={vendorOptions}
|
||||||
|
isLoading={isLoadingVendorOptions}
|
||||||
|
onInputChange={setVendorInputValue}
|
||||||
|
className={{ wrapper: 'col-span-12' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropFileInput
|
||||||
|
label='Dokumen Pengajuan'
|
||||||
|
name='request_documents'
|
||||||
|
values={formik.values.request_documents}
|
||||||
|
onChange={requestDocumentsChangeHandler}
|
||||||
|
accept={{
|
||||||
|
...ACCEPTED_FILE_TYPE.PDF,
|
||||||
|
...ACCEPTED_FILE_TYPE.IMAGE,
|
||||||
|
}}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12',
|
||||||
|
inputWrapper: 'h-12 flex items-center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formik.values.existing_documents &&
|
||||||
|
formik.values.existing_documents.length > 0 && (
|
||||||
|
<div className='w-full col-span-12'>
|
||||||
|
<ul className='pl-4 list-disc'>
|
||||||
|
{formik.values.existing_documents.map(
|
||||||
|
(existingDocument, existingDocumentIdx) => (
|
||||||
|
<li key={existingDocumentIdx}>
|
||||||
|
<Link
|
||||||
|
href={existingDocument.url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='text-blue-500 underline'
|
||||||
|
>
|
||||||
|
{existingDocument.name}{' '}
|
||||||
|
<Icon
|
||||||
|
icon='cuida:open-in-new-tab-outline'
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
className='inline'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExpenseRequestKandangDetailExpense
|
||||||
|
formik={formik}
|
||||||
|
className={{
|
||||||
|
wrapper: 'col-span-12',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-row justify-between gap-2 flex-wrap'>
|
||||||
|
{type !== 'add' && (
|
||||||
|
<div className='flex flex-row justify-start gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={deleteExpenseClickHandler}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{type !== 'edit' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='warning'
|
||||||
|
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:edit-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className='justify-start text-sm'
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row justify-end gap-2', {
|
||||||
|
'w-full': type === 'add',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type='reset' color='warning' className='px-4'>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='primary'
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
disabled={!formik.isValid || formik.isSubmitting}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expenseFormErrorMessage && (
|
||||||
|
<div role='alert' className='alert alert-error'>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:error-outline'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span>{expenseFormErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{type !== 'add' && (
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={deleteModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin menghapus data Expense ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
onClick: confirmationModalDeleteClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'detail' && (
|
||||||
|
<>
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={approveModal.ref}
|
||||||
|
type='success'
|
||||||
|
text='Apakah anda yakin ingin approve data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'success',
|
||||||
|
onClick: confirmationModalApproveClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationModal
|
||||||
|
ref={rejectModal.ref}
|
||||||
|
type='error'
|
||||||
|
text='Apakah anda yakin ingin reject data transfer ke laying ini?'
|
||||||
|
secondaryButton={{
|
||||||
|
text: 'Tidak',
|
||||||
|
}}
|
||||||
|
primaryButton={{
|
||||||
|
text: 'Ya',
|
||||||
|
color: 'error',
|
||||||
|
onClick: confirmationModalRejectClickHandler,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseRequestForm;
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormikContextType } from 'formik';
|
||||||
|
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import Card from '@/components/Card';
|
||||||
|
import SelectInput, {
|
||||||
|
OptionType,
|
||||||
|
useSelect,
|
||||||
|
} from '@/components/input/SelectInput';
|
||||||
|
import NumberInput from '@/components/input/NumberInput';
|
||||||
|
import TextInput from '@/components/input/TextInput';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
|
||||||
|
import { ExpenseRequestFormValues } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||||
|
import { cn } from '@/lib/helper';
|
||||||
|
import { NonstockApi } from '@/services/api/master-data';
|
||||||
|
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||||
|
import { removeArrayItemAndSync } from '@/lib/utils/formik';
|
||||||
|
|
||||||
|
interface ExpenseRequestKandangDetailExpenseProps {
|
||||||
|
type?: 'add' | 'edit' | 'detail';
|
||||||
|
formik: FormikContextType<ExpenseRequestFormValues>;
|
||||||
|
className?: {
|
||||||
|
wrapper?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpenseRequestKandangDetailExpense: React.FC<
|
||||||
|
ExpenseRequestKandangDetailExpenseProps
|
||||||
|
> = ({ type, formik, className }) => {
|
||||||
|
const {
|
||||||
|
setInputValue: setNonstockInputValue,
|
||||||
|
options: nonstockOptions,
|
||||||
|
isLoadingOptions: isLoadingNonstockOptions,
|
||||||
|
} = useSelect<Nonstock>(NonstockApi.basePath, 'id', 'name');
|
||||||
|
|
||||||
|
const nonstockChangeHandler = (
|
||||||
|
kandangExpenseIdx: number,
|
||||||
|
expenseIdx: number,
|
||||||
|
val: OptionType | OptionType[] | null
|
||||||
|
) => {
|
||||||
|
formik.setFieldTouched(
|
||||||
|
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
formik.setFieldValue(
|
||||||
|
`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].nonstock`,
|
||||||
|
val
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExpenseItemHandler = (kandangExpenseIdx: number) => {
|
||||||
|
const newExpensesValue = [
|
||||||
|
...formik.values.kandangExpenses[kandangExpenseIdx].expenses,
|
||||||
|
{
|
||||||
|
nonstock: undefined,
|
||||||
|
totalExpense: undefined,
|
||||||
|
totalQuantity: undefined,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
formik.setFieldValue(
|
||||||
|
`kandangExpenses[${kandangExpenseIdx}].expenses`,
|
||||||
|
newExpensesValue
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteExpenseItemHandler = (
|
||||||
|
kandangExpenseIdx: number,
|
||||||
|
expenseIdx: number
|
||||||
|
) => {
|
||||||
|
const path = `kandangExpenses[${kandangExpenseIdx}].expenses`;
|
||||||
|
|
||||||
|
// trims values, errors, and touched at expenseIdx
|
||||||
|
removeArrayItemAndSync(formik, path, expenseIdx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpenseRepeaterInputError = (
|
||||||
|
column: 'nonstock' | 'totalQuantity' | 'totalExpense' | 'notes',
|
||||||
|
kandangExpenseIdx: number,
|
||||||
|
expenseIdx: number
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
formik.touched.kandangExpenses?.[kandangExpenseIdx]?.expenses?.[
|
||||||
|
expenseIdx
|
||||||
|
]?.[column] &&
|
||||||
|
Boolean(
|
||||||
|
formik.errors.kandangExpenses?.[kandangExpenseIdx] instanceof Object &&
|
||||||
|
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
|
||||||
|
expenseIdx
|
||||||
|
] instanceof Object &&
|
||||||
|
formik.errors.kandangExpenses?.[kandangExpenseIdx].expenses?.[
|
||||||
|
expenseIdx
|
||||||
|
]?.[column]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={{
|
||||||
|
wrapper: cn('w-full', className?.wrapper),
|
||||||
|
body: 'p-4 shadow',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='mb-4 text-center'>
|
||||||
|
<h4 className='font-bold text-xl'>
|
||||||
|
Rincian Pengajuan Biaya Operasional
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='w-full flex flex-col gap-6'>
|
||||||
|
{formik.values.kandangExpenses.length === 0 && (
|
||||||
|
<div>
|
||||||
|
<p className='text-sm text-gray-400 text-center'>
|
||||||
|
Pilih kandang terlebih dahulu!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formik.values.kandangExpenses.map(
|
||||||
|
(kandangExpense, kandangExpenseIdx) => {
|
||||||
|
const kandangName = formik.values.kandangs?.find(
|
||||||
|
(kandang) => kandang.id === kandangExpense.kandangId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
kandangName?.name && (
|
||||||
|
<div
|
||||||
|
key={`kandangExpense-${kandangExpenseIdx}`}
|
||||||
|
className='w-full flex flex-col gap-4'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h5 className='mb-2 text-lg font-bold text-center'>
|
||||||
|
Biaya {kandangName?.name}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<table className='table'>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nonstock</th>
|
||||||
|
<th>Total Kuantitas</th>
|
||||||
|
<th>Total Biaya</th>
|
||||||
|
<th>Catatan</th>
|
||||||
|
{type !== 'detail' && <th>Aksi</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{kandangExpense.expenses.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' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<NumberInput
|
||||||
|
required
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalQuantity`}
|
||||||
|
placeholder='Masukkan Total Kuantitas'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].totalQuantity ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'totalQuantity',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<NumberInput
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].totalExpense`}
|
||||||
|
placeholder='Masukkan Total Biaya'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].totalExpense ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'totalExpense',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
inputPrefix={
|
||||||
|
<span className='text-gray-600 font-medium'>
|
||||||
|
Rp
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className='p-2'>
|
||||||
|
<TextInput
|
||||||
|
name={`kandangExpenses[${kandangExpenseIdx}].expenses[${expenseIdx}].notes`}
|
||||||
|
placeholder='Tuliskan catatan'
|
||||||
|
value={
|
||||||
|
formik.values.kandangExpenses[
|
||||||
|
kandangExpenseIdx
|
||||||
|
].expenses[expenseIdx].notes ?? ''
|
||||||
|
}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
onBlur={formik.handleBlur}
|
||||||
|
isError={isExpenseRepeaterInputError(
|
||||||
|
'notes',
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)}
|
||||||
|
className={{ wrapper: 'min-w-24' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
color='error'
|
||||||
|
onClick={() =>
|
||||||
|
deleteExpenseItemHandler(
|
||||||
|
kandangExpenseIdx,
|
||||||
|
expenseIdx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon='material-symbols:delete-outline-rounded'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type !== 'detail' && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
color='success'
|
||||||
|
onClick={() => addExpenseItemHandler(kandangExpenseIdx)}
|
||||||
|
className='w-fit mx-auto'
|
||||||
|
>
|
||||||
|
<Icon icon='ic:round-plus' width={24} height={24} />{' '}
|
||||||
|
Tambah
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpenseRequestKandangDetailExpense;
|
||||||
@@ -55,3 +55,26 @@ export const PURCHASE_ORDER_APPROVAL_LINE: ApprovalLine = [
|
|||||||
step_name: 'Selesai',
|
step_name: 'Selesai',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const EXPENSE_REQUEST_APPROVAL_LINE: ApprovalLine = [
|
||||||
|
{
|
||||||
|
step_number: 1,
|
||||||
|
step_name: 'Pengajuan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step_number: 2,
|
||||||
|
step_name: 'Approval Manager Area',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step_number: 3,
|
||||||
|
step_name: 'Approval Finance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step_number: 4,
|
||||||
|
step_name: 'Realisasi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step_number: 5,
|
||||||
|
step_name: 'Selesai',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
|
|||||||
icon: 'mdi:attach-money',
|
icon: 'mdi:attach-money',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Biaya Operasional',
|
||||||
|
link: '/expense',
|
||||||
|
icon: 'uil:wallet',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Persediaan',
|
title: 'Persediaan',
|
||||||
link: '/inventory',
|
link: '/inventory',
|
||||||
|
|||||||
@@ -100,3 +100,23 @@ export function getByPath<T, D = undefined>(
|
|||||||
|
|
||||||
return cur as D;
|
return cur as D;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const convertRowSelectionArrToObj = (
|
||||||
|
rowSelectionArr: string[] | number[]
|
||||||
|
) => {
|
||||||
|
const result: Record<string | number, boolean> = {};
|
||||||
|
|
||||||
|
rowSelectionArr.forEach((item) => {
|
||||||
|
result[item] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertRowSelectionObjToArr = (
|
||||||
|
rowSelection: string[] | number[]
|
||||||
|
) => {
|
||||||
|
const result = Object.keys(rowSelection).map(Number);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { FormikContextType, getIn, setIn } from 'formik';
|
||||||
|
|
||||||
|
function spliceArray<T>(arr: T[] | undefined, index: number) {
|
||||||
|
const a = Array.isArray(arr) ? arr.slice() : [];
|
||||||
|
if (index >= 0 && index < a.length) a.splice(index, 1);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one item from an array field and also trim Formik's errors & touched
|
||||||
|
* at the SAME index to keep everything aligned.
|
||||||
|
*
|
||||||
|
* @param formik - your useFormik instance
|
||||||
|
* @param arrayPath - path to the array field, e.g. "kandangExpenses[0].expenses"
|
||||||
|
* @param index - the index to remove
|
||||||
|
* @param validateAfter - optional: revalidate after removal (default false)
|
||||||
|
*/
|
||||||
|
export async function removeArrayItemAndSync<FormValues>(
|
||||||
|
formik: FormikContextType<FormValues>,
|
||||||
|
arrayPath: string,
|
||||||
|
index: number,
|
||||||
|
validateAfter: boolean = false
|
||||||
|
) {
|
||||||
|
// 1) VALUES: remove at index
|
||||||
|
const currValues = getIn(formik.values, arrayPath);
|
||||||
|
const nextValues = spliceArray(currValues, index);
|
||||||
|
formik.setFieldValue(arrayPath, nextValues, false);
|
||||||
|
|
||||||
|
// 2) ERRORS: remove the same index (if array exists)
|
||||||
|
const currErrors = getIn(formik.errors, arrayPath);
|
||||||
|
if (Array.isArray(currErrors)) {
|
||||||
|
const nextErrors = spliceArray(currErrors, index);
|
||||||
|
formik.setErrors(setIn(formik.errors, arrayPath, nextErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) TOUCHED: remove the same index (if array exists)
|
||||||
|
const currTouched = getIn(formik.touched, arrayPath);
|
||||||
|
if (Array.isArray(currTouched)) {
|
||||||
|
const nextTouched = spliceArray(currTouched, index);
|
||||||
|
formik.setTouched(setIn(formik.touched, arrayPath, nextTouched), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) (optional) revalidate to rebuild a perfectly clean error tree
|
||||||
|
if (validateAfter) {
|
||||||
|
const newErrors = await formik.validateForm();
|
||||||
|
formik.setErrors(newErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
+54
@@ -0,0 +1,54 @@
|
|||||||
|
import { BaseApproval, BaseMetadata } from '@/types/api/api-general';
|
||||||
|
import { Location } from '@/types/api/master-data/location';
|
||||||
|
import { Kandang } from '@/types/api/master-data/kandang';
|
||||||
|
import { Supplier } from '@/types/api/master-data/supplier';
|
||||||
|
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||||
|
|
||||||
|
export type BaseExpense = {
|
||||||
|
id: number;
|
||||||
|
reference_number: string;
|
||||||
|
po_number?: string;
|
||||||
|
location: Location;
|
||||||
|
transaction_date: string;
|
||||||
|
realization_date?: string;
|
||||||
|
kandangs: Kandang[];
|
||||||
|
vendor: Supplier;
|
||||||
|
request_documents: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
kandang_expenses: {
|
||||||
|
kandang: Kandang;
|
||||||
|
expenses: {
|
||||||
|
nonstock: Nonstock;
|
||||||
|
total_quantity: number;
|
||||||
|
total_expense: number;
|
||||||
|
notes?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
nominal: number;
|
||||||
|
paid?: number;
|
||||||
|
remaining_cost?: number;
|
||||||
|
approval: BaseApproval;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Expense = BaseMetadata & BaseExpense;
|
||||||
|
|
||||||
|
export type CreateExpensePayload = {
|
||||||
|
locationId: number;
|
||||||
|
transaction_date: string;
|
||||||
|
kandangIds: number[];
|
||||||
|
vendorId: number;
|
||||||
|
request_documents: File[];
|
||||||
|
kandang_expenses: {
|
||||||
|
kandangId: number;
|
||||||
|
expenses: {
|
||||||
|
nonstockId: number;
|
||||||
|
total_quantity: number;
|
||||||
|
total_expense: number;
|
||||||
|
notes?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateExpensePayload = CreateExpensePayload;
|
||||||
Reference in New Issue
Block a user