Compare commits

...

84 Commits

Author SHA1 Message Date
Giovanni Gabriel Septriadi fce4d52214 Merge branch 'fix/laying-transition-logic-removal' into 'rc/00'
Fix/laying transition logic removal

See merge request mbugroup/lti-web-client!507
2026-06-05 02:41:10 +00:00
Giovanni Gabriel Septriadi e2354b5ea7 Merge branch 'feat/enable-edit-chick-in-date' into 'rc/00'
feat: add inline edit for chick-in date in chickin logs

See merge request mbugroup/lti-web-client!503
2026-06-04 16:53:13 +00:00
Giovanni Gabriel Septriadi 8f88677191 Merge branch 'feat/marketing-filter-range-date' into 'rc/00'
feat: add date range, filter by, and warehouse filter to marketing table

See merge request mbugroup/lti-web-client!504
2026-06-04 16:52:49 +00:00
ValdiANS 4151829cb8 fix: disabled deliver item button if is submitting and set the is loading prop 2026-06-03 14:24:39 +07:00
ValdiANS f167916a21 fix: replace throw error with axios error handling in SalesOrderService and MarketingExportService
All catch blocks in singleApproval, bulkApprovals (both classes), and
delivery now return error.response?.data for axios errors and undefined
otherwise, consistent with the BaseApiService pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:20:43 +07:00
ValdiANS 97acc17ca5 feat: add inline edit for chick-in date in chickin logs
- Add updateChickinDate method to ChickinService (PATCH /production/chickins/chick-in-date)
- Add pencil icon button next to each chickin date in ChickLogsView
- Clicking the icon toggles an inline DateInput with Simpan/Batal buttons
- Save button is disabled and shows loading state while request is in flight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:05:06 +07:00
ValdiANS e73af7e252 feat: add date range, filter by, and warehouse filter to marketing table
- Add start_date and end_date range inputs to the marketing filter modal
  with validation that prevents end date from being earlier than start date
- Add 'Filter Berdasarkan' single-select radio (so_date / created_at)
  to let users choose which date field the range applies to
- Add single-select Gudang (warehouse) filter backed by WarehouseApi,
  serialized as warehouse_id query param
- Wire all three new filters into useTableFilter (paramMap, persist,
  excludeKeysFromUrl for label-only fields) and propagate through
  filterSubmitHandler, filterResetHandler, and marketingFilterInitialValues
  so filter state survives page refreshes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:22:01 +07:00
ValdiANS 7b4bd7605b fix: remove transition restriction for recording 2026-06-02 09:45:19 +07:00
Rivaldi A N S 9bd646294b Merge branch 'fix/laying-transition-logic-removal' into 'development'
[FIX/FE] Laying Transition Restrict Logic Removal

See merge request mbugroup/lti-web-client!499
2026-05-30 02:19:24 +00:00
ValdiANS 366260608f fix: remove transition restrict logic 2026-05-30 09:13:56 +07:00
Giovanni Gabriel Septriadi a1cb401a1c Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-web-client!498
2026-05-29 16:32:00 +00:00
Rivaldi A N S 3f1c1b62e2 Merge branch 'feat/purchase-po-pr-copy-paste' into 'development'
[FEAT/FE] Purchase PO & PR Number Copy Button

See merge request mbugroup/lti-web-client!497
2026-05-29 10:13:13 +00:00
ValdiANS 5c9fa12347 feat: add copy button for PR and PO number 2026-05-29 17:11:42 +07:00
Giovanni Gabriel Septriadi 2ea6e1a5a5 Merge branch 'development' into 'production'
feat: add server-side Excel export to PurchasesPerSupplierTab

See merge request mbugroup/lti-web-client!496
2026-05-25 08:16:37 +00:00
Rivaldi A N S aa935b8851 Merge branch 'feat/export-balance-monitoring' into 'development'
[FEAT/FE] Export Balance Monitoring

See merge request mbugroup/lti-web-client!495
2026-05-25 07:25:07 +00:00
ValdiANS b8419a3f69 feat: add Excel export to BalanceMonitoringTab
Add exportBalanceMonitoringToExcel to FinanceApiService (server-side
blob download hitting reports/balance-monitoring?export=excel). Wire it
into BalanceMonitoringTab via a Dropdown export button in the tab
actions. Wrap the handler in useCallback to prevent an infinite
setTabActions loop caused by a new function reference on every render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:14:40 +07:00
Rivaldi A N S eaf70ead70 Merge branch 'feat/export-report-purchases-per-supplier' into 'development'
[FEAT/FE] Export Report Purchases Per Supplier

See merge request mbugroup/lti-web-client!494
2026-05-25 04:33:09 +00:00
ValdiANS 7e6f250864 feat: add server-side Excel export to PurchasesPerSupplierTab
Add exportToExcelSupplierPerSheet and exportToExcelGeneral methods to
LogisticApiService, hitting the existing purchase-supplier endpoint with
export=excel / export=excel-all query params and downloading the server
blob response. Replace the client-side Excel generation in
PurchasesPerSupplierTab with calls to these two service methods, and
split the single Export to Excel button into Export to Excel - Supplier
Per Sheet and Export to Excel - General.
2026-05-25 11:28:41 +07:00
Giovanni Gabriel Septriadi d7a98a77ea Merge branch 'development' into 'production'
refactor: optimize BalanceMonitoringTab with useTableFilter persistence pattern

See merge request mbugroup/lti-web-client!493
2026-05-25 04:04:15 +00:00
Rivaldi A N S 13eb0594a8 Merge branch 'hotfix/transfer-to-laying' into 'development'
[HOTFIX/FE] Transfer to Laying

See merge request mbugroup/lti-web-client!492
2026-05-25 03:39:13 +00:00
ValdiANS 22b3350e4a fix: set flock source and destination raw data accordingly 2026-05-25 10:37:41 +07:00
Rivaldi A N S 55424b272f Merge branch 'fix/depreciation-report' into 'development'
[FIX/FE] Depreciation Report

See merge request mbugroup/lti-web-client!491
2026-05-22 04:17:04 +00:00
ValdiANS 05138dbb6f feat: implement table filter state persist 2026-05-22 11:14:16 +07:00
ValdiANS b5a0614218 feat: implement url query param tab navigation 2026-05-22 11:13:52 +07:00
Rivaldi A N S 7ec46ffa8c Merge branch 'fix/transfer-stock' into 'development'
[FIX/FE] Transfer Stock

See merge request mbugroup/lti-web-client!490
2026-05-22 03:19:56 +00:00
ValdiANS 07dd2d26be fix: cache product stock 2026-05-22 10:17:04 +07:00
Rivaldi A N S 9a56bf732a Merge branch 'fix/purchase-filter' into 'development'
[FIX/FE] Purchase Filter

See merge request mbugroup/lti-web-client!489
2026-05-21 07:46:44 +00:00
ValdiANS 585918cc28 fix: update purchase type 2026-05-21 14:44:28 +07:00
ValdiANS 80e0bd5a8e fix: update table columns 2026-05-21 14:44:20 +07:00
ValdiANS a4e5116bef feat: add start_date, end_date, and filter_by input 2026-05-21 14:44:07 +07:00
ValdiANS 027668a1bf feat: add export to excel feature 2026-05-21 14:43:08 +07:00
Rivaldi A N S 7e1fab9a69 Merge branch 'feat/balance-monitoring-report' into 'development'
[FEAT/FE] Balance Monitoring Report

See merge request mbugroup/lti-web-client!488
2026-05-20 09:41:51 +00:00
ValdiANS ef56f87e45 feat: create report finance layout file 2026-05-20 16:35:43 +07:00
ValdiANS c4827bb810 feat: implement Query Param Tab Navigation 2026-05-20 16:35:26 +07:00
ValdiANS 9abb8b0b58 feat: add hide field in TabItem type 2026-05-20 16:34:53 +07:00
ValdiANS 8d014a8fea fix: adjust BalanceMonitoringRow type 2026-05-20 16:14:37 +07:00
ValdiANS 3d37fb2ecb fix: remove dummy data 2026-05-20 16:10:36 +07:00
ValdiANS d60877d391 refactor: optimize DebtSupplierTab with useTableFilter persistence pattern
Replace filterParams/currentPage/pageSize state with useTableFilter (persist:true),
switch SWR to httpClientFetcher with explicit type, store OptionType[] directly for
suppliers/filterBy, add formikResetHandler using resetFilter(), remove TabActions
component anti-pattern and handleFilterModalOpenRef, pass filterModal.openModal directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:19 +07:00
ValdiANS b3b60018bb refactor: optimize CustomerPaymentTab with useTableFilter persistence pattern
Replace filterParams/currentPage/pageSize state with useTableFilter (persist:true),
switch SWR to httpClientFetcher with explicit type, store OptionType[] directly for
customers/filterBy, add formikResetHandler using resetFilter(), remove enableReinitialize
and handleFilterModalOpenRef, pass filterModal.openModal directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:10 +07:00
ValdiANS c98a51326f refactor: optimize BalanceMonitoringTab with useTableFilter persistence pattern
Replace single-select customerFilter/salesFilter with OptionType[] multi-select
(customers, salesPersons, filterBy), switch SWR to httpClientFetcher with explicit
type, remove PDF export, enableReinitialize, useRef modal hack, useMemo on data/meta,
and useCallback on trivial handlers. Add formikResetHandler using resetFilter().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:01 +07:00
ValdiANS 7437e2e584 fix: update pattern context 2026-05-20 16:08:19 +07:00
ValdiANS ac6f6ecf78 Merge branch 'development' into feat/balance-monitoring-report 2026-05-20 11:21:39 +07:00
Rivaldi A N S 7f961b2f8b Merge branch 'feat/debt-supplier-general-export' into 'development'
[FEAT/FE] Debt Supplier General Export

