Compare commits

..

22 Commits

Author SHA1 Message Date
Giovanni Gabriel Septriadi a314a62f1f Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!484
2026-05-19 06:48:45 +00:00
Giovanni Gabriel Septriadi 2bf5f36a77 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!474
2026-05-12 09:31:27 +00:00
Giovanni Gabriel Septriadi 989e30fbed Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!468
2026-05-11 08:32:23 +00:00
Adnan Zahir 40139cd636 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!456
2026-05-05 14:12:28 +07:00
Adnan Zahir 8c03f10043 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!454
2026-05-02 13:43:29 +07:00
Adnan Zahir 89a6e51b48 Merge branch 'development' into 'production'
Revert "fixing devops"

See merge request mbugroup/lti-web-client!449
2026-04-30 09:54:39 +07:00
Adnan Zahir f6727dc4dc Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!446
2026-04-29 12:53:07 +07:00
Adnan Zahir 1284b22345 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!441
2026-04-28 13:43:32 +07:00
Adnan Zahir f73ea182ae Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!435
2026-04-26 00:13:15 +07:00
Adnan Zahir 047266b6d8 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!432
2026-04-25 14:46:53 +07:00
Adnan Zahir 6b95edfb72 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!427
2026-04-23 12:38:36 +07:00
Adnan Zahir 4b62b02a13 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!420
2026-04-22 13:12:50 +07:00
Adnan Zahir 12a50c6100 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!410
2026-04-20 08:24:51 +07:00
Adnan Zahir 09537d84d0 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!407
2026-04-18 09:41:09 +07:00
Adnan Zahir 1aa2ca9b31 Merge branch 'development' into 'production'
refactor(FE-add-param): Update MarketingFilter to refine API calls and

See merge request mbugroup/lti-web-client!400
2026-04-14 13:20:27 +07:00
Adnan Zahir c87107b4ee Merge branch 'development' into 'production'
refactor(FE-load-more-option): Add infinite scroll to location and

