Compare commits

...

57 Commits

Author SHA1 Message Date
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
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
Rivaldi A N S 456070491f Merge branch 'fix/marketing' into 'development'
[FIX/FE] Marketing

See merge request mbugroup/lti-web-client!481
2026-05-18 07:43:05 +00:00
ValdiANS c12beca4d7 fix: recalculate qty if product change 2026-05-18 14:26:52 +07:00
ValdiANS 910981645b fix: remove unnecessary code 2026-05-18 14:25:19 +07:00
ValdiANS 82b5429d02 fix: update DeliveryOrderSchema validation, make all delivery_order should valid instead of some 2026-05-18 14:24:59 +07:00
ValdiANS 6c6f739fc0 fix: remove onAfterSubmit callback in useFormikErrorList 2026-05-18 14:20:30 +07:00
ValdiANS 001dafecb7 fix: adjust copywriting for approve button based on approval step number 2026-05-18 14:18:35 +07:00
Rivaldi A N S 4bb3ada779 Merge branch 'feat/server-side-sorting' into 'development'
[FEAT/FE] Server-Side Sorting

See merge request mbugroup/lti-web-client!480
2026-05-18 04:38:58 +00:00
ValdiANS 0b63dcb532 feat: implement server-side sorting in FinanceTable 2026-05-18 11:37:40 +07:00
Rivaldi A N S 23dd220b2f Merge branch 'fix/daily-checklist' into 'development'
[FIX/FE] Daily Checklist

See merge request mbugroup/lti-web-client!479
2026-05-18 03:46:22 +00:00
ValdiANS 770c293257 fix: adjust empty_kandang type in BaseDailyChecklist 2026-05-18 10:25:27 +07:00
ValdiANS 3374ab4779 fix: show Tanggal Selesai Kandang Kosong if category is empty kandang 2026-05-18 10:25:10 +07:00
ValdiANS 7a668c0cf9 fix: adjust empty kandang condition check 2026-05-18 10:20:52 +07:00
Rivaldi A N S 14151f6f5a Merge branch 'feat/add-bank-name-in-supplier-customer' into 'development'
[FEAT/FE] Bank Name in Supplier & Customer

See merge request mbugroup/lti-web-client!478
2026-05-13 09:26:25 +00:00
ValdiANS 0275e66eda feat: add bank_name 2026-05-13 16:25:35 +07:00
ValdiANS 9bc5842493 feat: add bank name input 2026-05-13 16:25:25 +07:00
ValdiANS 4cad8aba64 feat: add bank name column 2026-05-13 16:25:13 +07:00
Rivaldi A N S 7b5af69dd1 Merge branch 'feat/server-side-sorting-purchasing-expense' into 'development'
[FEAT/FE] Server-Side Sorting Purchasing & Expense

See merge request mbugroup/lti-web-client!477
2026-05-13 08:49:54 +00:00
ValdiANS 2e179b74ba fix: add sort for PO number 2026-05-13 15:29:19 +07:00
ValdiANS fe2a2dfb43 fix: add loading state to approve modal 2026-05-13 15:29:03 +07:00
ValdiANS 910a36857e fix: pass the rest of secondaryButton props 2026-05-13 15:26:30 +07:00
ValdiANS 58ddd9b991 fix: set sortDescFirst false 2026-05-13 15:26:10 +07:00
Rivaldi A N S bb9c6ab969 Merge branch 'fix/marketing' into 'development'
[FIX/FE] Marketing

See merge request mbugroup/lti-web-client!476
2026-05-13 06:55:55 +00:00
ValdiANS ddffdd1b27 fix: adjust marketing_type default value 2026-05-13 13:46:59 +07:00
Rivaldi A N S f097620c4b Merge branch 'feat/server-side-sorting-purchasing-expense' into 'development'
[FEAT/FE] Server-Side Sorting Purchasing & Expense