See merge request mbugroup/lti-web-client!487
2026-05-20 04:14:59 +00:00
ValdiANS a8c02243a4 feat: implement export general and server-side export 2026-05-20 11:13:50 +07:00
Rivaldi A N S 82dca3b57e Merge branch 'feat/debt-supplier-general-export' into 'development'
[FEAT/FE] Debt Supplier General Export

See merge request mbugroup/lti-web-client!486
2026-05-19 10:33:48 +00:00
ValdiANS 94d623d793 feat: update gitignore 2026-05-19 17:31:18 +07:00
ValdiANS f76b5b981c feat: create balance-monitoring type 2026-05-19 17:31:00 +07:00
ValdiANS 8df5af0124 feat: create getBalanceMonitoringReport method 2026-05-19 17:30:38 +07:00
ValdiANS 3c175d4586 feat: create BalanceMonitoringTab component 2026-05-19 17:30:28 +07:00
ValdiANS 9350a6bd3e fix: add Monitoring Saldo tab 2026-05-19 17:30:13 +07:00
ValdiANS 6668c7b1f9 feat: update .gitignore 2026-05-19 16:07:08 +07:00
ValdiANS ce4f50c92a feat: create Export to Excel - General button 2026-05-19 16:06:54 +07:00
ValdiANS 146192a5b3 feat: create exportToExcelGeneral method 2026-05-19 16:06:41 +07:00
Rivaldi A N S 27c24e7c82 Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!485
2026-05-19 07:47:51 +00:00
ValdiANS a99a399f09 fix: show kandang label even if its not loaded yet in kandang options 2026-05-19 14:44:12 +07:00
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
Rivaldi A N S 37d0041a4f Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!483
2026-05-19 05:12:37 +00:00
ValdiANS 3647f1a1ea fix: make empty kandang end date required 2026-05-19 12:11:24 +07:00
ValdiANS 7b5049165a fix: add hasError props 2026-05-19 12:11:07 +07:00
Rivaldi A N S 3839b46edc Merge branch 'feat/server-side-sorting' into 'development'
[FEAT/FE] Server Side Sorting