See merge request mbugroup/lti-web-client!396
2026-04-13 14:08:57 +07:00
Adnan Zahir 55b13988bf Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!393
2026-04-13 11:17:24 +07:00
Adnan Zahir 19033278b3 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!388
2026-04-11 14:13:02 +07:00
Adnan Zahir 4a6ac8a57d Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!382
2026-04-09 15:36:40 +07:00
Adnan Zahir 2b9847e1a9 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!378
2026-04-08 13:39:17 +07:00
Adnan Zahir 167769a711 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!375
2026-04-07 22:56:27 +07:00
Adnan Zahir 417dbba458 Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!373
2026-04-07 16:53:36 +07:00
22 changed files with 987 additions and 2203 deletions
-3
View File
@@ -48,6 +48,3 @@ next-env.d.ts
# rtk # rtk
rtk.exe rtk.exe
# local specs
/local-specs
+39 -111
View File
@@ -80,124 +80,76 @@ Data tables across all modules (master-data, inventory, finance, purchase, etc.)
- Apply to: search handlers, filter form submissions, reset handlers - Apply to: search handlers, filter form submissions, reset handlers
3. **Create custom formikResetHandler function** 3. **Create custom formikResetHandler function**
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults) - Clear each filter with `updateFilter(fieldName, defaultValue, true)`
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts) - Call `formik.resetForm({ values: { ...defaults } })`
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults - Close the modal at the end
- Call `filterModal.closeModal()` at the end - Attach to both button `onClick` and form `onReset` handler
- Attach to form `onReset` handler (not `formik.handleReset`)
**Optimization: Avoid useCallback for simple handlers**
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
- Simple pass-through handlers don't need it:
```tsx ```tsx
const formikResetHandler = () => { // ✅ Good: Simple handler without useCallback
resetFilter(); const handleFilterChange = (val) => setFieldValue('location', val);
setHasDateError(false);
if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); }
formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } });
filterModal.closeModal();
};
// ...
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
```
**Optimization: Avoid useCallback and useMemo for trivial operations** // ❌ Avoid: Unnecessary useCallback overhead
const handleFilterChange = useCallback(
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
- Simple derivations and pass-through handlers don't need them:
```tsx
// ✅ Good: plain derivation
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
const meta =
isResponseSuccess(response) && response.meta ? response.meta : null;
// ❌ Avoid: useMemo for trivial conditional access
const data = useMemo(
() => (isResponseSuccess(response) ? (response.data ?? []) : []),
[response]
);
// ✅ Good: simple handler
const handleChange = (val) => setFieldValue('location', val);
// ❌ Avoid: unnecessary useCallback
const handleChange = useCallback(
(val) => setFieldValue('location', val), (val) => setFieldValue('location', val),
[setFieldValue] [setFieldValue]
); );
``` ```
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
**Best practice: Store OptionType objects directly, not IDs** **Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object (or `OptionType[]` for multi-select) in both formik state and tableFilterState. `useTableFilter`'s `serializeValue` handles serialization automatically: For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
- `OptionType<T>` → serialized as `String(value)` in the query string
- `OptionType<T>[]` → serialized as comma-separated values (CSV) — ideal for multi-select API params like `customer_ids`, `sales_ids`
```tsx ```tsx
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{ const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string; search: string;
customers: OptionType<number>[]; // multi-select → serializes as CSV locationFilter?: OptionType<string>;
location?: OptionType<string>; // single-select → serializes as value string picFilter?: OptionType<string>;
filterBy?: OptionType<string>; // single-select radio
}>({ }>({
initial: { initial: {
search: '', search: '',
customers: [], locationFilter: undefined,
location: undefined, picFilter: undefined
filterBy: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
customers: 'customer_ids', // serializes OptionType[] → "1,2,3" locationFilter: 'location_id',
location: 'location_id', // serializes OptionType → "abc" picFilter: 'pic_id',
filterBy: 'filter_by',
}, },
persist: true, persist: true,
storeName: 'my-table', storeName: 'kandangs-table',
}); });
// Initialize formik directly from tableFilterState (no hardcoded defaults) // Initialize formik with tableFilterState values (now typed OptionType objects)
const formik = useFormik({ const formik = useFormik<KandangFilterType>({
initialValues: { initialValues: {
customers: tableFilterState.customers, location: tableFilterState.locationFilter,
location: tableFilterState.location, pic: tableFilterState.picFilter,
filterBy: tableFilterState.filterBy,
}, },
... ...
}); });
// Use formik values directly — no computed helpers needed // Handlers store the complete OptionType, not just the ID
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} /> const handleFilterLocationChange = useCallback(
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} /> (val) => setFieldValue('location', val),
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} /> [setFieldValue]
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
``` ```
**Filter field naming convention**
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
```tsx
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
// ❌ Avoid: unnecessary ref indirection
const handleFilterModalOpenRef = useRef(() => {});
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
// ✅ Correct: pass openModal directly
<ButtonFilter onClick={filterModal.openModal} ... />
```
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
**Apply this pattern to:** **Apply this pattern to:**
- Any data table component across any module that needs persistent filters - Any data table component across any module that needs persistent filters
@@ -207,31 +159,7 @@ Include `filterModal.openModal` in the `useEffect` deps array when it's used ins
**Reference implementations:** **Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` - `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range - Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
## SWR fetch pattern
Use `FinanceApi.getAllFetcher` (or the relevant service's `getAllFetcher`) when the result type matches the service generic `T`. When it differs, use `httpClientFetcher` with an explicit type:
```tsx
// ✅ Same type as service generic — use getAllFetcher
const { data } = useSWR(
`${Api.basePath}${getTableFilterQueryString()}`,
Api.getAllFetcher
);
// ✅ Different type — use httpClientFetcher with explicit useSWR type
const { data } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
```
Always name the `toQueryString` alias `getTableFilterQueryString` when destructuring from `useTableFilter`.
## Server-side sorting pattern ## Server-side sorting pattern
-11
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
-11
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+2 -5
View File
@@ -6,7 +6,6 @@ export interface TabItem {
label: ReactNode; label: ReactNode;
content?: ReactNode; content?: ReactNode;
disabled?: boolean; disabled?: boolean;
hide?: boolean;
} }
export interface TabsProps export interface TabsProps
@@ -123,8 +122,7 @@ const Tabs = ({
> >
<div className={getSideContentClasses()}> <div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}> <div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled, hide }) => {tabs.map(({ id, label, disabled }) => (
hide ? null : (
<button <button
key={id} key={id}
role='tab' role='tab'
@@ -134,8 +132,7 @@ const Tabs = ({
> >
{label} {label}
</button> </button>
) ))}
)}
</div> </div>
{sideContent && sideContent} {sideContent && sideContent}
</div> </div>
+1 -62
View File
@@ -29,7 +29,7 @@ import {
FINANCE_TRANSACTION_TYPE_OPTIONS, FINANCE_TRANSACTION_TYPE_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
@@ -39,7 +39,6 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/dropdown/Dropdown';
import { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
@@ -234,7 +233,6 @@ const FinanceTable = () => {
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null); const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null); const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isExportLoading, setIsExportLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
@@ -554,20 +552,6 @@ const FinanceTable = () => {
filterModal.openModal(); filterModal.openModal();
}; };
const exportToExcel = async () => {
setIsExportLoading(true);
try {
await FinanceApi.exportToExcel(getTableFilterQueryString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data finance.')
);
} finally {
setIsExportLoading(false);
}
};
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -775,51 +759,6 @@ const FinanceTable = () => {
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Ekspor</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcel}
isLoading={isExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
</Dropdown>
</div> </div>
</div> </div>
@@ -56,9 +56,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [productQtyErrorShown, setProductQtyErrorShown] = useState(false); const [productQtyErrorShown, setProductQtyErrorShown] = useState(false);
const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false); const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const productStockCacheRef = useRef<
Map<number, { quantity: number; transfer_available_qty?: number }>
>(new Map());
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createMovementHandler = useCallback( const createMovementHandler = useCallback(
@@ -340,7 +337,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId && prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null prevSourceWarehouseId !== null
) { ) {
productStockCacheRef.current = new Map();
formik.setFieldValue('products', [ formik.setFieldValue('products', [
{ {
product: null, product: null,
@@ -403,15 +399,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: []; : [];
}, [productWarehouses]); }, [productWarehouses]);
useEffect(() => {
productWarehouseOptions.forEach((pw) => {
productStockCacheRef.current.set(pw.product_id, {
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
});
});
}, [productWarehouseOptions]);
// ===== HELPER FUNCTIONS ===== // ===== HELPER FUNCTIONS =====
const isRepeaterInputError = <T extends 'products' | 'deliveries'>( const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T, arrayName: T,
@@ -853,12 +840,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getAvailableStock = useCallback( const getAvailableStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const live = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
if (live) return live.transfer_available_qty ?? live.quantity ?? 0;
const cached = productStockCacheRef.current.get(productId); return (
return cached?.transfer_available_qty ?? cached?.quantity ?? 0; productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
}, },
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
@@ -866,25 +856,20 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getTotalStock = useCallback( const getTotalStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const live = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
if (live) return live.quantity ?? 0; return productWarehouse?.quantity ?? 0;
return productStockCacheRef.current.get(productId)?.quantity ?? 0;
}, },
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
const hasAvailableQty = useCallback( const hasAvailableQty = useCallback(
(productId: number) => { (productId: number) => {
const live = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
if (live) return live.transfer_available_qty !== undefined; return productWarehouse?.transfer_available_qty !== undefined;
return (
productStockCacheRef.current.get(productId)?.transfer_available_qty !==
undefined
);
}, },
[productWarehouseOptions] [productWarehouseOptions]
); );
@@ -10,7 +10,6 @@ import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInput from '@/components/input/SelectInput'; import SelectInput from '@/components/input/SelectInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase'; import { PurchaseFilter } from '@/types/api/purchase/purchase';
@@ -25,20 +24,10 @@ import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
const filterByOptions: OptionType<string>[] = [
{ value: 'po_date', label: 'Tanggal PO' },
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'due_date', label: 'Tanggal Jatuh Tempo' },
{ value: 'created_at', label: 'Tanggal Dibuat' },
];
interface PurchaseFilterModalProps { interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
initialValues?: { initialValues?: {
poDate: string; poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: OptionType<number>[]; category: OptionType<number>[];
status: OptionType<string>[]; status: OptionType<string>[];
supplier: OptionType<number> | null; supplier: OptionType<number> | null;
@@ -62,7 +51,6 @@ const PurchaseFilterModal = ({
}, [ref]); }, [ref]);
// ===== DATE ERROR STATE ===== // ===== DATE ERROR STATE =====
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT ===== // ===== CLEANUP TOAST ON UNMOUNT =====
@@ -151,9 +139,6 @@ const PurchaseFilterModal = ({
const formik = useFormik<{ const formik = useFormik<{
poDate: string; poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: { label: string; value: number }[]; category: { label: string; value: number }[];
status: { label: string; value: string }[]; status: { label: string; value: string }[];
supplier: OptionType<number> | null; supplier: OptionType<number> | null;
@@ -165,9 +150,6 @@ const PurchaseFilterModal = ({
// enableReinitialize: true, // enableReinitialize: true,
initialValues: initialValues || { initialValues: initialValues || {
poDate: '', poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [], category: [],
status: [], status: [],
supplier: null, supplier: null,
@@ -248,17 +230,9 @@ const PurchaseFilterModal = ({
}; };
const formikResetHandler = useCallback(() => { const formikResetHandler = useCallback(() => {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
resetForm({ resetForm({
values: { values: {
poDate: '', poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [], category: [],
status: [], status: [],
supplier: null, supplier: null,
@@ -272,56 +246,7 @@ const PurchaseFilterModal = ({
setSelectedLocationId(''); setSelectedLocationId('');
onReset?.(); onReset?.();
closeModalHandler(); closeModalHandler();
}, [resetForm, onReset, closeModalHandler, dateErrorShown]); }, [resetForm, onReset, closeModalHandler]);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const formikSubmitHandler = useCallback(async () => { const formikSubmitHandler = useCallback(async () => {
await submitForm(); await submitForm();
@@ -362,44 +287,6 @@ const PurchaseFilterModal = ({
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'> <div className='flex flex-col'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
/>
<DateInput <DateInput
label='PO Date' label='PO Date'
name='poDate' name='poDate'
@@ -549,7 +436,6 @@ const PurchaseFilterModal = ({
<Button <Button
type='button' type='button'
onClick={formikSubmitHandler} onClick={formikSubmitHandler}
disabled={hasDateError}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm' className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
> >
Apply Filter Apply Filter
+27 -235
View File
@@ -28,7 +28,7 @@ import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal
import Dropdown from '@/components/dropdown/Dropdown'; import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper'; import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -41,9 +41,6 @@ type PurchaseTableFilters = {
search: string; search: string;
sort_by: string; sort_by: string;
order_by: string; order_by: string;
start_date: string;
end_date: string;
filter_by: string;
po_date: string; po_date: string;
approval_status: string; approval_status: string;
product_category_id: string; product_category_id: string;
@@ -180,9 +177,6 @@ const PurchaseTable = () => {
search: '', search: '',
sort_by: '', sort_by: '',
order_by: '', order_by: '',
start_date: '',
end_date: '',
filter_by: '',
po_date: '', po_date: '',
approval_status: '', approval_status: '',
product_category_id: '', product_category_id: '',
@@ -203,9 +197,6 @@ const PurchaseTable = () => {
pageSize: 'limit', pageSize: 'limit',
sort_by: 'sort_by', sort_by: 'sort_by',
order_by: 'sort_order', order_by: 'sort_order',
start_date: 'start_date',
end_date: 'end_date',
filter_by: 'filter_by',
po_date: 'po_date', po_date: 'po_date',
approval_status: 'approval_status', approval_status: 'approval_status',
product_category_id: 'product_category_id', product_category_id: 'product_category_id',
@@ -306,11 +297,36 @@ const PurchaseTable = () => {
); );
}, },
}, },
{
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
{ {
accessorKey: 'requester_name', accessorKey: 'requester_name',
header: 'Nama Pengaju', header: 'Nama Pengaju',
cell: (props) => props.row.original.requester_name || '-', cell: (props) => props.row.original.requester_name || '-',
}, },
{
accessorKey: 'products',
header: 'Produk',
cell: (props) => {
const products = props.row.original.products;
if (!products || products.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{products.map((product, index) => (
<li key={index}>{product.name}</li>
))}
</ul>
);
},
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{ {
accessorKey: 'po_date', accessorKey: 'po_date',
header: 'Tgl. PO', header: 'Tgl. PO',
@@ -348,202 +364,6 @@ const PurchaseTable = () => {
return `${diffDays} hari`; return `${diffDays} hari`;
}, },
}, },
{
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
{
accessorKey: 'location',
header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-',
},
{
accessorKey: 'warehouse',
header: 'Gudang',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.warehouse?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'products',
header: 'Produk',
cell: (props) => {
const products = props.row.original.products;
if (!products || products.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{products.map((product, index) => (
<li key={index}>{product.name}</li>
))}
</ul>
);
},
},
{
accessorKey: 'total_qty',
header: 'Kuantitas',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatNumber(item.total_qty ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'uom',
header: 'Satuan',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.product?.uom?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'price',
header: 'Harga',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatCurrency(item.price ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'total_price',
header: 'Total Harga',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatCurrency(item.total_price ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'products_total',
header: 'Total Harga Produk',
cell: (props) => formatCurrency(props.row.original.products_total ?? 0),
},
{
accessorKey: 'expedition_vendor',
header: 'Vendor Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.expedition_vendor?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'expedition_qty',
header: 'Qty Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.expedition_qty != null
? formatNumber(item.expedition_qty)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'transport_per_item',
header: 'Harga Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.transport_per_item != null
? formatCurrency(item.transport_per_item)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'item_expedition_total',
header: 'Total Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.expedition_total != null
? formatCurrency(item.expedition_total)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'expedition_total',
header: 'Total Ekspedisi Semua Produk',
cell: (props) => formatCurrency(props.row.original.expedition_total ?? 0),
},
{
accessorKey: 'grand_total_all',
header: 'Grand Total All',
cell: (props) => formatCurrency(props.row.original.grand_total_all ?? 0),
},
{ {
accessorKey: 'status', accessorKey: 'status',
header: 'Status Approval', header: 'Status Approval',
@@ -590,11 +410,6 @@ const PurchaseTable = () => {
); );
}, },
}, },
{
accessorKey: 'notes',
header: 'Notes',
cell: (props) => props.row.original.notes || '-',
},
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: 'Tanggal Dibuat', header: 'Tanggal Dibuat',
@@ -661,9 +476,6 @@ const PurchaseTable = () => {
const filterSubmitHandler = (values: PurchaseFilter) => { const filterSubmitHandler = (values: PurchaseFilter) => {
setFilters({ setFilters({
start_date: values.start_date || '',
end_date: values.end_date || '',
filter_by: values.filterBy?.value || '',
po_date: values.poDate, po_date: values.poDate,
product_category_id: values.category.join(','), product_category_id: values.category.join(','),
product_category_name: product_category_name:
@@ -688,9 +500,6 @@ const PurchaseTable = () => {
const filterResetHandler = () => { const filterResetHandler = () => {
setFilters({ setFilters({
start_date: '',
end_date: '',
filter_by: '',
po_date: '', po_date: '',
product_category_id: '', product_category_id: '',
product_category_name: '', product_category_name: '',
@@ -709,13 +518,6 @@ const PurchaseTable = () => {
}; };
const purchaseFilterInitialValues = useMemo(() => { const purchaseFilterInitialValues = useMemo(() => {
const filterByLabelMap: Record<string, string> = {
po_date: 'Tanggal PO',
received_date: 'Tanggal Terima',
due_date: 'Tanggal Jatuh Tempo',
created_at: 'Tanggal Dibuat',
};
const categoryIds = tableFilterState.product_category_id const categoryIds = tableFilterState.product_category_id
? tableFilterState.product_category_id ? tableFilterState.product_category_id
.split(',') .split(',')
@@ -737,16 +539,6 @@ const PurchaseTable = () => {
return { return {
poDate: tableFilterState.po_date, poDate: tableFilterState.po_date,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
filterBy: tableFilterState.filter_by
? {
value: tableFilterState.filter_by,
label:
filterByLabelMap[tableFilterState.filter_by] ||
tableFilterState.filter_by,
}
: undefined,
category: categoryIds.map((value, index) => ({ category: categoryIds.map((value, index) => ({
value: Number(value), value: Number(value),
label: categoryLabels[index] || value, label: categoryLabels[index] || value,
@@ -914,7 +706,7 @@ const PurchaseTable = () => {
'project_flock_name', 'project_flock_name',
'project_flock_kandang_name', 'project_flock_kandang_name',
]} ]}
fieldGroups={[['start_date', 'end_date']]} fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal} onClick={filterModal.openModal}
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
@@ -1,38 +1,26 @@
'use client'; 'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab'; import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab';
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab'; import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
const VALID_TAB_IDS = ['operational-expense', 'depreciation'];
const ReportExpenseTabs = () => { const ReportExpenseTabs = () => {
const router = useRouter(); const [activeTabId, setActiveTabId] = useState<string>('1');
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') ?? 'operational-expense';
const activeTabId = VALID_TAB_IDS.includes(tabParam)
? tabParam
: 'operational-expense';
const tabActions = useTabActionsStore((state) => state.tabActions); const tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [ const tabs = [
{ {
id: 'operational-expense', id: '1',
label: 'Laporan Biaya Operasional', label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'operational-expense'} />, content: <ReportExpenseTab tabId={'1'} />,
}, },
{ {
id: 'depreciation', id: '2',
label: 'Laporan Depresiasi', label: 'Laporan Depresiasi',
content: <ReportDepreciationTab tabId={'depreciation'} />, content: <ReportDepreciationTab tabId={'2'} />,
}, },
]; ];
@@ -42,7 +30,7 @@ const ReportExpenseTabs = () => {
tabs={tabs} tabs={tabs}
variant='boxed' variant='boxed'
activeTabId={activeTabId} activeTabId={activeTabId}
onTabChange={handleTabChange} onTabChange={setActiveTabId}
className={{ className={{
tabHeaderWrapper: tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10', 'justify-between items-center p-3 border-b border-base-content/10',
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject } from 'react'; import { RefObject, useEffect, useMemo, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as yup from 'yup'; import * as yup from 'yup';
@@ -20,34 +20,32 @@ import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
export type ReportDepreciationFilterValues = { export type ReportDepreciationFilterValues = {
area?: OptionType<string>; area_id: string | null;
location?: OptionType<string>; location_id: string | null;
projectFlock?: OptionType<string>; project_flock_id: string | null;
period: string | null; period: string | null;
}; };
export const ReportDepreciationFilterSchema = yup.object({ export const ReportDepreciationFilterSchema = yup.object({
area: yup.mixed<OptionType<string>>().optional(), area_id: yup.string().nullable(),
location: yup.mixed<OptionType<string>>().optional(), location_id: yup.string().nullable(),
projectFlock: yup.mixed<OptionType<string>>().optional(), project_flock_id: yup.string().nullable(),
period: yup.string().nullable().required('Periode wajib dipilih'), period: yup.string().nullable().required('Periode wajib dipilih'),
}); }) as yup.ObjectSchema<ReportDepreciationFilterValues>;
interface ReportDepreciationFilterModalProps { interface ReportDepreciationFilterModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
initialValues?: Partial<ReportDepreciationFilterValues>; initialValues?: ReportDepreciationFilterValues;
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void; onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
onReset?: () => void; onReset?: () => void;
} }
const defaultInitialValues: ( const defaultInitialValues: ReportDepreciationFilterValues = {
initialValues?: Partial<ReportDepreciationFilterValues> area_id: null,
) => ReportDepreciationFilterValues = (initialValues) => ({ location_id: null,
area: undefined, project_flock_id: null,
location: undefined, period: null,
projectFlock: undefined, };
period: initialValues?.period ?? null,
});
const ReportDepreciationFilterModal = ({ const ReportDepreciationFilterModal = ({
ref, ref,
@@ -55,19 +53,22 @@ const ReportDepreciationFilterModal = ({
onSubmit, onSubmit,
onReset, onReset,
}: ReportDepreciationFilterModalProps) => { }: ReportDepreciationFilterModalProps) => {
const [selectedAreaId, setSelectedAreaId] = useState<string | undefined>(
initialValues?.area_id || undefined
);
const [selectedLocationId, setSelectedLocationId] = useState<
string | undefined
>(initialValues?.location_id || undefined);
useEffect(() => {
setSelectedAreaId(initialValues?.area_id || undefined);
setSelectedLocationId(initialValues?.location_id || undefined);
}, [initialValues?.area_id, initialValues?.location_id]);
const closeModalHandler = () => { const closeModalHandler = () => {
ref.current?.close(); ref.current?.close();
}; };
const formik = useFormik<ReportDepreciationFilterValues>({
initialValues: { ...defaultInitialValues(initialValues), ...initialValues },
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
});
const { const {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
options: areaOptions, options: areaOptions,
@@ -81,7 +82,7 @@ const ReportDepreciationFilterModal = ({
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations, loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', { } = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: String(formik.values.area?.value ?? ''), area_id: selectedAreaId || '',
}); });
const { const {
@@ -95,35 +96,73 @@ const ReportDepreciationFilterModal = ({
'flock_name', 'flock_name',
'search', 'search',
{ {
location_id: String(formik.values.location?.value ?? ''), location_id: selectedLocationId || '',
} }
); );
const formikResetHandler = () => { const formik = useFormik<ReportDepreciationFilterValues>({
onReset?.(); initialValues: initialValues || defaultInitialValues,
formik.resetForm({ values: defaultInitialValues(initialValues) }); enableReinitialize: true,
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler(); closeModalHandler();
}; },
onReset: (_) => {
onReset?.();
closeModalHandler();
},
});
const areaValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const projectFlockValue = useMemo(() => {
if (!formik.values.project_flock_id) return null;
return (
projectFlockOptions.find(
(opt) => String(opt.value) === formik.values.project_flock_id
) || null
);
}, [formik.values.project_flock_id, projectFlockOptions]);
const areaChangeHandler = (val: OptionType | OptionType[] | null) => { const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
const area = const areaId = val && !Array.isArray(val) ? String(val.value) : null;
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('area', area); setSelectedAreaId(areaId || undefined);
formik.setFieldValue('location', undefined); formik.setFieldValue('area_id', areaId);
formik.setFieldValue('projectFlock', undefined); formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
setSelectedLocationId(undefined);
}; };
const locationChangeHandler = (val: OptionType | OptionType[] | null) => { const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const location = const locationId = val && !Array.isArray(val) ? String(val.value) : null;
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('location', location); setSelectedLocationId(locationId || undefined);
formik.setFieldValue('projectFlock', undefined); formik.setFieldValue('location_id', locationId);
formik.setFieldValue('project_flock_id', null);
}; };
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => { const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
const projectFlock = const projectFlockId =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined; val && !Array.isArray(val) ? String(val.value) : null;
formik.setFieldValue('projectFlock', projectFlock);
formik.setFieldValue('project_flock_id', projectFlockId);
}; };
return ( return (
@@ -135,7 +174,7 @@ const ReportDepreciationFilterModal = ({
> >
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formikResetHandler} onReset={formik.handleReset}
className='w-full flex flex-col' className='w-full flex flex-col'
> >
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'> <div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
@@ -160,7 +199,7 @@ const ReportDepreciationFilterModal = ({
label='Area' label='Area'
placeholder='Pilih Area' placeholder='Pilih Area'
options={areaOptions} options={areaOptions}
value={formik.values.area ?? null} value={areaValue}
onChange={areaChangeHandler} onChange={areaChangeHandler}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas} onMenuScrollToBottom={loadMoreAreas}
@@ -174,7 +213,7 @@ const ReportDepreciationFilterModal = ({
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={formik.values.location ?? null} value={locationValue}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations} onMenuScrollToBottom={loadMoreLocations}
@@ -188,7 +227,7 @@ const ReportDepreciationFilterModal = ({
label='Project Flock' label='Project Flock'
placeholder='Pilih Project Flock' placeholder='Pilih Project Flock'
options={projectFlockOptions} options={projectFlockOptions}
value={formik.values.projectFlock ?? null} value={projectFlockValue}
onChange={projectFlockChangeHandler} onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue} onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks} onMenuScrollToBottom={loadMoreProjectFlocks}
@@ -17,7 +17,6 @@ import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import { ReportDepreciation } from '@/types/api/report/report-expense'; import { ReportDepreciation } from '@/types/api/report/report-expense';
import { DepreciationReportApi } from '@/services/api/report/expense-report'; import { DepreciationReportApi } from '@/services/api/report/expense-report';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
@@ -33,27 +32,20 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
reset: resetFilter, reset: resetFilter,
} = useTableFilter<{ } = useTableFilter({
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string;
}>({
initial: { initial: {
area: undefined, area_id: '',
location: undefined, location_id: '',
projectFlock: undefined, project_flock_id: '',
period: formatDate(Date.now(), 'YYYY-MM-DD'), period: formatDate(Date.now(), 'YYYY-MM-DD'),
}, },
paramMap: { paramMap: {
pageSize: 'limit', pageSize: 'limit',
area: 'area_id', area_id: 'area_id',
location: 'location_id', location_id: 'location_id',
projectFlock: 'project_flock_id', project_flock_id: 'project_flock_id',
period: 'period', period: 'period',
}, },
persist: true,
storeName: 'report-depreciation-table',
}); });
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } = const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
@@ -117,7 +109,7 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
<ButtonFilter <ButtonFilter
values={tableFilterState} values={tableFilterState}
excludeFields={['page', 'pageSize']} excludeFields={['page', 'pageSize']}
onClick={filterModal.openModal} onClick={() => filterModal.openModal()}
variant='outline' variant='outline'
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
@@ -247,13 +239,12 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
initialValues={tableFilterState} initialValues={tableFilterState}
onReset={resetFilter} onReset={resetFilter}
onSubmit={(values) => { onSubmit={(values) => {
updateFilter('area', values.area, true); updateFilter('area_id', values.area_id ?? '');
updateFilter('location', values.location, true); updateFilter('location_id', values.location_id ?? '');
updateFilter('projectFlock', values.projectFlock, true); updateFilter('project_flock_id', values.project_flock_id ?? '');
updateFilter( updateFilter(
'period', 'period',
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '', values.period ? formatDate(values.period, 'YYYY-MM-DD') : ''
true
); );
}} }}
/> />
@@ -1,47 +1,25 @@
'use client'; 'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
import BalanceMonitoringTab from '@/components/pages/report/finance/tab/BalanceMonitoringTab';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
const VALID_TAB_IDS = [
'debt-supplier',
'customer-payment',
'balance-monitoring',
];
const FinanceTabs = () => { const FinanceTabs = () => {
const router = useRouter(); const [activeTabId, setActiveTabId] = useState<string>('1');
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') ?? 'debt-supplier';
const activeTabId = VALID_TAB_IDS.includes(tabParam)
? tabParam
: 'debt-supplier';
const tabActions = useTabActionsStore((state) => state.tabActions); const tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [ const tabs = [
{ {
id: 'debt-supplier', id: '1',
label: 'Rekapitulasi Hutang Ke Supplier', label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab tabId={'debt-supplier'} />, content: <DebtSupplierTab tabId={'1'} />,
}, },
{ {
id: 'customer-payment', id: '2',
label: 'Kontrol Pembayaran Customer', label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab tabId={'customer-payment'} />, content: <CustomerPaymentTab tabId={'2'} />,
},
{
id: 'balance-monitoring',
label: 'Monitoring Saldo',
content: <BalanceMonitoringTab tabId={'balance-monitoring'} />,
}, },
]; ];
@@ -51,7 +29,7 @@ const FinanceTabs = () => {
tabs={tabs} tabs={tabs}
variant='boxed' variant='boxed'
activeTabId={activeTabId} activeTabId={activeTabId}
onTabChange={handleTabChange} onTabChange={setActiveTabId}
className={{ className={{
tabHeaderWrapper: tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10', 'justify-between items-center p-3 border-b border-base-content/10',
@@ -1,602 +0,0 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { ColumnDef } from '@tanstack/react-table';
import { AxiosError } from 'axios';
import { FinanceApi } from '@/services/api/report/finance-report';
import { CustomerApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import Table from '@/components/Table';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
interface BalanceMonitoringTabProps {
tabId: string;
}
const filterByOptions: OptionType<string>[] = [
{ label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' },
{ label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' },
];
const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
salesPersons: OptionType<number>[];
filterBy?: OptionType<string>;
sort_by: string;
order_by: string;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
salesPersons: 'sales_ids',
filterBy: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
persist: true,
storeName: 'balance-monitoring-table',
});
// const sorting: SortingState = tableFilterState.sort_by
// ? [
// {
// id: tableFilterState.sort_by,
// desc: tableFilterState.order_by === 'desc',
// },
// ]
// : [];
// const handleSortingChange = (updater: Updater<SortingState>) => {
// const next = typeof updater === 'function' ? updater(sorting) : updater;
// if (next.length > 0) {
// updateFilter('sort_by', next[0].id, true);
// updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
// } else {
// updateFilter('sort_by', '', true);
// updateFilter('order_by', '', true);
// }
// };
const {
options: customerOptions,
setInputValue: setCustomerInput,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: salesOptions,
setInputValue: setSalesInput,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const formik = useFormik({
initialValues: {
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
},
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customers', values.customers, true);
updateFilter('salesPersons', values.salesPersons, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const { data: balanceMonitoringsResponse, isLoading } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess(
balanceMonitoringsResponse
)
? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? [])
: [];
const meta =
isResponseSuccess(balanceMonitoringsResponse) &&
balanceMonitoringsResponse.meta
? balanceMonitoringsResponse.meta
: null;
// Inject tab actions directly — no nested component, no remount cycle
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
</div>
);
}, [tabId, setTabActions, tableFilterState, filterModal.openModal]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const columns = useMemo(
(): ColumnDef<BalanceMonitoringRow>[] => [
{
header: 'No',
enableSorting: false,
cell: (props) =>
(tableFilterState.page - 1) * tableFilterState.pageSize +
props.row.index +
1,
},
{
header: 'Customer',
accessorKey: 'customer.name',
enableSorting: true,
id: 'customer_name',
cell: ({ row }) => row.original.customer.name,
},
{
header: 'Saldo Awal',
accessorKey: 'saldo_awal',
id: 'saldo_awal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.saldo_awal)}
</div>
),
},
{
header: 'Penjualan Ayam',
columns: [
{
header: 'Ekor',
accessorKey: 'penjualan_ayam.ekor',
id: 'penjualan_ayam_ekor',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.ekor)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_ayam.kg',
id: 'penjualan_ayam_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_ayam.nominal',
id: 'penjualan_ayam_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_ayam.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Telur',
columns: [
{
header: 'Butir',
accessorKey: 'penjualan_telur.butir',
id: 'penjualan_telur_butir',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.butir)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_telur.kg',
id: 'penjualan_telur_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_telur.nominal',
id: 'penjualan_telur_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_telur.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Trading',
accessorKey: 'penjualan_trading.nominal',
id: 'penjualan_trading',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_trading.nominal)}
</div>
),
},
{
header: 'Pembayaran',
accessorKey: 'pembayaran',
id: 'pembayaran',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.pembayaran)}
</div>
),
},
{
header: 'Aging',
accessorKey: 'aging',
id: 'aging',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging)} hari
</div>
),
},
{
header: 'Aging Rata-Rata',
accessorKey: 'aging_rata_rata',
id: 'aging_rata_rata',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging_rata_rata)} hari
</div>
),
},
{
header: 'Saldo Akhir',
accessorKey: 'saldo_akhir',
id: 'saldo_akhir',
enableSorting: true,
cell: ({ row }) => (
<div
className={`text-right font-semibold ${row.original.saldo_akhir < 0 ? 'text-error' : ''}`}
>
{formatCurrency(row.original.saldo_akhir)}
</div>
),
},
],
[tableFilterState.page, tableFilterState.pageSize]
);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoading && balanceMonitorings.length === 0 && (
<CustomerSupplierSkeleton
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoading && balanceMonitorings.length > 0 && (
<>
<div className='w-full overflow-x-auto'>
<Table
data={balanceMonitorings}
columns={columns}
pageSize={tableFilterState.pageSize || 10}
page={tableFilterState.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
// sorting={sorting}
// setSorting={handleSortingChange}
// manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</div>
</>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-3'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInput}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={formik.values.salesPersons}
onChange={(val) =>
formik.setFieldValue(
'salesPersons',
Array.isArray(val) ? val : []
)
}
onInputChange={setSalesInput}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default BalanceMonitoringTab;
@@ -1,17 +1,14 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { AxiosError } from 'axios';
import Card from '@/components/Card'; import Card from '@/components/Card';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect, OptionType } from '@/components/input/SelectInput'; import { useSelect } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report'; import { FinanceApi } from '@/services/api/report/finance-report';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { import {
@@ -30,70 +27,56 @@ import Dropdown from '@/components/Dropdown';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import {
CustomerPaymentFilterSchema,
CustomerPaymentFilterType,
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import { useTableFilter } from '@/services/hooks/useTableFilter';
interface CustomerPaymentTabProps { interface CustomerPaymentTabProps {
tabId: string; tabId: string;
} }
const dataTypeOptions: OptionType<string>[] = [ interface FilterParams {
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' }, customer_ids?: string;
{ value: 'realization_date', label: 'Tanggal Realisasi' }, start_date?: string;
]; end_date?: string;
filter_by?: string;
}
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] = const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions); const dataTypeOptions = useMemo(
const clearTabActions = useTabActionsStore((state) => state.clearTabActions); () => [
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
const { { value: 'realization_date', label: 'Tanggal Realisasi' },
state: tableFilterState, ],
updateFilter, []
setPage, );
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'customer-payment-report-table',
});
const { const {
options: customerOptions, options: customerOptions,
@@ -103,57 +86,72 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search'); } = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik({ const formik = useFormik<CustomerPaymentFilterType>({
initialValues: { initialValues: {
start_date: tableFilterState.start_date, start_date: null,
end_date: tableFilterState.end_date, end_date: null,
customers: tableFilterState.customers, customer_ids: null,
filterBy: tableFilterState.filterBy, filter_by: null,
},
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customers', values.customers, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
}, },
validationSchema: CustomerPaymentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
setFilterParams({
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
customer_ids: values.customer_ids || undefined,
filter_by: values.filter_by || undefined,
}); });
filterModal.closeModal();
const formikResetHandler = () => { setCurrentPage(1);
resetFilter(); setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setCurrentPage(1);
setHasDateError(false); setHasDateError(false);
if (dateErrorShown) { if (dateErrorShown) {
toast.dismiss(); toast.dismiss();
setDateErrorShown(false); setDateErrorShown(false);
} }
filterModal.closeModal();
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
}, },
}); });
filterModal.closeModal(); handleFilterModalOpenRef.current = () => {
formik.setValues({
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
customer_ids: filterParams.customer_ids || null,
filter_by: filterParams.filter_by || null,
});
filterModal.openModal();
}; };
const getPaymentStatusBadgeColor = (notes: string): Color => { const getPaymentStatusBadgeColor = (notes: string): Color => {
const normalizedValue = notes.toLowerCase(); const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') return 'primary';
if (normalizedValue.includes('belum')) return 'warning'; if (normalizedValue === 'lunas') {
return 'primary';
}
if (normalizedValue.includes('belum')) {
return 'warning';
}
return 'neutral'; return 'neutral';
}; };
// ===== DATE CHANGE HANDLERS ===== // ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
formik.setFieldValue('start_date', value); formik.setFieldValue('start_date', value || null);
if (value && formik.values.end_date) { if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) { const startDate = new Date(value);
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true); setHasDateError(true);
if (!dateErrorShown) { if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', { toast.error('Tanggal akhir tidak boleh masa lampau', {
@@ -171,14 +169,20 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} else { } else {
setHasDateError(false); setHasDateError(false);
} }
}; },
[formik, dateErrorShown]
);
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleEndDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
formik.setFieldValue('end_date', value); formik.setFieldValue('end_date', value || null);
if (value && formik.values.start_date) { if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) { const startDateObj = new Date(formik.values.start_date);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true); setHasDateError(true);
if (!dateErrorShown) { if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', { toast.error('Tanggal akhir tidak boleh masa lampau', {
@@ -195,96 +199,123 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
toast.dismiss(); toast.dismiss();
setDateErrorShown(false); setDateErrorShown(false);
} }
}; },
[formik, dateErrorShown]
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR<
BaseApiResponse<CustomerPaymentReport>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`,
httpClientFetcher
); );
const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment) // ===== FILTER HELPERS =====
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || [] const customerIdsValue = useMemo(() => {
: []; if (!formik.values.customer_ids) return [];
return customerOptions.filter((opt) =>
formik.values.customer_ids?.split(',').includes(String(opt.value))
);
}, [formik.values.customer_ids, customerOptions]);
const meta = const filterByValue = useMemo(() => {
if (!formik.values.filter_by) return null;
return (
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
null
);
}, [formik.values.filter_by, dataTypeOptions]);
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
() => {
const params = {
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
return ['customer-payment-report', params];
},
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
const meta = useMemo(
() =>
isResponseSuccess(customerPayment) && customerPayment.meta isResponseSuccess(customerPayment) && customerPayment.meta
? customerPayment.meta ? customerPayment.meta
: null; : null,
[customerPayment]
);
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise< const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null CustomerPaymentReport[] | null
> => { > => {
const customer_ids = const params = {
tableFilterState.customers.length > 0 customer_ids: filterParams.customer_ids,
? tableFilterState.customers.map((o) => String(o.value)).join(',') filter_by: filterParams.filter_by as
: undefined;
const filter_by = tableFilterState.filterBy?.value as
| 'trans_date' | 'trans_date'
| 'realization_date' | 'realization_date'
| undefined; | undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
limit: 100,
page: 1,
};
const response = await FinanceApi.getCustomerPaymentReport( const response = await FinanceApi.getCustomerPaymentReport(
customer_ids, params.customer_ids,
filter_by, params.filter_by,
tableFilterState.start_date || undefined, params.start_date,
tableFilterState.end_date || undefined, params.end_date,
1, params.page,
100 params.limit
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[]) ? (response.data as unknown as CustomerPaymentReport[])
: null; : null;
}, [tableFilterState]); }, [filterParams]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportCustomerPaymentToExcelGeneral(
customer_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const customer_ids = const allDataForExport = await customerPaymentExport();
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',') if (
: undefined; !allDataForExport ||
await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet( !Array.isArray(allDataForExport) ||
customer_ids, allDataForExport.length === 0
tableFilterState.filterBy?.value, ) {
tableFilterState.start_date || undefined, toast.error('Tidak ada data untuk diekspor.');
tableFilterState.end_date || undefined return;
); }
await generateCustomerPaymentExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.'); toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [tableFilterState]); }, [customerPaymentExport]);
const handleExportPdf = useCallback(async () => { const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -300,18 +331,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return; return;
} }
const customerName = const customerName = filterParams.customer_ids
tableFilterState.customers.length > 0 ? customerOptions
? tableFilterState.customers.map((o) => o.label).join(', ') .filter((opt) =>
filterParams.customer_ids?.split(',').includes(String(opt.value))
)
.map((opt) => opt.label)
.join(', ') || 'Semua Customer'
: 'Semua Customer'; : 'Semua Customer';
await generateCustomerPaymentPDF({ await generateCustomerPaymentPDF({
data: allDataForExport, data: allDataForExport,
params: { params: {
customer_name: customerName, customer_name: customerName,
start_date: tableFilterState.start_date || undefined, start_date: filterParams.start_date,
end_date: tableFilterState.end_date || undefined, end_date: filterParams.end_date,
filter_by: tableFilterState.filterBy?.value as filter_by: filterParams.filter_by as
| 'trans_date' | 'trans_date'
| 'realization_date' | 'realization_date'
| undefined, | undefined,
@@ -323,22 +358,24 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally { } finally {
setIsPdfExportLoading(false); setIsPdfExportLoading(false);
} }
}, [customerPaymentExport, tableFilterState]); }, [customerPaymentExport, filterParams, customerOptions]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
useEffect(() => { useEffect(() => {
setTabActions( setTabActions(
tabId, tabId,
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={{ values={filterParams}
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]} fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal} onClick={() => handleFilterModalOpenRef.current()}
variant='outline' variant='outline'
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
@@ -363,9 +400,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
width={20} width={20}
height={20} height={20}
/> />
<span>Export</span> <span>Export</span>
<div className='w-px self-stretch bg-base-content/10' /> <div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div> </div>
</Button> </Button>
} }
@@ -378,17 +422,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
> >
<Icon icon='heroicons:table-cells' width={20} height={20} /> <Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel - Customer Per Sheet Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportExcelGeneral}
isLoading={isExcelGeneralExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel - General
</Button> </Button>
<Button <Button
variant='ghost' variant='ghost'
@@ -403,23 +437,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</Dropdown> </Dropdown>
</div> </div>
); );
}, [ }, [setTabActions]);
tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
]);
useEffect(() => { useEffect(() => {
return () => clearTabActions(tabId); return () => {
}, [tabId, clearTabActions]); clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
}, [
tabId,
isAnyExportLoading,
handleExportExcel,
handleExportPdf,
isExcelExportLoading,
isPdfExportLoading,
filterParams,
]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
const getTableColumns = ( const getTableColumns = (
summary: CustomerPaymentSummary summary: CustomerPaymentSummary
@@ -626,7 +664,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
enableSorting: false, enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.status; const value = props.row.original.status;
if (!value) return '-';
if (!value) {
return '-';
}
return ( return (
<StatusBadge <StatusBadge
color={getPaymentStatusBadgeColor(value)} color={getPaymentStatusBadgeColor(value)}
@@ -665,6 +707,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return ( return (
<> <>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && ( {isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -693,16 +736,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page} currentPage={meta.page || 0}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setPage( setCurrentPage((curr) =>
meta.total_pages && tableFilterState.page < meta.total_pages meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={setPage} onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -809,16 +852,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page} currentPage={meta.page || 0}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setPage( setCurrentPage((curr) =>
meta.total_pages && tableFilterState.page < meta.total_pages meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={setPage} onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -848,7 +891,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div> <div>
<label className='block text-xs font-semibold text-base-content py-2'> <label className='block text-xs font-semibold text-base-content py-2'>
@@ -858,18 +901,29 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput <DateInput
name='start_date' name='start_date'
value={formik.values.start_date || ''} value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange} onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/> />
<hr className='w-full max-w-3 h-px border-base-content/10' /> <hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput <DateInput
name='end_date' name='end_date'
value={formik.values.end_date || ''} value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange} onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal isNestedModal
isError={hasDateError} isError={
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
/> />
</div> </div>
</div> </div>
@@ -878,10 +932,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Customer' label='Customer'
placeholder='Pilih Customer' placeholder='Pilih Customer'
options={customerOptions} options={customerOptions}
value={formik.values.customers} value={customerIdsValue}
onChange={(val) => onChange={(val) => {
formik.setFieldValue('customers', Array.isArray(val) ? val : []) formik.setFieldValue(
} 'customer_ids',
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers} isLoading={isLoadingCustomers}
isClearable isClearable
@@ -893,15 +952,14 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions} options={dataTypeOptions}
value={formik.values.filterBy ?? null} value={filterByValue}
onChange={(val) => onChange={(val) => {
formik.setFieldValue( if (!Array.isArray(val)) {
'filterBy', formik.setFieldValue('filter_by', val?.value || null);
!Array.isArray(val) ? (val ?? undefined) : undefined
)
} }
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable isClearable={true}
/> />
</div> </div>
@@ -917,7 +975,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Button <Button
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError} disabled={hasDateError || !formik.isValid || formik.isSubmitting}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -9,15 +9,24 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier'; import {
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF'; import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier'; import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
@@ -26,10 +35,6 @@ import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios';
const dueStatus: Record<string, Color> = { const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error', 'Sudah Jatuh Tempo': 'error',
@@ -47,6 +52,7 @@ const getPillBadge = (
statusText: string, statusText: string,
type: 'due' | 'payment' = 'payment' type: 'due' | 'payment' = 'payment'
) => { ) => {
// Get color based on type
const color = const color =
type === 'due' type === 'due'
? dueStatus[statusText] || 'neutral' ? dueStatus[statusText] || 'neutral'
@@ -63,11 +69,6 @@ const getPillBadge = (
); );
}; };
const dataTypeOptions: OptionType<string>[] = [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
];
interface DebtSupplierTabProps { interface DebtSupplierTabProps {
tabId: string; tabId: string;
} }
@@ -76,50 +77,28 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] = const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
suppliers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
suppliers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
suppliers: 'supplier_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'debt-supplier-report-table',
});
const { const {
setInputValue: setSupplierInputValue, setInputValue: setSupplierInputValue,
options: supplierOptions, options: supplierOptions,
@@ -127,180 +106,168 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
loadMore: loadMoreSuppliers, loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik({ const formik = useFormik<DebtSupplierFilterType>({
initialValues: { initialValues: {
start_date: tableFilterState.start_date, startDate: null,
end_date: tableFilterState.end_date, endDate: null,
suppliers: tableFilterState.suppliers, supplierIds: null,
filterBy: tableFilterState.filterBy, filterBy: null,
}, },
validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => { onSubmit: (values) => {
updateFilter('start_date', values.start_date, true); setFilterParams({
updateFilter('end_date', values.end_date, true); start_date: values.startDate?.toString() || undefined,
updateFilter('suppliers', values.suppliers, true); end_date: values.endDate?.toString() || undefined,
updateFilter('filterBy', values.filterBy, true); supplier_ids:
values.supplierIds?.map((v) => String(v.value)).join(',') ||
undefined,
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
setCurrentPage(1);
},
onReset: () => {
setFilterParams({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
setCurrentPage(1);
filterModal.closeModal(); filterModal.closeModal();
}, },
}); });
const formikResetHandler = () => { handleFilterModalOpenRef.current = () => {
resetFilter(); const restoredFilterBy =
dataTypeOptions.find((opt) => opt.value === filterParams.filter_by) ||
null;
setHasDateError(false); const supplierIdList = filterParams.supplier_ids
if (dateErrorShown) { ? filterParams.supplier_ids.split(',')
toast.dismiss(); : [];
setDateErrorShown(false); const restoredSupplierIds = supplierOptions.filter((opt) =>
} supplierIdList.includes(String(opt.value))
);
formik.resetForm({ formik.setValues({
values: { startDate: filterParams.start_date || null,
start_date: '', endDate: filterParams.end_date || null,
end_date: '', supplierIds: restoredSupplierIds.length > 0 ? restoredSupplierIds : null,
suppliers: [], filterBy: restoredFilterBy,
filterBy: undefined,
},
}); });
filterModal.openModal();
filterModal.closeModal();
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}; };
// ===== DATA FETCHING ===== // ===== DATA FETCHING =====
const { data: debtSupplierResponse, isLoading } = useSWR< const { data: debtSupplier, isLoading } = useSWR(
BaseApiResponse<DebtSupplier[]>, () => {
AxiosError<BaseApiResponse>, const params = {
SWRHttpKey supplier_ids: filterParams.supplier_ids,
>( filter_by: filterParams.filter_by,
`${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`, start_date: filterParams.start_date,
httpClientFetcher end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
return ['debt-supplier-report', params];
},
([, params]) =>
DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
); );
const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse) const data: DebtSupplier[] = useMemo(
? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? []) () =>
: []; isResponseSuccess(debtSupplier)
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
: [],
[debtSupplier]
);
const meta = const meta = useMemo(
isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta () =>
? debtSupplierResponse.meta isResponseSuccess(debtSupplier) && debtSupplier.meta
: null; ? debtSupplier.meta
: null,
[debtSupplier]
);
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise< const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null DebtSupplier[] | null
> => { > => {
const supplier_ids = const params = {
tableFilterState.suppliers.length > 0 supplier_ids:
? tableFilterState.suppliers.map((o) => String(o.value)).join(',') formik.values.supplierIds && formik.values.supplierIds.length > 0
: undefined; ? formik.values.supplierIds.map((v) => String(v.value)).join(',')
: undefined,
filter_by: formik.values.filterBy?.value?.toString() || undefined,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
date_type: formik.values.filterBy
? formik.values.filterBy.value
: undefined,
limit: 100,
page: 1,
};
const response = await DebtSupplierApi.getDebtSupplierReport( const response = await DebtSupplierApi.getDebtSupplierReport(
supplier_ids, params.supplier_ids,
tableFilterState.filterBy?.value, params.filter_by,
tableFilterState.start_date || undefined, params.start_date,
tableFilterState.end_date || undefined, params.end_date
1,
100
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[]) ? (response.data as unknown as DebtSupplier[])
: null; : null;
}, [tableFilterState]); }, [
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const supplier_ids = const allDataForExport = await debtSupplierExport();
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',') if (
: undefined; !allDataForExport ||
await DebtSupplierApi.exportToExcelSupplierPerSheet( !Array.isArray(allDataForExport) ||
supplier_ids, allDataForExport.length === 0
tableFilterState.filterBy?.value, ) {
tableFilterState.start_date || undefined, toast.error('Tidak ada data untuk diekspor.');
tableFilterState.end_date || undefined return;
); }
generateDebtSupplierExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.'); toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [tableFilterState]); }, [debtSupplierExport]);
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
await DebtSupplierApi.exportToExcelGeneral(
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportPdf = useCallback(async () => { const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -316,18 +283,15 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
return; return;
} }
const supplierName =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => o.label).join(', ')
: undefined;
await generateDebtSupplierPDF({ await generateDebtSupplierPDF({
data: allDataForExport, data: allDataForExport,
params: { params: {
supplier_name: supplierName, supplier_name: formik.values.supplierIds
filter_by: tableFilterState.filterBy?.label, ?.map((v) => v.label)
start_date: tableFilterState.start_date || undefined, .join(', '),
end_date: tableFilterState.end_date || undefined, filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
}, },
}); });
toast.success('PDF berhasil dibuat dan diunduh.'); toast.success('PDF berhasil dibuat dan diunduh.');
@@ -336,22 +300,30 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
} finally { } finally {
setIsPdfExportLoading(false); setIsPdfExportLoading(false);
} }
}, [debtSupplierExport, tableFilterState]); }, [
debtSupplierExport,
formik.values.supplierIds,
formik.values.filterBy,
formik.values.startDate,
formik.values.endDate,
]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
useEffect(() => { useEffect(() => {
setTabActions( setTabActions(
tabId, tabId,
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={{ values={filterParams}
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]} fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal} onClick={() => handleFilterModalOpenRef.current()}
variant='outline' variant='outline'
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
@@ -376,9 +348,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
width={20} width={20}
height={20} height={20}
/> />
<span>Export</span> <span>Export</span>
<div className='w-px self-stretch bg-base-content/10' /> <div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div> </div>
</Button> </Button>
} }
@@ -391,17 +370,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
> >
<Icon icon='heroicons:table-cells' width={20} height={20} /> <Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel - Supplier Per Sheet Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportExcelGeneral}
isLoading={isExcelGeneralExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel - General
</Button> </Button>
<Button <Button
variant='ghost' variant='ghost'
@@ -416,23 +385,44 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Dropdown> </Dropdown>
</div> </div>
); );
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
}, [ }, [
tabId, tabId,
setTabActions, filterParams,
tableFilterState,
filterModal.openModal,
isAnyExportLoading, isAnyExportLoading,
handleExportExcel, handleExportExcel,
handleExportExcelGeneral,
handleExportPdf, handleExportPdf,
isExcelExportLoading, isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading, isPdfExportLoading,
]); ]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => { useEffect(() => {
return () => clearTabActions(tabId); return () => {
}, [tabId, clearTabActions]); if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [ const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{ {
@@ -647,9 +637,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
}, },
}, },
]; ];
return ( return (
<> <>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && ( {isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -678,16 +668,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page} currentPage={meta.page || 0}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setPage( setCurrentPage((curr) =>
meta.total_pages && tableFilterState.page < meta.total_pages meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={setPage} onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -787,16 +777,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page} currentPage={meta.page || 0}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))} onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setPage( setCurrentPage((curr) =>
meta.total_pages && tableFilterState.page < meta.total_pages meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={setPage} onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -812,6 +802,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}} }}
> >
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'> <div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center gap-2 text-primary'>
@@ -828,7 +819,6 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div> <div>
@@ -837,68 +827,153 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</label> </label>
<div className='flex flex-row gap-1.5 items-center justify-between'> <div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput <DateInput
name='start_date' name='startDate'
value={formik.values.start_date || ''} value={formik.values.startDate || ''}
onChange={handleStartDateChange} onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('startDate', value || null);
if (value && formik.values.endDate) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.endDate);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
isNestedModal isNestedModal
/> />
<hr className='w-full max-w-3 h-px border-base-content/10' /> <hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<DateInput <DateInput
name='end_date' name='endDate'
value={formik.values.end_date || ''} value={formik.values.endDate || ''}
onChange={handleEndDateChange} onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('endDate', value || null);
if (value && formik.values.startDate) {
const startDateObj = new Date(formik.values.startDate);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
(formik.touched.endDate && !!formik.errors.endDate) ||
hasDateError
}
errorMessage={formik.errors.endDate}
isNestedModal isNestedModal
isError={hasDateError}
/> />
</div> </div>
</div> </div>
<div>
<SelectInputCheckbox <SelectInputCheckbox
label='Supplier' label='Supplier'
placeholder='Pilih Supplier' placeholder='Pilih Supplier'
isMulti
options={supplierOptions} options={supplierOptions}
value={formik.values.suppliers} value={
onChange={(val) => (formik.values.supplierIds as
formik.setFieldValue('suppliers', Array.isArray(val) ? val : []) | { value: number; label: string }
| { value: number; label: string }[]
| null
| undefined) || []
} }
onChange={(val) => {
formik.setFieldValue(
'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
);
}}
onInputChange={setSupplierInputValue} onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers} onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions} isLoading={isLoadingSupplierOptions}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/> />
</div>
<div>
<SelectInputRadio <SelectInputRadio
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions} options={dataTypeOptions}
value={formik.values.filterBy ?? null} value={
onChange={(val) => (formik.values.filterBy as
| { value: string; label: string }
| { value: string; label: string }[]
| null
| undefined) || null
}
onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
'filterBy', 'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined val ? (val as OptionType) : null
) );
} }}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/> />
</div> </div>
</div>
{/* Modal Footer */} {/* Action Buttons */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='reset'
variant='soft' variant='soft'
color='none'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
type='reset'
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError} type='submit'
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -184,11 +184,6 @@ export function DailyChecklistContent() {
const [emptyKandangEndDateError, setEmptyKandangEndDateError] = const [emptyKandangEndDateError, setEmptyKandangEndDateError] =
useState<string>(''); useState<string>('');
const [preloadedKandang, setPreloadedKandang] = useState<{
id: string;
name: string;
} | null>(null);
const [existingDocuments, setExistingDocuments] = useState<Document[]>([]); const [existingDocuments, setExistingDocuments] = useState<Document[]>([]);
const [documents, setDocuments] = useState<File[]>([]); const [documents, setDocuments] = useState<File[]>([]);
const [deletedDocumentIds, setDeletedDocumentIds] = useState<number[]>([]); const [deletedDocumentIds, setDeletedDocumentIds] = useState<number[]>([]);
@@ -233,11 +228,7 @@ export function DailyChecklistContent() {
const rawDate = data.date || ''; const rawDate = data.date || '';
setDate(rawDate.length > 10 ? rawDate.slice(0, 10) : rawDate); setDate(rawDate.length > 10 ? rawDate.slice(0, 10) : rawDate);
skipKandangClearRef.current = true; skipKandangClearRef.current = true;
const loadedKandangId = String(data.kandang?.id || ''); setKandangId(String(data.kandang?.id || ''));
setKandangId(loadedKandangId);
if (data.kandang?.name) {
setPreloadedKandang({ id: loadedKandangId, name: data.kandang.name });
}
const isEmptyKandang = const isEmptyKandang =
!!data.empty_kandang || data.category === 'empty_kandang'; !!data.empty_kandang || data.category === 'empty_kandang';
@@ -1171,17 +1162,9 @@ export function DailyChecklistContent() {
<SelectValue placeholder='Pilih kandang' /> <SelectValue placeholder='Pilih kandang' />
</SelectTrigger> </SelectTrigger>
<SelectContent onScroll={handleKandangScroll}> <SelectContent onScroll={handleKandangScroll}>
{preloadedKandang && {kandangOptions.map((kandang) => (
!kandangOptions.some(
(k) => String(k.value) === preloadedKandang.id
) && (
<SelectItem value={preloadedKandang.id}>
{preloadedKandang.name}
</SelectItem>
)}
{kandangOptions.map((kandang, kandangIdx) => (
<SelectItem <SelectItem
key={`${kandang.value}-${kandangIdx}`} key={kandang.value}
value={String(kandang.value)} value={String(kandang.value)}
> >
{kandang.label} {kandang.label}
-25
View File
@@ -2,7 +2,6 @@ import axios from 'axios';
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient, httpClientFetcher } from '@/services/http/client'; import { httpClient, httpClientFetcher } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
import { import {
CreateFinancePayment, CreateFinancePayment,
CreateInitialBalance, CreateInitialBalance,
@@ -175,30 +174,6 @@ export class FinanceApiService extends BaseApiService<
} }
} }
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(
`${this.basePath}/transactions?${params.toString()}`,
{ method: 'GET', responseType: 'blob' }
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
link.setAttribute(
'download',
`finance-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`
);
document.body.appendChild(link);
link.click();
link.remove();
}
async delete(id: number) { async delete(id: number) {
try { try {
const deletePath = `${this.basePath}/transactions/${id}`; const deletePath = `${this.basePath}/transactions/${id}`;
-78
View File
@@ -1,6 +1,4 @@
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { httpClient } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { DebtSupplier } from '@/types/api/report/debt-supplier'; import { DebtSupplier } from '@/types/api/report/debt-supplier';
@@ -13,82 +11,6 @@ export class DebtSupplierApiService extends BaseApiService<
super(basePath); super(basePath);
} }
async exportToExcelGeneral(
supplier_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (supplier_ids) params.set('supplier_ids', supplier_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel-all');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(
`${this.basePath.replace(/\/$/, '')}/debt-supplier${queryString}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-hutang-supplier-general-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportToExcelSupplierPerSheet(
supplier_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (supplier_ids) params.set('supplier_ids', supplier_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(
`${this.basePath.replace(/\/$/, '')}/debt-supplier${queryString}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-hutang-supplier-per-sheet-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async getDebtSupplierReport( async getDebtSupplierReport(
supplier_ids?: string, supplier_ids?: string,
filter_by?: string, filter_by?: string,
-92
View File
@@ -1,9 +1,6 @@
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment'; import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
import { httpClient } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
export class FinanceApiService extends BaseApiService< export class FinanceApiService extends BaseApiService<
CustomerPaymentReport, CustomerPaymentReport,
@@ -14,95 +11,6 @@ export class FinanceApiService extends BaseApiService<
super(basePath); super(basePath);
} }
async exportCustomerPaymentToExcelGeneral(
customer_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (customer_ids) params.set('customer_ids', customer_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel-all');
params.set('page', '1');
params.set('limit', '9999999999');
const res = await httpClient<Blob>(
`${this.basePath}/customer-payment?${params.toString()}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-piutang-customer-general-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportCustomerPaymentToExcelCustomerPerSheet(
customer_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (customer_ids) params.set('customer_ids', customer_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '9999999999');
const res = await httpClient<Blob>(
`${this.basePath}/customer-payment?${params.toString()}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-piutang-customer-per-sheet-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async getBalanceMonitoringReport(params: {
start_date?: string;
end_date?: string;
customer_ids?: string;
sales_ids?: string;
filter_by?: string;
sort_by?: string;
sort_order?: string;
page?: number;
limit?: number;
}): Promise<BaseApiResponse<BalanceMonitoringRow[]> | undefined> {
return await this.customRequest<BaseApiResponse<BalanceMonitoringRow[]>>(
'balance-monitoring',
{ method: 'GET', params }
);
}
async getCustomerPaymentReport( async getCustomerPaymentReport(
customer_ids?: string, customer_ids?: string,
// TODO: Uncomment when BE is ready // TODO: Uncomment when BE is ready
-8
View File
@@ -57,8 +57,6 @@ export type PurchaseItem = {
alias?: string; alias?: string;
category?: string; category?: string;
} | null; } | null;
expedition_qty?: number;
expedition_total?: number;
}; };
export type BasePurchase = { export type BasePurchase = {
@@ -83,9 +81,6 @@ export type BasePurchase = {
po_expedition?: { id: number; refrence: string }[]; po_expedition?: { id: number; refrence: string }[];
created_user?: CreatedUser; created_user?: CreatedUser;
products?: PurchaseItemProduct[]; products?: PurchaseItemProduct[];
products_total?: number;
expedition_total?: number;
grand_total_all?: number;
}; };
export type Purchase = BaseMetadata & BasePurchase; export type Purchase = BaseMetadata & BasePurchase;
@@ -154,9 +149,6 @@ export type UpdatePurchaseRequestPayload = CreatePurchaseRequestPayload;
export type PurchaseFilter = { export type PurchaseFilter = {
poDate: string; poDate: string;
start_date?: string;
end_date?: string;
filterBy?: { label: string; value: string };
category: string[]; category: string[];
category_labels?: { label: string; value: number }[]; category_labels?: { label: string; value: number }[];
status: string[]; status: string[];
-25
View File
@@ -1,25 +0,0 @@
import { Customer } from '@/services/api/master-data';
export type BalanceMonitoringRow = {
customer: Customer;
saldo_awal: number;
penjualan_ayam: {
ekor: number;
kg: number;
nominal: number;
};
penjualan_telur: {
butir: number;
kg: number;
nominal: number;
};
penjualan_trading: {
qty: number;
kg: number;
nominal: number;
};
pembayaran: number;
aging: number;
aging_rata_rata: number;
saldo_akhir: number;
};