See merge request mbugroup/lti-web-client!475
2026-05-13 04:18:52 +00:00
ValdiANS 280d790f0c fix: add created_at column 2026-05-13 10:51:46 +07:00
ValdiANS 3a2e74b559 feat: implement server-side sorting 2026-05-13 10:51:35 +07:00
36 changed files with 2221 additions and 966 deletions
+3
View File
@@ -48,3 +48,6 @@ next-env.d.ts
# rtk # rtk
rtk.exe 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"
+263 -39
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 - Apply to: search handlers, filter form submissions, reset handlers
3. **Create custom formikResetHandler function** 3. **Create custom formikResetHandler function**
- Clear each filter with `updateFilter(fieldName, defaultValue, true)` - Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
- Call `formik.resetForm({ values: { ...defaults } })` - Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
- Close the modal at the end - Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
- Attach to both button `onClick` and form `onReset` handler - Call `filterModal.closeModal()` at the end
- Attach to form `onReset` handler (not `formik.handleReset`)
**Optimization: Avoid useCallback for simple handlers**
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
- Simple pass-through handlers don't need it:
```tsx ```tsx
// ✅ Good: Simple handler without useCallback const formikResetHandler = () => {
const handleFilterChange = (val) => setFieldValue('location', val); 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}>
```
// ❌ Avoid: Unnecessary useCallback overhead **Optimization: Avoid useCallback and useMemo for trivial operations**
const handleFilterChange = useCallback(
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
- Simple derivations and pass-through handlers don't need them:
```tsx
// ✅ Good: plain derivation
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
const meta =
isResponseSuccess(response) && response.meta ? response.meta : null;
// ❌ Avoid: useMemo for trivial conditional access
const data = useMemo(
() => (isResponseSuccess(response) ? (response.data ?? []) : []),
[response]
);
// ✅ Good: simple handler
const handleChange = (val) => setFieldValue('location', val);
// ❌ Avoid: unnecessary useCallback
const handleChange = useCallback(
(val) => setFieldValue('location', val), (val) => setFieldValue('location', val),
[setFieldValue] [setFieldValue]
); );
``` ```
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
**Best practice: Store OptionType objects directly, not IDs** **Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object 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 ```tsx
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{ const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string; search: string;
locationFilter?: OptionType<string>; customers: OptionType<number>[]; // multi-select → serializes as CSV
picFilter?: OptionType<string>; location?: OptionType<string>; // single-select → serializes as value string
filterBy?: OptionType<string>; // single-select radio
}>({ }>({
initial: { initial: {
search: '', search: '',
locationFilter: undefined, customers: [],
picFilter: undefined location: undefined,
filterBy: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
locationFilter: 'location_id', customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
picFilter: 'pic_id', location: 'location_id', // serializes OptionType → "abc"
filterBy: 'filter_by',
}, },
persist: true, persist: true,
storeName: 'kandangs-table', storeName: 'my-table',
}); });
// Initialize formik with tableFilterState values (now typed OptionType objects) // Initialize formik directly from tableFilterState (no hardcoded defaults)
const formik = useFormik<KandangFilterType>({ const formik = useFormik({
initialValues: { initialValues: {
location: tableFilterState.locationFilter, customers: tableFilterState.customers,
pic: tableFilterState.picFilter, location: tableFilterState.location,
filterBy: tableFilterState.filterBy,
}, },
... ...
}); });
// Handlers store the complete OptionType, not just the ID // Use formik values directly — no computed helpers needed
const handleFilterLocationChange = useCallback( <SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
(val) => setFieldValue('location', val), <SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
[setFieldValue] <SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
``` ```
**Filter field naming convention**
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
```tsx
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
// ❌ Avoid: unnecessary ref indirection
const handleFilterModalOpenRef = useRef(() => {});
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
// ✅ Correct: pass openModal directly
<ButtonFilter onClick={filterModal.openModal} ... />
```
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
**Apply this pattern to:** **Apply this pattern to:**
- Any data table component across any module that needs persistent filters - Any data table component across any module that needs persistent filters
@@ -159,7 +207,31 @@ const handleFilterLocationChange = useCallback(
**Reference implementations:** **Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` - `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 ## 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. - 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). **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;
+1
View File
@@ -173,6 +173,7 @@ const Table = <TData extends object>({
const tableOptions: TableOptions<TData> = { const tableOptions: TableOptions<TData> = {
columns, columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
defaultColumn: { sortDescFirst: false },
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
+5 -2
View File
@@ -6,6 +6,7 @@ export interface TabItem {
label: ReactNode; label: ReactNode;
content?: ReactNode; content?: ReactNode;
disabled?: boolean; disabled?: boolean;
hide?: boolean;
} }
export interface TabsProps export interface TabsProps
@@ -122,7 +123,8 @@ const Tabs = ({
> >
<div className={getSideContentClasses()}> <div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}> <div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled }) => ( {tabs.map(({ id, label, disabled, hide }) =>
hide ? null : (
<button <button
key={id} key={id}
role='tab' role='tab'
@@ -132,7 +134,8 @@ const Tabs = ({
> >
{label} {label}
</button> </button>
))} )
)}
</div> </div>
{sideContent && sideContent} {sideContent && sideContent}
</div> </div>
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
secondaryButton={ secondaryButton={
secondaryButton secondaryButton
? { ? {
...secondaryButton,
text: secondaryButton?.text ?? 'Tidak', text: secondaryButton?.text ?? 'Tidak',
onClick: (e) => { onClick: (e) => {
if (secondaryButton && secondaryButton?.onClick) { if (secondaryButton && secondaryButton?.onClick) {
+45 -24
View File
@@ -1,18 +1,13 @@
'use client'; 'use client';
import { import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
CellContext, CellContext,
ColumnDef, ColumnDef,
Row, Row,
SortingState, SortingState,
Updater,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -47,7 +42,8 @@ import { BaseApiResponse } from '@/types/api/api-general';
type ExpenseTableFilters = { type ExpenseTableFilters = {
search: string; search: string;
nameSort: string; sort_by: string;
order_by: string;
transactionDate: string; transactionDate: string;
realizationDate: string; realizationDate: string;
locationId: string; locationId: string;
@@ -242,7 +238,8 @@ const ExpensesTable = () => {
page: 1, page: 1,
pageSize: 10, pageSize: 10,
search: '', search: '',
nameSort: '', sort_by: '',
order_by: '',
transactionDate: '', transactionDate: '',
realizationDate: '', realizationDate: '',
locationId: '', locationId: '',
@@ -261,7 +258,8 @@ const ExpensesTable = () => {
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
nameSort: 'sort_name', sort_by: 'sort_by',
order_by: 'sort_order',
transactionDate: 'transaction_date', transactionDate: 'transaction_date',
realizationDate: 'realization_date', realizationDate: 'realization_date',
locationId: 'location_id', locationId: 'location_id',
@@ -319,7 +317,26 @@ const ExpensesTable = () => {
const [exportProgressStartDate, setExportProgressStartDate] = useState(''); const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState(''); const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const [sorting, setSorting] = useState<SortingState>([]); 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 [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) => const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item) parseInt(item)
@@ -437,10 +454,12 @@ const ExpensesTable = () => {
cell: (props) => props.row.original.location?.name ?? '-', cell: (props) => props.row.original.location?.name ?? '-',
}, },
{ {
accessorKey: 'created_user',
accessorFn: (row) => row.created_user.name ?? '-', accessorFn: (row) => row.created_user.name ?? '-',
header: 'Nama Pengaju', header: 'Nama Pengaju',
}, },
{ {
accessorKey: 'supplier',
accessorFn: (row) => row.supplier.name ?? '-', accessorFn: (row) => row.supplier.name ?? '-',
header: 'Uraian', header: 'Uraian',
}, },
@@ -454,17 +473,20 @@ const ExpensesTable = () => {
}, },
{ {
header: 'Status Pencairan', header: 'Status Pencairan',
enableSorting: false,
cell: (props) => ( cell: (props) => (
<RealizationStatusBadge approval={props.row.original.latest_approval} /> <RealizationStatusBadge approval={props.row.original.latest_approval} />
), ),
}, },
{ {
header: 'Status BOP', header: 'Status BOP',
enableSorting: false,
cell: (props) => ( cell: (props) => (
<ExpenseStatusBadge approval={props.row.original.latest_approval} /> <ExpenseStatusBadge approval={props.row.original.latest_approval} />
), ),
}, },
{ {
accessorKey: 'is_paid',
header: 'Status Lunas', header: 'Status Lunas',
cell: (props) => { cell: (props) => {
return ( return (
@@ -478,6 +500,14 @@ const ExpensesTable = () => {
); );
}, },
}, },
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
: '-',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
@@ -882,17 +912,6 @@ const ExpensesTable = () => {
} }
}, [getTableFilterQueryString]); }, [getTableFilterQueryString]);
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '', false);
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
}, [sorting, updateFilter]);
return ( return (
<> <>
<div className='w-full'> <div className='w-full'>
@@ -1051,7 +1070,8 @@ const ExpensesTable = () => {
'page', 'page',
'pageSize', 'pageSize',
'search', 'search',
'nameSort', 'sort_by',
'order_by',
'userId', 'userId',
'locationName', 'locationName',
'vendorName', 'vendorName',
@@ -1152,7 +1172,8 @@ const ExpensesTable = () => {
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={handleSortingChange}
manualSorting
rowSelection={rowSelection} rowSelection={rowSelection}
setRowSelection={setRowSelection} setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler} enableRowSelection={tableEnableRowSelectionHandler}
+66 -18
View File
@@ -1,7 +1,12 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table'; import {
CellContext,
ColumnDef,
SortingState,
Updater,
} from '@tanstack/react-table';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -183,7 +188,8 @@ const FinanceTable = () => {
bankIds: '', bankIds: '',
customerIds: '', customerIds: '',
supplierIds: '', supplierIds: '',
sortBy: '', sort_by: '',
orderBy: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
bankNames: '', bankNames: '',
@@ -197,7 +203,8 @@ const FinanceTable = () => {
bankIds: 'bank_ids', bankIds: 'bank_ids',
customerIds: 'customer_ids', customerIds: 'customer_ids',
supplierIds: 'supplier_ids', supplierIds: 'supplier_ids',
sortBy: 'sort_date', sort_by: 'sort_by',
orderBy: 'sort_order',
startDate: 'start_date', startDate: 'start_date',
endDate: 'end_date', endDate: 'end_date',
}, },
@@ -248,7 +255,7 @@ const FinanceTable = () => {
updateFilter('bankIds', values.bank_ids, true); updateFilter('bankIds', values.bank_ids, true);
updateFilter('customerIds', values.customer_ids, true); updateFilter('customerIds', values.customer_ids, true);
updateFilter('supplierIds', values.supplier_ids, true); updateFilter('supplierIds', values.supplier_ids, true);
updateFilter('sortBy', values.sort_by, true); updateFilter('sort_by', values.sort_by, true);
updateFilter('startDate', values.start_date, true); updateFilter('startDate', values.start_date, true);
updateFilter('endDate', values.end_date, true); updateFilter('endDate', values.end_date, true);
// Save display names for restoration on modal reopen // Save display names for restoration on modal reopen
@@ -276,7 +283,8 @@ const FinanceTable = () => {
updateFilter('bankIds', '', true); updateFilter('bankIds', '', true);
updateFilter('customerIds', '', true); updateFilter('customerIds', '', true);
updateFilter('supplierIds', '', true); updateFilter('supplierIds', '', true);
updateFilter('sortBy', '', true); updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
updateFilter('startDate', '', true); updateFilter('startDate', '', true);
updateFilter('endDate', '', true); updateFilter('endDate', '', true);
updateFilter('bankNames', '', true); updateFilter('bankNames', '', true);
@@ -394,6 +402,26 @@ const FinanceTable = () => {
); );
}; };
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.orderBy === '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('orderBy', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
}
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
const endDate = filterFormik.values.end_date; const endDate = filterFormik.values.end_date;
@@ -505,7 +533,7 @@ const FinanceTable = () => {
// Restore sort by // Restore sort by
const restoredSortBy = const restoredSortBy =
sortByOptions.find( sortByOptions.find(
(opt) => String(opt.value) === tableFilterState.sortBy (opt) => String(opt.value) === tableFilterState.sort_by
) || null; ) || null;
setSelectedSortBy(restoredSortBy); setSelectedSortBy(restoredSortBy);
@@ -516,7 +544,7 @@ const FinanceTable = () => {
bank_ids: tableFilterState.bankIds || '', bank_ids: tableFilterState.bankIds || '',
customer_ids: tableFilterState.customerIds || '', customer_ids: tableFilterState.customerIds || '',
supplier_ids: tableFilterState.supplierIds || '', supplier_ids: tableFilterState.supplierIds || '',
sort_by: tableFilterState.sortBy || '', sort_by: tableFilterState.sort_by || '',
start_date: tableFilterState.startDate || '', start_date: tableFilterState.startDate || '',
end_date: tableFilterState.endDate || '', end_date: tableFilterState.endDate || '',
}); });
@@ -540,10 +568,12 @@ const FinanceTable = () => {
{ {
header: 'ID', header: 'ID',
accessorKey: 'payment_code', accessorKey: 'payment_code',
enableSorting: true,
}, },
{ {
header: 'References Number', header: 'References Number',
accessorKey: 'reference_number', accessorKey: 'reference_number',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.reference_number; const value = props.row.original.reference_number;
return <span>{value ?? '-'}</span>; return <span>{value ?? '-'}</span>;
@@ -552,6 +582,7 @@ const FinanceTable = () => {
{ {
header: 'Jenis Transaksi', header: 'Jenis Transaksi',
accessorKey: 'transaction_type', accessorKey: 'transaction_type',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.transaction_type const value = props.row.original.transaction_type
.split('_') .split('_')
@@ -561,7 +592,8 @@ const FinanceTable = () => {
}, },
{ {
header: 'Pihak', header: 'Pihak',
accessorFn: (finance: Finance) => finance.party?.name, accessorKey: 'customer_name',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party?.id) { if (props.row.original.party?.id) {
return <span>{props.row.original.party?.name}</span>; return <span>{props.row.original.party?.name}</span>;
@@ -571,16 +603,22 @@ const FinanceTable = () => {
}, },
{ {
header: 'Tanggal Pembayaran', header: 'Tanggal Pembayaran',
accessorFn: (finance: Finance) => accessorKey: 'payment_date',
formatDate(finance.payment_date, 'DD MMM YYYY'), enableSorting: true,
cell: (props) =>
formatDate(props.row.original.payment_date, 'DD MMM YYYY'),
}, },
{ {
header: 'Tanggal Dibuat', header: 'Tanggal Dibuat',
accessorFn: (finance) => formatDate(finance.created_at, 'DD MMM YYYY'), accessorKey: 'created_at',
enableSorting: true,
cell: (props) =>
formatDate(props.row.original.created_at, 'DD MMM YYYY'),
}, },
{ {
header: 'Metode Pembayaran', header: 'Metode Pembayaran',
accessorKey: 'payment_method', accessorKey: 'payment_method',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.payment_method.split('_').join(' '); const value = props.row.original.payment_method.split('_').join(' ');
return <span>{formatTitleCase(value)}</span>; return <span>{formatTitleCase(value)}</span>;
@@ -588,20 +626,26 @@ const FinanceTable = () => {
}, },
{ {
header: 'Bank', header: 'Bank',
accessorFn: (finance: Finance) => accessorKey: 'bank',
finance.bank enableSorting: true,
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}` cell: (props) =>
props.row.original.bank
? `${props.row.original.bank?.alias} - ${props.row.original.bank?.account_number} - ${props.row.original.bank?.owner}`
: '-', : '-',
}, },
{ {
header: 'Pengeluaran (Rp)', header: 'Pengeluaran (Rp)',
accessorFn: (finance: Finance) => accessorKey: 'expense_amount',
formatCurrency(Math.abs(finance.expense_amount)), enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.expense_amount)),
}, },
{ {
header: 'Pemasukan (Rp)', header: 'Pemasukan (Rp)',
accessorFn: (finance: Finance) => accessorKey: 'income_amount',
formatCurrency(Math.abs(finance.income_amount)), enableSorting: true,
cell: (props) =>
formatCurrency(Math.abs(props.row.original.income_amount)),
}, },
{ {
header: 'Aksi', header: 'Aksi',
@@ -707,6 +751,7 @@ const FinanceTable = () => {
'page', 'page',
'pageSize', 'pageSize',
'search', 'search',
'orderBy',
'bankNames', 'bankNames',
'customerNames', 'customerNames',
'supplierNames', 'supplierNames',
@@ -749,6 +794,9 @@ const FinanceTable = () => {
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{ className={{
containerClassName: cn('p-3 mb-0'), containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap', headerColumnClassName: 'text-nowrap',
@@ -849,7 +849,11 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold' className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected} disabled={deliveryRejected}
> >
Approve {marketing?.data?.latest_approval?.step_number === 1 &&
'Approve'}
{marketing?.data?.latest_approval?.step_number === 2 &&
'Deliver Item'}
</Button> </Button>
</div> </div>
)} )}
@@ -297,6 +297,8 @@ const MarketingTable = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false); useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeliveryLoading, setIsDeliveryLoading] = useState(false);
const filterResetHandler = () => { const filterResetHandler = () => {
updateFilter('product_ids', '', true); updateFilter('product_ids', '', true);
@@ -452,6 +454,9 @@ const MarketingTable = () => {
return; return;
} }
setIsApproveLoading(true);
try {
const approveMarketingRes: BaseApiResponse<unknown> | undefined = const approveMarketingRes: BaseApiResponse<unknown> | undefined =
approveAction === 'APPROVED' approveAction === 'APPROVED'
? await MarketingApi.bulkApprovals( ? await MarketingApi.bulkApprovals(
@@ -460,7 +465,11 @@ const MarketingTable = () => {
'', '',
notes || `APPROVED marketing ${idsToProcess.join(', ')}` notes || `APPROVED marketing ${idsToProcess.join(', ')}`
) )
: await SalesOrderApi.bulkApprovals(idsToProcess, approveAction, notes); : await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) { if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal(); confirmationModal.closeModal();
@@ -469,6 +478,9 @@ const MarketingTable = () => {
} }
refreshMarketing(); refreshMarketing();
} finally {
setIsApproveLoading(false);
}
}; };
const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = ( const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
@@ -530,13 +542,21 @@ const MarketingTable = () => {
}; };
const confirmationModalDeliveryClickHandler = async (notes: string) => { const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes); setIsDeliveryLoading(true);
try {
const res = await SalesOrderApi.delivery(
selectedItem?.id as number,
notes
);
deliveryModal.closeModal(); deliveryModal.closeModal();
toast.success(res?.message as string); toast.success(res?.message as string);
refreshMarketing?.(); refreshMarketing?.();
router.push( router.push(
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}` `/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
); );
} finally {
setIsDeliveryLoading(false);
}
}; };
const getRowCanSelect = useCallback( const getRowCanSelect = useCallback(
@@ -772,6 +792,14 @@ const MarketingTable = () => {
} }
}, },
}, },
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM yyyy')
: '-',
},
{ {
id: 'actions', id: 'actions',
maxSize: 80, maxSize: 80,
@@ -1012,11 +1040,13 @@ const MarketingTable = () => {
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`} text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
isLoading: isApproveLoading,
onClick: confirmationModal.closeModal, onClick: confirmationModal.closeModal,
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error', color: approveAction === 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: approveMarketingHandler, onClick: approveMarketingHandler,
}} }}
/> />
@@ -1040,10 +1070,12 @@ const MarketingTable = () => {
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`} text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
isLoading: isDeliveryLoading,
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: 'success',
isLoading: isDeliveryLoading,
onClick: confirmationModalDeliveryClickHandler, onClick: confirmationModalDeliveryClickHandler,
}} }}
/> />
@@ -1103,6 +1135,7 @@ const MarketingTable = () => {
<Button <Button
variant='outline' variant='outline'
color='none' color='none'
disabled={isSubmittingBulkDelivery}
onClick={() => { onClick={() => {
bulkDeliveryModal.closeModal(); bulkDeliveryModal.closeModal();
setBulkDeliveryDate(''); setBulkDeliveryDate('');
@@ -1115,6 +1148,7 @@ const MarketingTable = () => {
<Button <Button
color='success' color='success'
isLoading={isSubmittingBulkDelivery} isLoading={isSubmittingBulkDelivery}
disabled={isSubmittingBulkDelivery}
onClick={() => onClick={() =>
submitBulkDeliveryApprovalHandler( submitBulkDeliveryApprovalHandler(
idsToProcess, idsToProcess,
@@ -246,6 +246,7 @@ const SalesOrderFormModal = ({
}) })
.filter((item) => Boolean(item)), .filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload); } as UpdateDeliveryOrderPayload);
switch (modalAction) { switch (modalAction) {
case 'add': case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload); await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -261,11 +262,7 @@ const SalesOrderFormModal = ({
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, setFormErrorList, close, handleFormSubmit } = const { formErrorList, setFormErrorList, close, handleFormSubmit } =
useFormikErrorList(formik, { useFormikErrorList(formik);
onAfterSubmit: () => {
router.push('/marketing');
},
});
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
@@ -71,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
.required('Pengiriman wajib diisi!') .required('Pengiriman wajib diisi!')
.test( .test(
'at-least-one-valid-row', 'at-least-one-valid-row',
'Minimal harus ada satu baris pengiriman yang lengkap diisi!', 'Seluruh data pengiriman harus diisi lengkap!',
function (items) { function (items) {
if (!items || items.length === 0) return false; if (!items || items.length === 0) return false;
// VALIDASI: minimal 1 item valid full // VALIDASI: seluruh item harus valid full
const itemSchema = DeliveryOrderProductSchema; const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.some((item) => { const hasValidItem = items.every((item) => {
if (!item) return false; if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true }); return itemSchema.isValidSync(item, { abortEarly: true });
}); });
@@ -123,8 +123,17 @@ export const SalesProductToFieldValues = (
total_price: product.total_price, total_price: product.total_price,
marketing_type: product.marketing_type marketing_type: product.marketing_type
? { ? {
value: product.marketing_type, value:
label: formatTitleCase(product.marketing_type), product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: product.marketing_type,
label: formatTitleCase(
product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: product.marketing_type
),
} }
: null, : null,
convertion_unit: product.convertion_unit convertion_unit: product.convertion_unit
@@ -185,8 +194,17 @@ export const DeliveryProductToFieldValues = (
marketing_product_id: item.marketing_product_id ?? salesOrder?.id, marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
marketing_type: salesOrder?.marketing_type marketing_type: salesOrder?.marketing_type
? { ? {
value: salesOrder?.marketing_type, value:
label: formatTitleCase(salesOrder?.marketing_type), salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: salesOrder?.marketing_type,
label: formatTitleCase(
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: salesOrder?.marketing_type
),
} }
: null, : null,
convertion_unit: salesOrder?.convertion_unit convertion_unit: salesOrder?.convertion_unit
@@ -146,15 +146,6 @@ const DeliveryOrderProductForm = ({
); );
// ============ Fetch Data ============ // ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
// Options Week dari minggu 1 - 22 // Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => { // const optionsWeek = useMemo(() => {
@@ -440,7 +431,8 @@ const DeliveryOrderProductForm = ({
handleBlurField(currentInput); handleBlurField(currentInput);
formik.setFieldValue( formik.setFieldValue(
'uom', 'uom',
isResponseSuccess(productData) ? productData?.data?.uom?.name : '' initialValues?.marketing_product?.product_warehouse_data?.product?.uom
?.name ?? ''
); );
}, },
} }
@@ -813,9 +805,8 @@ const DeliveryOrderProductForm = ({
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'> <span className='text-sm text-gray-500'>
{isResponseSuccess(productData) {initialValues?.marketing_product?.product_warehouse_data
? productData?.data?.uom.name ?.product?.uom?.name ?? ''}
: ''}
</span> </span>
</div> </div>
} }
@@ -826,9 +817,8 @@ const DeliveryOrderProductForm = ({
(item) => item.id === formik.values.marketing_product_id (item) => item.id === formik.values.marketing_product_id
)?.qty + )?.qty +
' ' + ' ' +
(isResponseSuccess(productData) (initialValues?.marketing_product?.product_warehouse_data
? productData?.data?.uom.name ?.product?.uom?.name ?? '')
: '')
: '' : ''
} }
/> />
@@ -252,6 +252,11 @@ const SalesOrderProductForm = ({
setSelectedProductWarehouse(productWarehouse || null); setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null); formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
if (productWarehouse?.quantity) {
handleFieldChange('qty', productWarehouse?.quantity);
}
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || ''); formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if ( if (
productWarehouse?.week !== undefined && productWarehouse?.week !== undefined &&
@@ -189,6 +189,11 @@ const CustomersTable = () => {
accessorKey: 'email', accessorKey: 'email',
header: 'Email', header: 'Email',
}, },
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => { cell: (props: CellContext<Customer, unknown>) => {
@@ -27,6 +27,9 @@ export const CustomerFormSchema = Yup.object({
.email('Format email tidak valid!') .email('Format email tidak valid!')
.required('Email wajib diisi!'), .required('Email wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string() account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'), .required('Nomor rekening wajib diisi!'),
@@ -142,6 +142,7 @@ const CustomerForm = ({
}, },
type: normalizeType(initialValues?.type), type: normalizeType(initialValues?.type),
address: initialValues?.address ?? '', address: initialValues?.address ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '', account_number: initialValues?.account_number ?? '',
}; };
}, [initialValues]); }, [initialValues]);
@@ -164,6 +165,7 @@ const CustomerForm = ({
pic_id: values.picId, pic_id: values.picId,
type: (values.type as OptionType).value as string, type: (values.type as OptionType).value as string,
address: values.address, address: values.address,
bank_name: values.bank_name,
account_number: values.account_number, account_number: values.account_number,
}; };
@@ -286,6 +288,22 @@ const CustomerForm = ({
errorMessage={formik.errors.phone} errorMessage={formik.errors.phone}
readOnly={formType === 'detail'} readOnly={formType === 'detail'}
/> />
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank customer'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput <TextInput
required required
label='Nomor Rekening' label='Nomor Rekening'
@@ -326,6 +326,11 @@ const SuppliersTable = () => {
accessorKey: 'email', accessorKey: 'email',
header: 'Email', header: 'Email',
}, },
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{ {
accessorKey: 'address', accessorKey: 'address',
header: 'Alamat', header: 'Alamat',
@@ -31,6 +31,9 @@ export const SupplierFormSchema = Yup.object({
npwp: Yup.string() npwp: Yup.string()
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
.required('Nomor NPWP wajib diisi!'), .required('Nomor NPWP wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string() account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'), .required('Nomor rekening wajib diisi!'),
@@ -122,6 +122,7 @@ const SupplierForm = ({
email: initialValues?.email ?? '', email: initialValues?.email ?? '',
address: initialValues?.address ?? '', address: initialValues?.address ?? '',
npwp: initialValues?.npwp ?? '', npwp: initialValues?.npwp ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '', account_number: initialValues?.account_number ?? '',
due_date: initialValues?.due_date ?? 1, due_date: initialValues?.due_date ?? 1,
}; };
@@ -149,6 +150,7 @@ const SupplierForm = ({
email: values.email, email: values.email,
address: values.address, address: values.address,
npwp: values.npwp, npwp: values.npwp,
bank_name: values.bank_name,
account_number: values.account_number, account_number: values.account_number,
due_date: parseInt(values.due_date.toString()), due_date: parseInt(values.due_date.toString()),
}; };
@@ -368,6 +370,22 @@ const SupplierForm = ({
errorMessage={formik.errors.npwp} errorMessage={formik.errors.npwp}
readOnly={formType === 'detail'} readOnly={formType === 'detail'}
/> />
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank supplier'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput <TextInput
required required
label='Nomor Rekening' label='Nomor Rekening'
+58 -16
View File
@@ -2,7 +2,12 @@
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import {
CellContext,
ColumnDef,
SortingState,
Updater,
} from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Link from 'next/link'; import Link from 'next/link';
@@ -34,6 +39,8 @@ import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
type PurchaseTableFilters = { type PurchaseTableFilters = {
search: string; search: string;
sort_by: string;
order_by: string;
po_date: string; po_date: string;
approval_status: string; approval_status: string;
product_category_id: string; product_category_id: string;
@@ -157,18 +164,6 @@ const RowOptionsMenu = ({
}; };
const PurchaseTable = () => { const PurchaseTable = () => {
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
// ===== TABLE FILTER STATE ===== // ===== TABLE FILTER STATE =====
const { const {
state: tableFilterState, state: tableFilterState,
@@ -180,6 +175,8 @@ const PurchaseTable = () => {
} = useTableFilter<PurchaseTableFilters>({ } = useTableFilter<PurchaseTableFilters>({
initial: { initial: {
search: '', search: '',
sort_by: '',
order_by: '',
po_date: '', po_date: '',
approval_status: '', approval_status: '',
product_category_id: '', product_category_id: '',
@@ -198,6 +195,8 @@ const PurchaseTable = () => {
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
sort_by: 'sort_by',
order_by: 'sort_order',
po_date: 'po_date', po_date: 'po_date',
approval_status: 'approval_status', approval_status: 'approval_status',
product_category_id: 'product_category_id', product_category_id: 'product_category_id',
@@ -219,6 +218,36 @@ const PurchaseTable = () => {
storeName: 'purchase-table', storeName: 'purchase-table',
}); });
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
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);
}
};
// ===== MODAL HOOKS ===== // ===== MODAL HOOKS =====
const filterModal = useModal(); const filterModal = useModal();
const deleteModal = useModal(); const deleteModal = useModal();
@@ -237,6 +266,7 @@ const PurchaseTable = () => {
// ===== TABLE COLUMNS DEFINITION ===== // ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [ const purchaseColumns: ColumnDef<Purchase>[] = [
{ {
accessorKey: 'po_number',
header: 'No. PR/PO', header: 'No. PR/PO',
cell: (props) => { cell: (props) => {
const { pr_number, po_number } = props.row.original; const { pr_number, po_number } = props.row.original;
@@ -278,7 +308,7 @@ const PurchaseTable = () => {
cell: (props) => props.row.original.requester_name || '-', cell: (props) => props.row.original.requester_name || '-',
}, },
{ {
accessorKey: 'products.name', accessorKey: 'products',
header: 'Produk', header: 'Produk',
cell: (props) => { cell: (props) => {
const products = props.row.original.products; const products = props.row.original.products;
@@ -293,7 +323,7 @@ const PurchaseTable = () => {
}, },
}, },
{ {
accessorKey: 'location.name', accessorKey: 'location',
header: 'Lokasi', header: 'Lokasi',
cell: (props) => props.row.original.location?.name || '-', cell: (props) => props.row.original.location?.name || '-',
}, },
@@ -323,6 +353,7 @@ const PurchaseTable = () => {
}, },
{ {
header: 'Aging', header: 'Aging',
enableSorting: false,
cell: (props) => { cell: (props) => {
const purchase = props.row.original; const purchase = props.row.original;
if (!purchase.po_date) return '-'; if (!purchase.po_date) return '-';
@@ -334,6 +365,7 @@ const PurchaseTable = () => {
}, },
}, },
{ {
accessorKey: 'status',
header: 'Status Approval', header: 'Status Approval',
cell: (props) => { cell: (props) => {
const approval = props.row.original.latest_approval; const approval = props.row.original.latest_approval;
@@ -378,6 +410,14 @@ const PurchaseTable = () => {
); );
}, },
}, },
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
: '-',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
@@ -658,6 +698,7 @@ const PurchaseTable = () => {
'search', 'search',
'filter_by', 'filter_by',
'sort_by', 'sort_by',
'order_by',
'product_category_name', 'product_category_name',
'supplier_name', 'supplier_name',
'area_name', 'area_name',
@@ -771,7 +812,8 @@ const PurchaseTable = () => {
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting} sorting={sorting}
setSorting={setSorting} setSorting={handleSortingChange}
manualSorting
className={{ className={{
containerClassName: cn('p-3 mb-0'), containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap', headerColumnClassName: 'text-nowrap',
@@ -39,7 +39,7 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { Nonstock } from '@/types/api/master-data/nonstock'; 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 { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
@@ -73,6 +73,25 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); 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 handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
@@ -252,6 +271,8 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
if (filterParams.category) { if (filterParams.category) {
params.append('category', 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]) => { Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value); params.set(key, value);
@@ -259,7 +280,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
return params.toString(); return params.toString();
}, },
[filterParams] [filterParams, sortBy, orderBy]
); );
// ===== DATA FETCHING ===== // ===== DATA FETCHING =====
@@ -443,19 +464,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
return [ return [
{ {
header: 'No', header: 'No',
enableSorting: false,
cell: (props) => (page - 1) * pageSize + props.row.index + 1, cell: (props) => (page - 1) * pageSize + props.row.index + 1,
}, },
{ {
header: 'No. PO', header: 'No. PO',
accessorKey: 'po_number', accessorKey: 'po_number',
enableSorting: true,
}, },
{ {
header: 'No. Referensi', header: 'No. Referensi',
accessorKey: 'reference_number', accessorKey: 'reference_number',
enableSorting: true,
}, },
{ {
header: 'Tanggal Realisasi', header: 'Tanggal Realisasi',
accessorKey: 'realization_date', accessorKey: 'realization_date',
enableSorting: true,
cell: ({ row }) => { cell: ({ row }) => {
return formatDate(row.original?.realization_date, 'DD MMM, YYYY'); return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
}, },
@@ -463,6 +488,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{ {
header: 'Tanggal Transaksi', header: 'Tanggal Transaksi',
accessorKey: 'transaction_date', accessorKey: 'transaction_date',
enableSorting: true,
cell: ({ row }) => { cell: ({ row }) => {
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY'); return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
}, },
@@ -470,21 +496,30 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{ {
header: 'Kategori', header: 'Kategori',
accessorKey: 'category', accessorKey: 'category',
enableSorting: true,
}, },
{ {
header: 'Produk', header: 'Produk',
accessorKey: 'product',
enableSorting: true,
accessorFn: (row) => row.pengajuan?.nonstock?.name, accessorFn: (row) => row.pengajuan?.nonstock?.name,
}, },
{ {
header: 'Supplier', header: 'Supplier',
accessorKey: 'supplier',
enableSorting: true,
accessorFn: (row) => row.supplier?.name, accessorFn: (row) => row.supplier?.name,
}, },
{ {
header: 'Lokasi', header: 'Lokasi',
accessorKey: 'location',
enableSorting: true,
accessorFn: (row) => row.kandang?.location?.name, accessorFn: (row) => row.kandang?.location?.name,
}, },
{ {
header: 'Kandang', header: 'Kandang',
accessorKey: 'kandang',
enableSorting: true,
accessorFn: (row) => row.kandang?.name, accessorFn: (row) => row.kandang?.name,
}, },
{ {
@@ -492,23 +527,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [ columns: [
{ {
header: 'Qty', header: 'Qty',
id: 'qty_pengajuan', accessorKey: 'qty_pengajuan',
accessorFn: (row) => row.pengajuan?.qty,
cell: ({ row }) => cell: ({ row }) =>
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0', row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
}, },
{ {
header: 'Harga', header: 'Harga',
id: 'harga_pengajuan', accessorKey: 'price_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
cell: ({ row }) => cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0), formatCurrency(row.original.pengajuan?.price || 0),
}, },
{ {
header: 'Total', header: 'Total',
id: 'total_pengajuan', accessorKey: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
cell: ({ row }) => { cell: ({ row }) => {
const total = const total =
(row.original.pengajuan?.qty || 0) * (row.original.pengajuan?.qty || 0) *
@@ -523,23 +554,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [ columns: [
{ {
header: 'Qty', header: 'Qty',
id: 'qty_realisasi', accessorKey: 'qty_realisasi',
accessorFn: (row) => row.realisasi?.qty,
cell: ({ row }) => cell: ({ row }) =>
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0', row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
}, },
{ {
header: 'Harga', header: 'Harga',
id: 'harga_realisasi', accessorKey: 'price_realisasi',
accessorFn: (row) => row.realisasi?.price,
cell: ({ row }) => cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0), formatCurrency(row.original.realisasi?.price || 0),
}, },
{ {
header: 'Total', header: 'Total',
id: 'total_realisasi', accessorKey: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
cell: ({ row }) => { cell: ({ row }) => {
const total = const total =
(row.original.realisasi?.qty || 0) * (row.original.realisasi?.qty || 0) *
@@ -550,6 +577,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
], ],
}, },
{ {
id: 'realization_status',
header: 'Status Pencairan', header: 'Status Pencairan',
cell: (props) => ( cell: (props) => (
<RealizationStatusBadge <RealizationStatusBadge
@@ -558,6 +586,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
), ),
}, },
{ {
id: 'bop_status',
header: 'Status BOP', header: 'Status BOP',
cell: (props) => ( cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} /> <ExpenseStatusBadge approval={props.row.original?.latest_approval} />
@@ -602,6 +631,9 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
totalItems={meta?.total_results || 0} totalItems={meta?.total_results || 0}
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{ className={{
containerClassName: 'w-full mb-0!', containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto', tableWrapperClassName: 'overflow-x-auto',
@@ -1,25 +1,47 @@
'use client'; 'use client';
import { useState } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
import BalanceMonitoringTab from '@/components/pages/report/finance/tab/BalanceMonitoringTab';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
const VALID_TAB_IDS = [
'debt-supplier',
'customer-payment',
'balance-monitoring',
];
const FinanceTabs = () => { const FinanceTabs = () => {
const [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 tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [ const tabs = [
{ {
id: '1', id: 'debt-supplier',
label: 'Rekapitulasi Hutang Ke Supplier', label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab tabId={'1'} />, content: <DebtSupplierTab tabId={'debt-supplier'} />,
}, },
{ {
id: '2', id: 'customer-payment',
label: 'Kontrol Pembayaran Customer', 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} tabs={tabs}
variant='boxed' variant='boxed'
activeTabId={activeTabId} activeTabId={activeTabId}
onTabChange={setActiveTabId} onTabChange={handleTabChange}
className={{ className={{
tabHeaderWrapper: tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10', 'justify-between items-center p-3 border-b border-base-content/10',
@@ -0,0 +1,602 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { ColumnDef } from '@tanstack/react-table';
import { AxiosError } from 'axios';
import { FinanceApi } from '@/services/api/report/finance-report';
import { CustomerApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import Table from '@/components/Table';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
interface BalanceMonitoringTabProps {
tabId: string;
}
const filterByOptions: OptionType<string>[] = [
{ label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' },
{ label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' },
];
const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
salesPersons: OptionType<number>[];
filterBy?: OptionType<string>;
sort_by: string;
order_by: string;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
salesPersons: 'sales_ids',
filterBy: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
persist: true,
storeName: 'balance-monitoring-table',
});
// const sorting: SortingState = tableFilterState.sort_by
// ? [
// {
// id: tableFilterState.sort_by,
// desc: tableFilterState.order_by === 'desc',
// },
// ]
// : [];
// const handleSortingChange = (updater: Updater<SortingState>) => {
// const next = typeof updater === 'function' ? updater(sorting) : updater;
// if (next.length > 0) {
// updateFilter('sort_by', next[0].id, true);
// updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
// } else {
// updateFilter('sort_by', '', true);
// updateFilter('order_by', '', true);
// }
// };
const {
options: customerOptions,
setInputValue: setCustomerInput,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: salesOptions,
setInputValue: setSalesInput,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const formik = useFormik({
initialValues: {
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
},
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customers', values.customers, true);
updateFilter('salesPersons', values.salesPersons, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const { data: balanceMonitoringsResponse, isLoading } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess(
balanceMonitoringsResponse
)
? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? [])
: [];
const meta =
isResponseSuccess(balanceMonitoringsResponse) &&
balanceMonitoringsResponse.meta
? balanceMonitoringsResponse.meta
: null;
// Inject tab actions directly — no nested component, no remount cycle
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
</div>
);
}, [tabId, setTabActions, tableFilterState, filterModal.openModal]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const columns = useMemo(
(): ColumnDef<BalanceMonitoringRow>[] => [
{
header: 'No',
enableSorting: false,
cell: (props) =>
(tableFilterState.page - 1) * tableFilterState.pageSize +
props.row.index +
1,
},
{
header: 'Customer',
accessorKey: 'customer.name',
enableSorting: true,
id: 'customer_name',
cell: ({ row }) => row.original.customer.name,
},
{
header: 'Saldo Awal',
accessorKey: 'saldo_awal',
id: 'saldo_awal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.saldo_awal)}
</div>
),
},
{
header: 'Penjualan Ayam',
columns: [
{
header: 'Ekor',
accessorKey: 'penjualan_ayam.ekor',
id: 'penjualan_ayam_ekor',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.ekor)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_ayam.kg',
id: 'penjualan_ayam_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_ayam.nominal',
id: 'penjualan_ayam_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_ayam.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Telur',
columns: [
{
header: 'Butir',
accessorKey: 'penjualan_telur.butir',
id: 'penjualan_telur_butir',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.butir)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_telur.kg',
id: 'penjualan_telur_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_telur.nominal',
id: 'penjualan_telur_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_telur.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Trading',
accessorKey: 'penjualan_trading.nominal',
id: 'penjualan_trading',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_trading.nominal)}
</div>
),
},
{
header: 'Pembayaran',
accessorKey: 'pembayaran',
id: 'pembayaran',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.pembayaran)}
</div>
),
},
{
header: 'Aging',
accessorKey: 'aging',
id: 'aging',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging)} hari
</div>
),
},
{
header: 'Aging Rata-Rata',
accessorKey: 'aging_rata_rata',
id: 'aging_rata_rata',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging_rata_rata)} hari
</div>
),
},
{
header: 'Saldo Akhir',
accessorKey: 'saldo_akhir',
id: 'saldo_akhir',
enableSorting: true,
cell: ({ row }) => (
<div
className={`text-right font-semibold ${row.original.saldo_akhir < 0 ? 'text-error' : ''}`}
>
{formatCurrency(row.original.saldo_akhir)}
</div>
),
},
],
[tableFilterState.page, tableFilterState.pageSize]
);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoading && balanceMonitorings.length === 0 && (
<CustomerSupplierSkeleton
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoading && balanceMonitorings.length > 0 && (
<>
<div className='w-full overflow-x-auto'>
<Table
data={balanceMonitorings}
columns={columns}
pageSize={tableFilterState.pageSize || 10}
page={tableFilterState.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
// sorting={sorting}
// setSorting={handleSortingChange}
// manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</div>
</>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-3'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInput}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={formik.values.salesPersons}
onChange={(val) =>
formik.setFieldValue(
'salesPersons',
Array.isArray(val) ? val : []
)
}
onInputChange={setSalesInput}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default BalanceMonitoringTab;
@@ -1,14 +1,17 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { AxiosError } from 'axios';
import Card from '@/components/Card'; import Card from '@/components/Card';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect } from '@/components/input/SelectInput'; import { useSelect, OptionType } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report'; import { FinanceApi } from '@/services/api/report/finance-report';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { import {
@@ -27,56 +30,70 @@ import Dropdown from '@/components/Dropdown';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import {
CustomerPaymentFilterSchema,
CustomerPaymentFilterType,
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
import { useTableFilter } from '@/services/hooks/useTableFilter';
interface CustomerPaymentTabProps { interface CustomerPaymentTabProps {
tabId: string; tabId: string;
} }
interface FilterParams { const dataTypeOptions: OptionType<string>[] = [
customer_ids?: string; { value: 'trans_date', label: 'Tanggal Jual/Bayar' },
start_date?: string; { value: 'realization_date', label: 'Tanggal Realisasi' },
end_date?: string; ];
filter_by?: string;
}
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const 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 [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
const dataTypeOptions = useMemo( const setTabActions = useTabActionsStore((state) => state.setTabActions);
() => [ const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
{ value: 'realization_date', label: 'Tanggal Realisasi' }, 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 { const {
options: customerOptions, options: customerOptions,
@@ -86,72 +103,57 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search'); } = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<CustomerPaymentFilterType>({ const formik = useFormik({
initialValues: { initialValues: {
start_date: null, start_date: tableFilterState.start_date,
end_date: null, end_date: tableFilterState.end_date,
customer_ids: null, customers: tableFilterState.customers,
filter_by: null, filterBy: tableFilterState.filterBy,
}, },
validationSchema: CustomerPaymentFilterSchema, onSubmit: (values) => {
onSubmit: (values, { setSubmitting }) => { updateFilter('start_date', values.start_date, true);
setFilterParams({ updateFilter('end_date', values.end_date, true);
start_date: values.start_date || undefined, updateFilter('customers', values.customers, true);
end_date: values.end_date || undefined, updateFilter('filterBy', values.filterBy, true);
customer_ids: values.customer_ids || undefined,
filter_by: values.filter_by || undefined,
});
filterModal.closeModal(); filterModal.closeModal();
setCurrentPage(1);
setSubmitting(false);
}, },
onReset: () => { });
setFilterParams({});
setCurrentPage(1); const formikResetHandler = () => {
resetFilter();
setHasDateError(false); setHasDateError(false);
if (dateErrorShown) { if (dateErrorShown) {
toast.dismiss(); toast.dismiss();
setDateErrorShown(false); setDateErrorShown(false);
} }
filterModal.closeModal();
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
}, },
}); });
handleFilterModalOpenRef.current = () => { filterModal.closeModal();
formik.setValues({
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
customer_ids: filterParams.customer_ids || null,
filter_by: filterParams.filter_by || null,
});
filterModal.openModal();
}; };
const getPaymentStatusBadgeColor = (notes: string): Color => { const getPaymentStatusBadgeColor = (notes: string): Color => {
const normalizedValue = notes.toLowerCase(); const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') return 'primary';
if (normalizedValue === 'lunas') { if (normalizedValue.includes('belum')) return 'warning';
return 'primary';
}
if (normalizedValue.includes('belum')) {
return 'warning';
}
return 'neutral'; return 'neutral';
}; };
// ===== DATE CHANGE HANDLERS ===== // ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = useCallback( const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
formik.setFieldValue('start_date', value || null); formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) { if (value && formik.values.end_date) {
const startDate = new Date(value); if (new Date(formik.values.end_date) < new Date(value)) {
const endDateObj = new Date(formik.values.end_date);
if (endDateObj < startDate) {
setHasDateError(true); setHasDateError(true);
if (!dateErrorShown) { if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', { toast.error('Tanggal akhir tidak boleh masa lampau', {
@@ -169,20 +171,14 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} else { } else {
setHasDateError(false); setHasDateError(false);
} }
}, };
[formik, dateErrorShown]
);
const handleEndDateChange = useCallback( const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
formik.setFieldValue('end_date', value || null); formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) { if (value && formik.values.start_date) {
const startDateObj = new Date(formik.values.start_date); if (new Date(value) < new Date(formik.values.start_date)) {
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true); setHasDateError(true);
if (!dateErrorShown) { if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', { toast.error('Tanggal akhir tidak boleh masa lampau', {
@@ -199,123 +195,96 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
toast.dismiss(); toast.dismiss();
setDateErrorShown(false); setDateErrorShown(false);
} }
},
[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]);
// ===== 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]; // ===== DATA FETCHING =====
}, const { data: customerPayment, isLoading } = useSWR<
([, params]) => BaseApiResponse<CustomerPaymentReport>,
FinanceApi.getCustomerPaymentReport( AxiosError<BaseApiResponse>,
params.customer_ids, SWRHttpKey
params.filter_by, >(
params.start_date, `${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`,
params.end_date, httpClientFetcher
params.page,
params.limit
)
); );
const data: CustomerPaymentReport[] = useMemo( const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment)
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || [] ? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [], : [];
[customerPayment]
);
const meta = useMemo( const meta =
() =>
isResponseSuccess(customerPayment) && customerPayment.meta isResponseSuccess(customerPayment) && customerPayment.meta
? customerPayment.meta ? customerPayment.meta
: null, : null;
[customerPayment]
);
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise< const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null CustomerPaymentReport[] | null
> => { > => {
const params = { const customer_ids =
customer_ids: filterParams.customer_ids, tableFilterState.customers.length > 0
filter_by: filterParams.filter_by as ? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
const filter_by = tableFilterState.filterBy?.value as
| 'trans_date' | 'trans_date'
| 'realization_date' | 'realization_date'
| undefined, | undefined;
start_date: filterParams.start_date,
end_date: filterParams.end_date,
limit: 100,
page: 1,
};
const response = await FinanceApi.getCustomerPaymentReport( const response = await FinanceApi.getCustomerPaymentReport(
params.customer_ids, customer_ids,
params.filter_by, filter_by,
params.start_date, tableFilterState.start_date || undefined,
params.end_date, tableFilterState.end_date || undefined,
params.page, 1,
params.limit 100
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[]) ? (response.data as unknown as CustomerPaymentReport[])
: null; : null;
}, [filterParams]); }, [tableFilterState]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportCustomerPaymentToExcelGeneral(
customer_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const allDataForExport = await customerPaymentExport(); const customer_ids =
tableFilterState.customers.length > 0
if ( ? tableFilterState.customers.map((o) => String(o.value)).join(',')
!allDataForExport || : undefined;
!Array.isArray(allDataForExport) || await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet(
allDataForExport.length === 0 customer_ids,
) { tableFilterState.filterBy?.value,
toast.error('Tidak ada data untuk diekspor.'); tableFilterState.start_date || undefined,
return; tableFilterState.end_date || undefined
} );
await generateCustomerPaymentExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.'); toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [customerPaymentExport]); }, [tableFilterState]);
const handleExportPdf = useCallback(async () => { const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -331,22 +300,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return; return;
} }
const customerName = filterParams.customer_ids const customerName =
? customerOptions tableFilterState.customers.length > 0
.filter((opt) => ? tableFilterState.customers.map((o) => o.label).join(', ')
filterParams.customer_ids?.split(',').includes(String(opt.value))
)
.map((opt) => opt.label)
.join(', ') || 'Semua Customer'
: 'Semua Customer'; : 'Semua Customer';
await generateCustomerPaymentPDF({ await generateCustomerPaymentPDF({
data: allDataForExport, data: allDataForExport,
params: { params: {
customer_name: customerName, customer_name: customerName,
start_date: filterParams.start_date, start_date: tableFilterState.start_date || undefined,
end_date: filterParams.end_date, end_date: tableFilterState.end_date || undefined,
filter_by: filterParams.filter_by as filter_by: tableFilterState.filterBy?.value as
| 'trans_date' | 'trans_date'
| 'realization_date' | 'realization_date'
| undefined, | undefined,
@@ -358,24 +323,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally { } finally {
setIsPdfExportLoading(false); 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(() => { useEffect(() => {
setTabActions( setTabActions(
tabId, tabId,
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={filterParams} values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]} fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()} onClick={filterModal.openModal}
variant='outline' variant='outline'
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
@@ -400,16 +363,9 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
width={20} width={20}
height={20} height={20}
/> />
<span>Export</span> <span>Export</span>
<div className='w-px self-stretch bg-base-content/10' /> <div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div> </div>
</Button> </Button>
} }
@@ -422,7 +378,17 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
> >
<Icon icon='heroicons:table-cells' width={20} height={20} /> <Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel 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>
<Button <Button
variant='ghost' variant='ghost'
@@ -437,27 +403,23 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</Dropdown> </Dropdown>
</div> </div>
); );
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
}, [ }, [
tabId, tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading, isAnyExportLoading,
handleExportExcel, handleExportExcel,
handleExportExcelGeneral,
handleExportPdf, handleExportPdf,
isExcelExportLoading, isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading, isPdfExportLoading,
filterParams,
]); ]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]); useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const getTableColumns = ( const getTableColumns = (
summary: CustomerPaymentSummary summary: CustomerPaymentSummary
@@ -664,11 +626,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
enableSorting: false, enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.status; const value = props.row.original.status;
if (!value) return '-';
if (!value) {
return '-';
}
return ( return (
<StatusBadge <StatusBadge
color={getPaymentStatusBadgeColor(value)} color={getPaymentStatusBadgeColor(value)}
@@ -707,7 +665,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return ( return (
<> <>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && ( {isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -736,16 +693,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0} currentPage={tableFilterState.page}
onPrevPage={() => onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setCurrentPage((curr) => setPage(
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={(pageNumber) => setCurrentPage(pageNumber)} onPageChange={setPage}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -852,16 +809,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0} currentPage={tableFilterState.page}
onPrevPage={() => onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setCurrentPage((curr) => setPage(
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={(pageNumber) => setCurrentPage(pageNumber)} onPageChange={setPage}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -891,7 +848,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}> <form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div> <div>
<label className='block text-xs font-semibold text-base-content py-2'> <label className='block text-xs font-semibold text-base-content py-2'>
@@ -901,29 +858,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput <DateInput
name='start_date' name='start_date'
value={formik.values.start_date || ''} value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange} onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/> />
<hr className='w-full max-w-3 h-px border-base-content/10' /> <hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput <DateInput
name='end_date' name='end_date'
value={formik.values.end_date || ''} value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange} onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal isNestedModal
isError={ isError={hasDateError}
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
/> />
</div> </div>
</div> </div>
@@ -932,15 +878,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Customer' label='Customer'
placeholder='Pilih Customer' placeholder='Pilih Customer'
options={customerOptions} options={customerOptions}
value={customerIdsValue} value={formik.values.customers}
onChange={(val) => { onChange={(val) =>
formik.setFieldValue( formik.setFieldValue('customers', Array.isArray(val) ? val : [])
'customer_ids', }
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers} isLoading={isLoadingCustomers}
isClearable isClearable
@@ -952,14 +893,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions} options={dataTypeOptions}
value={filterByValue} value={formik.values.filterBy ?? null}
onChange={(val) => { onChange={(val) =>
if (!Array.isArray(val)) { formik.setFieldValue(
formik.setFieldValue('filter_by', val?.value || null); 'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
} }
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable={true} isClearable
/> />
</div> </div>
@@ -975,7 +917,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Button <Button
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError || !formik.isValid || formik.isSubmitting} disabled={hasDateError}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -9,24 +9,15 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF'; import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier'; import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
@@ -35,6 +26,10 @@ import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios';
const dueStatus: Record<string, Color> = { const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error', 'Sudah Jatuh Tempo': 'error',
@@ -52,7 +47,6 @@ const getPillBadge = (
statusText: string, statusText: string,
type: 'due' | 'payment' = 'payment' type: 'due' | 'payment' = 'payment'
) => { ) => {
// Get color based on type
const color = const color =
type === 'due' type === 'due'
? dueStatus[statusText] || 'neutral' ? dueStatus[statusText] || 'neutral'
@@ -69,6 +63,11 @@ const getPillBadge = (
); );
}; };
const dataTypeOptions: OptionType<string>[] = [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
];
interface DebtSupplierTabProps { interface DebtSupplierTabProps {
tabId: string; tabId: string;
} }
@@ -77,28 +76,50 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const 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 [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
suppliers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
suppliers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
suppliers: 'supplier_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'debt-supplier-report-table',
});
const { const {
setInputValue: setSupplierInputValue, setInputValue: setSupplierInputValue,
options: supplierOptions, options: supplierOptions,
@@ -106,168 +127,180 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
loadMore: loadMoreSuppliers, loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<DebtSupplierFilterType>({ const formik = useFormik({
initialValues: { initialValues: {
startDate: null, start_date: tableFilterState.start_date,
endDate: null, end_date: tableFilterState.end_date,
supplierIds: null, suppliers: tableFilterState.suppliers,
filterBy: null, filterBy: tableFilterState.filterBy,
}, },
validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => { onSubmit: (values) => {
setFilterParams({ updateFilter('start_date', values.start_date, true);
start_date: values.startDate?.toString() || undefined, updateFilter('end_date', values.end_date, true);
end_date: values.endDate?.toString() || undefined, updateFilter('suppliers', values.suppliers, true);
supplier_ids: updateFilter('filterBy', values.filterBy, true);
values.supplierIds?.map((v) => String(v.value)).join(',') ||
undefined,
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
setCurrentPage(1);
},
onReset: () => {
setFilterParams({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
setCurrentPage(1);
filterModal.closeModal(); filterModal.closeModal();
}, },
}); });
handleFilterModalOpenRef.current = () => { const formikResetHandler = () => {
const restoredFilterBy = resetFilter();
dataTypeOptions.find((opt) => opt.value === filterParams.filter_by) ||
null;
const supplierIdList = filterParams.supplier_ids setHasDateError(false);
? filterParams.supplier_ids.split(',') if (dateErrorShown) {
: []; toast.dismiss();
const restoredSupplierIds = supplierOptions.filter((opt) => setDateErrorShown(false);
supplierIdList.includes(String(opt.value)) }
);
formik.setValues({ formik.resetForm({
startDate: filterParams.start_date || null, values: {
endDate: filterParams.end_date || null, start_date: '',
supplierIds: restoredSupplierIds.length > 0 ? restoredSupplierIds : null, end_date: '',
filterBy: restoredFilterBy, 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 ===== // ===== DATA FETCHING =====
const { data: debtSupplier, isLoading } = useSWR( const { data: debtSupplierResponse, isLoading } = useSWR<
() => { BaseApiResponse<DebtSupplier[]>,
const params = { AxiosError<BaseApiResponse>,
supplier_ids: filterParams.supplier_ids, SWRHttpKey
filter_by: filterParams.filter_by, >(
start_date: filterParams.start_date, `${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`,
end_date: filterParams.end_date, httpClientFetcher
page: currentPage,
limit: pageSize,
};
return ['debt-supplier-report', params];
},
([, params]) =>
DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
); );
const data: DebtSupplier[] = useMemo( const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse)
() => ? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? [])
isResponseSuccess(debtSupplier) : [];
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
: [],
[debtSupplier]
);
const meta = useMemo( const meta =
() => isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta
isResponseSuccess(debtSupplier) && debtSupplier.meta ? debtSupplierResponse.meta
? debtSupplier.meta : null;
: null,
[debtSupplier]
);
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise< const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null DebtSupplier[] | null
> => { > => {
const params = { const supplier_ids =
supplier_ids: tableFilterState.suppliers.length > 0
formik.values.supplierIds && formik.values.supplierIds.length > 0 ? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
? formik.values.supplierIds.map((v) => String(v.value)).join(',') : undefined;
: undefined,
filter_by: formik.values.filterBy?.value?.toString() || undefined,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
date_type: formik.values.filterBy
? formik.values.filterBy.value
: undefined,
limit: 100,
page: 1,
};
const response = await DebtSupplierApi.getDebtSupplierReport( const response = await DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids, supplier_ids,
params.filter_by, tableFilterState.filterBy?.value,
params.start_date, tableFilterState.start_date || undefined,
params.end_date tableFilterState.end_date || undefined,
1,
100
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[]) ? (response.data as unknown as DebtSupplier[])
: null; : null;
}, [ }, [tableFilterState]);
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const allDataForExport = await debtSupplierExport(); const supplier_ids =
tableFilterState.suppliers.length > 0
if ( ? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
!allDataForExport || : undefined;
!Array.isArray(allDataForExport) || await DebtSupplierApi.exportToExcelSupplierPerSheet(
allDataForExport.length === 0 supplier_ids,
) { tableFilterState.filterBy?.value,
toast.error('Tidak ada data untuk diekspor.'); tableFilterState.start_date || undefined,
return; tableFilterState.end_date || undefined
} );
generateDebtSupplierExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.'); toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [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 () => { const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -283,15 +316,18 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
return; return;
} }
const supplierName =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => o.label).join(', ')
: undefined;
await generateDebtSupplierPDF({ await generateDebtSupplierPDF({
data: allDataForExport, data: allDataForExport,
params: { params: {
supplier_name: formik.values.supplierIds supplier_name: supplierName,
?.map((v) => v.label) filter_by: tableFilterState.filterBy?.label,
.join(', '), start_date: tableFilterState.start_date || undefined,
filter_by: formik.values.filterBy?.label, end_date: tableFilterState.end_date || undefined,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
}, },
}); });
toast.success('PDF berhasil dibuat dan diunduh.'); toast.success('PDF berhasil dibuat dan diunduh.');
@@ -300,30 +336,22 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
} finally { } finally {
setIsPdfExportLoading(false); setIsPdfExportLoading(false);
} }
}, [ }, [debtSupplierExport, tableFilterState]);
debtSupplierExport,
formik.values.supplierIds,
formik.values.filterBy,
formik.values.startDate,
formik.values.endDate,
]);
// ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => {
return function TabActionsComponent() {
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore(
(state) => state.clearTabActions
);
// ===== TAB ACTIONS =====
useEffect(() => { useEffect(() => {
setTabActions( setTabActions(
tabId, tabId,
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={filterParams} values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]} fieldGroups={[['start_date', 'end_date']]}
onClick={() => handleFilterModalOpenRef.current()} onClick={filterModal.openModal}
variant='outline' variant='outline'
className='px-3 py-2.5' className='px-3 py-2.5'
/> />
@@ -348,16 +376,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
width={20} width={20}
height={20} height={20}
/> />
<span>Export</span> <span>Export</span>
<div className='w-px self-stretch bg-base-content/10' /> <div className='w-px self-stretch bg-base-content/10' />
<Icon icon='heroicons:chevron-down' width={14} height={14} />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div> </div>
</Button> </Button>
} }
@@ -370,7 +391,17 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
> >
<Icon icon='heroicons:table-cells' width={20} height={20} /> <Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel 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>
<Button <Button
variant='ghost' variant='ghost'
@@ -385,44 +416,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Dropdown> </Dropdown>
</div> </div>
); );
}, [setTabActions]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions]);
return null;
};
}, [ }, [
tabId, tabId,
filterParams, setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading, isAnyExportLoading,
handleExportExcel, handleExportExcel,
handleExportExcelGeneral,
handleExportPdf, handleExportPdf,
isExcelExportLoading, isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading, isPdfExportLoading,
]); ]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => { useEffect(() => {
return () => { return () => clearTabActions(tabId);
if (dateErrorShown) { }, [tabId, clearTabActions]);
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [ const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{ {
@@ -637,9 +647,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
}, },
}, },
]; ];
return ( return (
<> <>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && ( {isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -668,16 +678,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0} currentPage={tableFilterState.page}
onPrevPage={() => onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setCurrentPage((curr) => setPage(
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={(pageNumber) => setCurrentPage(pageNumber)} onPageChange={setPage}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -777,16 +787,16 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
<Pagination <Pagination
totalItems={meta.total_results || 0} totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0} itemsPerPage={meta.limit || 0}
currentPage={meta.page || 0} currentPage={tableFilterState.page}
onPrevPage={() => onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
}
onNextPage={() => onNextPage={() =>
setCurrentPage((curr) => setPage(
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
) )
} }
onPageChange={(pageNumber) => setCurrentPage(pageNumber)} onPageChange={setPage}
rowOptions={[10, 20, 50, 100]} rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize} onRowChange={setPageSize}
/> />
@@ -802,7 +812,6 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}} }}
> >
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Header */} {/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'> <div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center gap-2 text-primary'>
@@ -819,6 +828,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div> <div>
@@ -827,153 +837,68 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</label> </label>
<div className='flex flex-row gap-1.5 items-center justify-between'> <div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput <DateInput
name='startDate' name='start_date'
value={formik.values.startDate || ''} value={formik.values.start_date || ''}
onChange={(e) => { onChange={handleStartDateChange}
const value = e.target.value;
formik.setFieldValue('startDate', value || null);
if (value && formik.values.endDate) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.endDate);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
isNestedModal isNestedModal
/> />
<hr className='w-full max-w-3 h-px border-base-content/10'></hr> <hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput <DateInput
name='endDate' name='end_date'
value={formik.values.endDate || ''} value={formik.values.end_date || ''}
onChange={(e) => { onChange={handleEndDateChange}
const value = e.target.value;
formik.setFieldValue('endDate', value || null);
if (value && formik.values.startDate) {
const startDateObj = new Date(formik.values.startDate);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
(formik.touched.endDate && !!formik.errors.endDate) ||
hasDateError
}
errorMessage={formik.errors.endDate}
isNestedModal isNestedModal
isError={hasDateError}
/> />
</div> </div>
</div> </div>
<div>
<SelectInputCheckbox <SelectInputCheckbox
label='Supplier' label='Supplier'
placeholder='Pilih Supplier' placeholder='Pilih Supplier'
isMulti
options={supplierOptions} options={supplierOptions}
value={ value={formik.values.suppliers}
(formik.values.supplierIds as onChange={(val) =>
| { value: number; label: string } formik.setFieldValue('suppliers', Array.isArray(val) ? val : [])
| { value: number; label: string }[]
| null
| undefined) || []
} }
onChange={(val) => {
formik.setFieldValue(
'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
);
}}
onInputChange={setSupplierInputValue} onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers} onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions} isLoading={isLoadingSupplierOptions}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/> />
</div>
<div>
<SelectInputRadio <SelectInputRadio
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions} options={dataTypeOptions}
value={ value={formik.values.filterBy ?? null}
(formik.values.filterBy as onChange={(val) =>
| { value: string; label: string }
| { value: string; label: string }[]
| null
| undefined) || null
}
onChange={(val) => {
formik.setFieldValue( formik.setFieldValue(
'filterBy', 'filterBy',
val ? (val as OptionType) : null !Array.isArray(val) ? (val ?? undefined) : undefined
); )
}} }
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/> />
</div> </div>
</div>
{/* Action Buttons */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
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' 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 Reset Filter
</Button> </Button>
<Button <Button
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -20,6 +20,7 @@ interface DatePickerProps {
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
formatDisplay?: (date: string) => string; formatDisplay?: (date: string) => string;
hasError?: boolean;
} }
export function DatePicker({ export function DatePicker({
@@ -28,6 +29,7 @@ export function DatePicker({
disabled = false, disabled = false,
placeholder = 'Select date', placeholder = 'Select date',
formatDisplay, formatDisplay,
hasError = false,
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(() => { const [currentMonth, setCurrentMonth] = useState(() => {
@@ -154,7 +156,7 @@ export function DatePicker({
<Button <Button
variant='outline' variant='outline'
disabled={disabled} 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' /> <CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
{date ? ( {date ? (
@@ -181,6 +181,13 @@ export function DailyChecklistContent() {
const [initialLoading, setInitialLoading] = useState(!!checklistIdFromUrl); const [initialLoading, setInitialLoading] = useState(!!checklistIdFromUrl);
const [emptyKandangEndDate, setEmptyKandangEndDate] = useState<string>(''); 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 [existingDocuments, setExistingDocuments] = useState<Document[]>([]);
const [documents, setDocuments] = useState<File[]>([]); const [documents, setDocuments] = useState<File[]>([]);
@@ -226,15 +233,23 @@ export function DailyChecklistContent() {
const rawDate = data.date || ''; const rawDate = data.date || '';
setDate(rawDate.length > 10 ? rawDate.slice(0, 10) : rawDate); setDate(rawDate.length > 10 ? rawDate.slice(0, 10) : rawDate);
skipKandangClearRef.current = true; skipKandangClearRef.current = true;
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 = const isEmptyKandang =
!!data.empty_kandang || data.category === 'empty_kandang'; !!data.empty_kandang || data.category === 'empty_kandang';
setEmptyKandang(isEmptyKandang); setEmptyKandang(isEmptyKandang);
setSelectedCategory(isEmptyKandang ? 'empty_kandang' : data.category); setSelectedCategory(isEmptyKandang ? 'empty_kandang' : data.category);
if (isEmptyKandang && data.empty_kandang_end_date) { if (
const rawEnd = data.empty_kandang_end_date; isEmptyKandang &&
data.empty_kandang &&
data.empty_kandang.end_date
) {
const rawEnd = data.empty_kandang.end_date;
setEmptyKandangEndDate( setEmptyKandangEndDate(
rawEnd.length > 10 ? rawEnd.slice(0, 10) : rawEnd rawEnd.length > 10 ? rawEnd.slice(0, 10) : rawEnd
); );
@@ -784,6 +799,11 @@ export function DailyChecklistContent() {
return; return;
} }
if (emptyKandang && !emptyKandangEndDate) {
setEmptyKandangEndDateError('Tanggal akhir kandang kosong wajib diisi');
return;
}
setIsLoadingDraft(true); setIsLoadingDraft(true);
try { try {
@@ -861,6 +881,11 @@ export function DailyChecklistContent() {
return; return;
} }
if (emptyKandang && !emptyKandangEndDate) {
setEmptyKandangEndDateError('Tanggal akhir kandang kosong wajib diisi');
return;
}
if (!isKandangEmpty) { if (!isKandangEmpty) {
if (selectedEmployees.length === 0) { if (selectedEmployees.length === 0) {
toast.error('Pilih minimal 1 ABK'); toast.error('Pilih minimal 1 ABK');
@@ -1146,9 +1171,17 @@ export function DailyChecklistContent() {
<SelectValue placeholder='Pilih kandang' /> <SelectValue placeholder='Pilih kandang' />
</SelectTrigger> </SelectTrigger>
<SelectContent onScroll={handleKandangScroll}> <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 <SelectItem
key={kandang.value} key={`${kandang.value}-${kandangIdx}`}
value={String(kandang.value)} value={String(kandang.value)}
> >
{kandang.label} {kandang.label}
@@ -1220,11 +1253,20 @@ export function DailyChecklistContent() {
<div className='mt-1.5'> <div className='mt-1.5'>
<DatePicker <DatePicker
date={emptyKandangEndDate} date={emptyKandangEndDate}
onDateChange={setEmptyKandangEndDate} onDateChange={(val) => {
setEmptyKandangEndDate(val);
if (val) setEmptyKandangEndDateError('');
}}
disabled={!isChecklistStatusDraft} disabled={!isChecklistStatusDraft}
placeholder='Pilih tanggal' placeholder='Pilih tanggal'
formatDisplay={formatDateForDisplay} formatDisplay={formatDateForDisplay}
hasError={!!emptyKandangEndDateError}
/> />
{emptyKandangEndDateError && (
<p className='text-xs text-red-500 mt-1'>
{emptyKandangEndDateError}
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -60,6 +60,7 @@ interface ChecklistHeader {
progress_percent: number; progress_percent: number;
total_phases: number; total_phases: number;
total_activities: number; total_activities: number;
empty_kandang_end_date?: string | null;
} }
interface PhaseGroup { interface PhaseGroup {
@@ -179,6 +180,9 @@ export function DetailDailyChecklistContent() {
setDocuments(rawDetailChecklist?.document_urls || []); setDocuments(rawDetailChecklist?.document_urls || []);
const emptyKandangEndDate =
rawDetailChecklist?.empty_kandang?.end_date ?? null;
const checklistData = { const checklistData = {
id: rawDetailChecklist?.id, id: rawDetailChecklist?.id,
date: rawDetailChecklist?.date, date: rawDetailChecklist?.date,
@@ -205,6 +209,7 @@ export function DetailDailyChecklistContent() {
progress_percent: 0, progress_percent: 0,
total_phases: 0, total_phases: 0,
total_activities: 0, total_activities: 0,
empty_kandang_end_date: emptyKandangEndDate,
}); });
setLoading(false); setLoading(false);
return; return;
@@ -272,6 +277,7 @@ export function DetailDailyChecklistContent() {
progress_percent: 0, progress_percent: 0,
total_phases: new Set(tasks.map((t) => t.phase_id)).size, total_phases: new Set(tasks.map((t) => t.phase_id)).size,
total_activities: tasks.length, total_activities: tasks.length,
empty_kandang_end_date: emptyKandangEndDate,
}); });
setLoading(false); setLoading(false);
return; return;
@@ -322,6 +328,7 @@ export function DetailDailyChecklistContent() {
progress_percent: progressPercent, progress_percent: progressPercent,
total_phases: uniquePhases.size, total_phases: uniquePhases.size,
total_activities: uniqueActivities.size, total_activities: uniqueActivities.size,
empty_kandang_end_date: emptyKandangEndDate,
}); });
} catch (error) { } catch (error) {
console.error('Error fetching checklist detail:', error); console.error('Error fetching checklist detail:', error);
@@ -777,6 +784,18 @@ export function DetailDailyChecklistContent() {
{CATEGORY_LABELS[header.category] || header.category} {CATEGORY_LABELS[header.category] || header.category}
</p> </p>
</div> </div>
{header.category === 'empty_kandang' && (
<div>
<Label className='text-xs text-gray-500'>
Tanggal Selesai Kandang Kosong
</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{header.empty_kandang_end_date
? formatDate(header.empty_kandang_end_date)
: '-'}
</p>
</div>
)}
<div> <div>
<Label className='text-xs text-gray-500'>Status</Label> <Label className='text-xs text-gray-500'>Status</Label>
<div className='mt-1'>{getStatusBadge(header.status)}</div> <div className='mt-1'>{getStatusBadge(header.status)}</div>
+78
View File
@@ -1,4 +1,6 @@
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { httpClient } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { DebtSupplier } from '@/types/api/report/debt-supplier'; import { DebtSupplier } from '@/types/api/report/debt-supplier';
@@ -11,6 +13,82 @@ export class DebtSupplierApiService extends BaseApiService<
super(basePath); super(basePath);
} }
async exportToExcelGeneral(
supplier_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (supplier_ids) params.set('supplier_ids', supplier_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel-all');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(
`${this.basePath.replace(/\/$/, '')}/debt-supplier${queryString}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-hutang-supplier-general-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportToExcelSupplierPerSheet(
supplier_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (supplier_ids) params.set('supplier_ids', supplier_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '99999999999');
const queryString = `?${params.toString()}`;
const res = await httpClient<Blob>(
`${this.basePath.replace(/\/$/, '')}/debt-supplier${queryString}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-hutang-supplier-per-sheet-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async getDebtSupplierReport( async getDebtSupplierReport(
supplier_ids?: string, supplier_ids?: string,
filter_by?: string, filter_by?: string,
+92
View File
@@ -1,6 +1,9 @@
import { BaseApiService } from '@/services/api/base'; import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment'; import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
import { httpClient } from '@/services/http/client';
import { formatDate } from '@/lib/helper';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
export class FinanceApiService extends BaseApiService< export class FinanceApiService extends BaseApiService<
CustomerPaymentReport, CustomerPaymentReport,
@@ -11,6 +14,95 @@ export class FinanceApiService extends BaseApiService<
super(basePath); super(basePath);
} }
async exportCustomerPaymentToExcelGeneral(
customer_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (customer_ids) params.set('customer_ids', customer_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel-all');
params.set('page', '1');
params.set('limit', '9999999999');
const res = await httpClient<Blob>(
`${this.basePath}/customer-payment?${params.toString()}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-piutang-customer-general-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async exportCustomerPaymentToExcelCustomerPerSheet(
customer_ids?: string,
filter_by?: string,
start_date?: string,
end_date?: string
) {
const params = new URLSearchParams();
if (customer_ids) params.set('customer_ids', customer_ids);
if (filter_by) params.set('filter_by', filter_by);
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
params.set('export', 'excel');
params.set('page', '1');
params.set('limit', '9999999999');
const res = await httpClient<Blob>(
`${this.basePath}/customer-payment?${params.toString()}`,
{
method: 'GET',
responseType: 'blob',
}
);
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
const fileName = `laporan-piutang-customer-per-sheet-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
}
async getBalanceMonitoringReport(params: {
start_date?: string;
end_date?: string;
customer_ids?: string;
sales_ids?: string;
filter_by?: string;
sort_by?: string;
sort_order?: string;
page?: number;
limit?: number;
}): Promise<BaseApiResponse<BalanceMonitoringRow[]> | undefined> {
return await this.customRequest<BaseApiResponse<BalanceMonitoringRow[]>>(
'balance-monitoring',
{ method: 'GET', params }
);
}
async getCustomerPaymentReport( async getCustomerPaymentReport(
customer_ids?: string, customer_ids?: string,
// TODO: Uncomment when BE is ready // TODO: Uncomment when BE is ready
+5 -2
View File
@@ -12,8 +12,11 @@ export type BaseDailyChecklist = {
status: string; status: string;
category: string; category: string;
date: string; date: string;
empty_kandang?: boolean; empty_kandang?: {
empty_kandang_end_date?: string | null; id: boolean;
start_date: string;
end_date: string;
};
kandang?: Pick<BaseKandang, 'id' | 'name' | 'status' | 'capacity'>; kandang?: Pick<BaseKandang, 'id' | 'name' | 'status' | 'capacity'>;
total_phase: number; total_phase: number;
total_activity: number; total_activity: number;
+2
View File
@@ -10,6 +10,7 @@ export type BaseCustomer = {
phone: string; phone: string;
email: string; email: string;
account_number: string; account_number: string;
bank_name: string;
}; };
export type Customer = BaseMetadata & BaseCustomer; export type Customer = BaseMetadata & BaseCustomer;
@@ -22,6 +23,7 @@ export type CreateCustomerPayload = {
phone: string; phone: string;
email: string; email: string;
account_number: string; account_number: string;
bank_name: string;
}; };
export type UpdateCustomerPayload = CreateCustomerPayload; export type UpdateCustomerPayload = CreateCustomerPayload;
+2
View File
@@ -16,6 +16,7 @@ export type BaseSupplier = {
account_number: string; account_number: string;
due_date: number; due_date: number;
balance?: number; balance?: number;
bank_name: string;
}; };
export type Supplier = BaseMetadata & BaseSupplier; export type Supplier = BaseMetadata & BaseSupplier;
@@ -45,6 +46,7 @@ export type CreateSupplierPayload = {
account_number: string; account_number: string;
due_date: number; due_date: number;
balance?: number; balance?: number;
bank_name: string;
}; };
export type UpdateSupplierPayload = CreateSupplierPayload; export type UpdateSupplierPayload = CreateSupplierPayload;
+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;
};