See merge request mbugroup/lti-web-client!482
2026-05-19 04:54:47 +00:00
ValdiANS b7f2bca931 feat: add rtk filters.toml 2026-05-19 11:51:59 +07:00
ValdiANS 802bf77bc5 feat: add rtk instructions 2026-05-19 11:51:27 +07:00
ValdiANS fd7b49ab93 feat: implement server-side sorting in report expense 2026-05-19 11:51:17 +07: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
39 changed files with 3157 additions and 1123 deletions
+3
View File
@@ -48,3 +48,6 @@ next-env.d.ts
# rtk
rtk.exe
# local specs
/local-specs
+13
View File
@@ -0,0 +1,13 @@
# Project-local RTK filters — commit this file with your repo.
# Filters here override user-global and built-in filters.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress build noise from a custom tool
# [filters.my-tool]
# description = "Compact my-tool output"
# match_command = "^my-tool\\s+build"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
# max_lines = 30
# on_empty = "my-tool: ok"
+261 -37
View File
@@ -80,76 +80,124 @@ Data tables across all modules (master-data, inventory, finance, purchase, etc.)
- Apply to: search handlers, filter form submissions, reset handlers
3. **Create custom formikResetHandler function**
- Clear each filter with `updateFilter(fieldName, defaultValue, true)`
- Call `formik.resetForm({ values: { ...defaults } })`
- Close the modal at the end
- Attach to both button `onClick` and form `onReset` handler
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
- Call `filterModal.closeModal()` at the end
- Attach to form `onReset` handler (not `formik.handleReset`)
**Optimization: Avoid useCallback for simple handlers**
```tsx
const formikResetHandler = () => {
resetFilter();
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}>
```
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
- Simple pass-through handlers don't need it:
**Optimization: Avoid useCallback and useMemo for trivial operations**
- `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: Simple handler without useCallback
const handleFilterChange = (val) => setFieldValue('location', val);
// ✅ Good: plain derivation
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
const meta =
isResponseSuccess(response) && response.meta ? response.meta : null;
// ❌ Avoid: Unnecessary useCallback overhead
const handleFilterChange = useCallback(
// ❌ 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),
[setFieldValue]
);
```
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
**Best practice: Store OptionType objects directly, not IDs**
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).
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:
- `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
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
customers: OptionType<number>[]; // multi-select → serializes as CSV
location?: OptionType<string>; // single-select → serializes as value string
filterBy?: OptionType<string>; // single-select radio
}>({
initial: {
search: '',
locationFilter: undefined,
picFilter: undefined
customers: [],
location: undefined,
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
locationFilter: 'location_id',
picFilter: 'pic_id',
customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
location: 'location_id', // serializes OptionType → "abc"
filterBy: 'filter_by',
},
persist: true,
storeName: 'kandangs-table',
storeName: 'my-table',
});
// Initialize formik with tableFilterState values (now typed OptionType objects)
const formik = useFormik<KandangFilterType>({
// Initialize formik directly from tableFilterState (no hardcoded defaults)
const formik = useFormik({
initialValues: {
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
customers: tableFilterState.customers,
location: tableFilterState.location,
filterBy: tableFilterState.filterBy,
},
...
});
// Handlers store the complete OptionType, not just the ID
const handleFilterLocationChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
// Use formik values directly — no computed helpers needed
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
```
**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:**
- Any data table component across any module that needs persistent filters
@@ -159,7 +207,31 @@ const handleFilterLocationChange = useCallback(
**Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range
## 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
@@ -260,3 +332,155 @@ const handleExportExcel = useCallback(async () => {
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (60-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk go test # Go test failures only (90%)
rtk jest # Jest failures only (99.5%)
rtk vitest # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk pytest # Python test failures only (90%)
rtk rake test # Ruby test failures only (90%)
rtk rspec # RSpec test failures only (60%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
| ---------------- | ------------------------------ | --------------- |
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+14 -11
View File
@@ -6,6 +6,7 @@ export interface TabItem {
label: ReactNode;
content?: ReactNode;
disabled?: boolean;
hide?: boolean;
}
export interface TabsProps
@@ -122,17 +123,19 @@ const Tabs = ({
>
<div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => (
<button
key={id}
role='tab'
className={getTabClasses(id === activeTabId, disabled)}
onClick={() => !disabled && handleTabChange(id)}
disabled={disabled}
>
{label}
</button>
))}
{tabs.map(({ id, label, disabled, hide }) =>
hide ? null : (
<button
key={id}
role='tab'
className={getTabClasses(id === activeTabId, disabled)}
onClick={() => !disabled && handleTabChange(id)}
disabled={disabled}
>
{label}
</button>
)
)}
</div>
{sideContent && sideContent}
</div>
+62 -1
View File
@@ -29,7 +29,7 @@ import {
FINANCE_TRANSACTION_TYPE_OPTIONS,
} from '@/config/constant';
import { FinanceApi } from '@/services/api/finance';
import { isResponseSuccess } from '@/lib/api-helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank';
import Modal, { useModal } from '@/components/Modal';
@@ -39,6 +39,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/dropdown/Dropdown';
import {
FinanceTableFilterSchema,
FinanceTableFilterValues,
@@ -233,6 +234,7 @@ const FinanceTable = () => {
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isExportLoading, setIsExportLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
@@ -552,6 +554,20 @@ const FinanceTable = () => {
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 () => {
setIsDeleteLoading(true);
@@ -759,6 +775,51 @@ const FinanceTable = () => {
onClick={handleFilterModalOpen}
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>
@@ -56,6 +56,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [productQtyErrorShown, setProductQtyErrorShown] = useState(false);
const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const productStockCacheRef = useRef<
Map<number, { quantity: number; transfer_available_qty?: number }>
>(new Map());
// ===== FORM HANDLERS =====
const createMovementHandler = useCallback(
@@ -337,6 +340,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null
) {
productStockCacheRef.current = new Map();
formik.setFieldValue('products', [
{
product: null,
@@ -399,6 +403,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: [];
}, [productWarehouses]);
useEffect(() => {
productWarehouseOptions.forEach((pw) => {
productStockCacheRef.current.set(pw.product_id, {
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
});
});
}, [productWarehouseOptions]);
// ===== HELPER FUNCTIONS =====
const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T,
@@ -840,15 +853,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
if (live) return live.transfer_available_qty ?? live.quantity ?? 0;
const cached = productStockCacheRef.current.get(productId);
return cached?.transfer_available_qty ?? cached?.quantity ?? 0;
},
[productWarehouseOptions, type]
);
@@ -856,20 +866,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getTotalStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.quantity ?? 0;
if (live) return live.quantity ?? 0;
return productStockCacheRef.current.get(productId)?.quantity ?? 0;
},
[productWarehouseOptions, type]
);
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
const live = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.transfer_available_qty !== undefined;
if (live) return live.transfer_available_qty !== undefined;
return (
productStockCacheRef.current.get(productId)?.transfer_available_qty !==
undefined
);
},
[productWarehouseOptions]
);
@@ -847,7 +847,8 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
}
}}
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected}
disabled={deliveryRejected || isLoading}
isLoading={isLoading}
>
{marketing?.data?.latest_approval?.step_number === 1 &&
'Approve'}
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useCallback, useMemo } from 'react';
import { RefObject, useCallback, useMemo, useState } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -9,6 +9,8 @@ import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
@@ -17,12 +19,17 @@ import {
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import {
CustomerApi,
ProductApi,
WarehouseApi,
} from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
@@ -34,6 +41,10 @@ interface MarketingFilterModal {
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
warehouse: OptionType<number> | null;
start_date: string;
end_date: string;
filter_by: OptionType<string> | null;
};
}
@@ -79,6 +90,13 @@ const MarketingFilterModal = ({
'search'
);
const {
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -87,6 +105,13 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' },
];
const filterByOptions = [
{ value: 'so_date', label: 'Tanggal SO' },
{ value: 'created_at', label: 'Tanggal Dibuat' },
];
const [hasDateError, setHasDateError] = useState(false);
const formik = useFormik<MarketingFilterFormValues>({
initialValues: initialValues || {
product_ids: [],
@@ -94,6 +119,10 @@ const MarketingFilterModal = ({
customer: null,
project_flock: null,
project_flock_kandang: null,
warehouse: null,
start_date: '',
end_date: '',
filter_by: null,
},
validationSchema: MarketingFilterSchema,
@@ -111,6 +140,12 @@ const MarketingFilterModal = ({
Number(values.project_flock_kandang?.value) || undefined,
project_flock_kandang_name:
values.project_flock_kandang?.label || undefined,
warehouse_id: Number(values.warehouse?.value) || undefined,
warehouse_name: values.warehouse?.label || undefined,
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
filter_by: values.filter_by?.value || undefined,
filter_by_name: values.filter_by?.label || undefined,
};
onSubmit?.(formattedValues);
@@ -133,12 +168,37 @@ const MarketingFilterModal = ({
customer: null,
project_flock: null,
project_flock_kandang: null,
warehouse: null,
start_date: '',
end_date: '',
filter_by: null,
},
});
setHasDateError(false);
onReset?.();
closeModalHandler();
}, [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) {
setHasDateError(new Date(formik.values.end_date) < new Date(value));
} 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) {
setHasDateError(new Date(value) < new Date(formik.values.start_date));
} else {
setHasDateError(false);
}
};
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]);
};
@@ -207,6 +267,44 @@ const MarketingFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<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.filter_by ?? null}
onChange={(val) =>
formik.setFieldValue(
'filter_by',
!Array.isArray(val) ? (val ?? null) : null
)
}
isClearable
/>
{/* select multiple product */}
<SelectInputCheckbox
label='Product'
@@ -272,6 +370,22 @@ const MarketingFilterModal = ({
}
isDisabled={!formik.values.project_flock}
/>
<SelectInput
label='Gudang'
isClearable
placeholder='Pilih Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={formik.values.warehouse}
onChange={(val) =>
formik.setFieldValue(
'warehouse',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
)
}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
/>
</div>
{/* Modal Footer */}
@@ -288,6 +402,7 @@ const MarketingFilterModal = ({
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
disabled={hasDateError}
>
Apply Filter
</Button>
@@ -203,6 +203,12 @@ const MarketingTable = () => {
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
warehouse_id: '',
warehouse_name: '',
start_date: '',
end_date: '',
filter_by: '',
filter_by_name: '',
sort_by: '',
order_by: '',
},
@@ -214,6 +220,10 @@ const MarketingTable = () => {
customer_id: 'customer_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
warehouse_id: 'warehouse_id',
start_date: 'start_date',
end_date: 'end_date',
filter_by: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
@@ -223,6 +233,8 @@ const MarketingTable = () => {
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'warehouse_name',
'filter_by_name',
],
persist: true,
@@ -293,6 +305,16 @@ const MarketingTable = () => {
values.project_flock_kandang_name ?? '',
true
);
updateFilter(
'warehouse_id',
values.warehouse_id ? values.warehouse_id.toString() : '',
true
);
updateFilter('warehouse_name', values.warehouse_name ?? '', true);
updateFilter('start_date', values.start_date ?? '', true);
updateFilter('end_date', values.end_date ?? '', true);
updateFilter('filter_by', values.filter_by ?? '', true);
updateFilter('filter_by_name', values.filter_by_name ?? '', true);
};
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
@@ -311,6 +333,12 @@ const MarketingTable = () => {
updateFilter('project_flock_name', '', true);
updateFilter('project_flock_kandang_id', '', true);
updateFilter('project_flock_kandang_name', '', true);
updateFilter('warehouse_id', '', true);
updateFilter('warehouse_name', '', true);
updateFilter('start_date', '', true);
updateFilter('end_date', '', true);
updateFilter('filter_by', '', true);
updateFilter('filter_by_name', '', true);
};
const approveClickHandler = () => {
@@ -433,6 +461,20 @@ const MarketingTable = () => {
label: tableFilterState.project_flock_kandang_name,
}
: null,
warehouse: tableFilterState.warehouse_id
? {
value: Number(tableFilterState.warehouse_id),
label: tableFilterState.warehouse_name,
}
: null,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
filter_by: tableFilterState.filter_by
? {
value: tableFilterState.filter_by,
label: tableFilterState.filter_by_name,
}
: null,
};
const approveMarketingHandler = async (notes: string) => {
@@ -707,7 +749,7 @@ const MarketingTable = () => {
},
{
accessorKey: 'so_date',
header: 'Tanggal',
header: 'Tanggal SO',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
@@ -753,18 +795,17 @@ const MarketingTable = () => {
cell: (props) => props.row.original.customer.name,
},
{
accessorKey: 'grand_total',
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total',
accessorKey: 'grand_total_so',
header: 'Grand Total SO',
cell: (props) => {
return formatCurrency(
props.row.original?.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0
);
return formatCurrency(props.row.original?.grand_total_so);
},
},
{
accessorKey: 'grand_total_do',
header: 'Grand Total DO',
cell: (props) => {
return formatCurrency(props.row.original?.grand_total_do);
},
},
{
@@ -911,6 +952,8 @@ const MarketingTable = () => {
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'warehouse_name',
'filter_by_name',
'sort_by',
'order_by',
]}
@@ -1,4 +1,4 @@
import { array, mixed, object } from 'yup';
import { array, mixed, object, string } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({
@@ -7,6 +7,10 @@ export const MarketingFilterSchema = object({
customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
warehouse: mixed<OptionType<number>>().nullable(),
start_date: string().optional(),
end_date: string().optional(),
filter_by: mixed<OptionType<string>>().nullable(),
});
export type MarketingFilterFormValues = {
@@ -15,4 +19,8 @@ export type MarketingFilterFormValues = {
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
warehouse: OptionType<number> | null;
start_date: string;
end_date: string;
filter_by: OptionType<string> | null;
};
@@ -2,16 +2,17 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button';
import Card from '@/components/Card';
import RequirePermission from '@/components/helper/RequirePermission';
import DateInput from '@/components/input/DateInput';
import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin';
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Icon } from '@iconify/react';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
const ChickinLogsView = ({
initialValues,
@@ -23,6 +24,9 @@ const ChickinLogsView = ({
rawDataApprovals: BaseApproval[];
}) => {
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const [editingChickinId, setEditingChickinId] = useState<number | null>(null);
const [editDate, setEditDate] = useState('');
const [isEditLoading, setIsEditLoading] = useState(false);
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore();
const handleClickApprove = () => {
@@ -44,6 +48,23 @@ const ChickinLogsView = ({
});
};
const handleSaveChickinDate = async () => {
setIsEditLoading(true);
const res = await ChickinApi.updateChickinDate(
initialValues.id as number,
formatDate(editDate, 'YYYY-MM-DD')
);
setIsEditLoading(false);
if (isResponseSuccess(res)) {
toast.success(res?.message as string);
setEditingChickinId(null);
afterSubmit && afterSubmit();
}
if (isResponseError(res)) {
toast.error(res?.message as string);
}
};
const handleDeleteChickin = (chickinId: number) => {
openChickinDeleteModal(chickinId, async () => {
const deleteRes = await ChickinApi.delete(chickinId);
@@ -133,9 +154,54 @@ const ChickinLogsView = ({
<Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
<span>Tanggal Chick In</span>
</div>
<div className='text-end text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</div>
{editingChickinId === chickin.id ? (
<div className='flex flex-col gap-2 items-end w-1/2'>
<DateInput
name='edit_chick_in_date'
value={editDate}
isNestedModal
onChange={(e) => setEditDate(e.target.value)}
/>
<div className='flex flex-row gap-1'>
<Button
color='none'
className='btn-xs btn-ghost text-gray-500'
onClick={() => setEditingChickinId(null)}
disabled={isEditLoading}
>
Batal
</Button>
<Button
color='success'
className='btn-xs text-base-100'
onClick={handleSaveChickinDate}
isLoading={isEditLoading}
disabled={!editDate || isEditLoading}
>
Simpan
</Button>
</div>
</div>
) : (
<div className='flex flex-row gap-2 items-center'>
<span className='text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</span>
<button
className='btn btn-ghost btn-xs p-0 text-gray-400 hover:text-primary'
onClick={() => {
setEditingChickinId(chickin.id);
setEditDate(chickin.chick_in_date);
}}
>
<Icon
icon='mdi:pencil-outline'
width={13}
height={13}
/>
</button>
</div>
)}
</div>
{/* Kandang */}
@@ -463,13 +463,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, [selectedKandang]);
// ===== TRANSITION RESTRICTION LOGIC =====
const isTransitionPeriod = useMemo(() => {
return (
initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ??
false
);
}, [initialValues, projectFlockKandangLookup]);
// const isTransitionPeriod = useMemo(() => {
// return (
// initialValues?.is_transition ??
// projectFlockKandangLookup?.is_transition ??
// false
// );
// }, [initialValues, projectFlockKandangLookup]);
// set to false by request: 30 May 2026, 09:11
const isTransitionPeriod = false;
const recordingRestriction = useMemo(() => {
let isLaying: boolean;
@@ -483,10 +486,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
false;
}
const isTransition =
initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ??
false;
// const isTransition =
// initialValues?.is_transition ??
// projectFlockKandangLookup?.is_transition ??
// false;
// set to false by request: 30 May 2026, 09:11
const isTransition = false;
const currentIsLaying =
type === 'edit'
@@ -11,63 +11,72 @@ export const getRecordingRestriction = (
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
// if (isTransition && !isLaying) {
// const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
// if (isLayingKandangInTransition) {
// return {
// canEditStock: false,
// canEditDepletion: true,
// canEditEgg: true,
// isLocked: false,
// lockReason: undefined,
// };
// } else {
// return {
// canEditStock: true,
// canEditDepletion: false,
// canEditEgg: false,
// isLocked: false,
// lockReason: undefined,
// };
// }
// }
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
// if (!isLaying && !isTransition && currentIsLaying) {
// return {
// canEditStock: false,
// canEditDepletion: false,
// canEditEgg: false,
// isLocked: true,
// lockReason:
// 'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
// };
// }
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
// if (!isLaying && !isTransition) {
// return {
// canEditStock: true,
// canEditDepletion: true,
// canEditEgg: false,
// isLocked: false,
// lockReason: undefined,
// };
// }
// if (isLaying && !isTransition) {
// return {
// canEditStock: true,
// canEditDepletion: true,
// canEditEgg: true,
// isLocked: false,
// lockReason: undefined,
// };
// }
// return {
// canEditStock: false,
// canEditDepletion: false,
// canEditEgg: false,
// isLocked: true,
// lockReason: 'Kondisi transisi tidak valid',
// };
// remove recording transition restriction by request: 30 May 2026, 09:11
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
};
@@ -233,13 +233,8 @@ const TransferToLayingFormModal = () => {
const [maxSourceQuantity, setMaxSourceQuantity] = useState<number>(0);
const selectedFlockDestinationRawData = isResponseSuccess(
flockDestinationRawData
)
? flockDestinationRawData.data.find(
(item) => item.id === formik.values.flockDestination?.value
)
: undefined;
const [selectedFlockDestinationRawData, setSelectedFlockDestinationRawData] =
useState<ProjectFlock | undefined>(undefined);
const { data: flockSourceKandangsAvailability } = useSWR(
formik.values.flockSource
@@ -456,15 +451,37 @@ const TransferToLayingFormModal = () => {
}, [transferToLayingId, transferToLaying]);
useEffect(() => {
if (!formik.values.flockSource) {
setSelectedFlockSourceRawData(undefined);
return;
}
if (isResponseSuccess(flockSourceRawData)) {
const currentSelectedFlockSourceRawData = flockSourceRawData.data.find(
const found = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value
);
setSelectedFlockSourceRawData(currentSelectedFlockSourceRawData);
if (found) {
setSelectedFlockSourceRawData(found);
}
}
}, [flockSourceRawData, formikFlockSource]);
useEffect(() => {
if (!formik.values.flockDestination) {
setSelectedFlockDestinationRawData(undefined);
return;
}
if (isResponseSuccess(flockDestinationRawData)) {
const found = flockDestinationRawData.data.find(
(item) => item.id === formik.values.flockDestination?.value
);
if (found) {
setSelectedFlockDestinationRawData(found);
}
}
}, [flockDestinationRawData, formik.values.flockDestination]);
useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken);
formik.setFieldValue('maxTotalQuantity', totalTransferedChicken);
@@ -10,6 +10,7 @@ import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInput from '@/components/input/SelectInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase';
@@ -24,10 +25,20 @@ import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
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 {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
@@ -51,6 +62,7 @@ const PurchaseFilterModal = ({
}, [ref]);
// ===== DATE ERROR STATE =====
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
@@ -139,6 +151,9 @@ const PurchaseFilterModal = ({
const formik = useFormik<{
poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: { label: string; value: number }[];
status: { label: string; value: string }[];
supplier: OptionType<number> | null;
@@ -150,6 +165,9 @@ const PurchaseFilterModal = ({
// enableReinitialize: true,
initialValues: initialValues || {
poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [],
status: [],
supplier: null,
@@ -230,9 +248,17 @@ const PurchaseFilterModal = ({
};
const formikResetHandler = useCallback(() => {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
resetForm({
values: {
poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [],
status: [],
supplier: null,
@@ -246,7 +272,56 @@ const PurchaseFilterModal = ({
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
}, [resetForm, onReset, closeModalHandler, dateErrorShown]);
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 () => {
await submitForm();
@@ -287,6 +362,44 @@ const PurchaseFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<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
label='PO Date'
name='poDate'
@@ -436,6 +549,7 @@ const PurchaseFilterModal = ({
<Button
type='button'
onClick={formikSubmitHandler}
disabled={hasDateError}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
+235 -27
View File
@@ -28,7 +28,7 @@ import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal
import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import { cn, formatDate } from '@/lib/helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
@@ -41,6 +41,9 @@ type PurchaseTableFilters = {
search: string;
sort_by: string;
order_by: string;
start_date: string;
end_date: string;
filter_by: string;
po_date: string;
approval_status: string;
product_category_id: string;
@@ -177,6 +180,9 @@ const PurchaseTable = () => {
search: '',
sort_by: '',
order_by: '',
start_date: '',
end_date: '',
filter_by: '',
po_date: '',
approval_status: '',
product_category_id: '',
@@ -197,6 +203,9 @@ const PurchaseTable = () => {
pageSize: 'limit',
sort_by: 'sort_by',
order_by: 'sort_order',
start_date: 'start_date',
end_date: 'end_date',
filter_by: 'filter_by',
po_date: 'po_date',
approval_status: 'approval_status',
product_category_id: 'product_category_id',
@@ -297,36 +306,11 @@ const PurchaseTable = () => {
);
},
},
{
accessorKey: 'supplier',
header: 'Vendor',
cell: (props) => props.row.original.supplier.name,
},
{
accessorKey: 'requester_name',
header: 'Nama Pengaju',
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',
header: 'Tgl. PO',
@@ -364,6 +348,202 @@ const PurchaseTable = () => {
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',
header: 'Status Approval',
@@ -410,6 +590,11 @@ const PurchaseTable = () => {
);
},
},
{
accessorKey: 'notes',
header: 'Notes',
cell: (props) => props.row.original.notes || '-',
},
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
@@ -476,6 +661,9 @@ const PurchaseTable = () => {
const filterSubmitHandler = (values: PurchaseFilter) => {
setFilters({
start_date: values.start_date || '',
end_date: values.end_date || '',
filter_by: values.filterBy?.value || '',
po_date: values.poDate,
product_category_id: values.category.join(','),
product_category_name:
@@ -500,6 +688,9 @@ const PurchaseTable = () => {
const filterResetHandler = () => {
setFilters({
start_date: '',
end_date: '',
filter_by: '',
po_date: '',
product_category_id: '',
product_category_name: '',
@@ -518,6 +709,13 @@ const PurchaseTable = () => {
};
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
? tableFilterState.product_category_id
.split(',')
@@ -539,6 +737,16 @@ const PurchaseTable = () => {
return {
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) => ({
value: Number(value),
label: categoryLabels[index] || value,
@@ -706,7 +914,7 @@ const PurchaseTable = () => {
'project_flock_name',
'project_flock_kandang_name',
]}
fieldGroups={[['startDate', 'endDate']]}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
@@ -847,9 +847,25 @@ const PurchaseOrderDetail = ({
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Nomor
</span>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.pr_number}
</span>
<div className='flex items-center gap-1'>
<span className='text-gray-900 ml-3 break-all'>
: {purchaseData.pr_number}
</span>
<Button
type='button'
variant='ghost'
color='none'
className='p-1 min-h-0 h-auto'
onClick={() => {
navigator.clipboard.writeText(
purchaseData.pr_number || ''
);
toast.success('Nomor berhasil disalin');
}}
>
<Icon icon='mdi:content-copy' width={14} height={14} />
</Button>
</div>
</div>
</div>
<div className='group'>
@@ -857,11 +873,31 @@ const PurchaseOrderDetail = ({
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Nomor PO
</span>
<div className='ml-3'>
<div className='ml-3 flex items-center gap-1'>
{canShowPurchaseOrderInvoice &&
purchaseData.po_number &&
purchaseData.po_number !== 'Belum dibuat' ? (
<PurchaseOrderInvoice data={purchaseData} />
<>
<PurchaseOrderInvoice data={purchaseData} />
<Button
type='button'
variant='ghost'
color='none'
className='p-1 min-h-0 h-auto'
onClick={() => {
navigator.clipboard.writeText(
purchaseData.po_number || ''
);
toast.success('Nomor PO berhasil disalin');
}}
>
<Icon
icon='mdi:content-copy'
width={14}
height={14}
/>
</Button>
</>
) : (
<>
: <i className='text-gray-400'>Belum dibuat</i>
@@ -1,26 +1,38 @@
'use client';
import { useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import Tabs from '@/components/Tabs';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab';
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
const VALID_TAB_IDS = ['operational-expense', 'depreciation'];
const ReportExpenseTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const router = useRouter();
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 handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [
{
id: '1',
id: 'operational-expense',
label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'1'} />,
content: <ReportExpenseTab tabId={'operational-expense'} />,
},
{
id: '2',
id: 'depreciation',
label: 'Laporan Depresiasi',
content: <ReportDepreciationTab tabId={'2'} />,
content: <ReportDepreciationTab tabId={'depreciation'} />,
},
];
@@ -30,7 +42,7 @@ const ReportExpenseTabs = () => {
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onTabChange={handleTabChange}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useEffect, useMemo, useState } from 'react';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import * as yup from 'yup';
@@ -20,32 +20,34 @@ import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock';
export type ReportDepreciationFilterValues = {
area_id: string | null;
location_id: string | null;
project_flock_id: string | null;
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string | null;
};
export const ReportDepreciationFilterSchema = yup.object({
area_id: yup.string().nullable(),
location_id: yup.string().nullable(),
project_flock_id: yup.string().nullable(),
area: yup.mixed<OptionType<string>>().optional(),
location: yup.mixed<OptionType<string>>().optional(),
projectFlock: yup.mixed<OptionType<string>>().optional(),
period: yup.string().nullable().required('Periode wajib dipilih'),
}) as yup.ObjectSchema<ReportDepreciationFilterValues>;
});
interface ReportDepreciationFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: ReportDepreciationFilterValues;
initialValues?: Partial<ReportDepreciationFilterValues>;
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
onReset?: () => void;
}
const defaultInitialValues: ReportDepreciationFilterValues = {
area_id: null,
location_id: null,
project_flock_id: null,
period: null,
};
const defaultInitialValues: (
initialValues?: Partial<ReportDepreciationFilterValues>
) => ReportDepreciationFilterValues = (initialValues) => ({
area: undefined,
location: undefined,
projectFlock: undefined,
period: initialValues?.period ?? null,
});
const ReportDepreciationFilterModal = ({
ref,
@@ -53,22 +55,19 @@ const ReportDepreciationFilterModal = ({
onSubmit,
onReset,
}: 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 = () => {
ref.current?.close();
};
const formik = useFormik<ReportDepreciationFilterValues>({
initialValues: { ...defaultInitialValues(initialValues), ...initialValues },
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
});
const {
setInputValue: setAreaInputValue,
options: areaOptions,
@@ -82,7 +81,7 @@ const ReportDepreciationFilterModal = ({
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
area_id: String(formik.values.area?.value ?? ''),
});
const {
@@ -96,73 +95,35 @@ const ReportDepreciationFilterModal = ({
'flock_name',
'search',
{
location_id: selectedLocationId || '',
location_id: String(formik.values.location?.value ?? ''),
}
);
const formik = useFormik<ReportDepreciationFilterValues>({
initialValues: initialValues || defaultInitialValues,
enableReinitialize: true,
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
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 formikResetHandler = () => {
onReset?.();
formik.resetForm({ values: defaultInitialValues(initialValues) });
closeModalHandler();
};
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
const areaId = val && !Array.isArray(val) ? String(val.value) : null;
setSelectedAreaId(areaId || undefined);
formik.setFieldValue('area_id', areaId);
formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
setSelectedLocationId(undefined);
const area =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('area', area);
formik.setFieldValue('location', undefined);
formik.setFieldValue('projectFlock', undefined);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const locationId = val && !Array.isArray(val) ? String(val.value) : null;
setSelectedLocationId(locationId || undefined);
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('project_flock_id', null);
const location =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('location', location);
formik.setFieldValue('projectFlock', undefined);
};
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
const projectFlockId =
val && !Array.isArray(val) ? String(val.value) : null;
formik.setFieldValue('project_flock_id', projectFlockId);
const projectFlock =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('projectFlock', projectFlock);
};
return (
@@ -174,7 +135,7 @@ const ReportDepreciationFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formik.handleReset}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
@@ -199,7 +160,7 @@ const ReportDepreciationFilterModal = ({
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={areaValue}
value={formik.values.area ?? null}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
@@ -213,7 +174,7 @@ const ReportDepreciationFilterModal = ({
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={locationValue}
value={formik.values.location ?? null}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
@@ -227,7 +188,7 @@ const ReportDepreciationFilterModal = ({
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={projectFlockValue}
value={formik.values.projectFlock ?? null}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
@@ -17,6 +17,7 @@ import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import { ReportDepreciation } from '@/types/api/report/report-expense';
import { DepreciationReportApi } from '@/services/api/report/expense-report';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
@@ -32,20 +33,27 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter({
} = useTableFilter<{
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string;
}>({
initial: {
area_id: '',
location_id: '',
project_flock_id: '',
area: undefined,
location: undefined,
projectFlock: undefined,
period: formatDate(Date.now(), 'YYYY-MM-DD'),
},
paramMap: {
pageSize: 'limit',
area_id: 'area_id',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
area: 'area_id',
location: 'location_id',
projectFlock: 'project_flock_id',
period: 'period',
},
persist: true,
storeName: 'report-depreciation-table',
});
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
@@ -109,7 +117,7 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize']}
onClick={() => filterModal.openModal()}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
@@ -239,12 +247,13 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
initialValues={tableFilterState}
onReset={resetFilter}
onSubmit={(values) => {
updateFilter('area_id', values.area_id ?? '');
updateFilter('location_id', values.location_id ?? '');
updateFilter('project_flock_id', values.project_flock_id ?? '');
updateFilter('area', values.area, true);
updateFilter('location', values.location, true);
updateFilter('projectFlock', values.projectFlock, true);
updateFilter(
'period',
values.period ? formatDate(values.period, 'YYYY-MM-DD') : ''
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '',
true
);
}}
/>
@@ -39,7 +39,7 @@ import {
} from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef } from '@tanstack/react-table';
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter';
@@ -73,6 +73,25 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SORTING STATE =====
const [sortBy, setSortBy] = useState('');
const [orderBy, setOrderBy] = useState('');
const sorting: SortingState = sortBy
? [{ id: sortBy, desc: orderBy === 'desc' }]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
setSortBy(next[0].id);
setOrderBy(next[0].desc ? 'desc' : 'asc');
} else {
setSortBy('');
setOrderBy('');
}
};
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
@@ -252,6 +271,8 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
if (filterParams.category) {
params.append('category', filterParams.category);
}
if (sortBy) params.append('sort_by', sortBy);
if (orderBy) params.append('sort_order', orderBy);
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value);
@@ -259,7 +280,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
return params.toString();
},
[filterParams]
[filterParams, sortBy, orderBy]
);
// ===== DATA FETCHING =====
@@ -443,19 +464,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
return [
{
header: 'No',
enableSorting: false,
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
},
{
header: 'No. PO',
accessorKey: 'po_number',
enableSorting: true,
},
{
header: 'No. Referensi',
accessorKey: 'reference_number',
enableSorting: true,
},
{
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
enableSorting: true,
cell: ({ row }) => {
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
},
@@ -463,6 +488,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{
header: 'Tanggal Transaksi',
accessorKey: 'transaction_date',
enableSorting: true,
cell: ({ row }) => {
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
},
@@ -470,21 +496,30 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{
header: 'Kategori',
accessorKey: 'category',
enableSorting: true,
},
{
header: 'Produk',
accessorKey: 'product',
enableSorting: true,
accessorFn: (row) => row.pengajuan?.nonstock?.name,
},
{
header: 'Supplier',
accessorKey: 'supplier',
enableSorting: true,
accessorFn: (row) => row.supplier?.name,
},
{
header: 'Lokasi',
accessorKey: 'location',
enableSorting: true,
accessorFn: (row) => row.kandang?.location?.name,
},
{
header: 'Kandang',
accessorKey: 'kandang',
enableSorting: true,
accessorFn: (row) => row.kandang?.name,
},
{
@@ -492,23 +527,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [
{
header: 'Qty',
id: 'qty_pengajuan',
accessorFn: (row) => row.pengajuan?.qty,
accessorKey: 'qty_pengajuan',
cell: ({ row }) =>
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
id: 'harga_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
accessorKey: 'price_pengajuan',
cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0),
},
{
header: 'Total',
id: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
accessorKey: 'total_pengajuan',
cell: ({ row }) => {
const total =
(row.original.pengajuan?.qty || 0) *
@@ -523,23 +554,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [
{
header: 'Qty',
id: 'qty_realisasi',
accessorFn: (row) => row.realisasi?.qty,
accessorKey: 'qty_realisasi',
cell: ({ row }) =>
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
},
{
header: 'Harga',
id: 'harga_realisasi',
accessorFn: (row) => row.realisasi?.price,
accessorKey: 'price_realisasi',
cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0),
},
{
header: 'Total',
id: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
accessorKey: 'total_realisasi',
cell: ({ row }) => {
const total =
(row.original.realisasi?.qty || 0) *
@@ -550,6 +577,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
],
},
{
id: 'realization_status',
header: 'Status Pencairan',
cell: (props) => (
<RealizationStatusBadge
@@ -558,6 +586,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
),
},
{
id: 'bop_status',
header: 'Status BOP',
cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
@@ -602,6 +631,9 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
@@ -1,25 +1,47 @@
'use client';
import { useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
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';
const VALID_TAB_IDS = [
'debt-supplier',
'customer-payment',
'balance-monitoring',
];
const FinanceTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
const router = useRouter();
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 handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [
{
id: '1',
id: 'debt-supplier',
label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab tabId={'1'} />,
content: <DebtSupplierTab tabId={'debt-supplier'} />,
},
{
id: '2',
id: 'customer-payment',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab tabId={'2'} />,
content: <CustomerPaymentTab tabId={'customer-payment'} />,
},
{
id: 'balance-monitoring',
label: 'Monitoring Saldo',
content: <BalanceMonitoringTab tabId={'balance-monitoring'} />,
},
];
@@ -29,7 +51,7 @@ const FinanceTabs = () => {
tabs={tabs}
variant='boxed'
activeTabId={activeTabId}
onTabChange={setActiveTabId}
onTabChange={handleTabChange}
className={{
tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10',
@@ -0,0 +1,677 @@
'use client';
import { useState, useMemo, useEffect, useCallback } 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 Dropdown from '@/components/Dropdown';
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 [isExcelExportLoading, setIsExcelExportLoading] = 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;
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
const sales_ids =
tableFilterState.salesPersons.length > 0
? tableFilterState.salesPersons.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportBalanceMonitoringToExcel(
customer_ids,
sales_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [tableFilterState]);
// 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'
/>
<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'
isLoading={isExcelExportLoading}
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>Export</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={handleExportExcel}
isLoading={isExcelExportLoading}
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>
);
}, [
tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isExcelExportLoading,
handleExportExcel,
]);
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,14 +1,17 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { AxiosError } from 'axios';
import Card from '@/components/Card';
import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect } from '@/components/input/SelectInput';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
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 { ColumnDef } from '@tanstack/react-table';
import {
@@ -27,56 +30,70 @@ import Dropdown from '@/components/Dropdown';
import Modal, { useModal } from '@/components/Modal';
import toast from 'react-hot-toast';
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 { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Pagination from '@/components/Pagination';
import { useTableFilter } from '@/services/hooks/useTableFilter';
interface CustomerPaymentTabProps {
tabId: string;
}
interface FilterParams {
customer_ids?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
}
const dataTypeOptions: OptionType<string>[] = [
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
{ value: 'realization_date', label: 'Tanggal Realisasi' },
];
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
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 [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal();
const dataTypeOptions = useMemo(
() => [
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
{ value: 'realization_date', label: 'Tanggal Realisasi' },
],
[]
);
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>[];
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 {
options: customerOptions,
@@ -86,236 +103,188 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// ===== FORMIK SETUP =====
const formik = useFormik<CustomerPaymentFilterType>({
const formik = useFormik({
initialValues: {
start_date: null,
end_date: null,
customer_ids: null,
filter_by: null,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
filterBy: tableFilterState.filterBy,
},
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();
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
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();
},
});
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,
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
},
});
filterModal.openModal();
filterModal.closeModal();
};
const getPaymentStatusBadgeColor = (notes: string): Color => {
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';
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value || null);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.end_date);
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);
}
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);
}
},
[formik, dateErrorShown]
);
const handleEndDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value || null);
if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date);
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;
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(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;
}
},
[formik, dateErrorShown]
);
}
// ===== FILTER HELPERS =====
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 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]);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
// ===== 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: customerPayment, isLoading } = useSWR<
BaseApiResponse<CustomerPaymentReport>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`,
httpClientFetcher
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [];
const meta = useMemo(
() =>
isResponseSuccess(customerPayment) && customerPayment.meta
? customerPayment.meta
: null,
[customerPayment]
);
const meta =
isResponseSuccess(customerPayment) && customerPayment.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
> => {
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,
limit: 100,
page: 1,
};
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
const filter_by = tableFilterState.filterBy?.value as
| 'trans_date'
| 'realization_date'
| undefined;
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
customer_ids,
filter_by,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined,
1,
100
);
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [filterParams]);
}, [tableFilterState]);
// ===== 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 () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await customerPaymentExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateCustomerPaymentExcel({ data: allDataForExport });
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet(
customer_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [customerPaymentExport]);
}, [tableFilterState]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -331,22 +300,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return;
}
const customerName = filterParams.customer_ids
? customerOptions
.filter((opt) =>
filterParams.customer_ids?.split(',').includes(String(opt.value))
)
.map((opt) => opt.label)
.join(', ') || 'Semua Customer'
: 'Semua Customer';
const customerName =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => o.label).join(', ')
: 'Semua Customer';
await generateCustomerPaymentPDF({
data: allDataForExport,
params: {
customer_name: customerName,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
filter_by: filterParams.filter_by as
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
filter_by: tableFilterState.filterBy?.value as
| 'trans_date'
| 'realization_date'
| undefined,
@@ -358,106 +323,103 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport, filterParams, customerOptions]);
}, [customerPaymentExport, tableFilterState]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
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,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()}
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
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'
isLoading={isAnyExportLoading}
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>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
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
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</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={handleExportExcel}
isLoading={isExcelExportLoading}
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 - Customer Per Sheet
</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
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
filterParams,
]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const getTableColumns = (
summary: CustomerPaymentSummary
@@ -664,11 +626,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
enableSorting: false,
cell: (props) => {
const value = props.row.original.status;
if (!value) {
return '-';
}
if (!value) return '-';
return (
<StatusBadge
color={getPaymentStatusBadgeColor(value)}
@@ -707,7 +665,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return (
<>
{TabActionsElement}
<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'>
@@ -736,16 +693,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
@@ -852,16 +809,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
@@ -891,7 +848,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
@@ -901,29 +858,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput
name='start_date'
value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
isError={hasDateError}
/>
</div>
</div>
@@ -932,15 +878,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={customerIdsValue}
onChange={(val) => {
formik.setFieldValue(
'customer_ids',
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
@@ -952,14 +893,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={filterByValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('filter_by', val?.value || null);
}
}}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
className={{ wrapper: 'w-full' }}
isClearable={true}
isClearable
/>
</div>
@@ -975,7 +917,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
disabled={hasDateError}
>
Apply Filter
</Button>
@@ -9,24 +9,15 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data';
import {
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
@@ -35,6 +26,10 @@ import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import StatusBadge from '@/components/helper/StatusBadge';
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> = {
'Sudah Jatuh Tempo': 'error',
@@ -52,7 +47,6 @@ const getPillBadge = (
statusText: string,
type: 'due' | 'payment' = 'payment'
) => {
// Get color based on type
const color =
type === 'due'
? dueStatus[statusText] || 'neutral'
@@ -69,6 +63,11 @@ const getPillBadge = (
);
};
const dataTypeOptions: OptionType<string>[] = [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
];
interface DebtSupplierTabProps {
tabId: string;
}
@@ -77,28 +76,50 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
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 [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
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 {
setInputValue: setSupplierInputValue,
options: supplierOptions,
@@ -106,168 +127,180 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
// ===== FORMIK SETUP =====
const formik = useFormik<DebtSupplierFilterType>({
const formik = useFormik({
initialValues: {
startDate: null,
endDate: null,
supplierIds: null,
filterBy: null,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
},
validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => {
setFilterParams({
start_date: values.startDate?.toString() || undefined,
end_date: values.endDate?.toString() || undefined,
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);
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('suppliers', values.suppliers, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
handleFilterModalOpenRef.current = () => {
const restoredFilterBy =
dataTypeOptions.find((opt) => opt.value === filterParams.filter_by) ||
null;
const formikResetHandler = () => {
resetFilter();
const supplierIdList = filterParams.supplier_ids
? filterParams.supplier_ids.split(',')
: [];
const restoredSupplierIds = supplierOptions.filter((opt) =>
supplierIdList.includes(String(opt.value))
);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.setValues({
startDate: filterParams.start_date || null,
endDate: filterParams.end_date || null,
supplierIds: restoredSupplierIds.length > 0 ? restoredSupplierIds : null,
filterBy: restoredFilterBy,
formik.resetForm({
values: {
start_date: '',
end_date: '',
suppliers: [],
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 =====
const { data: debtSupplier, isLoading } = useSWR(
() => {
const params = {
supplier_ids: filterParams.supplier_ids,
filter_by: filterParams.filter_by,
start_date: filterParams.start_date,
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: debtSupplierResponse, isLoading } = useSWR<
BaseApiResponse<DebtSupplier[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`,
httpClientFetcher
);
const data: DebtSupplier[] = useMemo(
() =>
isResponseSuccess(debtSupplier)
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
: [],
[debtSupplier]
);
const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse)
? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? [])
: [];
const meta = useMemo(
() =>
isResponseSuccess(debtSupplier) && debtSupplier.meta
? debtSupplier.meta
: null,
[debtSupplier]
);
const meta =
isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta
? debtSupplierResponse.meta
: null;
// ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null
> => {
const params = {
supplier_ids:
formik.values.supplierIds && formik.values.supplierIds.length > 0
? 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 supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
const response = await DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined,
1,
100
);
return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[])
: null;
}, [
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
}, [tableFilterState]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await debtSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
generateDebtSupplierExcel({ data: allDataForExport });
const supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
await DebtSupplierApi.exportToExcelSupplierPerSheet(
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [debtSupplierExport]);
}, [tableFilterState]);
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 () => {
setIsPdfExportLoading(true);
@@ -283,15 +316,18 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
return;
}
const supplierName =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => o.label).join(', ')
: undefined;
await generateDebtSupplierPDF({
data: allDataForExport,
params: {
supplier_name: formik.values.supplierIds
?.map((v) => v.label)
.join(', '),
filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
supplier_name: supplierName,
filter_by: tableFilterState.filterBy?.label,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
@@ -300,129 +336,103 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
} finally {
setIsPdfExportLoading(false);
}
}, [
debtSupplierExport,
formik.values.supplierIds,
formik.values.filterBy,
formik.values.startDate,
formik.values.endDate,
]);
}, [debtSupplierExport, tableFilterState]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={filterParams}
fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()}
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
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'
isLoading={isAnyExportLoading}
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>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Button
variant='ghost'
color='none'
onClick={handleExportExcel}
isLoading={isExcelExportLoading}
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
</Button>
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</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={handleExportExcel}
isLoading={isExcelExportLoading}
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 - Supplier Per Sheet
</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
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [
tabId,
filterParams,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{
@@ -637,9 +647,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
},
},
];
return (
<>
{TabActionsElement}
<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'>
@@ -668,16 +678,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
@@ -777,16 +787,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0}
onPrevPage={() =>
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setCurrentPage((curr) =>
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
@@ -802,23 +812,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* 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'
type='button'
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>
{/* 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'
type='button'
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}>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div>
@@ -827,153 +837,68 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='startDate'
value={formik.values.startDate || ''}
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);
}
}}
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='endDate'
value={formik.values.endDate || ''}
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);
}
}}
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isError={
(formik.touched.endDate && !!formik.errors.endDate) ||
hasDateError
}
errorMessage={formik.errors.endDate}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<div>
<SelectInputCheckbox
label='Supplier'
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={
(formik.values.supplierIds as
| { value: number; label: string }
| { value: number; label: string }[]
| null
| undefined) || []
}
onChange={(val) => {
formik.setFieldValue(
'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
);
}}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/>
</div>
<SelectInputCheckbox
label='Supplier'
placeholder='Pilih Supplier'
options={supplierOptions}
value={formik.values.suppliers}
onChange={(val) =>
formik.setFieldValue('suppliers', Array.isArray(val) ? val : [])
}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
/>
<div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={
(formik.values.filterBy as
| { value: string; label: string }
| { value: string; label: string }[]
| null
| undefined) || null
}
onChange={(val) => {
formik.setFieldValue(
'filterBy',
val ? (val as OptionType) : null
);
}}
className={{ wrapper: 'w-full' }}
isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/>
</div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
{/* Action Buttons */}
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
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'
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
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
>
Apply Filter
</Button>
@@ -16,7 +16,6 @@ import {
LogisticPurchasePerSupplierReport,
LogisticPurchasePerSupplierSummary,
} from '@/types/api/report/logistic-stock';
import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX';
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
@@ -53,7 +52,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
@@ -360,25 +362,44 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await logisticPurchasePerSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generatePurchasesPerSupplierExcel({ data: allDataForExport });
await LogisticApi.exportToExcelSupplierPerSheet(
filterParams.area_id,
filterParams.supplier_id,
filterParams.product_id,
filterParams.product_category_id,
filterParams.start_date,
filterParams.end_date,
filterParams.sort_by,
filterParams.filter_by
);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [logisticPurchasePerSupplierExport]);
}, [filterParams]);
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
await LogisticApi.exportToExcelGeneral(
filterParams.area_id,
filterParams.supplier_id,
filterParams.product_id,
filterParams.product_category_id,
filterParams.start_date,
filterParams.end_date,
filterParams.sort_by,
filterParams.filter_by
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [filterParams]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
@@ -523,7 +544,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
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
Export to Excel - Supplier Per Sheet
</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
variant='ghost'
@@ -553,8 +584,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
filterParams,
isAnyExportLoading,
handleExportExcel,
handleExportExcelGeneral,
handleExportPdf,
isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading,
]);
@@ -20,6 +20,7 @@ interface DatePickerProps {
disabled?: boolean;
placeholder?: string;
formatDisplay?: (date: string) => string;
hasError?: boolean;
}
export function DatePicker({
@@ -28,6 +29,7 @@ export function DatePicker({
disabled = false,
placeholder = 'Select date',
formatDisplay,
hasError = false,
}: DatePickerProps) {
const [open, setOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(() => {
@@ -154,7 +156,7 @@ export function DatePicker({
<Button
variant='outline'
disabled={disabled}
className='w-full justify-start text-left font-normal border-gray-200 hover:bg-gray-50'
className={`w-full justify-start text-left font-normal hover:bg-gray-50 ${hasError ? 'border-red-500 focus:ring-red-500' : 'border-gray-200'}`}
>
<CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
{date ? (
@@ -181,6 +181,13 @@ export function DailyChecklistContent() {
const [initialLoading, setInitialLoading] = useState(!!checklistIdFromUrl);
const [emptyKandangEndDate, setEmptyKandangEndDate] = useState<string>('');
const [emptyKandangEndDateError, setEmptyKandangEndDateError] =
useState<string>('');
const [preloadedKandang, setPreloadedKandang] = useState<{
id: string;
name: string;
} | null>(null);
const [existingDocuments, setExistingDocuments] = useState<Document[]>([]);
const [documents, setDocuments] = useState<File[]>([]);
@@ -226,7 +233,11 @@ export function DailyChecklistContent() {
const rawDate = data.date || '';
setDate(rawDate.length > 10 ? rawDate.slice(0, 10) : rawDate);
skipKandangClearRef.current = true;
setKandangId(String(data.kandang?.id || ''));
const loadedKandangId = String(data.kandang?.id || '');
setKandangId(loadedKandangId);
if (data.kandang?.name) {
setPreloadedKandang({ id: loadedKandangId, name: data.kandang.name });
}
const isEmptyKandang =
!!data.empty_kandang || data.category === 'empty_kandang';
@@ -788,6 +799,11 @@ export function DailyChecklistContent() {
return;
}
if (emptyKandang && !emptyKandangEndDate) {
setEmptyKandangEndDateError('Tanggal akhir kandang kosong wajib diisi');
return;
}
setIsLoadingDraft(true);
try {
@@ -865,6 +881,11 @@ export function DailyChecklistContent() {
return;
}
if (emptyKandang && !emptyKandangEndDate) {
setEmptyKandangEndDateError('Tanggal akhir kandang kosong wajib diisi');
return;
}
if (!isKandangEmpty) {
if (selectedEmployees.length === 0) {
toast.error('Pilih minimal 1 ABK');
@@ -1150,9 +1171,17 @@ export function DailyChecklistContent() {
<SelectValue placeholder='Pilih kandang' />
</SelectTrigger>
<SelectContent onScroll={handleKandangScroll}>
{kandangOptions.map((kandang) => (
{preloadedKandang &&
!kandangOptions.some(
(k) => String(k.value) === preloadedKandang.id
) && (
<SelectItem value={preloadedKandang.id}>
{preloadedKandang.name}
</SelectItem>
)}
{kandangOptions.map((kandang, kandangIdx) => (
<SelectItem
key={kandang.value}
key={`${kandang.value}-${kandangIdx}`}
value={String(kandang.value)}
>
{kandang.label}
@@ -1224,11 +1253,20 @@ export function DailyChecklistContent() {
<div className='mt-1.5'>
<DatePicker
date={emptyKandangEndDate}
onDateChange={setEmptyKandangEndDate}
onDateChange={(val) => {
setEmptyKandangEndDate(val);
if (val) setEmptyKandangEndDateError('');
}}
disabled={!isChecklistStatusDraft}
placeholder='Pilih tanggal'
formatDisplay={formatDateForDisplay}
hasError={!!emptyKandangEndDateError}
/>
{emptyKandangEndDateError && (
<p className='text-xs text-red-500 mt-1'>
{emptyKandangEndDateError}
</p>
)}
</div>
</div>
</div>
+25
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient, httpClientFetcher } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
import {
CreateFinancePayment,
CreateInitialBalance,
@@ -174,6 +175,30 @@ 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) {
try {
const deletePath = `${this.basePath}/transactions/${id}`;
+20 -8
View File
@@ -45,8 +45,11 @@ export class SalesOrderService extends BaseApiService<
notes: notes || `${action} marketing ${id}`,
},
});
} catch (error) {
throw error;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
@@ -68,8 +71,11 @@ export class SalesOrderService extends BaseApiService<
notes: notes || `${action} marketing ${ids.join(', ')}`,
},
});
} catch (error) {
throw error;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
@@ -110,8 +116,11 @@ export class SalesOrderService extends BaseApiService<
notes: notes || `Delivery marketing ${id}`,
},
});
} catch (error) {
throw error;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
}
@@ -142,8 +151,11 @@ class MarketingExportService extends BaseApiService<
notes: notes,
},
});
} catch (error) {
throw error;
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<Marketing[] | Marketing>>(error)) {
return error.response?.data;
}
return undefined;
}
}
+24
View File
@@ -6,6 +6,7 @@ import {
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import axios from 'axios';
export class ChickinService extends BaseApiService<
Chickin,
@@ -16,6 +17,29 @@ export class ChickinService extends BaseApiService<
super(basePath);
}
async updateChickinDate(
projectFlockKandangId: number,
chickInDate: string
): Promise<BaseApiResponse<{ message: string }> | undefined> {
try {
return await httpClient<BaseApiResponse<{ message: string }>>(
`${this.basePath}/chick-in-date`,
{
method: 'PATCH',
body: {
project_flock_kandang_id: projectFlockKandangId,
chick_in_date: chickInDate,
},
}
);
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Approve single marketing data
*/
+78
View File
@@ -1,4 +1,6 @@
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 { DebtSupplier } from '@/types/api/report/debt-supplier';
@@ -11,6 +13,82 @@ export class DebtSupplierApiService extends BaseApiService<
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(
supplier_ids?: string,
filter_by?: string,
+126
View File
@@ -1,6 +1,9 @@
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
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<
CustomerPaymentReport,
@@ -11,6 +14,129 @@ export class FinanceApiService extends BaseApiService<
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 exportBalanceMonitoringToExcel(
customer_ids?: string,
sales_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 (sales_ids) params.set('sales_ids', sales_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}/balance-monitoring?${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',
`laporan-balance-monitoring-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`
);
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(
customer_ids?: string,
// TODO: Uncomment when BE is ready
+111
View File
@@ -1,4 +1,6 @@
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 { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
@@ -11,6 +13,115 @@ export class LogisticApiService extends BaseApiService<
super(basePath);
}
private buildPurchaseSupplierParams(
area_id?: string,
supplier_id?: string,
product_id?: string,
product_category_id?: string,
start_date?: string,
end_date?: string,
sort_by?: string,
filter_by?: string
): URLSearchParams {
const params = new URLSearchParams();
if (area_id) params.set('area_id', area_id);
if (supplier_id) params.set('supplier_id', supplier_id);
if (product_id) params.set('product_id', product_id);
if (product_category_id)
params.set('product_category_id', product_category_id);
if (filter_by === 'received_date' && start_date)
params.set('received_date', start_date);
if (filter_by === 'po_date' && start_date)
params.set('po_date', start_date);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
if (sort_by) params.set('sort_by', sort_by);
if (filter_by) params.set('filter_by', filter_by);
return params;
}
async exportToExcelSupplierPerSheet(
area_id?: string,
supplier_id?: string,
product_id?: string,
product_category_id?: string,
start_date?: string,
end_date?: string,
sort_by?: string,
filter_by?: string
) {
const params = this.buildPurchaseSupplierParams(
area_id,
supplier_id,
product_id,
product_category_id,
start_date,
end_date,
sort_by,
filter_by
);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(
`${this.basePath.replace(/\/$/, '')}/purchase-supplier?${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',
`laporan-pembelian-per-supplier-per-sheet-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`
);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportToExcelGeneral(
area_id?: string,
supplier_id?: string,
product_id?: string,
product_category_id?: string,
start_date?: string,
end_date?: string,
sort_by?: string,
filter_by?: string
) {
const params = this.buildPurchaseSupplierParams(
area_id,
supplier_id,
product_id,
product_category_id,
start_date,
end_date,
sort_by,
filter_by
);
params.set('export', 'excel-all');
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(
`${this.basePath.replace(/\/$/, '')}/purchase-supplier?${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',
`laporan-pembelian-per-supplier-general-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`
);
document.body.appendChild(link);
link.click();
link.remove();
}
async getLogisticPurchasePerSupplierReport(
area_id?: string,
supplier_id?: string,
+8
View File
@@ -23,6 +23,8 @@ export type BaseMarketing = {
latest_approval: BaseApproval;
sales_order: BaseSalesOrder[];
delivery_order: BaseDeliveryOrder[];
grand_total_do: number;
grand_total_so: number;
};
export type BaseSalesOrder = {
@@ -104,6 +106,12 @@ export type MarketingFilter = {
project_flock_name?: string;
project_flock_kandang_id?: number;
project_flock_kandang_name?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
filter_by_name?: string;
warehouse_id?: number;
warehouse_name?: string;
};
/**
+8
View File
@@ -57,6 +57,8 @@ export type PurchaseItem = {
alias?: string;
category?: string;
} | null;
expedition_qty?: number;
expedition_total?: number;
};
export type BasePurchase = {
@@ -81,6 +83,9 @@ export type BasePurchase = {
po_expedition?: { id: number; refrence: string }[];
created_user?: CreatedUser;
products?: PurchaseItemProduct[];
products_total?: number;
expedition_total?: number;
grand_total_all?: number;
};
export type Purchase = BaseMetadata & BasePurchase;
@@ -149,6 +154,9 @@ export type UpdatePurchaseRequestPayload = CreatePurchaseRequestPayload;
export type PurchaseFilter = {
poDate: string;
start_date?: string;
end_date?: string;
filterBy?: { label: string; value: string };
category: string[];
category_labels?: { label: string; value: number }[];
status: string[];
+25
View File
@@ -0,0 +1,25 @@
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;
};