diff --git a/package-lock.json b/package-lock.json
index 2cac4bc7..f64e3a8f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
+ "react-dropzone": "^14.3.8",
"react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2",
@@ -2524,6 +2525,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3744,6 +3754,18 @@
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5805,6 +5827,23 @@
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
diff --git a/package.json b/package.json
index 033c2963..c2f4f4e6 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0",
+ "react-dropzone": "^14.3.8",
"react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2",
diff --git a/src/app/expense/add/page.tsx b/src/app/expense/add/page.tsx
new file mode 100644
index 00000000..afa40f48
--- /dev/null
+++ b/src/app/expense/add/page.tsx
@@ -0,0 +1,11 @@
+import ExpenseRequestForm from '@/components/pages/expense/form/ExpenseRequestForm';
+
+const AddExpense = () => {
+ return (
+
+
+
+ );
+};
+
+export default AddExpense;
diff --git a/src/app/expense/detail/edit/page.tsx b/src/app/expense/detail/edit/page.tsx
new file mode 100644
index 00000000..b37fdb8f
--- /dev/null
+++ b/src/app/expense/detail/edit/page.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+ {isLoadingExpense && (
+
+ )}
+
+ {!isLoadingExpense && isResponseSuccess(expense) && (
+
+ )}
+
+ );
+};
+
+export default ExpenseEditPage;
diff --git a/src/app/expense/detail/layout.tsx b/src/app/expense/detail/layout.tsx
new file mode 100644
index 00000000..7220dfa1
--- /dev/null
+++ b/src/app/expense/detail/layout.tsx
@@ -0,0 +1,11 @@
+import SuspenseHelper from '@/components/helper/SuspenseHelper';
+
+const Layout = ({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) => {
+ return {children};
+};
+
+export default Layout;
diff --git a/src/app/expense/detail/page.tsx b/src/app/expense/detail/page.tsx
new file mode 100644
index 00000000..a0d90f70
--- /dev/null
+++ b/src/app/expense/detail/page.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (!isLoadingExpense && (!expense || isResponseError(expense))) {
+ router.replace('/404');
+ return;
+ }
+
+ return (
+
+ {isLoadingExpense && (
+
+ )}
+
+ {!isLoadingExpense && isResponseSuccess(expense) && (
+
+ )}
+
+ );
+};
+
+export default ExpenseDetailPage;
diff --git a/src/app/expense/page.tsx b/src/app/expense/page.tsx
new file mode 100644
index 00000000..d6b00286
--- /dev/null
+++ b/src/app/expense/page.tsx
@@ -0,0 +1,11 @@
+import ExpensesTable from '@/components/pages/expense/ExpensesTable';
+
+const Expense = () => {
+ return (
+
+ );
+};
+
+export default Expense;
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 7cad5b58..2f209ece 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -3,7 +3,7 @@ import Link from 'next/link';
import { cn } from '@/lib/helper';
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';
color?: Color;
href?: string;
diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx
index 7317b038..a85c1f10 100644
--- a/src/components/input/DateInput.tsx
+++ b/src/components/input/DateInput.tsx
@@ -201,6 +201,7 @@ const DateInput = ({
{label}
{required && (
+ {' '}
*
)}
diff --git a/src/components/input/DropFileInput.tsx b/src/components/input/DropFileInput.tsx
new file mode 100644
index 00000000..e146a994
--- /dev/null
+++ b/src/components/input/DropFileInput.tsx
@@ -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 = ({
+ 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 (
+
+
+ {label && (
+
+ )}
+
+
+
+ {caption && (
+
+ {caption}
+
+ )}
+
+
+ {!isError && bottomLabel && (
+
+ {bottomLabel}
+
+ )}
+ {isError && (
+
+ {errorMessage}
+
+ )}
+
+
+ {values && values.length > 0 && (
+
+ {values.map((file, idx) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default DropFileInput;
diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx
index 8fa8b555..d35e7589 100644
--- a/src/components/input/SelectInput.tsx
+++ b/src/components/input/SelectInput.tsx
@@ -179,9 +179,12 @@ const SelectInput = (props: SelectInputProps) => {
>
{label}
{required && (
-
- *
-
+ <>
+ {' '}
+
+ *
+
+ >
)}
)}
diff --git a/src/components/modal/ConfirmationModal.tsx b/src/components/modal/ConfirmationModal.tsx
index 683345f5..00b63c86 100644
--- a/src/components/modal/ConfirmationModal.tsx
+++ b/src/components/modal/ConfirmationModal.tsx
@@ -1,30 +1,23 @@
'use client';
-import { RefObject } from 'react';
+import { MouseEventHandler, RefObject, useState } from 'react';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
-import Button from '@/components/Button';
+import Button, { ButtonProps } from '@/components/Button';
import { cn } from '@/lib/helper';
-import { Color } from '@/types/theme';
export interface ConfirmationModalProps {
ref: RefObject;
type?: 'info' | 'success' | 'error';
text?: string;
closeOnBackdrop?: boolean;
- primaryButton?: {
+ primaryButton?: ButtonProps & {
text?: string;
- color?: Color;
- isLoading?: boolean;
- onClick?: () => void;
};
- secondaryButton?: {
+ secondaryButton?: ButtonProps & {
text?: string;
- color?: Color;
- isLoading?: boolean;
- onClick?: () => void;
};
className?: {
modal?: string;
@@ -43,10 +36,22 @@ const ConfirmationModal = ({
className,
children,
}: ConfirmationModalProps) => {
+ const [isPrimaryButtonLoading, setIsPrimaryButtonLoading] = useState(false);
+
const closeModalHandler = () => {
ref.current?.close();
};
+ const primaryButtonClickHandler: MouseEventHandler<
+ HTMLButtonElement
+ > = async (event) => {
+ setIsPrimaryButtonLoading(true);
+
+ await primaryButton?.onClick?.(event);
+
+ setIsPrimaryButtonLoading(false);
+ };
+
return (
@@ -97,10 +102,15 @@ const ConfirmationModal = ({
{secondaryButton && secondaryButton.text && (