mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-06-09 15:07:51 +00:00
Compare commits
322 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f604c9966 | |||
| 55737bb96f | |||
| 1ab1c9b027 | |||
| ab6ad7d7b1 | |||
| 3641d517ed | |||
| fce4d52214 | |||
| 68cadc42fc | |||
| e2354b5ea7 | |||
| 8f88677191 | |||
| 16c5c6c887 | |||
| 97ff90996a | |||
| 7fb86e9759 | |||
| 9b19e306bf | |||
| 4151829cb8 | |||
| f167916a21 | |||
| 97acc17ca5 | |||
| 5348d47e3c | |||
| e73af7e252 | |||
| 80f8b190fd | |||
| 7b4bd7605b | |||
| 9bd646294b | |||
| 366260608f | |||
| a1cb401a1c | |||
| 3f1c1b62e2 | |||
| 5c9fa12347 | |||
| 2ea6e1a5a5 | |||
| aa935b8851 | |||
| b8419a3f69 | |||
| eaf70ead70 | |||
| 7e6f250864 | |||
| d7a98a77ea | |||
| 13eb0594a8 | |||
| 22b3350e4a | |||
| 55424b272f | |||
| 05138dbb6f | |||
| b5a0614218 | |||
| 7ec46ffa8c | |||
| 07dd2d26be | |||
| 9a56bf732a | |||
| 585918cc28 | |||
| 80e0bd5a8e | |||
| a4e5116bef | |||
| 027668a1bf | |||
| 7e1fab9a69 | |||
| ef56f87e45 | |||
| c4827bb810 | |||
| 9abb8b0b58 | |||
| 8d014a8fea | |||
| 3d37fb2ecb | |||
| d60877d391 | |||
| b3b60018bb | |||
| c98a51326f | |||
| 7437e2e584 | |||
| ac6f6ecf78 | |||
| 7f961b2f8b | |||
| a8c02243a4 | |||
| 82dca3b57e | |||
| 94d623d793 | |||
| f76b5b981c | |||
| 8df5af0124 | |||
| 3c175d4586 | |||
| 9350a6bd3e | |||
| 6668c7b1f9 | |||
| ce4f50c92a | |||
| 146192a5b3 | |||
| 27c24e7c82 | |||
| a99a399f09 | |||
| a314a62f1f | |||
| 37d0041a4f | |||
| 3647f1a1ea | |||
| 7b5049165a | |||
| 3839b46edc | |||
| b7f2bca931 | |||
| 802bf77bc5 | |||
| fd7b49ab93 | |||
| 456070491f | |||
| c12beca4d7 | |||
| 910981645b | |||
| 82b5429d02 | |||
| 6c6f739fc0 | |||
| 001dafecb7 | |||
| 4bb3ada779 | |||
| 0b63dcb532 | |||
| 23dd220b2f | |||
| 770c293257 | |||
| 3374ab4779 | |||
| 7a668c0cf9 | |||
| 14151f6f5a | |||
| 0275e66eda | |||
| 9bc5842493 | |||
| 4cad8aba64 | |||
| 7b5af69dd1 | |||
| 2e179b74ba | |||
| fe2a2dfb43 | |||
| 910a36857e | |||
| 58ddd9b991 | |||
| bb9c6ab969 | |||
| ddffdd1b27 | |||
| f097620c4b | |||
| 280d790f0c | |||
| 3a2e74b559 | |||
| 2bf5f36a77 | |||
| b9ef0fa338 | |||
| 6d8cdeffe9 | |||
| 2e36247a1a | |||
| 37cd990b4f | |||
| bdc7ac4d22 | |||
| b6c2f36dd1 | |||
| 10cc4bee72 | |||
| ff6bcf019b | |||
| bb0508d456 | |||
| d6dd5e6709 | |||
| 3c75a7631a | |||
| e3d3e744b0 | |||
| 5767a078d9 | |||
| 67c7e85ba8 | |||
| c5a5582147 | |||
| 46cb8a7d61 | |||
| 0189733dec | |||
| d0c3581f57 | |||
| e7569b7448 | |||
| 69b998a61a | |||
| c50c110005 | |||
| 989e30fbed | |||
| 3775bb6093 | |||
| 3dc64d01db | |||
| 2ed8ecbbb7 | |||
| e5f6ef8a85 | |||
| 7ff0891ad5 | |||
| a9a5098a21 | |||
| 7f9bb8e11d | |||
| bef3f365bb | |||
| a0e8c60082 | |||
| e7f378823c | |||
| ba3cb98e2c | |||
| 7643645643 | |||
| 3b1e7e3b03 | |||
| 725111dc0c | |||
| 073d7eee03 | |||
| cce5a8df43 | |||
| 978067ac6c | |||
| 6255367366 | |||
| af9cb8ec6b | |||
| e0a1922ed4 | |||
| 4b5ad0dcab | |||
| ca62b31aa6 | |||
| 4ec32c51b2 | |||
| cdee616e18 | |||
| 50378a2ee2 | |||
| ab093467c4 | |||
| 79e41d8a6f | |||
| 35001ff422 | |||
| 40139cd636 | |||
| 7026619249 | |||
| 3945142966 | |||
| b19099cea2 | |||
| f65593de25 | |||
| a5f1a6ea75 | |||
| 4e58f20ba3 | |||
| dc41d6ce73 | |||
| 8869c9df2c | |||
| f2b3f2b584 | |||
| 31cea258a7 | |||
| 53d7439300 | |||
| 28a1852de8 | |||
| 8c03f10043 | |||
| ff92073d19 | |||
| 6ffc2c2806 | |||
| 9e402e373c | |||
| 2e4c19b714 | |||
| 039c926e2d | |||
| e52ba7b394 | |||
| 90dc7c80f2 | |||
| 15c883ca73 | |||
| 47f74b8842 | |||
| ef9009b304 | |||
| 89a6e51b48 | |||
| ce25758a17 | |||
| 371b236e25 | |||
| a54dd1fa9e | |||
| 31205a44f9 | |||
| 3c9c55e049 | |||
| 7a4f93cf0c | |||
| a738d58c37 | |||
| 46daed8fc4 | |||
| 29347c24f4 | |||
| f6727dc4dc | |||
| a0f603b707 | |||
| 631e3959cd | |||
| 2a340a26f9 | |||
| e75246ff8d | |||
| 64aee33452 | |||
| 1851f0e12f | |||
| 8b3f44708d | |||
| a5fd97a175 | |||
| 1284b22345 | |||
| 2ee5d1f7bd | |||
| 6eb257705f | |||
| 9ea1d06972 | |||
| ff8833b5b3 | |||
| 2dd98fd7e3 | |||
| 76fff98d9d | |||
| 18eeabd353 | |||
| 06b5a97de3 | |||
| 5cccc0b3c6 | |||
| 7ab9518a55 | |||
| ac51229398 | |||
| 5a2532a0fa | |||
| f9d2a875e2 | |||
| 6cf8e463c6 | |||
| 4206408db1 | |||
| ff2ed8757f | |||
| f73ea182ae | |||
| 0c5ee08f90 | |||
| bbf9581d3a | |||
| 5830ab4c67 | |||
| a1a0b71814 | |||
| 2b3b6b9549 | |||
| 047266b6d8 | |||
| be3034a94e | |||
| a11d05e720 | |||
| cb454e7eb7 | |||
| a6d6c53069 | |||
| c875ebd951 | |||
| a369386922 | |||
| b3198a44e9 | |||
| b2dfb8fec6 | |||
| d4d77bb13a | |||
| 7dfa5233f3 | |||
| 3d910f78db | |||
| 2dfac0be72 | |||
| afe0d2161d | |||
| 68c13c48c7 | |||
| b9a1e94a29 | |||
| d8c6a90c55 | |||
| 4d01ad7d1d | |||
| c487e7f53e | |||
| a316120a78 | |||
| 9af0537587 | |||
| f668bcecb8 | |||
| 6b95edfb72 | |||
| a12b09eb5f | |||
| cfb96c45c9 | |||
| 747b0f9c2c | |||
| ee2f530d81 | |||
| 617124efe4 | |||
| c0337f4d67 | |||
| e5dcca3408 | |||
| f2b05856bb | |||
| 5d6aaace86 | |||
| 9dcb3d7269 | |||
| e96bb46cfd | |||
| 37edc957d2 | |||
| 60df577cc6 | |||
| e0e2b0c406 | |||
| 244be32b59 | |||
| 1080a26f93 | |||
| 4b62b02a13 | |||
| c12bf92723 | |||
| 5c5b49d0a9 | |||
| b7f886b51e | |||
| 587266e23d | |||
| 9293b6321f | |||
| ddfd1206a7 | |||
| 75910960c5 | |||
| aae633edee | |||
| f129329d52 | |||
| 2afcc5d1c9 | |||
| 7f578c5d03 | |||
| 180b129550 | |||
| 8b2277c8c3 | |||
| 68f4562395 | |||
| c374a4a4e9 | |||
| bda66381b8 | |||
| 28adeee7bd | |||
| 727ac8ccdb | |||
| b77a8ef56f | |||
| 50e0ccd9e4 | |||
| e43a25307f | |||
| db4750217e | |||
| 19793cdcd4 | |||
| 15bddc43e2 | |||
| 3b7c7bb13f | |||
| 633cece581 | |||
| e455d203cc | |||
| d2a5229282 | |||
| 2391d6ceeb | |||
| 4bb57ed0a0 | |||
| 5bf3d32636 | |||
| 12a50c6100 | |||
| c5a0cfe118 | |||
| 898bbd57ec | |||
| 5b5113de6e | |||
| 267a6f37cc | |||
| 7d4898c266 | |||
| f49822d03d | |||
| aa4da686c6 | |||
| 5a668c469f | |||
| 2ca733de97 | |||
| 8afc1a6381 | |||
| d47142153e | |||
| 09537d84d0 | |||
| 188385b638 | |||
| 08aa79a06b | |||
| 16741aaa46 | |||
| 93083c7d2a | |||
| 8333b5138a | |||
| c0ee2013f3 | |||
| 022656cd80 | |||
| 9d3c22fcf3 | |||
| 11353809f0 | |||
| 6463b7a572 | |||
| 7a5ee2aca1 | |||
| 5e907d7e53 | |||
| 1aa2ca9b31 | |||
| c87107b4ee | |||
| 55b13988bf | |||
| 19033278b3 | |||
| 4a6ac8a57d | |||
| 2b9847e1a9 | |||
| 167769a711 | |||
| 417dbba458 |
@@ -48,3 +48,6 @@ next-env.d.ts
|
||||
|
||||
# rtk
|
||||
rtk.exe
|
||||
|
||||
# local specs
|
||||
/local-specs
|
||||
+21
-2
@@ -30,6 +30,10 @@ default:
|
||||
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
|
||||
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
|
||||
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
|
||||
- echo "NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV"
|
||||
- echo "NEXT_PUBLIC_HELPDESK_URL=$NEXT_PUBLIC_HELPDESK_URL"
|
||||
- echo "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL=$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
|
||||
- echo "NEXT_PUBLIC_S3_PUBLIC_BASE_URL=$NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
|
||||
- echo "Building Next.js static export..."
|
||||
- npx next build
|
||||
- |
|
||||
@@ -41,7 +45,11 @@ default:
|
||||
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
|
||||
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
|
||||
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
|
||||
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_APP_ENV": "$NEXT_PUBLIC_APP_ENV",
|
||||
"NEXT_PUBLIC_HELPDESK_URL": "$NEXT_PUBLIC_HELPDESK_URL",
|
||||
"NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL": "$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
|
||||
"NEXT_PUBLIC_S3_PUBLIC_BASE_URL": "NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
|
||||
}
|
||||
EOF
|
||||
artifacts:
|
||||
@@ -142,6 +150,10 @@ build:dev:
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
NEXT_PUBLIC_APP_ENV: 'development'
|
||||
NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/'
|
||||
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/'
|
||||
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com'
|
||||
|
||||
deploy:dev:
|
||||
<<: *deploy_template
|
||||
@@ -170,6 +182,9 @@ build:staging:
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
NEXT_PUBLIC_APP_ENV: 'staging'
|
||||
NEXT_PUBLIC_HELPDESK_URL: 'https://stg-helpdesk.mbugroup.id/'
|
||||
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://stg-dashboard-ho.mbugroup.id/'
|
||||
|
||||
deploy:staging:
|
||||
<<: *deploy_template
|
||||
@@ -185,7 +200,7 @@ deploy:staging:
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
|
||||
# ==========================================================
|
||||
# ====== STAGING (Branch production) ======
|
||||
# ====== (Branch production) ======
|
||||
# ==========================================================
|
||||
build:production:
|
||||
<<: *build_template
|
||||
@@ -198,6 +213,10 @@ build:production:
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
NEXT_PUBLIC_APP_ENV: 'production'
|
||||
NEXT_PUBLIC_HELPDESK_URL: 'https://helpdesk.mbugroup.id/'
|
||||
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dashboard-ho.mbugroup.id/'
|
||||
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/'
|
||||
|
||||
deploy:production:
|
||||
<<: *deploy_template
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
npm run format
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run typecheck
|
||||
git add .
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,486 @@
|
||||
# LTI Web Client
|
||||
|
||||
Next.js 15 (App Router) + React 19 + TypeScript front-end for the LTI ERP system.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Framework:** Next.js 15.5 (App Router, Turbopack)
|
||||
- **UI:** React 19, Tailwind CSS v4, Radix UI, daisyUI, lucide-react
|
||||
- **State:** zustand
|
||||
- **Forms:** Formik + Yup, react-hook-form
|
||||
- **Data fetching:** axios + SWR (custom `httpClient` / `httpClientFetcher` in `src/services/http`)
|
||||
- **Tables:** @tanstack/react-table
|
||||
- **Reporting:** @react-pdf/renderer, jspdf, exceljs, xlsx, recharts
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` — lint + dev server (Turbopack)
|
||||
- `npm run build` — production build
|
||||
- `npm run lint` — ESLint
|
||||
- `npm run typecheck` — `next typegen && tsc --noEmit`
|
||||
- `npm run format` — Prettier
|
||||
- `npm run pre-commit` — format + lint + typecheck + build (Husky pre-commit hook)
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
app/ # Next.js App Router routes (one folder per feature)
|
||||
components/
|
||||
pages/{feature}/ # Page-specific components (mirrors src/app)
|
||||
helper/ # Cross-cutting helpers (e.g. SuspenseHelper)
|
||||
ui/ # Shared UI primitives
|
||||
services/
|
||||
api/ # API service classes (extend BaseApiService)
|
||||
http/ # httpClient / httpClientFetcher
|
||||
hooks/ # Service-level hooks
|
||||
stores/ # zustand stores grouped by domain
|
||||
types/api/ # Request/response types per feature
|
||||
lib/ # Shared helpers (api-helper, formik-helper, utils, validation, …)
|
||||
config/, styles/
|
||||
```
|
||||
|
||||
## Feature development standard
|
||||
|
||||
**Always follow this order when adding a new feature.** This is a team convention — deviating creates churn in code review.
|
||||
|
||||
1. **Types** — Define payload and response types in `src/types/api/{feature}` (or `{feature}.d.ts` for small features).
|
||||
2. **API service** — Add `src/services/api/{feature}.ts` exporting a class that extends `BaseApiService<T, CreatePayload, UpdatePayload>` from [src/services/api/base.ts](src/services/api/base.ts). Use a subfolder (e.g. `src/services/api/daily-checklist/`) when the feature has multiple resource classes.
|
||||
3. **Page** — Create the route under `src/app/{feature}` and a matching `src/components/pages/{feature}` folder for its components.
|
||||
4. **Component slicing** — Break the page UI into components inside `src/components/pages/{feature}`.
|
||||
5. **Wire up the API** — Consume the service class from step 2 inside the page/components (often via SWR).
|
||||
6. **Detail layout** — When a route reads URL params via `useSearchParams` (e.g. `/feature/detail?id=123`), add `src/app/{feature}/detail/layout.tsx` that wraps `children` in `<SuspenseHelper>` from `@/components/helper/SuspenseHelper`.
|
||||
7. **Shared state** — Use zustand stores in `src/stores/{domain}` when state must cross component boundaries.
|
||||
8. **Helpers** — Reuse from [src/lib](src/lib) first (`api-helper.ts`, `formik-helper.ts`, `utils/`, `validation/`, etc.). Add new helpers there.
|
||||
|
||||
### Reference implementations
|
||||
|
||||
`closing`, `finance`, `expense`, `production`, `inventory`, `marketing`, `master-data`, `purchase`, `report`, `daily-checklist`, `dashboard` — all live in both `src/app/{feature}` and `src/components/pages/{feature}` and follow the standard above.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Path alias `@/` maps to `src/`.
|
||||
- Detail pages that read `useSearchParams` MUST be wrapped in `<SuspenseHelper>` via a `layout.tsx` (see [src/app/finance/detail/layout.tsx](src/app/finance/detail/layout.tsx) for the canonical pattern).
|
||||
- API service classes inherit CRUD methods (`getAll`, `getSingle`, etc.) from `BaseApiService` — extend the class for feature-specific endpoints rather than calling `httpClient` directly from components.
|
||||
- Pre-commit runs format + lint + typecheck + build; do not bypass with `--no-verify`.
|
||||
|
||||
## Table filter persistence pattern
|
||||
|
||||
Data tables across all modules (master-data, inventory, finance, purchase, etc.) use `useTableFilter` with `persist: true` to persist filter state in localStorage. This allows users' search, pagination, and filter choices to survive page refreshes.
|
||||
|
||||
**Three core principles (apply to all table components):**
|
||||
|
||||
1. **Set formik initialValues from tableFilterState** (not hardcoded defaults)
|
||||
- Ensures the filter modal displays currently active filters when opened
|
||||
- Initialize directly from persisted state: `location: tableFilterState.locationFilter`
|
||||
|
||||
2. **Pass `true` as last parameter to updateFilter calls**
|
||||
- `updateFilter('fieldName', value, true)` immediately persists to localStorage
|
||||
- Resets pagination to page 1 when filters change (via SWR revalidation)
|
||||
- Apply to: search handlers, filter form submissions, reset handlers
|
||||
|
||||
3. **Create custom formikResetHandler function**
|
||||
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
|
||||
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
|
||||
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
|
||||
- Call `filterModal.closeModal()` at the end
|
||||
- Attach to form `onReset` handler (not `formik.handleReset`)
|
||||
|
||||
```tsx
|
||||
const formikResetHandler = () => {
|
||||
resetFilter();
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); }
|
||||
formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } });
|
||||
filterModal.closeModal();
|
||||
};
|
||||
// ...
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
```
|
||||
|
||||
**Optimization: Avoid useCallback and useMemo for trivial operations**
|
||||
|
||||
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
|
||||
- Simple derivations and pass-through handlers don't need them:
|
||||
|
||||
```tsx
|
||||
// ✅ Good: 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),
|
||||
[setFieldValue]
|
||||
);
|
||||
```
|
||||
|
||||
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
|
||||
|
||||
**Best practice: Store OptionType objects directly, not IDs**
|
||||
|
||||
For select inputs, store the complete `OptionType` object (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
|
||||
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
|
||||
search: string;
|
||||
customers: OptionType<number>[]; // multi-select → serializes as CSV
|
||||
location?: OptionType<string>; // single-select → serializes as value string
|
||||
filterBy?: OptionType<string>; // single-select radio
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
customers: [],
|
||||
location: undefined,
|
||||
filterBy: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
|
||||
location: 'location_id', // serializes OptionType → "abc"
|
||||
filterBy: 'filter_by',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'my-table',
|
||||
});
|
||||
|
||||
// Initialize formik directly from tableFilterState (no hardcoded defaults)
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
customers: tableFilterState.customers,
|
||||
location: tableFilterState.location,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
},
|
||||
...
|
||||
});
|
||||
|
||||
// Use formik values directly — no computed helpers needed
|
||||
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
|
||||
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
|
||||
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
|
||||
```
|
||||
|
||||
**Filter field naming convention**
|
||||
|
||||
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
|
||||
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
|
||||
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
|
||||
|
||||
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
|
||||
|
||||
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
|
||||
|
||||
```tsx
|
||||
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
|
||||
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
|
||||
|
||||
// ❌ Avoid: unnecessary ref indirection
|
||||
const handleFilterModalOpenRef = useRef(() => {});
|
||||
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
|
||||
|
||||
// ✅ Correct: pass openModal directly
|
||||
<ButtonFilter onClick={filterModal.openModal} ... />
|
||||
```
|
||||
|
||||
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
|
||||
|
||||
**Apply this pattern to:**
|
||||
|
||||
- Any data table component across any module that needs persistent filters
|
||||
- Master-data tables, inventory lists, finance reports, purchase orders, etc.
|
||||
- Whenever users' filter/search/pagination choices should survive page refreshes
|
||||
|
||||
**Reference implementations:**
|
||||
|
||||
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
|
||||
- `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
|
||||
|
||||
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
|
||||
|
||||
**Four-part wiring:**
|
||||
|
||||
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
|
||||
|
||||
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
|
||||
|
||||
```ts
|
||||
initial: { sort_by: '', order_by: '' }
|
||||
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
|
||||
```
|
||||
|
||||
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (sorting.length > 0) {
|
||||
updateFilter('sort_by', sorting[0].id, true);
|
||||
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
|
||||
} else {
|
||||
updateFilter('sort_by', '');
|
||||
updateFilter('order_by', '');
|
||||
}
|
||||
}, [sorting]);
|
||||
```
|
||||
|
||||
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
|
||||
|
||||
**Pass `sorting`, `setSorting`, and `manualSorting` to `<Table>`:**
|
||||
|
||||
```tsx
|
||||
<Table sorting={sorting} setSorting={handleSortingChange} manualSorting={true} ... />
|
||||
```
|
||||
|
||||
`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order.
|
||||
|
||||
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
|
||||
|
||||
## Server-side file export pattern
|
||||
|
||||
All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries.
|
||||
|
||||
**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method.
|
||||
|
||||
### Service method (in `src/services/api/{feature}.ts`)
|
||||
|
||||
```ts
|
||||
async exportToExcel(initialQueryString: string) {
|
||||
const params = new URLSearchParams(initialQueryString);
|
||||
|
||||
params.set('export', 'excel'); // or 'pdf', 'csv', etc.
|
||||
params.set('page', '1');
|
||||
params.set('limit', '99999999999');
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
```
|
||||
|
||||
- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports.
|
||||
- Add one method per format; keep them side-by-side in the same service class.
|
||||
|
||||
### Component handler (in the page/tab component)
|
||||
|
||||
```ts
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterParams.foo) params.set('foo', filterParams.foo);
|
||||
// ... map all active filter params ...
|
||||
|
||||
await FeatureApi.exportToExcel(params.toString());
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [filterParams, searchValue]);
|
||||
```
|
||||
|
||||
- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method.
|
||||
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
|
||||
|
||||
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
|
||||
|
||||
<!-- rtk-instructions v2 -->
|
||||
|
||||
# RTK (Rust Token Killer) - Token-Optimized Commands
|
||||
|
||||
## Golden Rule
|
||||
|
||||
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
|
||||
|
||||
**Important**: Even in command chains with `&&`, use `rtk`:
|
||||
|
||||
```bash
|
||||
# ❌ Wrong
|
||||
git add . && git commit -m "msg" && git push
|
||||
|
||||
# ✅ Correct
|
||||
rtk git add . && rtk git commit -m "msg" && rtk git push
|
||||
```
|
||||
|
||||
## RTK Commands by Workflow
|
||||
|
||||
### Build & Compile (80-90% savings)
|
||||
|
||||
```bash
|
||||
rtk cargo build # Cargo build output
|
||||
rtk cargo check # Cargo check output
|
||||
rtk cargo clippy # Clippy warnings grouped by file (80%)
|
||||
rtk tsc # TypeScript errors grouped by file/code (83%)
|
||||
rtk lint # ESLint/Biome violations grouped (84%)
|
||||
rtk prettier --check # Files needing format only (70%)
|
||||
rtk next build # Next.js build with route metrics (87%)
|
||||
```
|
||||
|
||||
### Test (60-99% savings)
|
||||
|
||||
```bash
|
||||
rtk cargo test # Cargo test failures only (90%)
|
||||
rtk go test # Go test failures only (90%)
|
||||
rtk jest # Jest failures only (99.5%)
|
||||
rtk vitest # Vitest failures only (99.5%)
|
||||
rtk playwright test # Playwright failures only (94%)
|
||||
rtk pytest # Python test failures only (90%)
|
||||
rtk rake test # Ruby test failures only (90%)
|
||||
rtk rspec # RSpec test failures only (60%)
|
||||
rtk test <cmd> # Generic test wrapper - failures only
|
||||
```
|
||||
|
||||
### Git (59-80% savings)
|
||||
|
||||
```bash
|
||||
rtk git status # Compact status
|
||||
rtk git log # Compact log (works with all git flags)
|
||||
rtk git diff # Compact diff (80%)
|
||||
rtk git show # Compact show (80%)
|
||||
rtk git add # Ultra-compact confirmations (59%)
|
||||
rtk git commit # Ultra-compact confirmations (59%)
|
||||
rtk git push # Ultra-compact confirmations
|
||||
rtk git pull # Ultra-compact confirmations
|
||||
rtk git branch # Compact branch list
|
||||
rtk git fetch # Compact fetch
|
||||
rtk git stash # Compact stash
|
||||
rtk git worktree # Compact worktree
|
||||
```
|
||||
|
||||
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
|
||||
|
||||
### GitHub (26-87% savings)
|
||||
|
||||
```bash
|
||||
rtk gh pr view <num> # Compact PR view (87%)
|
||||
rtk gh pr checks # Compact PR checks (79%)
|
||||
rtk gh run list # Compact workflow runs (82%)
|
||||
rtk gh issue list # Compact issue list (80%)
|
||||
rtk gh api # Compact API responses (26%)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript Tooling (70-90% savings)
|
||||
|
||||
```bash
|
||||
rtk pnpm list # Compact dependency tree (70%)
|
||||
rtk pnpm outdated # Compact outdated packages (80%)
|
||||
rtk pnpm install # Compact install output (90%)
|
||||
rtk npm run <script> # Compact npm script output
|
||||
rtk npx <cmd> # Compact npx command output
|
||||
rtk prisma # Prisma without ASCII art (88%)
|
||||
```
|
||||
|
||||
### Files & Search (60-75% savings)
|
||||
|
||||
```bash
|
||||
rtk ls <path> # Tree format, compact (65%)
|
||||
rtk read <file> # Code reading with filtering (60%)
|
||||
rtk grep <pattern> # Search grouped by file (75%)
|
||||
rtk find <pattern> # Find grouped by directory (70%)
|
||||
```
|
||||
|
||||
### Analysis & Debug (70-90% savings)
|
||||
|
||||
```bash
|
||||
rtk err <cmd> # Filter errors only from any command
|
||||
rtk log <file> # Deduplicated logs with counts
|
||||
rtk json <file> # JSON structure without values
|
||||
rtk deps # Dependency overview
|
||||
rtk env # Environment variables compact
|
||||
rtk summary <cmd> # Smart summary of command output
|
||||
rtk diff # Ultra-compact diffs
|
||||
```
|
||||
|
||||
### Infrastructure (85% savings)
|
||||
|
||||
```bash
|
||||
rtk docker ps # Compact container list
|
||||
rtk docker images # Compact image list
|
||||
rtk docker logs <c> # Deduplicated logs
|
||||
rtk kubectl get # Compact resource list
|
||||
rtk kubectl logs # Deduplicated pod logs
|
||||
```
|
||||
|
||||
### Network (65-70% savings)
|
||||
|
||||
```bash
|
||||
rtk curl <url> # Compact HTTP responses (70%)
|
||||
rtk wget <url> # Compact download output (65%)
|
||||
```
|
||||
|
||||
### Meta Commands
|
||||
|
||||
```bash
|
||||
rtk gain # View token savings statistics
|
||||
rtk gain --history # View command history with savings
|
||||
rtk discover # Analyze Claude Code sessions for missed RTK usage
|
||||
rtk proxy <cmd> # Run command without filtering (for debugging)
|
||||
rtk init # Add RTK instructions to CLAUDE.md
|
||||
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
|
||||
```
|
||||
|
||||
## Token Savings Overview
|
||||
|
||||
| Category | Commands | Typical Savings |
|
||||
| ---------------- | ------------------------------ | --------------- |
|
||||
| Tests | vitest, playwright, cargo test | 90-99% |
|
||||
| Build | next, tsc, lint, prettier | 70-87% |
|
||||
| Git | status, log, diff, add, commit | 59-80% |
|
||||
| GitHub | gh pr, gh run, gh issue | 26-87% |
|
||||
| Package Managers | pnpm, npm, npx | 70-90% |
|
||||
| Files | ls, read, grep, find | 60-75% |
|
||||
| Infrastructure | docker, kubectl | 85% |
|
||||
| Network | curl, wget | 65-70% |
|
||||
|
||||
Overall average: **60-90% token reduction** on common development operations.
|
||||
|
||||
<!-- /rtk-instructions -->
|
||||
@@ -15,8 +15,8 @@ const ExpenseDetailPage = () => {
|
||||
const expenseId = searchParams.get('expenseId');
|
||||
|
||||
const { data: expense, isLoading: isLoadingExpense } = useSWR(
|
||||
expenseId,
|
||||
(id: number) => ExpenseApi.getSingle(id)
|
||||
['expense-detail', expenseId],
|
||||
([_, id]) => ExpenseApi.getSingle(Number(id))
|
||||
);
|
||||
|
||||
if (!expenseId) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SystemConfigContent } from '@/figma-make/components/pages/master-data/system-config/SystemConfigContent';
|
||||
|
||||
const SystemConfigPage = () => {
|
||||
return (
|
||||
<section className='w-full'>
|
||||
<SystemConfigContent />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemConfigPage;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,11 @@
|
||||
import SuspenseHelper from '@/components/helper/SuspenseHelper';
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
return <SuspenseHelper>{children}</SuspenseHelper>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -226,7 +226,7 @@ const Pagination = ({
|
||||
|
||||
const PageInfo = () => (
|
||||
<span className='text-nowrap text-sm font-medium text-base-content/50'>
|
||||
Page {currentPage} of {totalPages}
|
||||
Total Item: {totalItems} | Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ const Table = <TData extends object>({
|
||||
const tableOptions: TableOptions<TData> = {
|
||||
columns,
|
||||
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
|
||||
defaultColumn: { sortDescFirst: false },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
|
||||
+14
-11
@@ -6,6 +6,7 @@ export interface TabItem {
|
||||
label: ReactNode;
|
||||
content?: ReactNode;
|
||||
disabled?: boolean;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsProps
|
||||
@@ -122,17 +123,19 @@ const Tabs = ({
|
||||
>
|
||||
<div className={getSideContentClasses()}>
|
||||
<div role='tablist' className={getTabsClasses()}>
|
||||
{tabs.map(({ id, label, disabled }) => (
|
||||
<button
|
||||
key={id}
|
||||
role='tab'
|
||||
className={getTabClasses(id === activeTabId, disabled)}
|
||||
onClick={() => !disabled && handleTabChange(id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{tabs.map(({ id, label, disabled, hide }) =>
|
||||
hide ? null : (
|
||||
<button
|
||||
key={id}
|
||||
role='tab'
|
||||
className={getTabClasses(id === activeTabId, disabled)}
|
||||
onClick={() => !disabled && handleTabChange(id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{sideContent && sideContent}
|
||||
</div>
|
||||
|
||||
@@ -523,7 +523,7 @@ const useSelect = <T,>(
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
...(params ?? {}),
|
||||
[searchKey]: inputValue ?? '',
|
||||
[searchKey ? searchKey : 'search']: inputValue ?? '',
|
||||
[pageKey]: String(pageIndex + 1),
|
||||
[limitKey]: String(limit),
|
||||
}).toString();
|
||||
|
||||
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
|
||||
secondaryButton={
|
||||
secondaryButton
|
||||
? {
|
||||
...secondaryButton,
|
||||
text: secondaryButton?.text ?? 'Tidak',
|
||||
onClick: (e) => {
|
||||
if (secondaryButton && secondaryButton?.onClick) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
@@ -15,6 +16,7 @@ interface ExpenseDetailProps {
|
||||
}
|
||||
|
||||
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<string>('request');
|
||||
|
||||
const expenseDetailTabs = useMemo(() => {
|
||||
@@ -46,8 +48,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
<section className='w-full max-w-full pb-16'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
variant='link'
|
||||
onClick={router.back}
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
|
||||
interface ExpenseRealizationContentProps {
|
||||
@@ -25,6 +29,8 @@ interface ExpenseRealizationContentProps {
|
||||
const ExpenseRealizationContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRealizationContentProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||
initialValues: {
|
||||
documents: [],
|
||||
@@ -74,7 +80,11 @@ const ExpenseRealizationContent = ({
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/edit/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -19,6 +20,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||
@@ -26,11 +28,15 @@ import {
|
||||
UploadRequestDocumentsFormSchema,
|
||||
UploadRequestDocumentsFormValues,
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import {
|
||||
buildExpenseActionHref,
|
||||
getExpenseListReturnTo,
|
||||
} from '@/lib/expense-list-navigation';
|
||||
|
||||
interface ExpenseRequestContentProps {
|
||||
initialValues?: Expense;
|
||||
@@ -40,6 +46,13 @@ const ExpenseRequestContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRequestContentProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const refreshExpense = () => {
|
||||
mutate((key) => Array.isArray(key) && key[0] === 'expense-detail');
|
||||
};
|
||||
|
||||
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
|
||||
useApprovalSteps({
|
||||
@@ -89,17 +102,24 @@ const ExpenseRequestContent = ({
|
||||
!isLatestApprovalRejected &&
|
||||
initialValues?.latest_approval.step_number === 4;
|
||||
|
||||
const isExpensePaidOff = initialValues?.is_paid;
|
||||
|
||||
const showPaidOffButton =
|
||||
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
|
||||
|
||||
// Modal hooks
|
||||
const deleteModal = useModal();
|
||||
const completeModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
const paidOffModal = useModal();
|
||||
|
||||
// Modal loading state
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
|
||||
const [, setApprovalNotes] = useState('');
|
||||
|
||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||
@@ -140,7 +160,31 @@ const ExpenseRequestContent = ({
|
||||
rejectModal.openModal();
|
||||
};
|
||||
|
||||
const paidOffClickHandler = () => {
|
||||
paidOffModal.openModal();
|
||||
};
|
||||
|
||||
// Modal confirm click handler
|
||||
const confirmationModalPaidOffClickHandler = async () => {
|
||||
setIsPaidOffLoading(true);
|
||||
|
||||
const paidOffResponse = await ExpenseApi.setExpensePaidOff(
|
||||
initialValues?.id as number
|
||||
);
|
||||
|
||||
if (isResponseSuccess(paidOffResponse)) {
|
||||
toast.success('Berhasil menandai biaya operasional sebagai lunas!');
|
||||
refreshExpense();
|
||||
} else {
|
||||
toast.error(
|
||||
'Gagal menandai biaya operasional sebagai lunas!: ' +
|
||||
paidOffResponse?.message
|
||||
);
|
||||
}
|
||||
|
||||
paidOffModal.closeModal();
|
||||
setIsPaidOffLoading(false);
|
||||
};
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
@@ -148,7 +192,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
if (isResponseSuccess(deleteResponse)) {
|
||||
toast.success('Berhasil menghapus data biaya operasional!');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
toast.error('Gagal menghapus data biaya operasional!');
|
||||
}
|
||||
@@ -164,7 +208,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
if (isResponseSuccess(completeRes)) {
|
||||
toast.success(completeRes.message);
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
toast.error(completeRes?.message as string);
|
||||
}
|
||||
@@ -204,7 +248,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
toast.success(approveResponse?.message);
|
||||
setApprovalNotes('');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
approveModal.closeModal();
|
||||
|
||||
@@ -239,7 +283,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
toast.success(rejectResponse.message);
|
||||
setApprovalNotes('');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
rejectModal.closeModal();
|
||||
|
||||
@@ -365,7 +409,11 @@ const ExpenseRequestContent = ({
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
@@ -378,13 +426,35 @@ const ExpenseRequestContent = ({
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{showPaidOffButton && (
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={paidOffClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:check-circle-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Tandai Lunas
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/edit/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
@@ -519,6 +589,19 @@ const ExpenseRequestContent = ({
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status Lunas</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
<StatusBadge
|
||||
color={initialValues?.is_paid ? 'primary' : 'warning'}
|
||||
text={initialValues?.is_paid ? 'Lunas' : 'Belum Lunas'}
|
||||
className={{
|
||||
badge: 'w-fit whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Dokumen Pengajuan</th>
|
||||
<th>:</th>
|
||||
@@ -534,21 +617,15 @@ const ExpenseRequestContent = ({
|
||||
<ul className='list-disc'>
|
||||
{initialValues?.documents.map(
|
||||
(requestDocument, requestDocumentIdx) => {
|
||||
const path = requestDocument.path.startsWith(
|
||||
'/'
|
||||
)
|
||||
? requestDocument.path.slice(1)
|
||||
: requestDocument.path;
|
||||
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
|
||||
return (
|
||||
<li key={requestDocumentIdx}>
|
||||
<Link
|
||||
href={documentUrl}
|
||||
href={requestDocument.path}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{requestDocument.path}{' '}
|
||||
{requestDocument.name}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
@@ -744,6 +821,21 @@ const ExpenseRequestContent = ({
|
||||
onClick: confirmationModalRejectClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={paidOffModal.ref}
|
||||
type='success'
|
||||
text='Apakah anda yakin ingin menandai biaya operasional ini sebagai lunas?'
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'success',
|
||||
isLoading: isPaidOffLoading,
|
||||
onClick: confirmationModalPaidOffClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,26 +3,60 @@ import * as yup from 'yup';
|
||||
export type ExpensesFilterType = {
|
||||
transaction_date: string | null;
|
||||
realization_date: string | null;
|
||||
location_id: string | null;
|
||||
vendor_id: string | null;
|
||||
location: { value: number; label: string } | null;
|
||||
vendor: { value: number; label: string } | null;
|
||||
category: { value: string; label: string } | null;
|
||||
approval_status: { value: string; label: string } | null;
|
||||
realization_status: { value: string; label: string } | null;
|
||||
project_flock: { value: number; label: string } | null;
|
||||
project_flock_kandang: { value: number; label: string } | null;
|
||||
};
|
||||
|
||||
export const ExpensesFilterSchema = yup.object({
|
||||
transaction_date: yup.string().nullable(),
|
||||
realization_date: yup
|
||||
.string()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-greater-or-equal-transaction',
|
||||
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
|
||||
function (value) {
|
||||
const { transaction_date } = this.parent;
|
||||
if (!transaction_date || !value) return true;
|
||||
return new Date(value) >= new Date(transaction_date);
|
||||
}
|
||||
),
|
||||
location_id: yup.string().nullable(),
|
||||
vendor_id: yup.string().nullable(),
|
||||
realization_date: yup.string().nullable(),
|
||||
location: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
vendor: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
category: yup
|
||||
.object({
|
||||
value: yup.string().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
approval_status: yup
|
||||
.object({
|
||||
value: yup.string().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
realization_status: yup
|
||||
.object({
|
||||
value: yup.string().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
project_flock: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
project_flock_kandang: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject } from 'react';
|
||||
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -11,8 +11,11 @@ import SelectInput from '@/components/input/SelectInput';
|
||||
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
ExpensesFilterSchema,
|
||||
ExpensesFilterValues,
|
||||
@@ -31,64 +34,143 @@ const ExpensesFilterModal = ({
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: ExpensesFilterModalProps) => {
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<string>(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'BOP', label: 'BOP' },
|
||||
{ value: 'NON-BOP', label: 'NON-BOP' },
|
||||
];
|
||||
|
||||
const approvalStatusOptions = [
|
||||
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
|
||||
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
|
||||
{ value: 'FINANCE', label: 'Approval Finance' },
|
||||
{ value: 'REALISASI', label: 'Realisasi' },
|
||||
{ value: 'SELESAI', label: 'Selesai' },
|
||||
{ value: 'DITOLAK', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const realizationStatusOptions = [
|
||||
{ value: 'NOT_REALIZED', label: 'Belum Realisasi' },
|
||||
{ value: 'REALIZED', label: 'Sudah Realisasi' },
|
||||
{ value: 'REJECTED', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setVendorInputValue,
|
||||
options: vendorOptions,
|
||||
isLoadingOptions: isLoadingVendorOptions,
|
||||
loadMore: loadMoreVendors,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
rawData: projectFlocksRawData,
|
||||
options: projectFlockOptions,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationId || '',
|
||||
}
|
||||
);
|
||||
|
||||
const formik = useFormik<ExpensesFilterValues>({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues || {
|
||||
transaction_date: null,
|
||||
realization_date: null,
|
||||
location_id: null,
|
||||
vendor_id: null,
|
||||
location: null,
|
||||
vendor: null,
|
||||
category: null,
|
||||
approval_status: null,
|
||||
realization_status: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
validationSchema: ExpensesFilterSchema,
|
||||
onSubmit: async (values) => {
|
||||
onSubmit?.(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: () => {
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const locationValue = formik.values.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
: null;
|
||||
useEffect(() => {
|
||||
setSelectedLocationId(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
}, [initialValues?.location]);
|
||||
|
||||
const vendorValue = formik.values.vendor_id
|
||||
? vendorOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.vendor_id
|
||||
) || null
|
||||
: null;
|
||||
const { resetForm } = formik;
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
resetForm({
|
||||
values: {
|
||||
transaction_date: null,
|
||||
realization_date: null,
|
||||
location: null,
|
||||
vendor: null,
|
||||
category: null,
|
||||
approval_status: null,
|
||||
realization_status: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
});
|
||||
setSelectedLocationId('');
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset, closeModalHandler]);
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const locationId =
|
||||
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
const value = val as OptionType | null;
|
||||
formik.setFieldValue('location', value);
|
||||
formik.setFieldValue('project_flock', null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
setSelectedLocationId(value?.value ? String(value.value) : '');
|
||||
};
|
||||
|
||||
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const vendorId =
|
||||
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||
formik.setFieldValue('vendor_id', vendorId);
|
||||
formik.setFieldValue('vendor', val as OptionType | null);
|
||||
};
|
||||
|
||||
const projectFlockKandangOptions = useMemo(() => {
|
||||
if (
|
||||
!formik.values.project_flock ||
|
||||
!projectFlocksRawData ||
|
||||
!isResponseSuccess(projectFlocksRawData)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedProjectFlock = projectFlocksRawData.data.find(
|
||||
(item) => item.id === formik.values.project_flock?.value
|
||||
);
|
||||
|
||||
return (
|
||||
selectedProjectFlock?.kandangs?.map((item) => ({
|
||||
value: item.project_flock_kandang_id,
|
||||
label: item.name,
|
||||
})) || []
|
||||
);
|
||||
}, [formik.values.project_flock, projectFlocksRawData]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
@@ -98,7 +180,7 @@ const ExpensesFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -121,49 +203,41 @@ const ExpensesFilterModal = ({
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='py-2 text-xs font-semibold'>Tanggal</span>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<DateInput
|
||||
name='transaction_date'
|
||||
placeholder='Tanggal Transaksi'
|
||||
value={formik.values.transaction_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.transaction_date &&
|
||||
!!formik.errors.transaction_date
|
||||
}
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='realization_date'
|
||||
placeholder='Tanggal Realisasi'
|
||||
value={formik.values.realization_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.realization_date &&
|
||||
!!formik.errors.realization_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{formik.touched.realization_date &&
|
||||
formik.errors.realization_date && (
|
||||
<span className='text-xs text-error'>
|
||||
{formik.errors.realization_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DateInput
|
||||
name='transaction_date'
|
||||
label='Tanggal Transaksi'
|
||||
placeholder='Tanggal Transaksi'
|
||||
value={formik.values.transaction_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.transaction_date &&
|
||||
!!formik.errors.transaction_date
|
||||
}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='realization_date'
|
||||
label='Tanggal Realisasi'
|
||||
placeholder='Tanggal Realisasi'
|
||||
value={formik.values.realization_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.realization_date &&
|
||||
!!formik.errors.realization_date
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationValue}
|
||||
value={formik.values.location}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
@@ -173,14 +247,87 @@ const ExpensesFilterModal = ({
|
||||
label='Vendor'
|
||||
placeholder='Pilih Vendor'
|
||||
options={vendorOptions}
|
||||
value={vendorValue}
|
||||
value={formik.values.vendor}
|
||||
onChange={vendorChangeHandler}
|
||||
onInputChange={setVendorInputValue}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
onMenuScrollToBottom={loadMoreVendors}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={categoryOptions}
|
||||
value={formik.values.category}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('category', val as OptionType | null)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status BOP'
|
||||
placeholder='Pilih Status BOP'
|
||||
options={approvalStatusOptions}
|
||||
value={formik.values.approval_status}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('approval_status', val as OptionType | null)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status Pencairan'
|
||||
placeholder='Pilih Status Pencairan'
|
||||
options={realizationStatusOptions}
|
||||
value={formik.values.realization_status}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'realization_status',
|
||||
val as OptionType | null
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
value={formik.values.project_flock}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('project_flock', val as OptionType | null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
}}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang'
|
||||
options={projectFlockKandangOptions}
|
||||
value={formik.values.project_flock_kandang}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'project_flock_kandang',
|
||||
val as OptionType | null
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
isDisabled={!formik.values.project_flock}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isResponseError } from '@/lib/api-helper';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
|
||||
@@ -48,6 +49,8 @@ const ExpenseRealizationForm = ({
|
||||
initialValues,
|
||||
}: ExpenseRealizationFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
|
||||
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
|
||||
|
||||
@@ -64,9 +67,9 @@ const ExpenseRealizationForm = ({
|
||||
}
|
||||
|
||||
toast.success(createExpenseRes?.message as string);
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
},
|
||||
[router]
|
||||
[initialValues?.id, returnTo, router]
|
||||
);
|
||||
|
||||
const updateExpenseHandler = useCallback(
|
||||
@@ -83,9 +86,9 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
toast.success(updateExpenseRes?.message as string);
|
||||
router.refresh();
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
},
|
||||
[router]
|
||||
[returnTo, router]
|
||||
);
|
||||
|
||||
const formik = useFormik<ExpenseRealizationFormValues>({
|
||||
@@ -258,7 +261,7 @@ const ExpenseRealizationForm = ({
|
||||
<section className='w-full'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
href={returnTo}
|
||||
variant='link'
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
Updater,
|
||||
} from '@tanstack/react-table';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
FINANCE_TRANSACTION_TYPE_OPTIONS,
|
||||
} from '@/config/constant';
|
||||
import { FinanceApi } from '@/services/api/finance';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Bank } from '@/types/api/master-data/bank';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
@@ -39,7 +38,8 @@ import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Dropdown from '@/components/dropdown/Dropdown';
|
||||
import {
|
||||
FinanceTableFilterSchema,
|
||||
FinanceTableFilterValues,
|
||||
@@ -176,9 +176,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const FinanceTable = () => {
|
||||
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
|
||||
const previousPathRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -187,14 +184,18 @@ const FinanceTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
transactionTypes: '',
|
||||
bankIds: '',
|
||||
customerIds: '',
|
||||
supplierIds: '',
|
||||
sortBy: '',
|
||||
sort_by: '',
|
||||
orderBy: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
bankNames: '',
|
||||
customerNames: '',
|
||||
supplierNames: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -203,10 +204,14 @@ const FinanceTable = () => {
|
||||
bankIds: 'bank_ids',
|
||||
customerIds: 'customer_ids',
|
||||
supplierIds: 'supplier_ids',
|
||||
sortBy: 'sort_date',
|
||||
sort_by: 'sort_by',
|
||||
orderBy: 'sort_order',
|
||||
startDate: 'start_date',
|
||||
endDate: 'end_date',
|
||||
},
|
||||
excludeKeysFromUrl: ['bankNames', 'customerNames', 'supplierNames'],
|
||||
persist: true,
|
||||
storeName: 'finance-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -229,13 +234,14 @@ const FinanceTable = () => {
|
||||
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
|
||||
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isExportLoading, setIsExportLoading] = useState(false);
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
|
||||
// ===== Formik for Filter =====
|
||||
const filterFormik = useFormik<FinanceTableFilterValues>({
|
||||
initialValues: {
|
||||
search: searchValue,
|
||||
search: tableFilterState.search || '',
|
||||
transaction_types: '',
|
||||
bank_ids: '',
|
||||
customer_ids: '',
|
||||
@@ -245,29 +251,48 @@ const FinanceTable = () => {
|
||||
end_date: '',
|
||||
},
|
||||
validationSchema: FinanceTableFilterSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: (values) => {
|
||||
updateFilter('search', values.search);
|
||||
setSearchValue(values.search);
|
||||
updateFilter('transactionTypes', values.transaction_types);
|
||||
updateFilter('bankIds', values.bank_ids);
|
||||
updateFilter('customerIds', values.customer_ids);
|
||||
updateFilter('supplierIds', values.supplier_ids);
|
||||
updateFilter('sortBy', values.sort_by);
|
||||
updateFilter('startDate', values.start_date);
|
||||
updateFilter('endDate', values.end_date);
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('search', values.search, true);
|
||||
updateFilter('transactionTypes', values.transaction_types, true);
|
||||
updateFilter('bankIds', values.bank_ids, true);
|
||||
updateFilter('customerIds', values.customer_ids, true);
|
||||
updateFilter('supplierIds', values.supplier_ids, true);
|
||||
updateFilter('sort_by', values.sort_by, true);
|
||||
updateFilter('startDate', values.start_date, true);
|
||||
updateFilter('endDate', values.end_date, true);
|
||||
// Save display names for restoration on modal reopen
|
||||
const toNames = (val: OptionType | OptionType[] | null) =>
|
||||
val
|
||||
? (Array.isArray(val) ? val : [val])
|
||||
.map((o) => String(o.label))
|
||||
.join(',')
|
||||
: '';
|
||||
updateFilter('bankNames', toNames(selectedBank), true);
|
||||
updateFilter('customerNames', toNames(selectedCustomerId), true);
|
||||
updateFilter('supplierNames', toNames(selectedSupplierId), true);
|
||||
filterModal.closeModal();
|
||||
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('search', '');
|
||||
resetSearchValue();
|
||||
updateFilter('transactionTypes', '');
|
||||
updateFilter('bankIds', '');
|
||||
updateFilter('customerIds', '');
|
||||
updateFilter('supplierIds', '');
|
||||
updateFilter('sortBy', '');
|
||||
updateFilter('startDate', '');
|
||||
updateFilter('endDate', '');
|
||||
setSelectedTransactionType(null);
|
||||
setSelectedBank(null);
|
||||
setSelectedCustomerId(null);
|
||||
setSelectedSupplierId(null);
|
||||
setSelectedSortBy(null);
|
||||
updateFilter('search', '', true);
|
||||
updateFilter('transactionTypes', '', true);
|
||||
updateFilter('bankIds', '', true);
|
||||
updateFilter('customerIds', '', true);
|
||||
updateFilter('supplierIds', '', true);
|
||||
updateFilter('sort_by', '', true);
|
||||
updateFilter('orderBy', '', true);
|
||||
updateFilter('startDate', '', true);
|
||||
updateFilter('endDate', '', true);
|
||||
updateFilter('bankNames', '', true);
|
||||
updateFilter('customerNames', '', true);
|
||||
updateFilter('supplierNames', '', true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,40 +345,10 @@ const FinanceTable = () => {
|
||||
});
|
||||
}, [bankOptions, bankRawData]);
|
||||
|
||||
// ===== ACTIVE FILTERS COUNT =====
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
|
||||
if (tableFilterState.transactionTypes) count += 1;
|
||||
if (tableFilterState.bankIds) count += 1;
|
||||
if (tableFilterState.customerIds) count += 1;
|
||||
if (tableFilterState.supplierIds) count += 1;
|
||||
if (tableFilterState.sortBy) count += 1;
|
||||
if (tableFilterState.startDate) count += 1;
|
||||
if (tableFilterState.endDate) count += 1;
|
||||
|
||||
return count;
|
||||
}, [
|
||||
tableFilterState.transactionTypes,
|
||||
tableFilterState.bankIds,
|
||||
tableFilterState.customerIds,
|
||||
tableFilterState.supplierIds,
|
||||
tableFilterState.sortBy,
|
||||
tableFilterState.startDate,
|
||||
tableFilterState.endDate,
|
||||
]);
|
||||
|
||||
const hasFilters = activeFiltersCount > 0;
|
||||
|
||||
// ===== Handler =====
|
||||
const searchChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
setSearchValue(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[updateFilter, setSearchValue, setPage]
|
||||
);
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const transactionTypeChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
@@ -409,6 +404,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 value = e.target.value;
|
||||
const endDate = filterFormik.values.end_date;
|
||||
@@ -469,28 +484,88 @@ const FinanceTable = () => {
|
||||
};
|
||||
|
||||
const handleFilterModalOpen = () => {
|
||||
// Restore transaction types from stored comma-separated IDs
|
||||
const txIds = tableFilterState.transactionTypes
|
||||
? tableFilterState.transactionTypes.split(',')
|
||||
: [];
|
||||
const restoredTxTypes = FINANCE_TRANSACTION_TYPE_OPTIONS.filter((opt) =>
|
||||
txIds.includes(String(opt.value))
|
||||
);
|
||||
setSelectedTransactionType(restoredTxTypes.length ? restoredTxTypes : null);
|
||||
|
||||
// Restore banks from stored IDs and names
|
||||
const bankIdList = tableFilterState.bankIds
|
||||
? tableFilterState.bankIds.split(',')
|
||||
: [];
|
||||
const bankNameList = tableFilterState.bankNames
|
||||
? tableFilterState.bankNames.split(',')
|
||||
: [];
|
||||
const restoredBanks = bankIdList.map((id, i) => ({
|
||||
value: id,
|
||||
label: bankNameList[i] || id,
|
||||
}));
|
||||
setSelectedBank(restoredBanks.length ? restoredBanks : null);
|
||||
|
||||
// Restore customers from stored IDs and names
|
||||
const customerIdList = tableFilterState.customerIds
|
||||
? tableFilterState.customerIds.split(',')
|
||||
: [];
|
||||
const customerNameList = tableFilterState.customerNames
|
||||
? tableFilterState.customerNames.split(',')
|
||||
: [];
|
||||
const restoredCustomers = customerIdList.map((id, i) => ({
|
||||
value: id,
|
||||
label: customerNameList[i] || id,
|
||||
}));
|
||||
setSelectedCustomerId(restoredCustomers.length ? restoredCustomers : null);
|
||||
|
||||
// Restore suppliers from stored IDs and names
|
||||
const supplierIdList = tableFilterState.supplierIds
|
||||
? tableFilterState.supplierIds.split(',')
|
||||
: [];
|
||||
const supplierNameList = tableFilterState.supplierNames
|
||||
? tableFilterState.supplierNames.split(',')
|
||||
: [];
|
||||
const restoredSuppliers = supplierIdList.map((id, i) => ({
|
||||
value: id,
|
||||
label: supplierNameList[i] || id,
|
||||
}));
|
||||
setSelectedSupplierId(restoredSuppliers.length ? restoredSuppliers : null);
|
||||
|
||||
// Restore sort by
|
||||
const restoredSortBy =
|
||||
sortByOptions.find(
|
||||
(opt) => String(opt.value) === tableFilterState.sort_by
|
||||
) || null;
|
||||
setSelectedSortBy(restoredSortBy);
|
||||
|
||||
// Restore formik values
|
||||
filterFormik.setValues({
|
||||
search: tableFilterState.search || '',
|
||||
transaction_types: tableFilterState.transactionTypes || '',
|
||||
bank_ids: tableFilterState.bankIds || '',
|
||||
customer_ids: tableFilterState.customerIds || '',
|
||||
supplier_ids: tableFilterState.supplierIds || '',
|
||||
sort_by: tableFilterState.sort_by || '',
|
||||
start_date: tableFilterState.startDate || '',
|
||||
end_date: tableFilterState.endDate || '',
|
||||
});
|
||||
|
||||
filterModal.openModal();
|
||||
filterFormik.validateForm();
|
||||
};
|
||||
|
||||
const resetFilterHandler = () => {
|
||||
setSelectedTransactionType(null);
|
||||
setSelectedBank(null);
|
||||
setSelectedCustomerId(null);
|
||||
setSelectedSupplierId(null);
|
||||
setSelectedSortBy(null);
|
||||
|
||||
filterFormik.resetForm();
|
||||
|
||||
updateFilter('search', '');
|
||||
resetSearchValue();
|
||||
updateFilter('transactionTypes', '');
|
||||
updateFilter('bankIds', '');
|
||||
updateFilter('customerIds', '');
|
||||
updateFilter('supplierIds', '');
|
||||
updateFilter('sortBy', '');
|
||||
updateFilter('startDate', '');
|
||||
updateFilter('endDate', '');
|
||||
const exportToExcel = async () => {
|
||||
setIsExportLoading(true);
|
||||
try {
|
||||
await FinanceApi.exportToExcel(getTableFilterQueryString());
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor data finance.')
|
||||
);
|
||||
} finally {
|
||||
setIsExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -509,10 +584,12 @@ const FinanceTable = () => {
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'payment_code',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'References Number',
|
||||
accessorKey: 'reference_number',
|
||||
enableSorting: true,
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const value = props.row.original.reference_number;
|
||||
return <span>{value ?? '-'}</span>;
|
||||
@@ -521,6 +598,7 @@ const FinanceTable = () => {
|
||||
{
|
||||
header: 'Jenis Transaksi',
|
||||
accessorKey: 'transaction_type',
|
||||
enableSorting: true,
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const value = props.row.original.transaction_type
|
||||
.split('_')
|
||||
@@ -530,7 +608,8 @@ const FinanceTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Pihak',
|
||||
accessorFn: (finance: Finance) => finance.party?.name,
|
||||
accessorKey: 'customer_name',
|
||||
enableSorting: true,
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
if (props.row.original.party?.id) {
|
||||
return <span>{props.row.original.party?.name}</span>;
|
||||
@@ -539,13 +618,23 @@ const FinanceTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Tanggal',
|
||||
accessorFn: (finance: Finance) =>
|
||||
formatDate(finance.payment_date, 'DD MMM YYYY'),
|
||||
header: 'Tanggal Pembayaran',
|
||||
accessorKey: 'payment_date',
|
||||
enableSorting: true,
|
||||
cell: (props) =>
|
||||
formatDate(props.row.original.payment_date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
header: 'Tanggal Dibuat',
|
||||
accessorKey: 'created_at',
|
||||
enableSorting: true,
|
||||
cell: (props) =>
|
||||
formatDate(props.row.original.created_at, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
header: 'Metode Pembayaran',
|
||||
accessorKey: 'payment_method',
|
||||
enableSorting: true,
|
||||
cell: (props: CellContext<Finance, unknown>) => {
|
||||
const value = props.row.original.payment_method.split('_').join(' ');
|
||||
return <span>{formatTitleCase(value)}</span>;
|
||||
@@ -553,20 +642,26 @@ const FinanceTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Bank',
|
||||
accessorFn: (finance: Finance) =>
|
||||
finance.bank
|
||||
? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
|
||||
accessorKey: 'bank',
|
||||
enableSorting: true,
|
||||
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)',
|
||||
accessorFn: (finance: Finance) =>
|
||||
formatCurrency(Math.abs(finance.expense_amount)),
|
||||
accessorKey: 'expense_amount',
|
||||
enableSorting: true,
|
||||
cell: (props) =>
|
||||
formatCurrency(Math.abs(props.row.original.expense_amount)),
|
||||
},
|
||||
{
|
||||
header: 'Pemasukan (Rp)',
|
||||
accessorFn: (finance: Finance) =>
|
||||
formatCurrency(Math.abs(finance.income_amount)),
|
||||
accessorKey: 'income_amount',
|
||||
enableSorting: true,
|
||||
cell: (props) =>
|
||||
formatCurrency(Math.abs(props.row.original.income_amount)),
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
@@ -605,27 +700,6 @@ const FinanceTable = () => {
|
||||
};
|
||||
}, [dateErrorShown]);
|
||||
|
||||
useEffect(() => {
|
||||
previousPathRef.current = window.location.pathname;
|
||||
|
||||
return () => {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
const isCurrentPathFinance = currentPath.includes('/finance');
|
||||
const isPreviousPathFinance =
|
||||
previousPathRef.current?.includes('/finance');
|
||||
|
||||
if (isPreviousPathFinance && !isCurrentPathFinance) {
|
||||
resetSearchValue();
|
||||
}
|
||||
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
}, [resetSearchValue, dateErrorShown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
@@ -687,25 +761,65 @@ const FinanceTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'orderBy',
|
||||
'bankNames',
|
||||
'customerNames',
|
||||
'supplierNames',
|
||||
]}
|
||||
onClick={handleFilterModalOpen}
|
||||
className={cn(
|
||||
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
|
||||
{
|
||||
'border-primary-gradient text-primary': hasFilters,
|
||||
}
|
||||
)}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Ekspor</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
Filter
|
||||
{hasFilters && (
|
||||
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportToExcel}
|
||||
isLoading={isExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor ke Excel
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -741,6 +855,9 @@ const FinanceTable = () => {
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={handleSortingChange}
|
||||
manualSorting
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
@@ -874,19 +991,9 @@ const FinanceTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='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'
|
||||
onClick={() => {
|
||||
filterFormik.resetForm();
|
||||
setSelectedTransactionType(null);
|
||||
setSelectedBank(null);
|
||||
setSelectedCustomerId(null);
|
||||
setSelectedSupplierId(null);
|
||||
setSelectedSortBy(null);
|
||||
resetFilterHandler();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -25,7 +24,6 @@ import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
|
||||
import { InventoryAdjustmentApi } from '@/services/api/inventory';
|
||||
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -100,25 +98,31 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const InventoryAdjustmentTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
productCategorySort: string;
|
||||
productSort: string;
|
||||
warehouseSort: string;
|
||||
stockSort: string;
|
||||
productFilter?: OptionType<string>;
|
||||
warehouseFilter?: OptionType<string>;
|
||||
transactionTypeFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
productCategorySort: '',
|
||||
productSort: '',
|
||||
warehouseSort: '',
|
||||
stockSort: '',
|
||||
productFilter: '',
|
||||
warehouseFilter: '',
|
||||
transactionTypeFilter: '',
|
||||
productFilter: undefined,
|
||||
warehouseFilter: undefined,
|
||||
transactionTypeFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -131,6 +135,8 @@ const InventoryAdjustmentTable = () => {
|
||||
warehouseFilter: 'warehouse_id',
|
||||
transactionTypeFilter: 'transaction_type',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'inventory-adjustment-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -139,22 +145,27 @@ const InventoryAdjustmentTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<AdjustmentFilterType>({
|
||||
initialValues: {
|
||||
product_id: null,
|
||||
warehouse: null,
|
||||
transaction_type: null,
|
||||
product: tableFilterState.productFilter,
|
||||
warehouse: tableFilterState.warehouseFilter,
|
||||
transaction_type: tableFilterState.transactionTypeFilter,
|
||||
},
|
||||
validationSchema: AdjustmentFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productFilter', values.product_id || '');
|
||||
updateFilter('warehouseFilter', String(values.warehouse?.value) || '');
|
||||
updateFilter('transactionTypeFilter', values.transaction_type || '');
|
||||
updateFilter('productFilter', values.product || undefined, true);
|
||||
updateFilter('warehouseFilter', values.warehouse || undefined, true);
|
||||
updateFilter(
|
||||
'transactionTypeFilter',
|
||||
values.transaction_type || undefined,
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productFilter', '');
|
||||
updateFilter('warehouseFilter', '');
|
||||
updateFilter('transactionTypeFilter', '');
|
||||
updateFilter('productFilter', undefined, true);
|
||||
updateFilter('warehouseFilter', undefined, true);
|
||||
updateFilter('transactionTypeFilter', undefined, true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,14 +204,9 @@ const InventoryAdjustmentTable = () => {
|
||||
}, []);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const product = val as OptionType | null;
|
||||
const productId = product?.value ? String(product.value) : null;
|
||||
formik.setFieldValue('product_id', productId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('product', val);
|
||||
};
|
||||
|
||||
const handleFilterWarehouseChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
@@ -208,38 +214,20 @@ const InventoryAdjustmentTable = () => {
|
||||
formik.setFieldValue('warehouse', val);
|
||||
};
|
||||
|
||||
const handleFilterTransactionTypeChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const type = val as OptionType | null;
|
||||
const typeValue = type?.value ? String(type.value) : null;
|
||||
formik.setFieldValue('transaction_type', typeValue);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productIdValue = useMemo(() => {
|
||||
if (!formik.values.product_id) return null;
|
||||
return (
|
||||
productOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_id, productOptions]);
|
||||
|
||||
const transactionTypeValue = useMemo(() => {
|
||||
if (!formik.values.transaction_type) return null;
|
||||
return (
|
||||
transactionTypeOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.transaction_type
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.transaction_type, transactionTypeOptions]);
|
||||
const handleFilterTransactionTypeChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('transaction_type', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
formik.setValues({
|
||||
product: tableFilterState.productFilter ?? undefined,
|
||||
warehouse: tableFilterState.warehouseFilter ?? undefined,
|
||||
transaction_type: tableFilterState.transactionTypeFilter ?? undefined,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -276,17 +264,8 @@ const InventoryAdjustmentTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const singleDeleteModal = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('inventory-adjustment-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
|
||||
@@ -507,6 +486,8 @@ const InventoryAdjustmentTable = () => {
|
||||
'productSort',
|
||||
'warehouseSort',
|
||||
'stockSort',
|
||||
'productName',
|
||||
'warehouseName',
|
||||
]}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
@@ -596,7 +577,7 @@ const InventoryAdjustmentTable = () => {
|
||||
label='Produk'
|
||||
placeholder='Pilih Produk'
|
||||
options={productOptions}
|
||||
value={productIdValue}
|
||||
value={formik.values.product}
|
||||
onChange={handleFilterProductChange}
|
||||
onInputChange={setProductInputValue}
|
||||
isLoading={isLoadingProductOptions}
|
||||
@@ -620,7 +601,7 @@ const InventoryAdjustmentTable = () => {
|
||||
label='Tipe Transaksi'
|
||||
placeholder='Pilih Tipe Transaksi'
|
||||
options={transactionTypeOptions}
|
||||
value={transactionTypeValue}
|
||||
value={formik.values.transaction_type}
|
||||
onChange={handleFilterTransactionTypeChange}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
@@ -630,13 +611,9 @@ const InventoryAdjustmentTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const AdjustmentFilterSchema = object().shape({
|
||||
product_id: string().nullable(),
|
||||
warehouse_id: string().nullable(),
|
||||
transaction_type: string().nullable(),
|
||||
export const AdjustmentFilterSchema = Yup.object().shape({
|
||||
product: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
warehouse: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
transaction_type: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type AdjustmentFilterType = {
|
||||
product_id: string | null;
|
||||
transaction_type: string | null;
|
||||
warehouse: OptionType<number> | null;
|
||||
product?: OptionType<string>;
|
||||
warehouse?: OptionType<string>;
|
||||
transaction_type?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
@@ -20,7 +13,6 @@ import { WarehouseApi, ProductApi } from '@/services/api/master-data';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from '@/components/Button';
|
||||
@@ -108,20 +100,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const MovementTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
productFilter?: OptionType<string>;
|
||||
warehouseFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
productFilter: '',
|
||||
warehouseFilter: '',
|
||||
productFilter: undefined,
|
||||
warehouseFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -129,6 +122,8 @@ const MovementTable = () => {
|
||||
productFilter: 'product_id',
|
||||
warehouseFilter: 'warehouse_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'movement-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -137,19 +132,20 @@ const MovementTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<MovementFilterType>({
|
||||
initialValues: {
|
||||
product_id: null,
|
||||
warehouse_id: null,
|
||||
product: tableFilterState.productFilter,
|
||||
warehouse: tableFilterState.warehouseFilter,
|
||||
},
|
||||
validationSchema: MovementFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productFilter', values.product_id || '');
|
||||
updateFilter('warehouseFilter', values.warehouse_id || '');
|
||||
updateFilter('productFilter', values.product || undefined, true);
|
||||
updateFilter('warehouseFilter', values.warehouse || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productFilter', '');
|
||||
updateFilter('warehouseFilter', '');
|
||||
updateFilter('productFilter', undefined, true);
|
||||
updateFilter('warehouseFilter', undefined, true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,47 +176,23 @@ const MovementTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const product = val as OptionType | null;
|
||||
const productId = product?.value ? String(product.value) : null;
|
||||
formik.setFieldValue('product_id', productId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('product', val);
|
||||
};
|
||||
|
||||
const handleFilterWarehouseChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const warehouse = val as OptionType | null;
|
||||
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
|
||||
formik.setFieldValue('warehouse_id', warehouseId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productIdValue = useMemo(() => {
|
||||
if (!formik.values.product_id) return null;
|
||||
return (
|
||||
productOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_id, productOptions]);
|
||||
|
||||
const warehouseIdValue = useMemo(() => {
|
||||
if (!formik.values.warehouse_id) return null;
|
||||
return (
|
||||
warehouseOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.warehouse_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.warehouse_id, warehouseOptions]);
|
||||
const handleFilterWarehouseChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('warehouse', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
formik.setValues({
|
||||
product: tableFilterState.productFilter ?? undefined,
|
||||
warehouse: tableFilterState.warehouseFilter ?? undefined,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -255,17 +227,8 @@ const MovementTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('movement-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const movementColumns: ColumnDef<Movement>[] = useMemo(
|
||||
@@ -464,7 +427,7 @@ const MovementTable = () => {
|
||||
label='Produk'
|
||||
placeholder='Pilih Produk'
|
||||
options={productOptions}
|
||||
value={productIdValue}
|
||||
value={formik.values.product}
|
||||
onChange={handleFilterProductChange}
|
||||
onInputChange={setProductInputValue}
|
||||
isLoading={isLoadingProductOptions}
|
||||
@@ -476,7 +439,7 @@ const MovementTable = () => {
|
||||
label='Gudang'
|
||||
placeholder='Pilih Gudang'
|
||||
options={warehouseOptions}
|
||||
value={warehouseIdValue}
|
||||
value={formik.values.warehouse}
|
||||
onChange={handleFilterWarehouseChange}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
@@ -489,13 +452,9 @@ const MovementTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const MovementFilterSchema = object().shape({
|
||||
product_id: string().nullable(),
|
||||
warehouse_id: string().nullable(),
|
||||
export const MovementFilterSchema = Yup.object().shape({
|
||||
product: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
warehouse: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type MovementFilterType = {
|
||||
product_id: string | null;
|
||||
warehouse_id: string | null;
|
||||
product?: OptionType<string>;
|
||||
warehouse?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -56,6 +56,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
const [productQtyErrorShown, setProductQtyErrorShown] = useState(false);
|
||||
const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const productStockCacheRef = useRef<
|
||||
Map<number, { quantity: number; transfer_available_qty?: number }>
|
||||
>(new Map());
|
||||
|
||||
// ===== FORM HANDLERS =====
|
||||
const createMovementHandler = useCallback(
|
||||
@@ -337,6 +340,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
prevSourceWarehouseId !== currentSourceWarehouseId &&
|
||||
prevSourceWarehouseId !== null
|
||||
) {
|
||||
productStockCacheRef.current = new Map();
|
||||
formik.setFieldValue('products', [
|
||||
{
|
||||
product: null,
|
||||
@@ -399,6 +403,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
: [];
|
||||
}, [productWarehouses]);
|
||||
|
||||
useEffect(() => {
|
||||
productWarehouseOptions.forEach((pw) => {
|
||||
productStockCacheRef.current.set(pw.product_id, {
|
||||
quantity: pw.quantity,
|
||||
transfer_available_qty: pw.transfer_available_qty,
|
||||
});
|
||||
});
|
||||
}, [productWarehouseOptions]);
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
|
||||
arrayName: T,
|
||||
@@ -840,15 +853,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
const getAvailableStock = useCallback(
|
||||
(productId: number) => {
|
||||
if (type === 'detail') return 0;
|
||||
const productWarehouse = productWarehouseOptions.find(
|
||||
const live = productWarehouseOptions.find(
|
||||
(pw) => pw.product_id === productId
|
||||
);
|
||||
|
||||
return (
|
||||
productWarehouse?.transfer_available_qty ??
|
||||
productWarehouse?.quantity ??
|
||||
0
|
||||
);
|
||||
if (live) return live.transfer_available_qty ?? live.quantity ?? 0;
|
||||
const cached = productStockCacheRef.current.get(productId);
|
||||
return cached?.transfer_available_qty ?? cached?.quantity ?? 0;
|
||||
},
|
||||
[productWarehouseOptions, type]
|
||||
);
|
||||
@@ -856,20 +866,25 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
const getTotalStock = useCallback(
|
||||
(productId: number) => {
|
||||
if (type === 'detail') return 0;
|
||||
const productWarehouse = productWarehouseOptions.find(
|
||||
const live = productWarehouseOptions.find(
|
||||
(pw) => pw.product_id === productId
|
||||
);
|
||||
return productWarehouse?.quantity ?? 0;
|
||||
if (live) return live.quantity ?? 0;
|
||||
return productStockCacheRef.current.get(productId)?.quantity ?? 0;
|
||||
},
|
||||
[productWarehouseOptions, type]
|
||||
);
|
||||
|
||||
const hasAvailableQty = useCallback(
|
||||
(productId: number) => {
|
||||
const productWarehouse = productWarehouseOptions.find(
|
||||
const live = productWarehouseOptions.find(
|
||||
(pw) => pw.product_id === productId
|
||||
);
|
||||
return productWarehouse?.transfer_available_qty !== undefined;
|
||||
if (live) return live.transfer_available_qty !== undefined;
|
||||
return (
|
||||
productStockCacheRef.current.get(productId)?.transfer_available_qty !==
|
||||
undefined
|
||||
);
|
||||
},
|
||||
[productWarehouseOptions]
|
||||
);
|
||||
|
||||
@@ -4,17 +4,23 @@ import Button from '@/components/Button';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import Table from '@/components/Table';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { InventoryProductApi } from '@/services/api/inventory';
|
||||
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||
import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
|
||||
@@ -71,25 +77,79 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const InventoryProductTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
categoryFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
categoryFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
categoryFilter: 'product_category_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'inventory-product-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<{ category?: OptionType<string> }>({
|
||||
initialValues: { category: tableFilterState.categoryFilter },
|
||||
validationSchema: Yup.object().shape({
|
||||
category: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
}),
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('categoryFilter', values.category || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('categoryFilter', undefined, true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
// ===== CATEGORY OPTIONS =====
|
||||
const {
|
||||
setInputValue: setCategoryInputValue,
|
||||
options: categoryOptions,
|
||||
isLoadingOptions: isLoadingCategoryOptions,
|
||||
loadMore: loadMoreCategories,
|
||||
} = useSelect<ProductCategory>(
|
||||
filterModal.open ? ProductCategoryApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search'
|
||||
);
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
formik.setValues({
|
||||
category: tableFilterState.categoryFilter ?? undefined,
|
||||
});
|
||||
filterModal.openModal();
|
||||
};
|
||||
|
||||
const handleFilterCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const { data: inventoryProducts, isLoading } = useSWR(
|
||||
@@ -97,17 +157,8 @@ const InventoryProductTable = () => {
|
||||
InventoryProductApi.getAllFetcher
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('inventory-product-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<InventoryProduct>[] = useMemo(
|
||||
@@ -182,96 +233,163 @@ const InventoryProductTable = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.inventory.product_stock.create'>
|
||||
<Button
|
||||
href='/inventory/product/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Product
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
<>
|
||||
<div className='w-full'>
|
||||
{/* Header Section */}
|
||||
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
|
||||
{/* Action Buttons */}
|
||||
<div className='w-fit flex flex-row gap-3 flex-wrap'>
|
||||
<RequirePermission permissions='lti.inventory.product_stock.create'>
|
||||
<Button
|
||||
href='/inventory/product/add'
|
||||
color='primary'
|
||||
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
|
||||
>
|
||||
<Icon icon='heroicons:plus' width={20} height={20} />
|
||||
Add Product
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
) : !isResponseSuccess(inventoryProducts) ||
|
||||
inventoryProducts.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<InventoryProductTableSkeleton
|
||||
columns={columns}
|
||||
icon={
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
|
||||
<DebouncedTextInput
|
||||
name='search'
|
||||
placeholder='Search'
|
||||
value={tableFilterState.search ?? ''}
|
||||
onChange={searchChangeHandler}
|
||||
startAdornment={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
icon='heroicons:magnifying-glass'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full min-w-24 max-w-3xs',
|
||||
inputWrapper: 'rounded-xl! shadow-button-soft',
|
||||
input:
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<InventoryProduct>
|
||||
data={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.data
|
||||
: []
|
||||
}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className='flex flex-col mb-4'>
|
||||
{isLoading ? (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
) : !isResponseSuccess(inventoryProducts) ||
|
||||
inventoryProducts.data?.length === 0 ? (
|
||||
<div className='p-3'>
|
||||
<InventoryProductTableSkeleton
|
||||
columns={columns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-text'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table<InventoryProduct>
|
||||
data={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.data
|
||||
: []
|
||||
}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(inventoryProducts)
|
||||
? inventoryProducts?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
<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={formik.handleReset}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih Kategori'
|
||||
options={categoryOptions}
|
||||
value={formik.values.category}
|
||||
onChange={handleFilterCategoryChange}
|
||||
onInputChange={setCategoryInputValue}
|
||||
isLoading={isLoadingCategoryOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreCategories}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
<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={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import Card from '@/components/Card';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import { FormHeader } from '@/components/helper/form/FormHeader';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import StockLogFilterModal from '@/components/pages/inventory/product/detail/StockLogFilterModal';
|
||||
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
|
||||
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
|
||||
import { formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { InventoryProduct } from '@/types/api/inventory/product';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -11,17 +19,34 @@ const InventoryProductDetail = ({
|
||||
}: {
|
||||
inventoryProduct?: InventoryProduct;
|
||||
}) => {
|
||||
const stockLogs = useMemo(() => {
|
||||
return (
|
||||
inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
|
||||
warehouse.stock_logs.map((log) => ({
|
||||
...log,
|
||||
warehouse_name: warehouse.warehouse_name,
|
||||
warehouse_id: warehouse.warehouse_id,
|
||||
}))
|
||||
) || []
|
||||
);
|
||||
}, [inventoryProduct]);
|
||||
const filterModal = useModal();
|
||||
|
||||
const { state: filterState, updateFilter } = useTableFilter<{
|
||||
warehouse_ids: OptionType<number>[];
|
||||
}>({
|
||||
initial: {
|
||||
warehouse_ids: [],
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'inventory-product-stock-log-filter',
|
||||
});
|
||||
|
||||
const filteredProductWarehouses = useMemo(() => {
|
||||
const warehouses = inventoryProduct?.product_warehouses ?? [];
|
||||
if (!filterState.warehouse_ids?.length) return warehouses;
|
||||
const selectedIds = new Set(filterState.warehouse_ids.map((w) => w.value));
|
||||
return warehouses.filter((pw) => selectedIds.has(pw.warehouse_id));
|
||||
}, [inventoryProduct?.product_warehouses, filterState.warehouse_ids]);
|
||||
|
||||
const filterSubmitHandler = (values: {
|
||||
warehouse_ids: OptionType<number>[];
|
||||
}) => {
|
||||
updateFilter('warehouse_ids', values.warehouse_ids, true);
|
||||
};
|
||||
|
||||
const filterResetHandler = () => {
|
||||
updateFilter('warehouse_ids', [], true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
@@ -114,7 +139,29 @@ const InventoryProductDetail = ({
|
||||
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
|
||||
/>
|
||||
|
||||
<StockLogTable stockLogs={stockLogs} />
|
||||
<RequirePermission permissions={'lti.inventory.stock_log.list'}>
|
||||
<div className='flex justify-end'>
|
||||
<ButtonFilter
|
||||
values={{ warehouse_ids: filterState.warehouse_ids }}
|
||||
onClick={filterModal.openModal}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
{filteredProductWarehouses.map((productWarehouse) => (
|
||||
<StockLogTable
|
||||
key={productWarehouse.id}
|
||||
productWarehouse={productWarehouse}
|
||||
/>
|
||||
))}
|
||||
</RequirePermission>
|
||||
|
||||
<StockLogFilterModal
|
||||
ref={filterModal.ref}
|
||||
productWarehouses={inventoryProduct?.product_warehouses ?? []}
|
||||
initialValues={{ warehouse_ids: filterState.warehouse_ids }}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import Modal from '@/components/Modal';
|
||||
import { ProductWarehouseStock } from '@/types/api/inventory/product';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
interface StockLogFilterModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
productWarehouses: ProductWarehouseStock[];
|
||||
initialValues: {
|
||||
warehouse_ids: OptionType<number>[];
|
||||
};
|
||||
onSubmit: (values: { warehouse_ids: OptionType<number>[] }) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const StockLogFilterModal = ({
|
||||
ref,
|
||||
productWarehouses,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: StockLogFilterModalProps) => {
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const warehouseOptions: OptionType<number>[] = productWarehouses.map(
|
||||
(pw) => ({
|
||||
label: pw.warehouse_name,
|
||||
value: pw.warehouse_id,
|
||||
})
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues,
|
||||
enableReinitialize: true,
|
||||
onSubmit: (values) => {
|
||||
onSubmit(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm } = formik;
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
resetForm({ values: { warehouse_ids: [] } });
|
||||
onReset();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset]);
|
||||
|
||||
return (
|
||||
<Modal ref={ref} className={{ modalBox: 'p-0 rounded-xl' }}>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
<div className='p-4 flex items-center justify-between gap-2 border-b border-gray-300'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='text-sm font-medium'>Filter Stock Log</h3>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={closeModalHandler}
|
||||
className='p-0 text-base-content/50 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputCheckbox
|
||||
label='Gudang'
|
||||
isClearable
|
||||
placeholder='Pilih gudang'
|
||||
options={warehouseOptions}
|
||||
value={formik.values.warehouse_ids}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('warehouse_ids', val as OptionType<number>[])
|
||||
}
|
||||
isMulti
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
|
||||
<Button
|
||||
type='reset'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-3 rounded-lg text-base-content/65'
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockLogFilterModal;
|
||||
@@ -1,95 +1,183 @@
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Table from '@/components/Table';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
|
||||
import { StockLog } from '@/types/api/inventory/product';
|
||||
import { StockLogApi } from '@/services/api/inventory';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ProductWarehouseStock, StockLog } from '@/types/api/inventory/product';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { FileDown } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const stockLogTableColumns: (warehouseName: string) => ColumnDef<StockLog>[] = (
|
||||
warehouseName
|
||||
) => [
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Tanggal',
|
||||
accessorKey: 'created_at',
|
||||
cell: (props) => {
|
||||
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Gudang',
|
||||
accessorKey: 'warehouse_name',
|
||||
cell: warehouseName,
|
||||
},
|
||||
{
|
||||
header: 'Stock Akhir',
|
||||
accessorKey: 'stock',
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.stock);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Peningkatan',
|
||||
accessorKey: 'increase',
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.increase);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Penurunan',
|
||||
accessorKey: 'decrease',
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.decrease);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Jenis Transaksi',
|
||||
accessorKey: 'loggable_type',
|
||||
cell: (props) => {
|
||||
return props.row.original.loggable_type
|
||||
? formatTitleCase(props.row.original.loggable_type)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Catatan',
|
||||
accessorKey: 'notes',
|
||||
cell: (props) => {
|
||||
return props.row.original.notes ? props.row.original.notes : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Oleh',
|
||||
accessorKey: 'created_user.name',
|
||||
},
|
||||
];
|
||||
|
||||
const StockLogTable = ({
|
||||
stockLogs,
|
||||
productWarehouse,
|
||||
}: {
|
||||
stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[];
|
||||
productWarehouse: ProductWarehouseStock;
|
||||
}) => {
|
||||
const [isExportLoading, setIsExportLoading] = useState(false);
|
||||
const [hasBeenVisible, setHasBeenVisible] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setHasBeenVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
product_warehouse_id: productWarehouse.id,
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsExportLoading(true);
|
||||
try {
|
||||
await StockLogApi.exportToExcel(
|
||||
productWarehouse.warehouse_name,
|
||||
getTableFilterQueryString()
|
||||
);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: stockLogsResponse, isLoading: isLoadingStockLogs } = useSWR(
|
||||
hasBeenVisible
|
||||
? `${StockLogApi.basePath}${getTableFilterQueryString()}`
|
||||
: null,
|
||||
StockLogApi.getAllFetcher
|
||||
);
|
||||
|
||||
const stockLogs = isResponseSuccess(stockLogsResponse)
|
||||
? stockLogsResponse.data
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='Informasi Stock Produk'
|
||||
collapsible
|
||||
variant='bordered'
|
||||
className={{
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
>
|
||||
<Table<StockLog>
|
||||
data={stockLogs}
|
||||
columns={[
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Tanggal',
|
||||
accessorKey: 'created_at',
|
||||
cell: (props) => {
|
||||
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Gudang',
|
||||
accessorKey: 'warehouse_name',
|
||||
},
|
||||
{
|
||||
header: 'Stock Akhir',
|
||||
accessorKey: 'stock',
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.stock);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Peningkatan',
|
||||
accessorKey: 'increase',
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.increase);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Penurunan',
|
||||
accessorKey: 'decrease',
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.decrease);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Jenis Transaksi',
|
||||
accessorKey: 'loggable_type',
|
||||
cell: (props) => {
|
||||
return props.row.original.loggable_type
|
||||
? formatTitleCase(props.row.original.loggable_type)
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Catatan',
|
||||
accessorKey: 'notes',
|
||||
cell: (props) => {
|
||||
return props.row.original.notes ? props.row.original.notes : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Oleh',
|
||||
accessorKey: 'created_user.name',
|
||||
},
|
||||
]}
|
||||
<div ref={containerRef}>
|
||||
<Card
|
||||
title={`Informasi Stock Produk - ${productWarehouse.warehouse_name}`}
|
||||
collapsible
|
||||
variant='bordered'
|
||||
className={{
|
||||
containerClassName: 'mt-6',
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
headerColumnClassName:
|
||||
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||
bodyRowClassName: 'border-b border-b-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||
wrapper: 'w-full',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
>
|
||||
<div className='flex justify-end px-6 pt-4'>
|
||||
<Button onClick={handleExportExcel} isLoading={isExportLoading}>
|
||||
<FileDown size={16} />
|
||||
Export Excel
|
||||
</Button>
|
||||
</div>
|
||||
<Table<StockLog>
|
||||
data={stockLogs}
|
||||
columns={stockLogTableColumns(productWarehouse.warehouse_name)}
|
||||
page={tableFilterState.page ?? 0}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoadingStockLogs}
|
||||
totalItems={
|
||||
isResponseSuccess(stockLogsResponse)
|
||||
? stockLogsResponse.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
className={{
|
||||
containerClassName: 'mt-4 mb-0',
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
headerColumnClassName:
|
||||
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
|
||||
bodyRowClassName: 'border-b border-b-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-6 py-3 last:flex last:flex-row last:justify-end',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import Card from '@/components/Card';
|
||||
import Table from '@/components/Table';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ProductWarehouseStock } from '@/types/api/inventory/product';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
const stockProductWarehouseTableColumns: ColumnDef<ProductWarehouseStock>[] = [
|
||||
{
|
||||
header: 'Nama Gudang',
|
||||
accessorKey: 'warehouse_name',
|
||||
},
|
||||
{
|
||||
header: 'Lokasi',
|
||||
accessorKey: 'location',
|
||||
cell: (props) => {
|
||||
return props.row.original.location != null
|
||||
? props.row.original.location.name
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Stok',
|
||||
accessorFn(row) {
|
||||
return row.current_stock;
|
||||
},
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.current_stock);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const StockProductWarehouseTable = ({
|
||||
productWarehouseStock,
|
||||
}: {
|
||||
productWarehouseStock?: ProductWarehouseStock[];
|
||||
}) => {
|
||||
const { state: tableFilterState, setPage, setPageSize } = useTableFilter();
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='Informasi Gudang'
|
||||
@@ -19,32 +48,14 @@ const StockProductWarehouseTable = ({
|
||||
>
|
||||
<Table<ProductWarehouseStock>
|
||||
data={productWarehouseStock ?? []}
|
||||
columns={[
|
||||
{
|
||||
header: 'Nama Gudang',
|
||||
accessorKey: 'warehouse_name',
|
||||
},
|
||||
{
|
||||
header: 'Lokasi',
|
||||
accessorKey: 'location',
|
||||
cell: (props) => {
|
||||
return props.row.original.location != null
|
||||
? props.row.original.location.name
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Stok',
|
||||
accessorFn(row) {
|
||||
return row.current_stock;
|
||||
},
|
||||
cell: (props) => {
|
||||
return formatNumber(props.row.original.current_stock);
|
||||
},
|
||||
},
|
||||
]}
|
||||
columns={stockProductWarehouseTableColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page ?? 0}
|
||||
totalItems={productWarehouseStock?.length ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
className={{
|
||||
containerClassName: 'mt-6',
|
||||
containerClassName: 'mt-6 mb-0',
|
||||
tableWrapperClassName: 'overflow-x-auto min-h-full!',
|
||||
tableClassName: 'font-inter w-full table-auto min-h-full!',
|
||||
headerRowClassName: 'border-b border-b-gray-200',
|
||||
|
||||
@@ -847,9 +847,14 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
}
|
||||
}}
|
||||
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
|
||||
disabled={deliveryRejected}
|
||||
disabled={deliveryRejected || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Approve
|
||||
{marketing?.data?.latest_approval?.step_number === 1 &&
|
||||
'Approve'}
|
||||
|
||||
{marketing?.data?.latest_approval?.step_number === 2 &&
|
||||
'Deliver Item'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { RefObject, useCallback, useMemo, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal from '@/components/Modal';
|
||||
@@ -9,6 +9,8 @@ import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import {
|
||||
MarketingFilterFormValues,
|
||||
@@ -17,20 +19,40 @@ import {
|
||||
import { MarketingFilter } from '@/types/api/marketing/marketing';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import { MarketingApi } from '@/services/api/marketing/marketing';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import {
|
||||
CustomerApi,
|
||||
ProductApi,
|
||||
WarehouseApi,
|
||||
} from '@/services/api/master-data';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
|
||||
interface MarketingFilterModal {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
onSubmit?: (values: MarketingFilter) => void;
|
||||
onReset?: () => void;
|
||||
initialValues?: {
|
||||
product_ids: OptionType<number>[];
|
||||
status: OptionType<string> | null;
|
||||
customer: OptionType<number> | null;
|
||||
project_flock: OptionType<number> | null;
|
||||
project_flock_kandang: OptionType<number> | null;
|
||||
warehouse: OptionType<number> | null;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
filter_by: OptionType<string> | null;
|
||||
};
|
||||
}
|
||||
|
||||
const MarketingFilterModal = ({
|
||||
ref,
|
||||
onSubmit,
|
||||
onReset,
|
||||
initialValues,
|
||||
}: MarketingFilterModal) => {
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
@@ -38,36 +60,13 @@ const MarketingFilterModal = ({
|
||||
|
||||
// ===== OPTIONS =====
|
||||
const {
|
||||
rawData: productsRawData,
|
||||
options: productsOptions,
|
||||
isLoadingOptions: isLoadingProductsOptions,
|
||||
setInputValue: setProductsInputValue,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<BaseMarketing>(
|
||||
MarketingApi.basePath,
|
||||
'id',
|
||||
'so_number',
|
||||
'search'
|
||||
);
|
||||
|
||||
const productsOptions = useMemo(() => {
|
||||
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
|
||||
|
||||
const productsMap = new Map<number, { value: number; label: string }>();
|
||||
|
||||
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
|
||||
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
|
||||
const product = so.product_warehouse?.product;
|
||||
if (product?.id && product?.name) {
|
||||
productsMap.set(product.id, {
|
||||
value: product.id,
|
||||
label: product.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(productsMap.values());
|
||||
}, [productsRawData]);
|
||||
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
|
||||
include_all: 'true',
|
||||
});
|
||||
|
||||
const {
|
||||
options: customersOptions,
|
||||
@@ -78,6 +77,26 @@ const MarketingFilterModal = ({
|
||||
has_marketing: 'true',
|
||||
});
|
||||
|
||||
const {
|
||||
options: projectFlockOptions,
|
||||
rawData: projectFlocksRawData,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search'
|
||||
);
|
||||
|
||||
const {
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
setInputValue: setWarehouseInputValue,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const statusOptions = [
|
||||
...MARKETING_APPROVAL_LINE.map((item) => ({
|
||||
value: item.step_name.split(' ').join('_').toUpperCase(),
|
||||
@@ -86,19 +105,47 @@ const MarketingFilterModal = ({
|
||||
{ value: 'DITOLAK', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const filterByOptions = [
|
||||
{ value: 'so_date', label: 'Tanggal SO' },
|
||||
{ value: 'created_at', label: 'Tanggal Dibuat' },
|
||||
];
|
||||
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
|
||||
const formik = useFormik<MarketingFilterFormValues>({
|
||||
initialValues: {
|
||||
initialValues: initialValues || {
|
||||
product_ids: [],
|
||||
status: null,
|
||||
customer: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
warehouse: null,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filter_by: null,
|
||||
},
|
||||
validationSchema: MarketingFilterSchema,
|
||||
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues: MarketingFilter = {
|
||||
product_ids: values.product_ids.map((item) => Number(item.value)),
|
||||
product_names: values.product_ids.map((item) => item.label),
|
||||
status: values.status?.value.toString() || '',
|
||||
status_name: values.status?.label || '-',
|
||||
customer_id: Number(values.customer?.value),
|
||||
customer_name: values.customer?.label || '-',
|
||||
project_flock_id: values.project_flock?.value || undefined,
|
||||
project_flock_name: values.project_flock?.label,
|
||||
project_flock_kandang_id:
|
||||
Number(values.project_flock_kandang?.value) || undefined,
|
||||
project_flock_kandang_name:
|
||||
values.project_flock_kandang?.label || undefined,
|
||||
warehouse_id: Number(values.warehouse?.value) || undefined,
|
||||
warehouse_name: values.warehouse?.label || undefined,
|
||||
start_date: values.start_date || undefined,
|
||||
end_date: values.end_date || undefined,
|
||||
filter_by: values.filter_by?.value || undefined,
|
||||
filter_by_name: values.filter_by?.label || undefined,
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues);
|
||||
@@ -111,6 +158,47 @@ const MarketingFilterModal = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm } = formik;
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
resetForm({
|
||||
values: {
|
||||
product_ids: [],
|
||||
status: null,
|
||||
customer: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
warehouse: null,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filter_by: null,
|
||||
},
|
||||
});
|
||||
setHasDateError(false);
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset, closeModalHandler]);
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value);
|
||||
if (value && formik.values.end_date) {
|
||||
setHasDateError(new Date(formik.values.end_date) < new Date(value));
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value);
|
||||
if (value && formik.values.start_date) {
|
||||
setHasDateError(new Date(value) < new Date(formik.values.start_date));
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('product_ids', val as OptionType[]);
|
||||
};
|
||||
@@ -126,6 +214,27 @@ const MarketingFilterModal = ({
|
||||
formik.setFieldValue('status', val as OptionType);
|
||||
};
|
||||
|
||||
const projectFlockKandangOptions = useMemo(() => {
|
||||
if (
|
||||
!formik.values.project_flock ||
|
||||
!projectFlocksRawData ||
|
||||
!isResponseSuccess(projectFlocksRawData)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedProjectFlock = projectFlocksRawData.data.find(
|
||||
(item) => item.id === formik.values.project_flock?.value
|
||||
);
|
||||
|
||||
return (
|
||||
selectedProjectFlock?.kandangs?.map((item) => ({
|
||||
value: item.project_flock_kandang_id,
|
||||
label: item.name,
|
||||
})) || []
|
||||
);
|
||||
}, [formik.values.project_flock, projectFlocksRawData]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
@@ -135,7 +244,7 @@ const MarketingFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -158,6 +267,44 @@ const MarketingFilterModal = ({
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div>
|
||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||
Tanggal
|
||||
</label>
|
||||
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
||||
<DateInput
|
||||
name='start_date'
|
||||
value={formik.values.start_date || ''}
|
||||
onChange={handleStartDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='end_date'
|
||||
value={formik.values.end_date || ''}
|
||||
onChange={handleEndDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
isError={hasDateError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectInputRadio
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={filterByOptions}
|
||||
value={formik.values.filter_by ?? null}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'filter_by',
|
||||
!Array.isArray(val) ? (val ?? null) : null
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
{/* select multiple product */}
|
||||
<SelectInputCheckbox
|
||||
label='Product'
|
||||
@@ -192,6 +339,53 @@ const MarketingFilterModal = ({
|
||||
onInputChange={setCustomersInputValue}
|
||||
onMenuScrollToBottom={loadMoreCustomers}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
isClearable
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
value={formik.values.project_flock}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue(
|
||||
'project_flock',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
}}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
isClearable
|
||||
placeholder='Pilih Kandang'
|
||||
options={projectFlockKandangOptions}
|
||||
value={formik.values.project_flock_kandang}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'project_flock_kandang',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
)
|
||||
}
|
||||
isDisabled={!formik.values.project_flock}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Gudang'
|
||||
isClearable
|
||||
placeholder='Pilih Gudang'
|
||||
options={warehouseOptions}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
value={formik.values.warehouse}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'warehouse',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
)
|
||||
}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
@@ -208,6 +402,7 @@ const MarketingFilterModal = ({
|
||||
<Button
|
||||
type='submit'
|
||||
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
|
||||
disabled={hasDateError}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import Table from '@/components/Table';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
getErrorMessage,
|
||||
isResponseError,
|
||||
isResponseSuccess,
|
||||
} from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import {
|
||||
MarketingApi,
|
||||
SalesOrderApi,
|
||||
} from '@/services/api/marketing/marketing';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import {
|
||||
BaseSalesOrder,
|
||||
Marketing,
|
||||
MarketingFilter,
|
||||
} from '@/types/api/marketing/marketing';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
Row,
|
||||
SortingState,
|
||||
Updater,
|
||||
} from '@tanstack/react-table';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
@@ -154,12 +167,21 @@ const MarketingTable = () => {
|
||||
);
|
||||
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [bulkDeliveryDate, setBulkDeliveryDate] = useState('');
|
||||
const [bulkDeliveryNotes, setBulkDeliveryNotes] = useState('');
|
||||
const [isSubmittingBulkDelivery, setIsSubmittingBulkDelivery] =
|
||||
useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
|
||||
const router = useRouter();
|
||||
const deleteModal = useModal();
|
||||
const confirmationModal = useModal();
|
||||
const productsModal = useModal();
|
||||
const deliveryModal = useModal();
|
||||
const bulkDeliveryModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
const filterModal = useModal();
|
||||
|
||||
const {
|
||||
@@ -172,8 +194,23 @@ const MarketingTable = () => {
|
||||
initial: {
|
||||
search: '',
|
||||
product_ids: '',
|
||||
product_names: '',
|
||||
status: '',
|
||||
status_name: '',
|
||||
customer_id: '',
|
||||
customer_name: '',
|
||||
project_flock_id: '',
|
||||
project_flock_name: '',
|
||||
project_flock_kandang_id: '',
|
||||
project_flock_kandang_name: '',
|
||||
warehouse_id: '',
|
||||
warehouse_name: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filter_by: '',
|
||||
filter_by_name: '',
|
||||
sort_by: '',
|
||||
order_by: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -181,9 +218,49 @@ const MarketingTable = () => {
|
||||
product_ids: 'product_ids',
|
||||
status: 'status',
|
||||
customer_id: 'customer_id',
|
||||
project_flock_id: 'project_flock_id',
|
||||
project_flock_kandang_id: 'project_flock_kandang_id',
|
||||
warehouse_id: 'warehouse_id',
|
||||
start_date: 'start_date',
|
||||
end_date: 'end_date',
|
||||
filter_by: 'filter_by',
|
||||
sort_by: 'sort_by',
|
||||
order_by: 'sort_order',
|
||||
},
|
||||
excludeKeysFromUrl: [
|
||||
'product_names',
|
||||
'status_name',
|
||||
'customer_name',
|
||||
'project_flock_name',
|
||||
'project_flock_kandang_name',
|
||||
'warehouse_name',
|
||||
'filter_by_name',
|
||||
],
|
||||
|
||||
persist: true,
|
||||
storeName: 'marketing-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);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== FETCH DATA =====
|
||||
const {
|
||||
data: marketing,
|
||||
@@ -198,26 +275,80 @@ const MarketingTable = () => {
|
||||
const filterSubmitHandler = (values: MarketingFilter) => {
|
||||
updateFilter(
|
||||
'product_ids',
|
||||
values.product_ids?.map((item) => item.toString()).join(',')
|
||||
values.product_ids?.map((item) => item.toString()).join(','),
|
||||
true
|
||||
);
|
||||
updateFilter('status', values.status ? values.status.toString() : '');
|
||||
updateFilter('product_names', values.product_names?.join(','));
|
||||
updateFilter('status', values.status ? values.status.toString() : '', true);
|
||||
updateFilter('status_name', values.status_name, true);
|
||||
updateFilter(
|
||||
'customer_id',
|
||||
values.customer_id ? values.customer_id.toString() : ''
|
||||
values.customer_id ? values.customer_id.toString() : '',
|
||||
true
|
||||
);
|
||||
updateFilter('customer_name', values.customer_name, true);
|
||||
updateFilter(
|
||||
'project_flock_id',
|
||||
values.project_flock_id ? values.project_flock_id.toString() : '',
|
||||
true
|
||||
);
|
||||
updateFilter('project_flock_name', values.project_flock_name ?? '', true);
|
||||
updateFilter(
|
||||
'project_flock_kandang_id',
|
||||
values.project_flock_kandang_id
|
||||
? values.project_flock_kandang_id.toString()
|
||||
: '',
|
||||
true
|
||||
);
|
||||
updateFilter(
|
||||
'project_flock_kandang_name',
|
||||
values.project_flock_kandang_name ?? '',
|
||||
true
|
||||
);
|
||||
updateFilter(
|
||||
'warehouse_id',
|
||||
values.warehouse_id ? values.warehouse_id.toString() : '',
|
||||
true
|
||||
);
|
||||
updateFilter('warehouse_name', values.warehouse_name ?? '', true);
|
||||
updateFilter('start_date', values.start_date ?? '', true);
|
||||
updateFilter('end_date', values.end_date ?? '', true);
|
||||
updateFilter('filter_by', values.filter_by ?? '', true);
|
||||
updateFilter('filter_by_name', values.filter_by_name ?? '', true);
|
||||
};
|
||||
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isDeliveryLoading, setIsDeliveryLoading] = useState(false);
|
||||
|
||||
const filterResetHandler = () => {
|
||||
updateFilter('product_ids', '');
|
||||
updateFilter('status', '');
|
||||
updateFilter('customer_id', '');
|
||||
updateFilter('product_ids', '', true);
|
||||
updateFilter('product_names', '', true);
|
||||
updateFilter('status', '', true);
|
||||
updateFilter('status_name', '', true);
|
||||
updateFilter('customer_id', '', true);
|
||||
updateFilter('customer_name', '', true);
|
||||
updateFilter('project_flock_id', '', true);
|
||||
updateFilter('project_flock_name', '', true);
|
||||
updateFilter('project_flock_kandang_id', '', true);
|
||||
updateFilter('project_flock_kandang_name', '', true);
|
||||
updateFilter('warehouse_id', '', true);
|
||||
updateFilter('warehouse_name', '', true);
|
||||
updateFilter('start_date', '', true);
|
||||
updateFilter('end_date', '', true);
|
||||
updateFilter('filter_by', '', true);
|
||||
updateFilter('filter_by_name', '', true);
|
||||
};
|
||||
|
||||
const approveClickHandler = () => {
|
||||
setApproveAction('APPROVED');
|
||||
|
||||
if (selectedApprovalStep === 2) {
|
||||
bulkDeliveryModal.openModal();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmationModal.openModal();
|
||||
};
|
||||
|
||||
@@ -226,10 +357,13 @@ const MarketingTable = () => {
|
||||
confirmationModal.openModal();
|
||||
};
|
||||
|
||||
const productsClickHandler = (item: Marketing) => {
|
||||
setSelectedItem(item);
|
||||
productsModal.openModal();
|
||||
};
|
||||
const productsClickHandler = useCallback(
|
||||
(item: Marketing) => {
|
||||
setSelectedItem(item);
|
||||
productsModal.openModal();
|
||||
},
|
||||
[productsModal]
|
||||
);
|
||||
|
||||
const deleteMarketingHandler = async () => {
|
||||
const deleteMarketingRes = await MarketingApi.delete(
|
||||
@@ -251,75 +385,240 @@ const MarketingTable = () => {
|
||||
const selectedRowsData = allData.filter(
|
||||
(row) => rowSelection[row.id.toString()]
|
||||
);
|
||||
const selectedApprovalStep =
|
||||
selectedRowsData.length > 0
|
||||
? selectedRowsData[0].latest_approval.step_number
|
||||
: null;
|
||||
|
||||
const hasApprovable = selectedRowsData.some(
|
||||
(row) =>
|
||||
row.latest_approval.step_number === 1 &&
|
||||
row.latest_approval.action !== 'REJECTED'
|
||||
);
|
||||
const hasRejectable = selectedRowsData.some(
|
||||
(row) =>
|
||||
row.latest_approval.step_number === 1 &&
|
||||
row.latest_approval.action !== 'REJECTED'
|
||||
);
|
||||
const eligibleSelectedRows = selectedRowsData.filter((row) => {
|
||||
const approval = row.latest_approval;
|
||||
|
||||
if (approval.action === 'REJECTED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedApprovalStep === null) {
|
||||
return approval.step_number === 1 || approval.step_number === 2;
|
||||
}
|
||||
|
||||
return approval.step_number === selectedApprovalStep;
|
||||
});
|
||||
|
||||
const hasApprovable = eligibleSelectedRows.length > 0;
|
||||
const hasRejectable = eligibleSelectedRows.length > 0;
|
||||
|
||||
const disableApprove = !hasApprovable;
|
||||
const disableReject = !hasRejectable;
|
||||
|
||||
const idsToProcess =
|
||||
approveAction === 'APPROVED'
|
||||
? selectedRowsData
|
||||
.filter((row) => row.latest_approval.step_number === 1)
|
||||
.map((row) => row.id)
|
||||
: selectedRowsData
|
||||
.filter((row) => row.latest_approval.step_number === 2)
|
||||
.map((row) => row.id);
|
||||
const idsToProcess = eligibleSelectedRows.map((row) => row.id);
|
||||
const nextApprovalStatus =
|
||||
selectedApprovalStep === 1
|
||||
? 'SALES_ORDER'
|
||||
: selectedApprovalStep === 2
|
||||
? 'DELIVERY_ORDER'
|
||||
: null;
|
||||
|
||||
const productIds = tableFilterState.product_ids
|
||||
? tableFilterState.product_ids
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const productLabels = tableFilterState.product_names
|
||||
? tableFilterState.product_names
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const marketingFilterInitialValues = {
|
||||
product_ids: productIds.map((value, idx) => ({
|
||||
value: Number(value),
|
||||
label: productLabels[idx] || '-',
|
||||
})),
|
||||
status: tableFilterState.status
|
||||
? {
|
||||
value: tableFilterState.status,
|
||||
label: tableFilterState.status_name,
|
||||
}
|
||||
: null,
|
||||
customer: tableFilterState.customer_id
|
||||
? {
|
||||
value: Number(tableFilterState.customer_id),
|
||||
label: tableFilterState.customer_name,
|
||||
}
|
||||
: null,
|
||||
project_flock: tableFilterState.project_flock_id
|
||||
? {
|
||||
value: Number(tableFilterState.project_flock_id),
|
||||
label: tableFilterState.project_flock_name,
|
||||
}
|
||||
: null,
|
||||
project_flock_kandang: tableFilterState.project_flock_kandang_id
|
||||
? {
|
||||
value: Number(tableFilterState.project_flock_kandang_id),
|
||||
label: tableFilterState.project_flock_kandang_name,
|
||||
}
|
||||
: null,
|
||||
warehouse: tableFilterState.warehouse_id
|
||||
? {
|
||||
value: Number(tableFilterState.warehouse_id),
|
||||
label: tableFilterState.warehouse_name,
|
||||
}
|
||||
: null,
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
filter_by: tableFilterState.filter_by
|
||||
? {
|
||||
value: tableFilterState.filter_by,
|
||||
label: tableFilterState.filter_by_name,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const approveMarketingHandler = async (notes: string) => {
|
||||
let idsToProcess: number[] = [];
|
||||
|
||||
idsToProcess = selectedRowsData
|
||||
.filter((row) => row.latest_approval.step_number === 1)
|
||||
.map((row) => row.id);
|
||||
|
||||
if (idsToProcess.length === 0) {
|
||||
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
|
||||
confirmationModal.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
|
||||
idsToProcess,
|
||||
approveAction,
|
||||
notes
|
||||
);
|
||||
if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) {
|
||||
toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.');
|
||||
confirmationModal.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseSuccess(approveMarketingRes)) {
|
||||
if (approveAction === 'APPROVED' && !nextApprovalStatus) {
|
||||
toast.error('Status approval berikutnya tidak valid.');
|
||||
confirmationModal.closeModal();
|
||||
toast.success(approveMarketingRes?.message as string);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApproveLoading(true);
|
||||
|
||||
try {
|
||||
const approveMarketingRes: BaseApiResponse<unknown> | undefined =
|
||||
approveAction === 'APPROVED'
|
||||
? await MarketingApi.bulkApprovals(
|
||||
idsToProcess,
|
||||
nextApprovalStatus as 'SALES_ORDER' | 'DELIVERY_ORDER',
|
||||
'',
|
||||
notes || `APPROVED marketing ${idsToProcess.join(', ')}`
|
||||
)
|
||||
: await SalesOrderApi.bulkApprovals(
|
||||
idsToProcess,
|
||||
approveAction,
|
||||
notes
|
||||
);
|
||||
|
||||
if (isResponseSuccess(approveMarketingRes)) {
|
||||
confirmationModal.closeModal();
|
||||
toast.success(approveMarketingRes?.message as string);
|
||||
setRowSelection({});
|
||||
}
|
||||
|
||||
refreshMarketing();
|
||||
} finally {
|
||||
setIsApproveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
|
||||
e
|
||||
) => {
|
||||
setBulkDeliveryDate(e.target.value);
|
||||
};
|
||||
|
||||
const bulkDeliveryNotesChangeHandler: ChangeEventHandler<
|
||||
HTMLTextAreaElement
|
||||
> = (e) => {
|
||||
setBulkDeliveryNotes(e.target.value);
|
||||
};
|
||||
|
||||
const submitBulkDeliveryApprovalHandler = async (
|
||||
selectedIds: number[],
|
||||
deliveryDate: string,
|
||||
notes: string
|
||||
) => {
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error('Tidak ada data yang valid untuk diproses.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deliveryDate) {
|
||||
toast.error('Tanggal pengiriman wajib diisi.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingBulkDelivery(true);
|
||||
|
||||
try {
|
||||
const bulkDeliveryApprovalRes = await MarketingApi.bulkApprovals(
|
||||
selectedIds,
|
||||
'DELIVERY_ORDER',
|
||||
deliveryDate,
|
||||
notes || `APPROVED delivery marketing ${selectedIds.join(', ')}`
|
||||
);
|
||||
|
||||
if (isResponseError(bulkDeliveryApprovalRes)) {
|
||||
toast.error(bulkDeliveryApprovalRes?.message as string);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isResponseSuccess(bulkDeliveryApprovalRes)) {
|
||||
toast.error('Gagal memproses bulk approve delivery.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(bulkDeliveryApprovalRes?.message as string);
|
||||
bulkDeliveryModal.closeModal();
|
||||
setBulkDeliveryDate('');
|
||||
setBulkDeliveryNotes('');
|
||||
setRowSelection({});
|
||||
refreshMarketing();
|
||||
} finally {
|
||||
setIsSubmittingBulkDelivery(false);
|
||||
}
|
||||
if (isResponseError(approveMarketingRes)) {
|
||||
confirmationModal.closeModal();
|
||||
toast.error(approveMarketingRes?.message as string);
|
||||
}
|
||||
refreshMarketing();
|
||||
};
|
||||
|
||||
const confirmationModalDeliveryClickHandler = async (notes: string) => {
|
||||
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
|
||||
deliveryModal.closeModal();
|
||||
toast.success(res?.message as string);
|
||||
refreshMarketing?.();
|
||||
router.push(
|
||||
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
|
||||
);
|
||||
setIsDeliveryLoading(true);
|
||||
try {
|
||||
const res = await SalesOrderApi.delivery(
|
||||
selectedItem?.id as number,
|
||||
notes
|
||||
);
|
||||
deliveryModal.closeModal();
|
||||
toast.success(res?.message as string);
|
||||
refreshMarketing?.();
|
||||
router.push(
|
||||
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
|
||||
);
|
||||
} finally {
|
||||
setIsDeliveryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRowCanSelect = (row: Row<Marketing>): boolean => {
|
||||
const approval = row.original.latest_approval;
|
||||
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
|
||||
};
|
||||
const getRowCanSelect = useCallback(
|
||||
(row: Row<Marketing>): boolean => {
|
||||
const approval = row.original.latest_approval;
|
||||
const isSelectableStep =
|
||||
approval?.step_number === 1 || approval?.step_number === 2;
|
||||
|
||||
if (!isSelectableStep || approval?.action === 'REJECTED') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedApprovalStep === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return approval?.step_number === selectedApprovalStep;
|
||||
},
|
||||
[selectedApprovalStep]
|
||||
);
|
||||
|
||||
const exportToExcelHandler = async () => {
|
||||
setIsLoadingExportingToExcel(true);
|
||||
@@ -329,6 +628,53 @@ const MarketingTable = () => {
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const resetExportProgressForm = () => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
};
|
||||
|
||||
const exportProgressStartDateChangeHandler: ChangeEventHandler<
|
||||
HTMLInputElement
|
||||
> = (e) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
};
|
||||
|
||||
const exportProgressEndDateChangeHandler: ChangeEventHandler<
|
||||
HTMLInputElement
|
||||
> = (e) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
};
|
||||
|
||||
const exportProgressInputToExcelClickHandler = () => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
};
|
||||
|
||||
const submitExportProgressInputHandler = async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await MarketingApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<Marketing>[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -336,7 +682,22 @@ const MarketingTable = () => {
|
||||
size: 1,
|
||||
header: ({ table }) => {
|
||||
const allRows = table.getRowModel().rows;
|
||||
const selectableRows = allRows.filter(getRowCanSelect);
|
||||
const stepForBulkSelection =
|
||||
selectedApprovalStep ??
|
||||
allRows.find(getRowCanSelect)?.original.latest_approval.step_number;
|
||||
const selectableRows = allRows.filter((row) => {
|
||||
if (!getRowCanSelect(row)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!stepForBulkSelection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
row.original.latest_approval.step_number === stepForBulkSelection
|
||||
);
|
||||
});
|
||||
|
||||
const allSelected =
|
||||
selectableRows.length > 0 &&
|
||||
@@ -378,7 +739,7 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'so_do_number',
|
||||
accessorKey: 'so_number',
|
||||
header: 'No. Order',
|
||||
cell: (props) => {
|
||||
return props.row.original.do_number
|
||||
@@ -388,13 +749,13 @@ const MarketingTable = () => {
|
||||
},
|
||||
{
|
||||
accessorKey: 'so_date',
|
||||
header: 'Tanggal',
|
||||
header: 'Tanggal SO',
|
||||
cell: (props) => {
|
||||
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'approval.step_name',
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.latest_approval;
|
||||
@@ -429,26 +790,28 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'customer.name',
|
||||
accessorKey: 'customer',
|
||||
header: 'Customer',
|
||||
cell: (props) => props.row.original.customer.name,
|
||||
},
|
||||
{
|
||||
accessorFn: (row) =>
|
||||
row.sales_order
|
||||
?.map((product) => product.total_price)
|
||||
.reduce((a, b) => a + b, 0) ?? 0,
|
||||
header: 'Grand Total',
|
||||
accessorKey: 'grand_total_so',
|
||||
header: 'Grand Total SO',
|
||||
cell: (props) => {
|
||||
return formatCurrency(
|
||||
props.row.original?.sales_order
|
||||
?.map((product) => product.total_price)
|
||||
.reduce((a, b) => a + b, 0) ?? 0
|
||||
);
|
||||
return formatCurrency(props.row.original?.grand_total_so);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'grand_total_do',
|
||||
header: 'Grand Total DO',
|
||||
cell: (props) => {
|
||||
return formatCurrency(props.row.original?.grand_total_do);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'marketing_products.length',
|
||||
header: 'Product Details',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
if (props?.row?.original?.sales_order?.length) {
|
||||
if (props?.row?.original?.sales_order?.length > 1) {
|
||||
@@ -470,6 +833,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',
|
||||
maxSize: 80,
|
||||
@@ -504,7 +875,13 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
}, [
|
||||
deleteModal,
|
||||
deliveryModal,
|
||||
getRowCanSelect,
|
||||
productsClickHandler,
|
||||
selectedApprovalStep,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -527,7 +904,7 @@ const MarketingTable = () => {
|
||||
</RequirePermission>
|
||||
{idsToProcess.length > 0 && (
|
||||
<>
|
||||
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px'></div>
|
||||
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px' />
|
||||
<RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
<Button
|
||||
color='error'
|
||||
@@ -541,7 +918,7 @@ const MarketingTable = () => {
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Reject
|
||||
Reject ({idsToProcess.length} Item)
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
@@ -557,7 +934,7 @@ const MarketingTable = () => {
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Approve
|
||||
Approve ({idsToProcess.length} Item)
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
@@ -566,7 +943,20 @@ const MarketingTable = () => {
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'product_names',
|
||||
'status_name',
|
||||
'customer_name',
|
||||
'project_flock_name',
|
||||
'project_flock_kandang_name',
|
||||
'warehouse_name',
|
||||
'filter_by_name',
|
||||
'sort_by',
|
||||
'order_by',
|
||||
]}
|
||||
onClick={() => {
|
||||
filterModal.openModal();
|
||||
}}
|
||||
@@ -612,7 +1002,17 @@ const MarketingTable = () => {
|
||||
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
|
||||
Ekspor ke Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@@ -646,6 +1046,9 @@ const MarketingTable = () => {
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
|
||||
sorting={sorting}
|
||||
setSorting={handleSortingChange}
|
||||
manualSorting
|
||||
totalItems={
|
||||
isResponseSuccess(marketing)
|
||||
? marketing?.meta?.total_results
|
||||
@@ -677,14 +1080,16 @@ const MarketingTable = () => {
|
||||
<ConfirmationModalWithNotes
|
||||
ref={confirmationModal.ref}
|
||||
type={approveAction === 'APPROVED' ? 'success' : 'error'}
|
||||
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`}
|
||||
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
isLoading: isApproveLoading,
|
||||
onClick: confirmationModal.closeModal,
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: approveAction === 'APPROVED' ? 'success' : 'error',
|
||||
isLoading: isApproveLoading,
|
||||
onClick: approveMarketingHandler,
|
||||
}}
|
||||
/>
|
||||
@@ -708,14 +1113,169 @@ const MarketingTable = () => {
|
||||
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
isLoading: isDeliveryLoading,
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'success',
|
||||
isLoading: isDeliveryLoading,
|
||||
onClick: confirmationModalDeliveryClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={bulkDeliveryModal.ref}
|
||||
className={{
|
||||
modalBox: 'max-w-lg rounded-lg p-0',
|
||||
}}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Bulk Approve Delivery
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
bulkDeliveryModal.closeModal();
|
||||
setBulkDeliveryDate('');
|
||||
setBulkDeliveryNotes('');
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<p className='text-sm text-base-content/70'>
|
||||
Pilih tanggal pengiriman untuk approve {idsToProcess.length} data
|
||||
penjualan tahap 2.
|
||||
</p>
|
||||
|
||||
<DateInput
|
||||
name='bulk_delivery_date'
|
||||
label='Tanggal Pengiriman'
|
||||
value={bulkDeliveryDate}
|
||||
onChange={bulkDeliveryDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
name='bulk_delivery_notes'
|
||||
label='Catatan'
|
||||
placeholder='Masukkan catatan approval...'
|
||||
value={bulkDeliveryNotes}
|
||||
onChange={bulkDeliveryNotesChangeHandler}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
disabled={isSubmittingBulkDelivery}
|
||||
onClick={() => {
|
||||
bulkDeliveryModal.closeModal();
|
||||
setBulkDeliveryDate('');
|
||||
setBulkDeliveryNotes('');
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
isLoading={isSubmittingBulkDelivery}
|
||||
disabled={isSubmittingBulkDelivery}
|
||||
onClick={() =>
|
||||
submitBulkDeliveryApprovalHandler(
|
||||
idsToProcess,
|
||||
bulkDeliveryDate,
|
||||
bulkDeliveryNotes
|
||||
)
|
||||
}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref={exportProgressInputModal.ref}
|
||||
className={{
|
||||
modalBox: 'max-w-lg rounded-lg p-0',
|
||||
}}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
ref={productsModal.ref}
|
||||
className={{
|
||||
@@ -777,6 +1337,7 @@ const MarketingTable = () => {
|
||||
ref={filterModal.ref}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
initialValues={marketingFilterInitialValues}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -246,6 +246,7 @@ const SalesOrderFormModal = ({
|
||||
})
|
||||
.filter((item) => Boolean(item)),
|
||||
} as UpdateDeliveryOrderPayload);
|
||||
|
||||
switch (modalAction) {
|
||||
case 'add':
|
||||
await createMarketingHandler(payload as CreateSalesOrderPayload);
|
||||
@@ -261,11 +262,7 @@ const SalesOrderFormModal = ({
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, setFormErrorList, close, handleFormSubmit } =
|
||||
useFormikErrorList(formik, {
|
||||
onAfterSubmit: () => {
|
||||
router.push('/marketing');
|
||||
},
|
||||
});
|
||||
useFormikErrorList(formik);
|
||||
|
||||
// ================== FORM REPEATER HANDLER ==================
|
||||
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { array, mixed, object } from 'yup';
|
||||
import { array, mixed, object, string } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
export const MarketingFilterSchema = object({
|
||||
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
|
||||
status: mixed<OptionType<string>>().nullable(),
|
||||
customer: mixed<OptionType<number>>().nullable(),
|
||||
project_flock: mixed<OptionType<number>>().nullable(),
|
||||
project_flock_kandang: mixed<OptionType<number>>().nullable(),
|
||||
warehouse: mixed<OptionType<number>>().nullable(),
|
||||
start_date: string().optional(),
|
||||
end_date: string().optional(),
|
||||
filter_by: mixed<OptionType<string>>().nullable(),
|
||||
});
|
||||
|
||||
export type MarketingFilterFormValues = {
|
||||
product_ids: OptionType<number>[];
|
||||
status: OptionType<string> | null;
|
||||
customer: OptionType<number> | null;
|
||||
project_flock: OptionType<number> | null;
|
||||
project_flock_kandang: OptionType<number> | null;
|
||||
warehouse: OptionType<number> | null;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
filter_by: OptionType<string> | null;
|
||||
};
|
||||
|
||||
@@ -71,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
|
||||
.required('Pengiriman wajib diisi!')
|
||||
.test(
|
||||
'at-least-one-valid-row',
|
||||
'Minimal harus ada satu baris pengiriman yang lengkap diisi!',
|
||||
'Seluruh data pengiriman harus diisi lengkap!',
|
||||
function (items) {
|
||||
if (!items || items.length === 0) return false;
|
||||
|
||||
// VALIDASI: minimal 1 item valid full
|
||||
// VALIDASI: seluruh item harus valid full
|
||||
const itemSchema = DeliveryOrderProductSchema;
|
||||
|
||||
const hasValidItem = items.some((item) => {
|
||||
const hasValidItem = items.every((item) => {
|
||||
if (!item) return false;
|
||||
return itemSchema.isValidSync(item, { abortEarly: true });
|
||||
});
|
||||
@@ -123,8 +123,17 @@ export const SalesProductToFieldValues = (
|
||||
total_price: product.total_price,
|
||||
marketing_type: product.marketing_type
|
||||
? {
|
||||
value: product.marketing_type,
|
||||
label: formatTitleCase(product.marketing_type),
|
||||
value:
|
||||
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,
|
||||
convertion_unit: product.convertion_unit
|
||||
@@ -144,9 +153,11 @@ export const DeliveryProductToFieldValues = (
|
||||
delivery: BaseDeliveryOrder
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const data = delivery.deliveries.map((item) => {
|
||||
const salesOrder = salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
);
|
||||
const salesOrder =
|
||||
salesOrders.find((so) => so.id === item.marketing_product_id) ??
|
||||
salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
);
|
||||
const warehouseOption = {
|
||||
value: item.product_warehouse.warehouse.id,
|
||||
label: item.product_warehouse.warehouse.name,
|
||||
@@ -180,11 +191,20 @@ export const DeliveryProductToFieldValues = (
|
||||
vehicle_number: item.vehicle_number,
|
||||
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
|
||||
do_number: delivery.do_number,
|
||||
marketing_product_id: salesOrder?.id,
|
||||
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
|
||||
marketing_type: salesOrder?.marketing_type
|
||||
? {
|
||||
value: salesOrder?.marketing_type,
|
||||
label: formatTitleCase(salesOrder?.marketing_type),
|
||||
value:
|
||||
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,
|
||||
convertion_unit: salesOrder?.convertion_unit
|
||||
@@ -194,7 +214,7 @@ export const DeliveryProductToFieldValues = (
|
||||
}
|
||||
: null,
|
||||
marketing_product: {
|
||||
id: salesOrder?.id,
|
||||
id: item.marketing_product_id ?? salesOrder?.id,
|
||||
vehicle_number: item.vehicle_number,
|
||||
warehouse_id: item.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
|
||||
+23
-23
@@ -146,15 +146,6 @@ const DeliveryOrderProductForm = ({
|
||||
);
|
||||
|
||||
// ============ 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
|
||||
// const optionsWeek = useMemo(() => {
|
||||
@@ -190,12 +181,19 @@ const DeliveryOrderProductForm = ({
|
||||
const deliveryOrder = useMemo(() => {
|
||||
if (!hasDeliveryOrder || !deliveryOrders) return null;
|
||||
|
||||
const marketingProductId =
|
||||
initialValues?.marketing_product_id ?? initialValues?.id;
|
||||
|
||||
for (const doItem of deliveryOrders) {
|
||||
const found = doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
const found =
|
||||
doItem.deliveries.find(
|
||||
(d) => d.marketing_product_id === marketingProductId
|
||||
) ??
|
||||
doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
if (found) {
|
||||
return {
|
||||
...found,
|
||||
@@ -403,7 +401,10 @@ const DeliveryOrderProductForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
if (!Boolean(initialValues.qty)) {
|
||||
if (
|
||||
!Boolean(initialValues.qty) &&
|
||||
!Boolean(initialValues.marketing_product_id)
|
||||
) {
|
||||
handleResetForm();
|
||||
} else {
|
||||
setFormikValues({
|
||||
@@ -413,7 +414,7 @@ const DeliveryOrderProductForm = ({
|
||||
});
|
||||
if (initialValues?.marketing_product_id) {
|
||||
setSelectedProduct({
|
||||
value: initialValues?.id,
|
||||
value: initialValues?.marketing_product_id,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
|
||||
} as OptionType);
|
||||
}
|
||||
@@ -430,7 +431,8 @@ const DeliveryOrderProductForm = ({
|
||||
handleBlurField(currentInput);
|
||||
formik.setFieldValue(
|
||||
'uom',
|
||||
isResponseSuccess(productData) ? productData?.data?.uom?.name : ''
|
||||
initialValues?.marketing_product?.product_warehouse_data?.product?.uom
|
||||
?.name ?? ''
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -803,9 +805,8 @@ const DeliveryOrderProductForm = ({
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500'>
|
||||
{isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: ''}
|
||||
{initialValues?.marketing_product?.product_warehouse_data
|
||||
?.product?.uom?.name ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -816,9 +817,8 @@ const DeliveryOrderProductForm = ({
|
||||
(item) => item.id === formik.values.marketing_product_id
|
||||
)?.qty +
|
||||
' ' +
|
||||
(isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: '')
|
||||
(initialValues?.marketing_product?.product_warehouse_data
|
||||
?.product?.uom?.name ?? '')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -252,6 +252,11 @@ const SalesOrderProductForm = ({
|
||||
setSelectedProductWarehouse(productWarehouse || null);
|
||||
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
|
||||
formik.setFieldValue('qty', productWarehouse?.quantity);
|
||||
|
||||
if (productWarehouse?.quantity) {
|
||||
handleFieldChange('qty', productWarehouse?.quantity);
|
||||
}
|
||||
|
||||
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
|
||||
if (
|
||||
productWarehouse?.week !== undefined &&
|
||||
|
||||
@@ -124,7 +124,7 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
@@ -273,7 +273,7 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const AreasTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const AreasTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'areas-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -137,17 +134,8 @@ const AreasTable = () => {
|
||||
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('areas-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Bank } from '@/types/api/master-data/bank';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const BanksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const BanksTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'banks-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -137,17 +134,8 @@ const BanksTable = () => {
|
||||
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('banks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Customer } from '@/types/api/master-data/customer';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const CustomersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const CustomersTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'customers-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -139,17 +136,8 @@ const CustomersTable = () => {
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('customers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -201,6 +189,11 @@ const CustomersTable = () => {
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank_name',
|
||||
header: 'Nama Bank',
|
||||
cell: (props) => props.row.original.bank_name || '-',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Customer, unknown>) => {
|
||||
|
||||
@@ -27,6 +27,9 @@ export const CustomerFormSchema = Yup.object({
|
||||
.email('Format email tidak valid!')
|
||||
.required('Email wajib diisi!'),
|
||||
|
||||
bank_name: Yup.string()
|
||||
.min(3, 'Nama bank minimal 3 karakter!')
|
||||
.required('Nama bank wajib diisi!'),
|
||||
account_number: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
|
||||
.required('Nomor rekening wajib diisi!'),
|
||||
|
||||
@@ -142,6 +142,7 @@ const CustomerForm = ({
|
||||
},
|
||||
type: normalizeType(initialValues?.type),
|
||||
address: initialValues?.address ?? '',
|
||||
bank_name: initialValues?.bank_name ?? '',
|
||||
account_number: initialValues?.account_number ?? '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
@@ -164,6 +165,7 @@ const CustomerForm = ({
|
||||
pic_id: values.picId,
|
||||
type: (values.type as OptionType).value as string,
|
||||
address: values.address,
|
||||
bank_name: values.bank_name,
|
||||
account_number: values.account_number,
|
||||
};
|
||||
|
||||
@@ -286,6 +288,22 @@ const CustomerForm = ({
|
||||
errorMessage={formik.errors.phone}
|
||||
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
|
||||
required
|
||||
label='Nomor Rekening'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Flock } from '@/types/api/master-data/flock';
|
||||
import { FlockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const FlockTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const FlockTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'flock-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -139,17 +136,8 @@ const FlockTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('flocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
KandangFilterSchema,
|
||||
KandangFilterType,
|
||||
@@ -122,20 +114,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const KandangsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
locationFilter?: OptionType<string>;
|
||||
picFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
locationFilter: '',
|
||||
picFilter: '',
|
||||
locationFilter: undefined,
|
||||
picFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -143,6 +136,8 @@ const KandangsTable = () => {
|
||||
locationFilter: 'location_id',
|
||||
picFilter: 'pic_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'kandangs-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -151,22 +146,34 @@ const KandangsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<KandangFilterType>({
|
||||
initialValues: {
|
||||
location_id: null,
|
||||
pic_id: null,
|
||||
location: tableFilterState.locationFilter,
|
||||
pic: tableFilterState.picFilter,
|
||||
},
|
||||
validationSchema: KandangFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('locationFilter', values.location_id || '');
|
||||
updateFilter('picFilter', values.pic_id || '');
|
||||
updateFilter('locationFilter', values.location || undefined, true);
|
||||
updateFilter('picFilter', values.pic || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('locationFilter', '');
|
||||
updateFilter('picFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('locationFilter', undefined, true);
|
||||
updateFilter('picFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
location: undefined,
|
||||
pic: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
// ===== LOCATION OPTIONS =====
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
@@ -194,43 +201,15 @@ const KandangsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location?.value ? String(location.value) : null;
|
||||
const handleFilterLocationChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setFieldValue('location', val);
|
||||
};
|
||||
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterPicChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const pic = val as OptionType | null;
|
||||
const picId = pic?.value ? String(pic.value) : null;
|
||||
|
||||
formik.setFieldValue('pic_id', picId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const locationIdValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const picIdValue = useMemo(() => {
|
||||
if (!formik.values.pic_id) return null;
|
||||
return (
|
||||
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.pic_id, picOptions]);
|
||||
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
|
||||
setFieldValue('pic', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -255,17 +234,8 @@ const KandangsTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('kandangs-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -475,13 +445,13 @@ const KandangsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationIdValue}
|
||||
value={formik.values.location}
|
||||
onChange={handleFilterLocationChange}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
@@ -494,7 +464,7 @@ const KandangsTable = () => {
|
||||
label='PIC'
|
||||
placeholder='Pilih PIC'
|
||||
options={picOptions}
|
||||
value={picIdValue}
|
||||
value={formik.values.pic}
|
||||
onChange={handleFilterPicChange}
|
||||
onInputChange={setPicInputValue}
|
||||
isLoading={isLoadingPicOptions}
|
||||
@@ -510,17 +480,14 @@ const KandangsTable = () => {
|
||||
type='button'
|
||||
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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={!formik.isValid}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const KandangFilterSchema = object().shape({
|
||||
location_id: string().nullable(),
|
||||
pic_id: string().nullable(),
|
||||
export const KandangFilterSchema = Yup.object().shape({
|
||||
location: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
|
||||
pic: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type KandangFilterType = {
|
||||
location_id: string | null;
|
||||
pic_id: string | null;
|
||||
location?: OptionType<string>;
|
||||
pic?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -32,7 +25,6 @@ import { Area } from '@/types/api/master-data/area';
|
||||
import { LocationApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
LocationFilterSchema,
|
||||
LocationFilterType,
|
||||
@@ -118,25 +110,27 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const LocationsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
areaFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
areaFilter: '',
|
||||
areaFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
areaFilter: 'area_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'locations-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -145,19 +139,28 @@ const LocationsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<LocationFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
area: tableFilterState.areaFilter,
|
||||
},
|
||||
validationSchema: LocationFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('areaFilter', values.area || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
@@ -172,24 +175,9 @@ const LocationsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterAreaChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const area = val as OptionType | null;
|
||||
const areaId = area?.value ? String(area.value) : null;
|
||||
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaIdValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('area', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -212,19 +200,10 @@ const LocationsTable = () => {
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('locations-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -425,13 +404,13 @@ const LocationsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaIdValue}
|
||||
value={formik.values.area}
|
||||
onChange={handleFilterAreaChange}
|
||||
onInputChange={setAreaInputValue}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
@@ -447,10 +426,7 @@ const LocationsTable = () => {
|
||||
type='button'
|
||||
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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const LocationFilterSchema = object().shape({
|
||||
area_id: string().nullable(),
|
||||
export const LocationFilterSchema = Yup.object().shape({
|
||||
area: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type LocationFilterType = {
|
||||
area_id: string | null;
|
||||
area?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -20,8 +20,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const NonstocksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,22 +109,16 @@ const NonstocksTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'nonstock-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('nonstocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -148,8 +137,7 @@ const NonstocksTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -21,7 +20,6 @@ import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProductCategoryTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -120,12 +115,10 @@ const ProductCategoryTable = () => {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'product-category-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -144,8 +137,7 @@ const ProductCategoryTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -214,10 +206,6 @@ const ProductCategoryTable = () => {
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('product-category-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { formatCurrency } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
ProductFilterSchema,
|
||||
ProductFilterType,
|
||||
@@ -119,25 +111,27 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProductsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
productCategoryFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
productCategoryFilter: '',
|
||||
productCategoryFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
productCategoryFilter: 'product_category_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'product-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -146,19 +140,32 @@ const ProductsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<ProductFilterType>({
|
||||
initialValues: {
|
||||
product_category_id: null,
|
||||
product_category: tableFilterState.productCategoryFilter,
|
||||
},
|
||||
validationSchema: ProductFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productCategoryFilter', values.product_category_id || '');
|
||||
updateFilter(
|
||||
'productCategoryFilter',
|
||||
values.product_category || undefined,
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productCategoryFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('productCategoryFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
product_category: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== PRODUCT CATEGORY OPTIONS =====
|
||||
const {
|
||||
setInputValue: setProductCategoryInputValue,
|
||||
@@ -173,25 +180,11 @@ const ProductsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductCategoryChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const category = val as OptionType | null;
|
||||
const categoryId = category?.value ? String(category.value) : null;
|
||||
|
||||
formik.setFieldValue('product_category_id', categoryId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productCategoryIdValue = useMemo(() => {
|
||||
if (!formik.values.product_category_id) return null;
|
||||
return (
|
||||
productCategoryOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_category_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_category_id, productCategoryOptions]);
|
||||
const handleFilterProductCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('product_category', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -199,10 +192,6 @@ const ProductsTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -220,13 +209,8 @@ const ProductsTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('product-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -477,13 +461,13 @@ const ProductsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih Kategori Produk'
|
||||
options={productCategoryOptions}
|
||||
value={productCategoryIdValue}
|
||||
value={formik.values.product_category}
|
||||
onChange={handleFilterProductCategoryChange}
|
||||
onInputChange={setProductCategoryInputValue}
|
||||
isLoading={isLoadingProductCategoryOptions}
|
||||
@@ -499,10 +483,7 @@ const ProductsTable = () => {
|
||||
type='button'
|
||||
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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const ProductFilterSchema = object().shape({
|
||||
product_category_id: string().nullable(),
|
||||
export const ProductFilterSchema = Yup.object().shape({
|
||||
product_category: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type ProductFilterType = {
|
||||
product_category_id: string | null;
|
||||
product_category?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -128,27 +128,44 @@ const ProductionStandardTable = () => {
|
||||
pageSize: 'limit',
|
||||
projectCategoryFilter: 'project_category',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'production-standard-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
|
||||
const filterInitialValues = useMemo<ProductionStandardFilterType>(
|
||||
() => ({
|
||||
project_category: tableFilterState.projectCategoryFilter || null,
|
||||
}),
|
||||
[tableFilterState.projectCategoryFilter]
|
||||
);
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<ProductionStandardFilterType>({
|
||||
initialValues: {
|
||||
project_category: null,
|
||||
},
|
||||
initialValues: filterInitialValues,
|
||||
validationSchema: ProductionStandardFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('projectCategoryFilter', values.project_category || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('projectCategoryFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('projectCategoryFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
project_category: null,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
|
||||
const projectCategoryOptions = useMemo(
|
||||
() => [
|
||||
@@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputRadio
|
||||
label='Kategori'
|
||||
@@ -397,13 +414,9 @@ const ProductionStandardTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -30,7 +29,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
import {
|
||||
SupplierFilterSchema,
|
||||
SupplierFilterType,
|
||||
@@ -117,20 +116,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const SuppliersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
categoryFilter?: OptionType<string>;
|
||||
flagFilter?: string;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
categoryFilter: '',
|
||||
flagFilter: '',
|
||||
categoryFilter: undefined,
|
||||
flagFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -138,6 +138,8 @@ const SuppliersTable = () => {
|
||||
categoryFilter: 'category_id',
|
||||
flagFilter: 'flag',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'supplier-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -146,26 +148,33 @@ const SuppliersTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<SupplierFilterType>({
|
||||
initialValues: {
|
||||
category_id: null,
|
||||
flag: false,
|
||||
category: tableFilterState.categoryFilter,
|
||||
flag: tableFilterState.flagFilter === 'EKSPEDISI',
|
||||
},
|
||||
validationSchema: SupplierFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('categoryFilter', values.category_id || '');
|
||||
updateFilter(
|
||||
'flagFilter',
|
||||
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
|
||||
);
|
||||
updateFilter('categoryFilter', values.category || undefined, true);
|
||||
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
|
||||
filterModal.closeModal();
|
||||
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('categoryFilter', '');
|
||||
updateFilter('flagFilter', '');
|
||||
formik.setFieldValue('flag', false);
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('categoryFilter', undefined, true);
|
||||
updateFilter('flagFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
category: undefined,
|
||||
flag: false,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
|
||||
@@ -187,15 +196,11 @@ const SuppliersTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterCategoryChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const option = val as OptionType | null;
|
||||
const categoryId = option?.value ? String(option.value) : null;
|
||||
|
||||
setFieldValue('category_id', categoryId);
|
||||
},
|
||||
[setFieldValue]
|
||||
);
|
||||
const handleFilterCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const handleFilterFlagChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
@@ -213,13 +218,13 @@ const SuppliersTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const categoryIdValue = useMemo(() => {
|
||||
if (!formik.values.category_id) return null;
|
||||
return (
|
||||
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.category_id, categoryOptions]);
|
||||
// const categoryIdValue = useMemo(() => {
|
||||
// if (!formik.values.category_id) return null;
|
||||
// return (
|
||||
// categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||
// null
|
||||
// );
|
||||
// }, [formik.values.category_id, categoryOptions]);
|
||||
|
||||
const flagValue = useMemo(() => {
|
||||
if (formik.values.flag === null) return null;
|
||||
@@ -243,14 +248,6 @@ const SuppliersTable = () => {
|
||||
}
|
||||
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('suppliers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -269,8 +266,7 @@ const SuppliersTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -330,6 +326,11 @@ const SuppliersTable = () => {
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank_name',
|
||||
header: 'Nama Bank',
|
||||
cell: (props) => props.row.original.bank_name || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: 'Alamat',
|
||||
@@ -491,13 +492,13 @@ const SuppliersTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputRadio
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={categoryOptions}
|
||||
value={categoryIdValue}
|
||||
value={formik.values.category}
|
||||
onChange={handleFilterCategoryChange}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
@@ -517,13 +518,9 @@ const SuppliersTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { string, boolean, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const SupplierFilterSchema = object().shape({
|
||||
category_id: string().nullable(),
|
||||
flag: boolean().nullable(),
|
||||
export const SupplierFilterSchema = Yup.object().shape({
|
||||
category: Yup.object({
|
||||
value: Yup.string().required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
|
||||
flag: Yup.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type SupplierFilterType = {
|
||||
category_id: string | null;
|
||||
category?: OptionType<string>;
|
||||
flag: boolean | null;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,9 @@ export const SupplierFormSchema = Yup.object({
|
||||
npwp: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
|
||||
.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()
|
||||
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
|
||||
.required('Nomor rekening wajib diisi!'),
|
||||
|
||||
@@ -122,6 +122,7 @@ const SupplierForm = ({
|
||||
email: initialValues?.email ?? '',
|
||||
address: initialValues?.address ?? '',
|
||||
npwp: initialValues?.npwp ?? '',
|
||||
bank_name: initialValues?.bank_name ?? '',
|
||||
account_number: initialValues?.account_number ?? '',
|
||||
due_date: initialValues?.due_date ?? 1,
|
||||
};
|
||||
@@ -149,6 +150,7 @@ const SupplierForm = ({
|
||||
email: values.email,
|
||||
address: values.address,
|
||||
npwp: values.npwp,
|
||||
bank_name: values.bank_name,
|
||||
account_number: values.account_number,
|
||||
due_date: parseInt(values.due_date.toString()),
|
||||
};
|
||||
@@ -368,6 +370,22 @@ const SupplierForm = ({
|
||||
errorMessage={formik.errors.npwp}
|
||||
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
|
||||
required
|
||||
label='Nomor Rekening'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Uom } from '@/types/api/master-data/uom';
|
||||
import { UomApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const UomsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,22 +109,16 @@ const UomsTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'uom-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('uoms-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -146,8 +135,7 @@ const UomsTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -31,7 +24,6 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { WarehouseApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
WarehouseFilterSchema,
|
||||
WarehouseFilterType,
|
||||
@@ -120,9 +112,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const WarehousesTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -141,6 +130,8 @@ const WarehousesTable = () => {
|
||||
areaFilter: 'area_id',
|
||||
activeProjectFlockFilter: 'active_project_flock',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'warehouses-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -149,27 +140,36 @@ const WarehousesTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<WarehouseFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
active_project_flock: false,
|
||||
area_id: tableFilterState.areaFilter || null,
|
||||
active_project_flock:
|
||||
tableFilterState.activeProjectFlockFilter === 'true',
|
||||
},
|
||||
validationSchema: WarehouseFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('areaFilter', values.area_id || '', true);
|
||||
updateFilter(
|
||||
'activeProjectFlockFilter',
|
||||
values.active_project_flock === true ? 'true' : ''
|
||||
values.active_project_flock === true ? 'true' : '',
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
updateFilter('activeProjectFlockFilter', '');
|
||||
formik.setFieldValue('active_project_flock', false);
|
||||
},
|
||||
});
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', '', true);
|
||||
updateFilter('activeProjectFlockFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area_id: null,
|
||||
active_project_flock: false,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
@@ -243,26 +243,6 @@ const WarehousesTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filterModal.open) {
|
||||
const activeProjectFlockValue =
|
||||
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
|
||||
setFieldValue('active_project_flock', activeProjectFlockValue);
|
||||
}
|
||||
}, [
|
||||
filterModal.open,
|
||||
tableFilterState.activeProjectFlockFilter,
|
||||
setFieldValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('warehouses-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -281,8 +261,7 @@ const WarehousesTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -507,7 +486,7 @@ const WarehousesTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
@@ -538,10 +517,7 @@ const WarehousesTable = () => {
|
||||
type='button'
|
||||
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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -2,16 +2,17 @@ import Alert from '@/components/Alert';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import PillBadge from '@/components/PillBadge';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatDate, formatNumber } from '@/lib/helper';
|
||||
import { ChickinApi } from '@/services/api/production/chickin';
|
||||
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
|
||||
import { BaseApproval } from '@/types/api/api-general';
|
||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
|
||||
|
||||
const ChickinLogsView = ({
|
||||
initialValues,
|
||||
@@ -23,6 +24,9 @@ const ChickinLogsView = ({
|
||||
rawDataApprovals: BaseApproval[];
|
||||
}) => {
|
||||
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
|
||||
const [editingChickinId, setEditingChickinId] = useState<number | null>(null);
|
||||
const [editDate, setEditDate] = useState('');
|
||||
const [isEditLoading, setIsEditLoading] = useState(false);
|
||||
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore();
|
||||
|
||||
const handleClickApprove = () => {
|
||||
@@ -44,6 +48,23 @@ const ChickinLogsView = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveChickinDate = async () => {
|
||||
setIsEditLoading(true);
|
||||
const res = await ChickinApi.updateChickinDate(
|
||||
initialValues.id as number,
|
||||
formatDate(editDate, 'YYYY-MM-DD')
|
||||
);
|
||||
setIsEditLoading(false);
|
||||
if (isResponseSuccess(res)) {
|
||||
toast.success(res?.message as string);
|
||||
setEditingChickinId(null);
|
||||
afterSubmit && afterSubmit();
|
||||
}
|
||||
if (isResponseError(res)) {
|
||||
toast.error(res?.message as string);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChickin = (chickinId: number) => {
|
||||
openChickinDeleteModal(chickinId, async () => {
|
||||
const deleteRes = await ChickinApi.delete(chickinId);
|
||||
@@ -133,9 +154,54 @@ const ChickinLogsView = ({
|
||||
<Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
|
||||
<span>Tanggal Chick In</span>
|
||||
</div>
|
||||
<div className='text-end text-gray-500'>
|
||||
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
|
||||
</div>
|
||||
{editingChickinId === chickin.id ? (
|
||||
<div className='flex flex-col gap-2 items-end w-1/2'>
|
||||
<DateInput
|
||||
name='edit_chick_in_date'
|
||||
value={editDate}
|
||||
isNestedModal
|
||||
onChange={(e) => setEditDate(e.target.value)}
|
||||
/>
|
||||
<div className='flex flex-row gap-1'>
|
||||
<Button
|
||||
color='none'
|
||||
className='btn-xs btn-ghost text-gray-500'
|
||||
onClick={() => setEditingChickinId(null)}
|
||||
disabled={isEditLoading}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
className='btn-xs text-base-100'
|
||||
onClick={handleSaveChickinDate}
|
||||
isLoading={isEditLoading}
|
||||
disabled={!editDate || isEditLoading}
|
||||
>
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-row gap-2 items-center'>
|
||||
<span className='text-gray-500'>
|
||||
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
|
||||
</span>
|
||||
<button
|
||||
className='btn btn-ghost btn-xs p-0 text-gray-400 hover:text-primary'
|
||||
onClick={() => {
|
||||
setEditingChickinId(chickin.id);
|
||||
setEditDate(chickin.chick_in_date);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:pencil-outline'
|
||||
width={13}
|
||||
height={13}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kandang */}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import Table from '@/components/Table';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
@@ -23,7 +22,6 @@ import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -45,6 +43,7 @@ import {
|
||||
import Modal from '@/components/Modal';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
props,
|
||||
@@ -148,7 +147,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
|
||||
@@ -174,6 +172,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
kandang_id: '',
|
||||
category: '',
|
||||
period: '',
|
||||
area_name: '',
|
||||
location_name: '',
|
||||
kandang_name: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -185,7 +186,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
category: 'category',
|
||||
period: 'period',
|
||||
},
|
||||
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
|
||||
persist: true,
|
||||
storeName: 'project-flock-table',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ===== State =====
|
||||
@@ -206,8 +211,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
|
||||
const {
|
||||
isChickinApproveModalOpen,
|
||||
isChickinApproveLoading,
|
||||
@@ -257,6 +261,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
updateFilter('kandang_id', values.kandang_id || '');
|
||||
updateFilter('category', values.category || '');
|
||||
updateFilter('period', values.period || '');
|
||||
updateFilter(
|
||||
'area_name',
|
||||
areaValue?.label ? String(areaValue.label) : ''
|
||||
);
|
||||
updateFilter(
|
||||
'location_name',
|
||||
locationValue?.label ? String(locationValue.label) : ''
|
||||
);
|
||||
updateFilter(
|
||||
'kandang_name',
|
||||
kandangValue?.label ? String(kandangValue.label) : ''
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
@@ -266,6 +282,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
updateFilter('kandang_id', '');
|
||||
updateFilter('category', '');
|
||||
updateFilter('period', '');
|
||||
updateFilter('area_name', '');
|
||||
updateFilter('location_name', '');
|
||||
updateFilter('kandang_name', '');
|
||||
setFilterAreaId(undefined);
|
||||
setFilterLocationId(undefined);
|
||||
filterModal.closeModal();
|
||||
@@ -307,40 +326,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const periodOptions = useMemo(
|
||||
() => [
|
||||
{ value: '1', label: 'Periode 1' },
|
||||
{ value: '2', label: 'Periode 2' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
const found = areaOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.area_id
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
if (found) return found;
|
||||
if (tableFilterState.area_name) {
|
||||
return {
|
||||
value: formik.values.area_id,
|
||||
label: tableFilterState.area_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [formik.values.area_id, areaOptions, tableFilterState.area_name]);
|
||||
|
||||
const locationValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
const found = locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
if (found) return found;
|
||||
if (tableFilterState.location_name) {
|
||||
return {
|
||||
value: formik.values.location_id,
|
||||
label: tableFilterState.location_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
formik.values.location_id,
|
||||
locationOptions,
|
||||
tableFilterState.location_name,
|
||||
]);
|
||||
|
||||
const kandangValue = useMemo(() => {
|
||||
if (!formik.values.kandang_id) return null;
|
||||
return (
|
||||
kandangOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.kandang_id
|
||||
) || null
|
||||
const found = kandangOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.kandang_id
|
||||
);
|
||||
}, [formik.values.kandang_id, kandangOptions]);
|
||||
if (found) return found;
|
||||
if (tableFilterState.kandang_name) {
|
||||
return {
|
||||
value: formik.values.kandang_id,
|
||||
label: tableFilterState.kandang_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [formik.values.kandang_id, kandangOptions, tableFilterState.kandang_name]);
|
||||
|
||||
const categoryValue = useMemo(() => {
|
||||
if (!formik.values.category) return null;
|
||||
@@ -350,13 +384,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
}, [formik.values.category, categoryOptions]);
|
||||
|
||||
const periodValue = useMemo(() => {
|
||||
if (!formik.values.period) return null;
|
||||
return (
|
||||
periodOptions.find((opt) => opt.value === formik.values.period) || null
|
||||
);
|
||||
}, [formik.values.period, periodOptions]);
|
||||
|
||||
// ===== FILTER DEPENDENCY HANDLERS =====
|
||||
const handleFilterAreaChange = (area: OptionType | null) => {
|
||||
const areaId = area?.value ? String(area.value) : undefined;
|
||||
@@ -425,18 +452,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
setIsDeleteLoading(false);
|
||||
setRowSelection({});
|
||||
};
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('project-flock-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmApprovalHandler = async (
|
||||
notes: string,
|
||||
approvalAction: 'APPROVED' | 'REJECTED'
|
||||
@@ -554,6 +574,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
price: budget.price,
|
||||
total_price: budget.qty * budget.price,
|
||||
})) || [],
|
||||
periode: createdProjectFlock.period ?? '-',
|
||||
} as ProjectFlockFormValues;
|
||||
}, [createdProjectFlock]);
|
||||
|
||||
@@ -776,14 +797,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const exportToExcelHandler = async () => {
|
||||
setIsLoadingExportingToExcel(true);
|
||||
|
||||
toast.error('Not implemented yet!');
|
||||
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const bulkApproveClickHandler = () => {
|
||||
setApprovalAction('APPROVED');
|
||||
confirmModal.openModal();
|
||||
@@ -972,55 +985,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'area_name',
|
||||
'location_name',
|
||||
'kandang_name',
|
||||
]}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportToExcelHandler}
|
||||
isLoading={isLoadingExportingToExcel}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1349,18 +1324,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
isClearable={true}
|
||||
/>
|
||||
|
||||
<SelectInputRadio
|
||||
<NumberInput
|
||||
label='Periode'
|
||||
placeholder='Pilih Periode'
|
||||
options={periodOptions}
|
||||
value={periodValue}
|
||||
onChange={(val) => {
|
||||
if (!Array.isArray(val)) {
|
||||
formik.setFieldValue('period', val?.value || null);
|
||||
}
|
||||
}}
|
||||
name='period'
|
||||
placeholder='Masukkan Periode'
|
||||
value={formik.values.period ?? ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type ProjectFlockFormSchemaType = {
|
||||
label: string;
|
||||
} | null;
|
||||
location_id: number;
|
||||
periode: number | string;
|
||||
kandang_ids: number[];
|
||||
project_budgets: ProjectFlockBudgetsSchemaType[];
|
||||
};
|
||||
@@ -109,6 +110,12 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
|
||||
.min(1, 'Lokasi wajib diisi!')
|
||||
.required('Lokasi wajib diisi!'),
|
||||
|
||||
// Period
|
||||
periode: Yup.number()
|
||||
.typeError('Periode harus berupa angka!')
|
||||
.min(1, 'Periode minimal 1!')
|
||||
.required('Periode wajib diisi!'),
|
||||
|
||||
kandang_ids: Yup.array()
|
||||
.of(Yup.number().required('Kandang tidak valid!'))
|
||||
.min(1, 'Minimal harus ada 1 kandang!')
|
||||
|
||||
@@ -152,6 +152,10 @@ export const ProjectFlockFormConfirmationTable = ({
|
||||
label: 'Standar Produksi',
|
||||
value: projectFlockForm?.production_standard?.label ?? '-',
|
||||
},
|
||||
{
|
||||
label: 'Periode',
|
||||
value: projectFlockForm?.periode ?? '-',
|
||||
},
|
||||
{
|
||||
label: 'Informasi Kandang',
|
||||
value: '',
|
||||
@@ -261,7 +265,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingFlocks,
|
||||
options: optionsFlock,
|
||||
loadMore: loadMoreFlock,
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
|
||||
project_category: selectedCategory,
|
||||
location_id: selectedLocation,
|
||||
area_id: selectedArea,
|
||||
@@ -279,7 +283,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
setInputValue: setInputValueLocation,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
@@ -291,7 +295,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingProductionStandards,
|
||||
setInputValue: setInputValueProductionStandard,
|
||||
loadMore: loadMoreProductionStandard,
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
|
||||
project_category: selectedCategory,
|
||||
});
|
||||
|
||||
@@ -307,7 +311,7 @@ const ProjectFlockForm = ({
|
||||
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
|
||||
|
||||
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
|
||||
`${selectedFlock?.toString()}/periods`,
|
||||
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
|
||||
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
|
||||
);
|
||||
|
||||
@@ -529,6 +533,7 @@ const ProjectFlockForm = ({
|
||||
kandang_ids: initialValues?.kandangs?.map(
|
||||
(k: Kandang) => k.id
|
||||
) as number[],
|
||||
periode: initialValues?.period ?? '',
|
||||
project_budgets: initialValues?.project_budgets?.map((budget) => {
|
||||
return {
|
||||
nonstock: {
|
||||
@@ -568,6 +573,7 @@ const ProjectFlockForm = ({
|
||||
category: values.category as string,
|
||||
production_standard_id: values.production_standard_id as number,
|
||||
location_id: values.location_id as number,
|
||||
periode: parseInt(values.periode as unknown as string),
|
||||
kandang_ids: values.kandang_ids as number[],
|
||||
project_budgets: values.project_budgets.flatMap((budget) => {
|
||||
return {
|
||||
@@ -793,6 +799,7 @@ const ProjectFlockForm = ({
|
||||
formik.values.kandang_ids?.includes(kandang.id)
|
||||
)?.period
|
||||
: undefined;
|
||||
|
||||
const inputPeriod =
|
||||
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
|
||||
|
||||
@@ -1022,12 +1029,18 @@ const ProjectFlockForm = ({
|
||||
isDisabled={formType != 'add'}
|
||||
/>
|
||||
<NumberInput
|
||||
name='period'
|
||||
name='periode'
|
||||
label='Periode'
|
||||
disabled
|
||||
readOnly
|
||||
placeholder='Periode Flock'
|
||||
value={selectedLocation ? inputPeriod : ''}
|
||||
value={formik.values.periode}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
allowNegative={false}
|
||||
decimalScale={0}
|
||||
isError={
|
||||
formik.touched.periode && Boolean(formik.errors.periode)
|
||||
}
|
||||
errorMessage={formik.errors.periode as string}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
@@ -18,6 +12,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -39,13 +34,11 @@ import Table from '@/components/Table';
|
||||
import { type Recording } from '@/types/api/production/recording';
|
||||
import { getRecordingRestriction } from './recording-utils';
|
||||
import { RecordingApi } from '@/services/api/production';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import toast from 'react-hot-toast';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Color } from '@/types/theme';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -75,6 +68,26 @@ const getStatusBadgeColor = (status: string): Color => {
|
||||
return statusBadgeColorMap[normalizedStatus] || 'neutral';
|
||||
};
|
||||
|
||||
const isRecordingApproved = (recording: Recording): boolean => {
|
||||
return (
|
||||
recording.approval?.action === 'APPROVED' &&
|
||||
recording.approval?.step_name === 'Disetujui'
|
||||
);
|
||||
};
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const recordingApprovalStatusOptions: OptionType<string>[] = [
|
||||
{ value: 'CREATED', label: 'Pengajuan' },
|
||||
{ value: 'UPDATED', label: 'Diperbarui' },
|
||||
{ value: 'APPROVED', label: 'Disetujui' },
|
||||
{ value: 'REJECTED', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const projectFlockCategoryOptions: OptionType<string>[] = [
|
||||
{ value: 'GROWING', label: 'Growing' },
|
||||
{ value: 'LAYING', label: 'Laying' },
|
||||
];
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
@@ -266,80 +279,111 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const RecordingTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
areaFilter: OptionType<number> | null;
|
||||
locationFilter: OptionType<number> | null;
|
||||
projectFlockFilter: OptionType<number> | null;
|
||||
kandangFilter: OptionType<number> | null;
|
||||
projectFlockKandangFilter: number | null;
|
||||
approvalStatusFilter: OptionType<string> | null;
|
||||
projectFlockCategoryFilter: OptionType<string> | null;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
areaFilter: '',
|
||||
locationFilter: '',
|
||||
kandangFilter: '',
|
||||
projectFlockKandangFilter: '',
|
||||
areaFilter: null,
|
||||
locationFilter: null,
|
||||
projectFlockFilter: null,
|
||||
kandangFilter: null,
|
||||
projectFlockKandangFilter: null,
|
||||
approvalStatusFilter: null,
|
||||
projectFlockCategoryFilter: null,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
search: 'search',
|
||||
areaFilter: 'area_id',
|
||||
locationFilter: 'location_id',
|
||||
projectFlockFilter: 'project_flock_id',
|
||||
kandangFilter: 'kandang_id',
|
||||
projectFlockKandangFilter: 'project_flock_kandang_id',
|
||||
approvalStatusFilter: 'approval_status',
|
||||
projectFlockCategoryFilter: 'project_flock_category',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
persist: true,
|
||||
storeName: 'recording-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FILTER STATE =====
|
||||
const [filterArea, setFilterArea] = useState<OptionType | null>(null);
|
||||
const [filterLocation, setFilterLocation] = useState<OptionType | null>(null);
|
||||
const [filterProjectFlock, setFilterProjectFlock] =
|
||||
useState<OptionType | null>(null);
|
||||
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
|
||||
const [, setFilterProjectFlockKandangId] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [filterLocationAreaId, setFilterLocationAreaId] = useState<string>('');
|
||||
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
|
||||
useState<string>('');
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<RecordingFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
kandang_id: null,
|
||||
project_flock_kandang_id: null,
|
||||
area_id: tableFilterState.areaFilter,
|
||||
location_id: tableFilterState.locationFilter,
|
||||
project_flock_id: tableFilterState.projectFlockFilter,
|
||||
kandang_id: tableFilterState.kandangFilter,
|
||||
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
|
||||
approval_status: tableFilterState.approvalStatusFilter,
|
||||
project_flock_category: tableFilterState.projectFlockCategoryFilter,
|
||||
},
|
||||
validationSchema: RecordingFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('locationFilter', values.location_id || '');
|
||||
updateFilter('kandangFilter', values.kandang_id || '');
|
||||
updateFilter('areaFilter', values.area_id, true);
|
||||
updateFilter('locationFilter', values.location_id, true);
|
||||
updateFilter('projectFlockFilter', values.project_flock_id, true);
|
||||
updateFilter('kandangFilter', values.kandang_id, true);
|
||||
updateFilter(
|
||||
'projectFlockKandangFilter',
|
||||
values.project_flock_kandang_id || ''
|
||||
values.project_flock_kandang_id,
|
||||
true
|
||||
);
|
||||
updateFilter('approvalStatusFilter', values.approval_status, true);
|
||||
updateFilter(
|
||||
'projectFlockCategoryFilter',
|
||||
values.project_flock_category,
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
updateFilter('locationFilter', '');
|
||||
updateFilter('kandangFilter', '');
|
||||
updateFilter('projectFlockKandangFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', null, true);
|
||||
updateFilter('locationFilter', null, true);
|
||||
updateFilter('projectFlockFilter', null, true);
|
||||
updateFilter('kandangFilter', null, true);
|
||||
updateFilter('projectFlockKandangFilter', null, true);
|
||||
updateFilter('approvalStatusFilter', null, true);
|
||||
updateFilter('projectFlockCategoryFilter', null, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
project_flock_id: null,
|
||||
kandang_id: null,
|
||||
project_flock_kandang_id: null,
|
||||
approval_status: null,
|
||||
project_flock_category: null,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { project_flock_id, kandang_id } = formik.values;
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const selectedRowIds = Object.keys(rowSelection).map((item) =>
|
||||
@@ -355,10 +399,14 @@ const RecordingTable = () => {
|
||||
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
|
||||
const singleDeleteModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
|
||||
const {
|
||||
data: recordings,
|
||||
@@ -370,13 +418,6 @@ const RecordingTable = () => {
|
||||
);
|
||||
|
||||
// ===== LOCATION, AREA, KANDANG OPTIONS =====
|
||||
const locationParams = useMemo(() => {
|
||||
if (filterLocationAreaId) {
|
||||
return { area_id: filterLocationAreaId };
|
||||
}
|
||||
return undefined;
|
||||
}, [filterLocationAreaId]);
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
@@ -387,7 +428,9 @@ const RecordingTable = () => {
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
locationParams
|
||||
{
|
||||
area_id: String(formik.values.area_id?.value),
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -402,13 +445,6 @@ const RecordingTable = () => {
|
||||
'search'
|
||||
);
|
||||
|
||||
const projectFlockParams = useMemo(() => {
|
||||
if (filterProjectFlockLocationId) {
|
||||
return { location_id: filterProjectFlockLocationId };
|
||||
}
|
||||
return undefined;
|
||||
}, [filterProjectFlockLocationId]);
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
options: projectFlockOptions,
|
||||
@@ -420,34 +456,41 @@ const RecordingTable = () => {
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
projectFlockParams
|
||||
{
|
||||
location_id: String(formik.values.location_id?.value),
|
||||
}
|
||||
);
|
||||
|
||||
const kandangOptions = useMemo(() => {
|
||||
if (!filterProjectFlock || !projectFlocksRawData) return [];
|
||||
if (!project_flock_id || !projectFlocksRawData) return [];
|
||||
if (!isResponseSuccess(projectFlocksRawData)) return [];
|
||||
|
||||
const data = projectFlocksRawData.data as ProjectFlock[];
|
||||
const selectedProjectFlockData = data.find(
|
||||
(pf) => pf.id === filterProjectFlock.value
|
||||
const selectedProjectFlockData = data.find((pf) =>
|
||||
pf.id === formik.values.project_flock_id?.value
|
||||
? Number(formik.values.project_flock_id.value)
|
||||
: 0
|
||||
);
|
||||
|
||||
if (!selectedProjectFlockData?.kandangs) return [];
|
||||
|
||||
return selectedProjectFlockData.kandangs.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.name || '',
|
||||
}));
|
||||
}, [filterProjectFlock, projectFlocksRawData]);
|
||||
}, [project_flock_id, projectFlocksRawData]);
|
||||
|
||||
// ===== PROJECT FLOCK KANDANG LOOKUP =====
|
||||
const projectFlockKandangLookupUrl = useMemo(() => {
|
||||
if (!filterProjectFlock || !filterKandang) return null;
|
||||
if (!project_flock_id?.value || !kandang_id?.value) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
project_flock_id: filterProjectFlock.value.toString(),
|
||||
kandang_id: filterKandang.value.toString(),
|
||||
project_flock_id: project_flock_id.value.toString(),
|
||||
kandang_id: kandang_id.value.toString(),
|
||||
});
|
||||
|
||||
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
|
||||
}, [filterProjectFlock, filterKandang]);
|
||||
}, [project_flock_id, kandang_id]);
|
||||
|
||||
const { data: projectFlockKandangLookupData } = useSWR(
|
||||
projectFlockKandangLookupUrl,
|
||||
@@ -469,118 +512,45 @@ const RecordingTable = () => {
|
||||
? projectFlockKandangLookupData.data
|
||||
: undefined;
|
||||
|
||||
const formikRef = useRef(formik);
|
||||
|
||||
useEffect(() => {
|
||||
formikRef.current = formik;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (projectFlockKandangLookup?.id) {
|
||||
const pfkId = String(projectFlockKandangLookup.id);
|
||||
setFilterProjectFlockKandangId(projectFlockKandangLookup.id);
|
||||
formikRef.current.setFieldValue('project_flock_kandang_id', pfkId);
|
||||
formik.setFieldValue('project_flock_kandang_id', pfkId);
|
||||
} else {
|
||||
setFilterProjectFlockKandangId(undefined);
|
||||
formikRef.current.setFieldValue('project_flock_kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
}
|
||||
}, [projectFlockKandangLookup]);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterAreaChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const area = val as OptionType | null;
|
||||
const areaId = area?.value ? String(area.value) : null;
|
||||
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('area_id', val);
|
||||
formik.setFieldValue('location_id', null);
|
||||
formik.setFieldValue('project_flock_id', null);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
formik.setFieldValue('location_id', null);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
const handleFilterLocationChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('location_id', val);
|
||||
formik.setFieldValue('project_flock_id', null);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
setFilterArea(area);
|
||||
setFilterLocation(null);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterLocationAreaId(areaId || '');
|
||||
setFilterProjectFlockLocationId('');
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
const handleFilterProjectFlockChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('project_flock_id', val);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location?.value ? String(location.value) : null;
|
||||
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
|
||||
setFilterLocation(location);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterProjectFlockLocationId(locationId || '');
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterProjectFlockChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const projectFlock = val as OptionType | null;
|
||||
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
|
||||
setFilterProjectFlock(projectFlock);
|
||||
setFilterKandang(null);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterKandangChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const kandang = val as OptionType | null;
|
||||
const kandangId = kandang?.value ? String(kandang.value) : null;
|
||||
|
||||
formik.setFieldValue('kandang_id', kandangId);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
|
||||
setFilterKandang(kandang);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaIdValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
|
||||
const locationIdValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const projectFlockIdValue = useMemo(() => {
|
||||
if (!filterProjectFlock) return null;
|
||||
return filterProjectFlock;
|
||||
}, [filterProjectFlock]);
|
||||
|
||||
const kandangIdValue = useMemo(() => {
|
||||
if (!formik.values.kandang_id) return null;
|
||||
return (
|
||||
kandangOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.kandang_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.kandang_id, kandangOptions]);
|
||||
const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('kandang_id', val);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -588,25 +558,9 @@ const RecordingTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const isRecordingApproved = useCallback((recording: Recording): boolean => {
|
||||
return (
|
||||
recording.approval?.action === 'APPROVED' &&
|
||||
recording.approval?.step_name === 'Disetujui'
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('recording-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
setSearchValue(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[updateFilter, setSearchValue, setPage]
|
||||
);
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const singleDeleteHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -698,6 +652,60 @@ const RecordingTable = () => {
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportProgressEndDateChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportProgressInputToExcelClickHandler = useCallback(() => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
}, [exportProgressInputModal, resetExportProgressForm]);
|
||||
|
||||
const submitExportProgressInputHandler = useCallback(async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await RecordingApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
}, [
|
||||
exportProgressEndDate,
|
||||
exportProgressInputModal,
|
||||
exportProgressStartDate,
|
||||
resetExportProgressForm,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(recordings) && recordings.data) {
|
||||
const newSelection: Record<string, boolean> = {};
|
||||
@@ -858,7 +866,8 @@ const RecordingTable = () => {
|
||||
<>
|
||||
<span>
|
||||
{props.row.original.day} (Minggu ke-
|
||||
{props.row.original.project_flock.production_standart.week})
|
||||
{props.row.original.week} hari ke-
|
||||
{props.row.original.excess_days})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -1104,7 +1113,7 @@ const RecordingTable = () => {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
? `${value.toFixed(2)} butir`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
@@ -1120,7 +1129,7 @@ const RecordingTable = () => {
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
? `${value.toFixed(2)} btr`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
@@ -1368,6 +1377,16 @@ const RecordingTable = () => {
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1446,13 +1465,13 @@ const RecordingTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaIdValue}
|
||||
value={formik.values.area_id}
|
||||
onChange={handleFilterAreaChange}
|
||||
onInputChange={setAreaInputValue}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
@@ -1465,13 +1484,13 @@ const RecordingTable = () => {
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationIdValue}
|
||||
value={formik.values.location_id}
|
||||
onChange={handleFilterLocationChange}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isDisabled={!filterArea}
|
||||
isDisabled={!formik.values.area_id?.value}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
@@ -1479,13 +1498,13 @@ const RecordingTable = () => {
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
value={projectFlockIdValue}
|
||||
value={formik.values.project_flock_id}
|
||||
onChange={handleFilterProjectFlockChange}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
isLoading={isLoadingProjectFlocks}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isDisabled={!filterLocation}
|
||||
isDisabled={!formik.values.location_id?.value}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
@@ -1493,11 +1512,35 @@ const RecordingTable = () => {
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang'
|
||||
options={kandangOptions}
|
||||
value={kandangIdValue}
|
||||
value={formik.values.kandang_id}
|
||||
onChange={handleFilterKandangChange}
|
||||
isLoading={!filterProjectFlock}
|
||||
isLoading={!formik.values.project_flock_id?.value}
|
||||
isClearable
|
||||
isDisabled={!formik.values.project_flock_id?.value}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={projectFlockCategoryOptions}
|
||||
value={formik.values.project_flock_category}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('project_flock_category', val);
|
||||
}}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status Approval'
|
||||
placeholder='Pilih Status Approval'
|
||||
options={recordingApprovalStatusOptions}
|
||||
value={formik.values.approval_status}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('approval_status', val);
|
||||
}}
|
||||
isClearable
|
||||
isDisabled={!filterProjectFlock}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1505,30 +1548,16 @@ const RecordingTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='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'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
setFilterArea(null);
|
||||
setFilterLocation(null);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterLocationAreaId('');
|
||||
setFilterProjectFlockLocationId('');
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={
|
||||
!formik.isValid ||
|
||||
formik.isSubmitting ||
|
||||
!formik.values.kandang_id
|
||||
}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
@@ -1551,6 +1580,76 @@ const RecordingTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={exportProgressInputModal.ref}
|
||||
className={{
|
||||
modalBox: 'max-w-lg rounded-lg p-0',
|
||||
}}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModalWithNotes
|
||||
ref={approveModal.ref}
|
||||
type='success'
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const RecordingFilterSchema = object().shape({
|
||||
area_id: string().nullable(),
|
||||
location_id: string().nullable(),
|
||||
kandang_id: string().nullable(),
|
||||
project_flock_kandang_id: string().nullable(),
|
||||
export const RecordingFilterSchema = Yup.object().shape({
|
||||
area_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
location_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
project_flock_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
kandang_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
project_flock_kandang_id: Yup.number().nullable(),
|
||||
approval_status: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
project_flock_category: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type RecordingFilterType = {
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
kandang_id: string | null;
|
||||
project_flock_kandang_id: string | null;
|
||||
area_id: OptionType<number> | null;
|
||||
location_id: OptionType<number> | null;
|
||||
project_flock_id: OptionType<number> | null;
|
||||
kandang_id: OptionType<number> | null;
|
||||
project_flock_kandang_id: number | null;
|
||||
approval_status: OptionType<string> | null;
|
||||
project_flock_category: OptionType<string> | null;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
CreateGrowingRecordingPayload,
|
||||
CreateLayingRecordingPayload,
|
||||
CreateEggPayload,
|
||||
RecordingStock,
|
||||
} from '@/types/api/production/recording';
|
||||
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
|
||||
|
||||
type RecordingGrowingFormSchemaType = {
|
||||
record_date: string;
|
||||
@@ -29,11 +31,19 @@ type RecordingGrowingFormSchemaType = {
|
||||
} | null;
|
||||
project_flock_kandang_id: number;
|
||||
stocks: {
|
||||
product_warehouse_id: number;
|
||||
product_warehouse_id:
|
||||
| {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
| undefined;
|
||||
qty: number | string;
|
||||
}[];
|
||||
depletions: {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
source_product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
}[];
|
||||
@@ -41,34 +51,48 @@ type RecordingGrowingFormSchemaType = {
|
||||
|
||||
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
|
||||
eggs: {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
qty?: number | string;
|
||||
weight?: number | string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type StockSchema = {
|
||||
product_warehouse_id: number;
|
||||
product_warehouse_id: {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
qty: number | string;
|
||||
};
|
||||
|
||||
export type DepletionSchema = {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
source_product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
};
|
||||
|
||||
export type EggSchema = {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
qty?: number | string;
|
||||
weight?: number | string;
|
||||
};
|
||||
|
||||
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
product_warehouse_id: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.typeError('Produk harus berupa angka!'),
|
||||
.typeError('Produk wajib diisi!'),
|
||||
qty: Yup.number()
|
||||
.required('Jumlah penggunaan wajib diisi!')
|
||||
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
|
||||
@@ -76,9 +100,12 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
|
||||
});
|
||||
|
||||
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
product_warehouse_id: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.optional()
|
||||
.typeError('Depletions harus berupa angka!'),
|
||||
.nullable(),
|
||||
source_product_warehouse_id: Yup.number()
|
||||
.optional()
|
||||
.typeError('Gudang sumber harus berupa angka!'),
|
||||
@@ -88,9 +115,12 @@ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
|
||||
});
|
||||
|
||||
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
product_warehouse_id: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.optional()
|
||||
.typeError('Kondisi telur harus berupa angka!'),
|
||||
.nullable(),
|
||||
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
|
||||
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
|
||||
});
|
||||
@@ -248,14 +278,18 @@ export const getRecordingGrowingFormInitialValues = (
|
||||
initialValues?.project_flock?.project_flock_kandang_id ??
|
||||
0,
|
||||
stocks: initialValues?.stocks?.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
product_warehouse_id: {
|
||||
value: stock.product_warehouse_id,
|
||||
label: getProductWarehouseOptionLabel(stock.product_warehouse),
|
||||
},
|
||||
qty:
|
||||
(stock as { qty?: number; usage_amount?: number }).qty ||
|
||||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
|
||||
(stock as RecordingStock).qty ||
|
||||
((stock as RecordingStock).usage_amount || 0) +
|
||||
((stock as RecordingStock).pending_qty || 0) ||
|
||||
'',
|
||||
})) ?? [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: undefined,
|
||||
qty: '',
|
||||
},
|
||||
],
|
||||
@@ -263,13 +297,16 @@ export const getRecordingGrowingFormInitialValues = (
|
||||
(
|
||||
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
|
||||
) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id,
|
||||
product_warehouse_id: {
|
||||
value: Number(depletion.product_warehouse_id ?? 0),
|
||||
label: getProductWarehouseOptionLabel(depletion.product_warehouse),
|
||||
},
|
||||
source_product_warehouse_id: depletion.source_product_warehouse_id,
|
||||
qty: depletion.qty,
|
||||
})
|
||||
) ?? [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: undefined,
|
||||
qty: '',
|
||||
},
|
||||
],
|
||||
@@ -281,12 +318,15 @@ export const getRecordingLayingFormInitialValues = (
|
||||
...getRecordingGrowingFormInitialValues(initialValues),
|
||||
|
||||
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
|
||||
product_warehouse_id: egg.product_warehouse_id,
|
||||
product_warehouse_id: {
|
||||
value: Number(egg.product_warehouse_id ?? 0),
|
||||
label: getProductWarehouseOptionLabel(egg.product_warehouse),
|
||||
},
|
||||
qty: egg.qty,
|
||||
weight: egg.weight,
|
||||
})) ?? [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
@@ -31,12 +31,14 @@ import {
|
||||
RecordingApi,
|
||||
ProjectFlockApi,
|
||||
} from '@/services/api/production';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
import { ProductionStandardApi, ProductApi } from '@/services/api/master-data';
|
||||
import {
|
||||
ProductionStandard,
|
||||
StandardDetails,
|
||||
} from '@/types/api/master-data/production-standard';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { SystemSettingsApi } from '@/services/api/system-settings';
|
||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
|
||||
@@ -461,13 +463,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
}, [selectedKandang]);
|
||||
|
||||
// ===== TRANSITION RESTRICTION LOGIC =====
|
||||
const isTransitionPeriod = useMemo(() => {
|
||||
return (
|
||||
initialValues?.is_transition ??
|
||||
projectFlockKandangLookup?.is_transition ??
|
||||
false
|
||||
);
|
||||
}, [initialValues, projectFlockKandangLookup]);
|
||||
// const isTransitionPeriod = useMemo(() => {
|
||||
// return (
|
||||
// initialValues?.is_transition ??
|
||||
// projectFlockKandangLookup?.is_transition ??
|
||||
// false
|
||||
// );
|
||||
// }, [initialValues, projectFlockKandangLookup]);
|
||||
|
||||
// set to false by request: 30 May 2026, 09:11
|
||||
const isTransitionPeriod = false;
|
||||
|
||||
const recordingRestriction = useMemo(() => {
|
||||
let isLaying: boolean;
|
||||
@@ -481,10 +486,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
false;
|
||||
}
|
||||
|
||||
const isTransition =
|
||||
initialValues?.is_transition ??
|
||||
projectFlockKandangLookup?.is_transition ??
|
||||
false;
|
||||
// const isTransition =
|
||||
// initialValues?.is_transition ??
|
||||
// projectFlockKandangLookup?.is_transition ??
|
||||
// false;
|
||||
|
||||
// set to false by request: 30 May 2026, 09:11
|
||||
const isTransition = false;
|
||||
|
||||
const currentIsLaying =
|
||||
type === 'edit'
|
||||
@@ -499,6 +507,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
type,
|
||||
]);
|
||||
|
||||
// ===== MIGRATION MODE =====
|
||||
const { data: systemSettingsResponse } = useSWR(
|
||||
SystemSettingsApi.basePath,
|
||||
SystemSettingsApi.getAllFetcher
|
||||
);
|
||||
|
||||
const isMigrationMode = useMemo(() => {
|
||||
if (!isResponseSuccess(systemSettingsResponse)) return false;
|
||||
const setting = systemSettingsResponse.data.find(
|
||||
(s) => s.key === 'allow_negative_pakan_ovk'
|
||||
);
|
||||
return setting?.value === 'true';
|
||||
}, [systemSettingsResponse]);
|
||||
|
||||
// ===== PAYLOAD CREATION HELPERS =====
|
||||
const createGrowingPayload = useCallback(
|
||||
(values: RecordingGrowingFormValues) => {
|
||||
@@ -506,7 +528,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
? values.depletions
|
||||
?.filter((d) => d.product_warehouse_id && d.qty)
|
||||
.map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id!,
|
||||
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
|
||||
...(depletion.source_product_warehouse_id && {
|
||||
source_product_warehouse_id:
|
||||
depletion.source_product_warehouse_id,
|
||||
@@ -517,9 +539,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
const stocks = recordingRestriction.canEditStock
|
||||
? (values.stocks ?? [])
|
||||
.filter((s) => s.product_warehouse_id && s.qty)
|
||||
.filter((s) => s.product_warehouse_id?.value && s.qty)
|
||||
.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
// In migration mode, product_warehouse_id field holds product.id;
|
||||
// send it as product_id so the backend auto-creates the warehouse entry.
|
||||
...(isMigrationMode
|
||||
? { product_id: stock.product_warehouse_id?.value }
|
||||
: { product_warehouse_id: stock.product_warehouse_id?.value }),
|
||||
qty: Number(stock.qty) || 0,
|
||||
}))
|
||||
: [];
|
||||
@@ -531,15 +557,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
...(depletions.length > 0 && { depletions }),
|
||||
};
|
||||
},
|
||||
[recordingRestriction.canEditStock, recordingRestriction.canEditDepletion]
|
||||
[
|
||||
isMigrationMode,
|
||||
recordingRestriction.canEditStock,
|
||||
recordingRestriction.canEditDepletion,
|
||||
]
|
||||
);
|
||||
|
||||
const createLayingPayload = useCallback(
|
||||
(values: RecordingLayingFormValues) => {
|
||||
const depletions = values.depletions
|
||||
?.filter((d) => d.product_warehouse_id && d.qty)
|
||||
?.filter((d) => d.product_warehouse_id?.value && d.qty)
|
||||
.map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id!,
|
||||
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
|
||||
...(depletion.source_product_warehouse_id && {
|
||||
source_product_warehouse_id: depletion.source_product_warehouse_id,
|
||||
}),
|
||||
@@ -549,7 +579,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const eggs = values.eggs
|
||||
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
|
||||
.map((egg) => ({
|
||||
product_warehouse_id: egg.product_warehouse_id!,
|
||||
product_warehouse_id: egg.product_warehouse_id?.value ?? 0,
|
||||
qty: Number(egg.qty) || 0,
|
||||
weight:
|
||||
typeof egg.weight === 'number'
|
||||
@@ -559,9 +589,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
const stocks = recordingRestriction.canEditStock
|
||||
? values.stocks
|
||||
.filter((s) => s.product_warehouse_id && s.qty)
|
||||
.filter((s) => s.product_warehouse_id?.value && s.qty)
|
||||
.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
...(isMigrationMode
|
||||
? { product_id: stock.product_warehouse_id?.value }
|
||||
: { product_warehouse_id: stock.product_warehouse_id?.value }),
|
||||
qty: Number(stock.qty) || 0,
|
||||
}))
|
||||
: [];
|
||||
@@ -574,7 +606,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
...(eggs && eggs.length > 0 && { eggs }),
|
||||
};
|
||||
},
|
||||
[recordingRestriction.canEditStock]
|
||||
[isMigrationMode, recordingRestriction.canEditStock]
|
||||
);
|
||||
|
||||
const isRecordingEditable = useCallback((recording?: Recording) => {
|
||||
@@ -603,11 +635,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// When migration mode ON: fetch all master PAKAN/OVK products (no warehouse entry needed).
|
||||
// When migration mode OFF: fetch from product-warehouses as usual.
|
||||
const {
|
||||
setInputValue: setStockProductInputValue,
|
||||
rawData: stockProducts,
|
||||
isLoadingOptions: isLoadingStockProducts,
|
||||
loadMore: loadMoreStockProducts,
|
||||
rawData: stockProductsPW,
|
||||
isLoadingOptions: isLoadingStockProductsPW,
|
||||
loadMore: loadMoreStockProductsPW,
|
||||
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', {
|
||||
flags: 'PAKAN,OVK',
|
||||
limit: '100',
|
||||
@@ -616,6 +650,29 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
...(selectedKandangId ? { kandang_id: selectedKandangId.toString() } : {}),
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setStockMasterInputValue,
|
||||
rawData: stockProductsMaster,
|
||||
isLoadingOptions: isLoadingStockProductsMaster,
|
||||
loadMore: loadMoreStockProductsMaster,
|
||||
} = useSelect(
|
||||
isMigrationMode ? ProductApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{ flags: 'PAKAN,OVK', limit: '100' }
|
||||
);
|
||||
|
||||
const isLoadingStockProducts = isMigrationMode
|
||||
? isLoadingStockProductsMaster
|
||||
: isLoadingStockProductsPW;
|
||||
const loadMoreStockProducts = isMigrationMode
|
||||
? loadMoreStockProductsMaster
|
||||
: loadMoreStockProductsPW;
|
||||
const setStockInputValue = isMigrationMode
|
||||
? setStockMasterInputValue
|
||||
: setStockProductInputValue;
|
||||
|
||||
const {
|
||||
rawData: depletionProductsData,
|
||||
isLoadingOptions: isLoadingDepletionProducts,
|
||||
@@ -999,9 +1056,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
useEffect(() => {
|
||||
const items: Array<ProductWarehouse | null | undefined> = [];
|
||||
|
||||
if (isResponseSuccess(stockProducts)) {
|
||||
if (!isMigrationMode && isResponseSuccess(stockProductsPW)) {
|
||||
items.push(
|
||||
...((stockProducts.data as unknown as ProductWarehouse[]) ?? [])
|
||||
...((stockProductsPW.data as unknown as ProductWarehouse[]) ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1035,7 +1092,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
mergeKnownProductWarehouses(items);
|
||||
}, [
|
||||
stockProducts,
|
||||
isMigrationMode,
|
||||
stockProductsPW,
|
||||
depletionProductsData,
|
||||
eggProductsData,
|
||||
initialValues,
|
||||
@@ -1066,9 +1124,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
);
|
||||
|
||||
const unifiedStockProducts = useMemo(() => {
|
||||
const options = isResponseSuccess(stockProducts)
|
||||
if (isMigrationMode) {
|
||||
// In migration mode, show all master PAKAN/OVK products (no warehouse context).
|
||||
// value = product.id; submission will send product_id to the backend.
|
||||
const options: OptionType[] = isResponseSuccess(stockProductsMaster)
|
||||
? (stockProductsMaster.data as unknown as Product[])
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
: [];
|
||||
return options;
|
||||
}
|
||||
|
||||
const options = isResponseSuccess(stockProductsPW)
|
||||
? buildProductWarehouseOptions(
|
||||
stockProducts.data as unknown as ProductWarehouse[]
|
||||
stockProductsPW.data as unknown as ProductWarehouse[]
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -1085,7 +1154,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
return options;
|
||||
}, [
|
||||
stockProducts,
|
||||
isMigrationMode,
|
||||
stockProductsMaster,
|
||||
stockProductsPW,
|
||||
buildProductWarehouseOptions,
|
||||
initialValues,
|
||||
type,
|
||||
@@ -1204,6 +1275,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
};
|
||||
}
|
||||
|
||||
// In migration mode (edit), the dropdown options use product.id as their value,
|
||||
// but the API returns product_warehouse_id (PW entity ID). Remap so the dropdown
|
||||
// can match the correct option. The product ID is available on the nested
|
||||
// product_warehouse object returned by the API.
|
||||
if (isMigrationMode && type === 'edit' && initialValues?.stocks?.length) {
|
||||
baseValues.stocks = initialValues.stocks.map((stock) => ({
|
||||
product_warehouse_id: {
|
||||
value: Number(
|
||||
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
|
||||
),
|
||||
label: getProductWarehouseOptionLabel(stock.product_warehouse),
|
||||
},
|
||||
qty: stock.usage_amount ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
if (!recordingRestriction.canEditStock) {
|
||||
baseValues.stocks = [];
|
||||
}
|
||||
@@ -1224,6 +1311,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
selectedKandang,
|
||||
recordingRestriction.canEditStock,
|
||||
recordingRestriction.canEditDepletion,
|
||||
isMigrationMode,
|
||||
]);
|
||||
|
||||
const formik = useFormik<
|
||||
@@ -1335,6 +1423,35 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
// SWR timing fix: formik initializes before system-settings load, so isMigrationMode
|
||||
// starts false. When it flips true, formikInitialValues recomputes but enableReinitialize
|
||||
// is false, so formik won't pick it up. Push the corrected stock values once, and only
|
||||
// once — the ref prevents re-firing if something causes isMigrationMode to re-evaluate.
|
||||
const migrationEditMappingApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
type !== 'edit' ||
|
||||
!isMigrationMode ||
|
||||
!initialValues?.stocks?.length ||
|
||||
migrationEditMappingApplied.current
|
||||
)
|
||||
return;
|
||||
migrationEditMappingApplied.current = true;
|
||||
formik.setFieldValue(
|
||||
'stocks',
|
||||
initialValues.stocks.map((stock) => ({
|
||||
product_warehouse_id: {
|
||||
value: Number(
|
||||
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
|
||||
),
|
||||
label: getProductWarehouseOptionLabel(stock.product_warehouse),
|
||||
},
|
||||
qty: stock.usage_amount ?? '',
|
||||
}))
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMigrationMode]);
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
@@ -1351,7 +1468,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
(stockIdx: number) => {
|
||||
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
|
||||
const stock = formik.values.stocks?.[stockIdx];
|
||||
if (!stock || !stock.product_warehouse_id) return null;
|
||||
if (!stock || !stock.product_warehouse_id?.value) return null;
|
||||
return null;
|
||||
},
|
||||
[formik.values.stocks, type]
|
||||
@@ -1361,7 +1478,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
(productWarehouseId: number) => {
|
||||
if ((type === 'edit' || type === 'detail') && initialValues?.stocks) {
|
||||
const existingStock = initialValues.stocks.find(
|
||||
(s) => s.product_warehouse_id === productWarehouseId
|
||||
(s) => Number(s.product_warehouse_id) === Number(productWarehouseId)
|
||||
) as RecordingStock | undefined;
|
||||
if (existingStock) {
|
||||
return {
|
||||
@@ -1381,21 +1498,25 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const getStockUsageAdornment = useCallback(
|
||||
(stockIdx: number) => {
|
||||
const stock = formik.values.stocks?.[stockIdx];
|
||||
if (!stock || !stock.product_warehouse_id) return null;
|
||||
if (!stock || !stock.product_warehouse_id?.value) return null;
|
||||
|
||||
const isDetail = (type as 'add' | 'edit' | 'detail') === 'detail';
|
||||
const availableStock = getAvailableStock(stock.product_warehouse_id);
|
||||
const availableStock = getAvailableStock(
|
||||
stock.product_warehouse_id.value
|
||||
);
|
||||
const requestedUsage = Number(stock.qty) || 0;
|
||||
const remainingStock = availableStock - requestedUsage;
|
||||
const { pendingQty } = getStockPendingInfo(stock.product_warehouse_id);
|
||||
const { pendingQty } = getStockPendingInfo(
|
||||
stock.product_warehouse_id.value
|
||||
);
|
||||
|
||||
if (isDetail) {
|
||||
if (pendingQty > 0) {
|
||||
return (
|
||||
<span className='text-sm text-gray-600 whitespace-nowrap'>
|
||||
(tersedia: {formatNumber(requestedUsage)} | pending:{' '}
|
||||
(tersedia: {formatNumber(availableStock)} | pending:{' '}
|
||||
<span className='text-error'>{formatNumber(pendingQty)}</span> |
|
||||
pakai: {formatNumber(requestedUsage + pendingQty)})
|
||||
pakai: {formatNumber(requestedUsage)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1494,10 +1615,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return (
|
||||
idx !== currentIdx &&
|
||||
s.product_warehouse_id &&
|
||||
s.product_warehouse_id !== 0
|
||||
s.product_warehouse_id.value !== 0
|
||||
);
|
||||
})
|
||||
.map((s) => s.product_warehouse_id) || [];
|
||||
.map((s) => s.product_warehouse_id?.value) || [];
|
||||
|
||||
return unifiedStockProducts.filter(
|
||||
(opt) => !selectedProductIds.includes(Number(opt.value))
|
||||
@@ -1514,10 +1635,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return (
|
||||
idx !== currentIdx &&
|
||||
d.product_warehouse_id &&
|
||||
d.product_warehouse_id !== 0
|
||||
d.product_warehouse_id.value !== 0
|
||||
);
|
||||
})
|
||||
.map((d) => d.product_warehouse_id) || [];
|
||||
.map((d) => d.product_warehouse_id?.value) || [];
|
||||
|
||||
return depletionProducts.filter(
|
||||
(opt) => !selectedProductIds.includes(Number(opt.value))
|
||||
@@ -1534,10 +1655,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return (
|
||||
idx !== currentIdx &&
|
||||
e.product_warehouse_id &&
|
||||
e.product_warehouse_id !== 0
|
||||
e.product_warehouse_id.value !== 0
|
||||
);
|
||||
})
|
||||
.map((e) => e.product_warehouse_id) || [];
|
||||
.map((e) => e.product_warehouse_id?.value) || [];
|
||||
|
||||
return eggProducts.filter(
|
||||
(opt) => !selectedProductIds.includes(Number(opt.value))
|
||||
@@ -1583,7 +1704,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
isError: touchedField && Boolean(errorField?.[column]),
|
||||
errorMessage:
|
||||
touchedField && errorField?.[column]
|
||||
? (errorField[column] as string)
|
||||
? errorField[column] instanceof Object
|
||||
? (errorField[column] as OptionType)?.label
|
||||
: (errorField[column] as string)
|
||||
: '',
|
||||
};
|
||||
};
|
||||
@@ -1614,14 +1737,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('stocks', false, false);
|
||||
formik.setFieldValue('stocks', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('depletions', false, false);
|
||||
formik.setFieldValue('depletions', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
@@ -1629,7 +1752,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('eggs', false, false);
|
||||
formik.setFieldValue('eggs', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
@@ -1678,14 +1801,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('stocks', false, false);
|
||||
formik.setFieldValue('stocks', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('depletions', false, false);
|
||||
formik.setFieldValue('depletions', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
@@ -1693,7 +1816,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('eggs', false, false);
|
||||
formik.setFieldValue('eggs', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
@@ -1731,14 +1854,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('stocks', false, false);
|
||||
formik.setFieldValue('stocks', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('depletions', false, false);
|
||||
formik.setFieldValue('depletions', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
@@ -1746,7 +1869,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('eggs', false, false);
|
||||
formik.setFieldValue('eggs', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
@@ -1959,7 +2082,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const newStocks = [
|
||||
...(formik.values.stocks || []),
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
];
|
||||
@@ -1991,7 +2114,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const newDepletions = [
|
||||
...(formik.values.depletions || []),
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
];
|
||||
@@ -2025,7 +2148,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const newEggs = [
|
||||
...((formik.values as RecordingLayingFormValues).eggs || []),
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
];
|
||||
@@ -2068,7 +2191,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') {
|
||||
const layingValues = formik.values as RecordingLayingFormValues;
|
||||
if (!layingValues.eggs || layingValues.eggs.length === 0) {
|
||||
setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]);
|
||||
setFieldValue('eggs', [{ product_warehouse_id: null, qty: '' }]);
|
||||
}
|
||||
}
|
||||
}, [isLayingCategory, type, formik.values, setFieldValue]);
|
||||
@@ -2790,20 +2913,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
<td>
|
||||
<SelectInput
|
||||
required
|
||||
key={`stock-product-${idx}-${stock.product_warehouse_id}`}
|
||||
value={
|
||||
unifiedStockProducts.find(
|
||||
(product) =>
|
||||
product.value === stock.product_warehouse_id
|
||||
) || null
|
||||
}
|
||||
onInputChange={setStockProductInputValue}
|
||||
key={`stock-product-${idx}-${stock.product_warehouse_id?.value}`}
|
||||
value={stock.product_warehouse_id}
|
||||
onInputChange={setStockInputValue}
|
||||
onChange={(selectedOption) => {
|
||||
const option =
|
||||
selectedOption as OptionType | null;
|
||||
formik.setFieldValue(
|
||||
`stocks.${idx}.product_warehouse_id`,
|
||||
option?.value || 0
|
||||
option
|
||||
);
|
||||
}}
|
||||
options={getAvailableStockProductOptions(idx)}
|
||||
@@ -2839,9 +2957,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
}
|
||||
isClearable={type !== 'detail'}
|
||||
inputPrefix={
|
||||
stock.product_warehouse_id
|
||||
stock.product_warehouse_id?.value
|
||||
? getProductFlagBadgeAdornment(
|
||||
stock.product_warehouse_id
|
||||
stock.product_warehouse_id.value
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@@ -2877,7 +2995,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
inputSuffix={
|
||||
stock.product_warehouse_id
|
||||
? getProductUomSuffix(
|
||||
stock.product_warehouse_id,
|
||||
stock.product_warehouse_id.value,
|
||||
'stock'
|
||||
)
|
||||
: null
|
||||
@@ -3070,19 +3188,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
<td>
|
||||
<SelectInput
|
||||
value={
|
||||
depletionProducts.find(
|
||||
(product) =>
|
||||
product.value ===
|
||||
depletion.product_warehouse_id
|
||||
) || null
|
||||
}
|
||||
value={depletion.product_warehouse_id}
|
||||
onChange={(selectedOption) => {
|
||||
const option =
|
||||
selectedOption as OptionType | null;
|
||||
formik.setFieldValue(
|
||||
`depletions.${idx}.product_warehouse_id`,
|
||||
option?.value || 0
|
||||
option
|
||||
);
|
||||
}}
|
||||
options={getAvailableDepletionProductOptions(idx)}
|
||||
@@ -3145,7 +3257,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
inputSuffix={
|
||||
depletion.product_warehouse_id
|
||||
? getProductUomSuffix(
|
||||
depletion.product_warehouse_id,
|
||||
depletion.product_warehouse_id.value,
|
||||
'depletion'
|
||||
)
|
||||
: null
|
||||
@@ -3323,18 +3435,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
<td>
|
||||
<SelectInput
|
||||
value={
|
||||
eggProducts.find(
|
||||
(product) =>
|
||||
product.value === egg.product_warehouse_id
|
||||
) || null
|
||||
}
|
||||
value={egg.product_warehouse_id}
|
||||
onChange={(selectedOption) => {
|
||||
const option =
|
||||
selectedOption as OptionType | null;
|
||||
formik.setFieldValue(
|
||||
`eggs.${idx}.product_warehouse_id`,
|
||||
option?.value || 0
|
||||
option
|
||||
);
|
||||
}}
|
||||
options={getAvailableEggProductOptions(idx)}
|
||||
|
||||
@@ -11,63 +11,72 @@ export const getRecordingRestriction = (
|
||||
isTransition: boolean,
|
||||
currentIsLaying?: boolean
|
||||
): RecordingRestriction => {
|
||||
if (isTransition && !isLaying) {
|
||||
const isLayingKandangInTransition = currentIsLaying === true;
|
||||
// if (isTransition && !isLaying) {
|
||||
// const isLayingKandangInTransition = currentIsLaying === true;
|
||||
|
||||
if (isLayingKandangInTransition) {
|
||||
return {
|
||||
canEditStock: false,
|
||||
canEditDepletion: true,
|
||||
canEditEgg: true,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
canEditStock: true,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
// if (isLayingKandangInTransition) {
|
||||
// return {
|
||||
// canEditStock: false,
|
||||
// canEditDepletion: true,
|
||||
// canEditEgg: true,
|
||||
// isLocked: false,
|
||||
// lockReason: undefined,
|
||||
// };
|
||||
// } else {
|
||||
// return {
|
||||
// canEditStock: true,
|
||||
// canEditDepletion: false,
|
||||
// canEditEgg: false,
|
||||
// isLocked: false,
|
||||
// lockReason: undefined,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
if (!isLaying && !isTransition && currentIsLaying) {
|
||||
return {
|
||||
canEditStock: false,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: true,
|
||||
lockReason:
|
||||
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
|
||||
};
|
||||
}
|
||||
// if (!isLaying && !isTransition && currentIsLaying) {
|
||||
// return {
|
||||
// canEditStock: false,
|
||||
// canEditDepletion: false,
|
||||
// canEditEgg: false,
|
||||
// isLocked: true,
|
||||
// lockReason:
|
||||
// 'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
|
||||
// };
|
||||
// }
|
||||
|
||||
if (!isLaying && !isTransition) {
|
||||
return {
|
||||
canEditStock: true,
|
||||
canEditDepletion: true,
|
||||
canEditEgg: false,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
}
|
||||
if (isLaying && !isTransition) {
|
||||
return {
|
||||
canEditStock: true,
|
||||
canEditDepletion: true,
|
||||
canEditEgg: true,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
}
|
||||
// if (!isLaying && !isTransition) {
|
||||
// return {
|
||||
// canEditStock: true,
|
||||
// canEditDepletion: true,
|
||||
// canEditEgg: false,
|
||||
// isLocked: false,
|
||||
// lockReason: undefined,
|
||||
// };
|
||||
// }
|
||||
// if (isLaying && !isTransition) {
|
||||
// return {
|
||||
// canEditStock: true,
|
||||
// canEditDepletion: true,
|
||||
// canEditEgg: true,
|
||||
// isLocked: false,
|
||||
// lockReason: undefined,
|
||||
// };
|
||||
// }
|
||||
|
||||
// return {
|
||||
// canEditStock: false,
|
||||
// canEditDepletion: false,
|
||||
// canEditEgg: false,
|
||||
// isLocked: true,
|
||||
// lockReason: 'Kondisi transisi tidak valid',
|
||||
// };
|
||||
|
||||
// remove recording transition restriction by request: 30 May 2026, 09:11
|
||||
return {
|
||||
canEditStock: false,
|
||||
canEditDepletion: false,
|
||||
canEditEgg: false,
|
||||
isLocked: true,
|
||||
lockReason: 'Kondisi transisi tidak valid',
|
||||
canEditStock: true,
|
||||
canEditDepletion: true,
|
||||
canEditEgg: true,
|
||||
isLocked: false,
|
||||
lockReason: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
+35
-23
@@ -40,6 +40,9 @@ const TransferToLayingDetailModal = () => {
|
||||
? transferToLayingResponse.data
|
||||
: undefined;
|
||||
|
||||
const isTransferToLayingApproved =
|
||||
transferToLaying?.approval.step_number === 2;
|
||||
|
||||
const { data: transferToLayingApprovalResponse } = useSWR(
|
||||
transferToLayingId
|
||||
? ['approval-transfer-to-laying', transferToLayingId]
|
||||
@@ -55,9 +58,9 @@ const TransferToLayingDetailModal = () => {
|
||||
|
||||
const detailModal = useModal();
|
||||
|
||||
const totalEnteredChickenForTransfer =
|
||||
const maxSourceQuantity =
|
||||
transferToLaying?.sources.reduce(
|
||||
(acc, item) => acc + Number(item.qty),
|
||||
(acc, item) => acc + Number(item.product_warehouse.quantity),
|
||||
0
|
||||
) ?? 0;
|
||||
|
||||
@@ -67,8 +70,9 @@ const TransferToLayingDetailModal = () => {
|
||||
0
|
||||
) ?? 0;
|
||||
|
||||
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
|
||||
const totalAvailableChickenForTransfer =
|
||||
totalEnteredChickenForTransfer - totalTransferedChicken;
|
||||
maxSourceQuantity - totalTransferedChicken;
|
||||
|
||||
const closeModalHandler = (shouldPushToRoute: boolean = true) => {
|
||||
if (shouldPushToRoute) {
|
||||
@@ -161,11 +165,34 @@ const TransferToLayingDetailModal = () => {
|
||||
|
||||
{/* Source Kandang */}
|
||||
<div className='flex flex-col'>
|
||||
<span className='w-full py-2 text-xs font-semibold'>
|
||||
Kandang Asal{' '}
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
|
||||
<span className='text-nowrap'>
|
||||
Kandang Asal{' '}
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{!isTransferToLayingApproved && (
|
||||
<>
|
||||
<div className='w-px h-5 bg-base-content/10' />
|
||||
|
||||
<StatusBadge
|
||||
color={
|
||||
totalAvailableChickenForTransfer < 0
|
||||
? 'error'
|
||||
: 'neutral'
|
||||
}
|
||||
text={`Sisa ayam: ${formatNumber(
|
||||
totalAvailableChickenForTransfer,
|
||||
'en-US'
|
||||
)} ekor`}
|
||||
className={{
|
||||
badge: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{transferToLaying?.sources.length === 0 && (
|
||||
@@ -225,21 +252,6 @@ const TransferToLayingDetailModal = () => {
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div className='w-px h-5 bg-base-content/10' />
|
||||
|
||||
<StatusBadge
|
||||
color={
|
||||
totalAvailableChickenForTransfer < 0 ? 'error' : 'neutral'
|
||||
}
|
||||
text={`Sisa transfer: ${formatNumber(
|
||||
totalAvailableChickenForTransfer,
|
||||
'en-US'
|
||||
)} ekor`}
|
||||
className={{
|
||||
badge: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{transferToLaying?.targets.length === 0 && (
|
||||
@@ -304,7 +316,7 @@ const TransferToLayingDetailModal = () => {
|
||||
readOnly
|
||||
errorMessage={
|
||||
totalAvailableChickenForTransfer < 0
|
||||
? `Jumlah transfer melebihi ketersediaan (${formatNumber(totalEnteredChickenForTransfer, 'en-US')} ayam)`
|
||||
? `Jumlah transfer melebihi ketersediaan (${formatNumber(maxSourceQuantity, 'en-US')} ayam)`
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
+10
-21
@@ -13,7 +13,6 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { Flock } from '@/types/api/master-data/flock';
|
||||
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
|
||||
import {
|
||||
TransferToLayingFilterSchema,
|
||||
TransferToLayingFilterValues,
|
||||
@@ -21,12 +20,14 @@ import {
|
||||
|
||||
interface TransferToLayingFilterModal {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
onSubmit?: (values: TransferToLayingFilter) => void;
|
||||
initialValues?: Partial<TransferToLayingFilterValues>;
|
||||
onSubmit?: (values: TransferToLayingFilterValues) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const TransferToLayingFilterModal = ({
|
||||
ref,
|
||||
initialValues: initialValuesProp,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: TransferToLayingFilterModal) => {
|
||||
@@ -86,28 +87,16 @@ const TransferToLayingFilterModal = ({
|
||||
|
||||
const formik = useFormik<TransferToLayingFilterValues>({
|
||||
initialValues: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
flockSource: [],
|
||||
flockDestination: [],
|
||||
status: [],
|
||||
startDate: initialValuesProp?.startDate ?? '',
|
||||
endDate: initialValuesProp?.endDate ?? '',
|
||||
flockSource: initialValuesProp?.flockSource ?? [],
|
||||
flockDestination: initialValuesProp?.flockDestination ?? [],
|
||||
status: initialValuesProp?.status ?? [],
|
||||
},
|
||||
enableReinitialize: true,
|
||||
validationSchema: TransferToLayingFilterSchema,
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues = {
|
||||
...values,
|
||||
flockSource: values.flockSource
|
||||
? (values.flockSource as OptionType[]).map((item) => item.value)
|
||||
: [],
|
||||
flockDestination: values.flockDestination
|
||||
? (values.flockDestination as OptionType[]).map((item) => item.value)
|
||||
: [],
|
||||
status: values.status
|
||||
? (values.status as OptionType[]).map((item) => item.value)
|
||||
: [],
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues as TransferToLayingFilter);
|
||||
onSubmit?.(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: () => {
|
||||
|
||||
@@ -223,6 +223,8 @@ const TransferToLayingFormModal = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { flockSource: formikFlockSource } = formik.values;
|
||||
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
|
||||
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
|
||||
@@ -231,13 +233,8 @@ const TransferToLayingFormModal = () => {
|
||||
|
||||
const [maxSourceQuantity, setMaxSourceQuantity] = useState<number>(0);
|
||||
|
||||
const selectedFlockDestinationRawData = isResponseSuccess(
|
||||
flockDestinationRawData
|
||||
)
|
||||
? flockDestinationRawData.data.find(
|
||||
(item) => item.id === formik.values.flockDestination?.value
|
||||
)
|
||||
: undefined;
|
||||
const [selectedFlockDestinationRawData, setSelectedFlockDestinationRawData] =
|
||||
useState<ProjectFlock | undefined>(undefined);
|
||||
|
||||
const { data: flockSourceKandangsAvailability } = useSWR(
|
||||
formik.values.flockSource
|
||||
@@ -454,14 +451,36 @@ const TransferToLayingFormModal = () => {
|
||||
}, [transferToLayingId, transferToLaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formik.values.flockSource) {
|
||||
setSelectedFlockSourceRawData(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseSuccess(flockSourceRawData)) {
|
||||
const selectedFlockSourceRawData = flockSourceRawData.data.find(
|
||||
const found = flockSourceRawData.data.find(
|
||||
(item) => item.id === formik.values.flockSource?.value
|
||||
);
|
||||
|
||||
setSelectedFlockSourceRawData(selectedFlockSourceRawData);
|
||||
if (found) {
|
||||
setSelectedFlockSourceRawData(found);
|
||||
}
|
||||
}
|
||||
}, [flockSourceRawData]);
|
||||
}, [flockSourceRawData, formikFlockSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formik.values.flockDestination) {
|
||||
setSelectedFlockDestinationRawData(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResponseSuccess(flockDestinationRawData)) {
|
||||
const found = flockDestinationRawData.data.find(
|
||||
(item) => item.id === formik.values.flockDestination?.value
|
||||
);
|
||||
if (found) {
|
||||
setSelectedFlockDestinationRawData(found);
|
||||
}
|
||||
}
|
||||
}, [flockDestinationRawData, formik.values.flockDestination]);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setFieldValue('totalQuantity', totalTransferedChicken);
|
||||
@@ -625,6 +644,7 @@ const TransferToLayingFormModal = () => {
|
||||
>
|
||||
<div className='flex flex-row items-center gap-3'>
|
||||
<input
|
||||
id={`flock-source-kandang-${item.project_flock_kandang_id}`}
|
||||
type='radio'
|
||||
name='flockSourceKandang'
|
||||
value={item.project_flock_kandang_id}
|
||||
@@ -637,13 +657,14 @@ const TransferToLayingFormModal = () => {
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`}
|
||||
className={cn('text-sm text-base-content/50', {
|
||||
'cursor-pointer': isAvailable,
|
||||
'cursor-not-allowed opacity-50': !isAvailable,
|
||||
})}
|
||||
>
|
||||
{item.kandang_name}{' '}
|
||||
<span className='text-base-content/20'>{`(Max: ${item.available_qty})`}</span>
|
||||
<span className='text-base-content/20'>{`(Max: ${item.available_qty ?? '-'})`}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -818,11 +839,33 @@ const TransferToLayingFormModal = () => {
|
||||
|
||||
{/* Source Kandang */}
|
||||
<div className='flex flex-col'>
|
||||
<span className='w-full py-2 text-xs font-semibold'>
|
||||
Kandang Asal{' '}
|
||||
<span className='tooltip tooltip-error' data-tip='required'>
|
||||
<span className='text-error'> *</span>
|
||||
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
|
||||
<span className='text-nowrap'>
|
||||
Kandang Asal{' '}
|
||||
<span
|
||||
className='tooltip tooltip-error'
|
||||
data-tip='required'
|
||||
>
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div className='w-px h-5 bg-base-content/10' />
|
||||
|
||||
<StatusBadge
|
||||
color={
|
||||
totalAvailableChickenForTransfer < 0
|
||||
? 'error'
|
||||
: 'neutral'
|
||||
}
|
||||
text={`Sisa ayam: ${formatNumber(
|
||||
totalAvailableChickenForTransfer,
|
||||
'en-US'
|
||||
)} ekor`}
|
||||
className={{
|
||||
badge: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{formik.values.flockSourceKandangs.length === 0 && (
|
||||
@@ -902,23 +945,6 @@ const TransferToLayingFormModal = () => {
|
||||
<span className='text-error'> *</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div className='w-px h-5 bg-base-content/10' />
|
||||
|
||||
<StatusBadge
|
||||
color={
|
||||
totalAvailableChickenForTransfer < 0
|
||||
? 'error'
|
||||
: 'neutral'
|
||||
}
|
||||
text={`Sisa transfer: ${formatNumber(
|
||||
totalAvailableChickenForTransfer,
|
||||
'en-US'
|
||||
)} ekor`}
|
||||
className={{
|
||||
badge: 'text-nowrap',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{formik.values.flockDestinationKandangs.length === 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useState } from 'react';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import useSWR from 'swr';
|
||||
@@ -26,10 +26,9 @@ import TransferToLayingFilterModal from '@/components/pages/production/transfer-
|
||||
import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal';
|
||||
import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
|
||||
|
||||
import {
|
||||
TransferToLaying,
|
||||
TransferToLayingFilter,
|
||||
} from '@/types/api/production/transfer-to-laying';
|
||||
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
|
||||
import { TransferToLayingFilterValues } from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
|
||||
import { cn, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
@@ -142,6 +141,8 @@ const TransferToLayingsTable = () => {
|
||||
status: '',
|
||||
filter_by: '',
|
||||
sort_by: '',
|
||||
flockSourceNames: '',
|
||||
flockDestinationNames: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -154,6 +155,9 @@ const TransferToLayingsTable = () => {
|
||||
filter_by: 'filter_by',
|
||||
sort_by: 'sort_by',
|
||||
},
|
||||
excludeKeysFromUrl: ['flockSourceNames', 'flockDestinationNames'],
|
||||
persist: true,
|
||||
storeName: 'transfer-to-laying-table',
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -431,12 +435,84 @@ const TransferToLayingsTable = () => {
|
||||
updateFilter('search', e.target.value);
|
||||
};
|
||||
|
||||
const filterSubmitHandler = (values: TransferToLayingFilter) => {
|
||||
updateFilter('startDate', values.startDate);
|
||||
updateFilter('endDate', values.endDate);
|
||||
updateFilter('flockSource', values.flockSource.join(','));
|
||||
updateFilter('flockDestination', values.flockDestination.join(','));
|
||||
updateFilter('status', values.status.join(','));
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: 'PENDING', label: 'Pengajuan' },
|
||||
{ value: 'APPROVED', label: 'Disetujui' },
|
||||
{ value: 'REJECTED', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const filterModalInitialValues = useMemo(() => {
|
||||
const flockSourceIds = tableFilterState.flockSource
|
||||
? tableFilterState.flockSource.split(',')
|
||||
: [];
|
||||
const flockSourceNameList = tableFilterState.flockSourceNames
|
||||
? tableFilterState.flockSourceNames.split(',')
|
||||
: [];
|
||||
const flockSourceOptions = flockSourceIds.filter(Boolean).map((id, i) => ({
|
||||
value: parseInt(id),
|
||||
label: flockSourceNameList[i] || id,
|
||||
}));
|
||||
|
||||
const flockDestIds = tableFilterState.flockDestination
|
||||
? tableFilterState.flockDestination.split(',')
|
||||
: [];
|
||||
const flockDestNameList = tableFilterState.flockDestinationNames
|
||||
? tableFilterState.flockDestinationNames.split(',')
|
||||
: [];
|
||||
const flockDestOptions = flockDestIds.filter(Boolean).map((id, i) => ({
|
||||
value: parseInt(id),
|
||||
label: flockDestNameList[i] || id,
|
||||
}));
|
||||
|
||||
const statusIds = tableFilterState.status
|
||||
? tableFilterState.status.split(',')
|
||||
: [];
|
||||
const statusOptions = statusIds.filter(Boolean).map((id) => {
|
||||
const found = STATUS_FILTER_OPTIONS.find((opt) => opt.value === id);
|
||||
return found || { value: id, label: id };
|
||||
});
|
||||
|
||||
return {
|
||||
startDate: tableFilterState.startDate || '',
|
||||
endDate: tableFilterState.endDate || '',
|
||||
flockSource: flockSourceOptions,
|
||||
flockDestination: flockDestOptions,
|
||||
status: statusOptions,
|
||||
};
|
||||
}, [
|
||||
tableFilterState.startDate,
|
||||
tableFilterState.endDate,
|
||||
tableFilterState.flockSource,
|
||||
tableFilterState.flockDestination,
|
||||
tableFilterState.status,
|
||||
tableFilterState.flockSourceNames,
|
||||
tableFilterState.flockDestinationNames,
|
||||
]);
|
||||
|
||||
const filterSubmitHandler = (values: TransferToLayingFilterValues) => {
|
||||
const flockSourceOpts = (values.flockSource as OptionType[]) || [];
|
||||
const flockDestOpts = (values.flockDestination as OptionType[]) || [];
|
||||
const statusOpts = (values.status as OptionType[]) || [];
|
||||
|
||||
updateFilter('startDate', values.startDate || '');
|
||||
updateFilter('endDate', values.endDate || '');
|
||||
updateFilter(
|
||||
'flockSource',
|
||||
flockSourceOpts.map((o) => String(o.value)).join(',')
|
||||
);
|
||||
updateFilter(
|
||||
'flockDestination',
|
||||
flockDestOpts.map((o) => String(o.value)).join(',')
|
||||
);
|
||||
updateFilter('status', statusOpts.map((o) => String(o.value)).join(','));
|
||||
updateFilter(
|
||||
'flockSourceNames',
|
||||
flockSourceOpts.map((o) => String(o.label)).join(',')
|
||||
);
|
||||
updateFilter(
|
||||
'flockDestinationNames',
|
||||
flockDestOpts.map((o) => String(o.label)).join(',')
|
||||
);
|
||||
};
|
||||
|
||||
const filterResetHandler = () => {
|
||||
@@ -445,6 +521,8 @@ const TransferToLayingsTable = () => {
|
||||
updateFilter('flockSource', '');
|
||||
updateFilter('flockDestination', '');
|
||||
updateFilter('status', '');
|
||||
updateFilter('flockSourceNames', '');
|
||||
updateFilter('flockDestinationNames', '');
|
||||
};
|
||||
|
||||
const exportToExcelHandler = async () => {
|
||||
@@ -558,6 +636,8 @@ const TransferToLayingsTable = () => {
|
||||
'search',
|
||||
'filter_by',
|
||||
'sort_by',
|
||||
'flockSourceNames',
|
||||
'flockDestinationNames',
|
||||
]}
|
||||
fieldGroups={[['startDate', 'endDate']]}
|
||||
onClick={filterModal.openModal}
|
||||
@@ -670,6 +750,7 @@ const TransferToLayingsTable = () => {
|
||||
|
||||
<TransferToLayingFilterModal
|
||||
ref={filterModal.ref}
|
||||
initialValues={filterModalInitialValues}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useState, useEffect } from 'react';
|
||||
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -9,31 +9,61 @@ import Modal from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import SelectInput from '@/components/input/SelectInput';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { PurchaseFilter } from '@/types/api/purchase/purchase';
|
||||
import { AreaApi, LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
const filterByOptions: OptionType<string>[] = [
|
||||
{ value: 'po_date', label: 'Tanggal PO' },
|
||||
{ value: 'received_date', label: 'Tanggal Terima' },
|
||||
{ value: 'due_date', label: 'Tanggal Jatuh Tempo' },
|
||||
{ value: 'created_at', label: 'Tanggal Dibuat' },
|
||||
];
|
||||
|
||||
interface PurchaseFilterModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
initialValues?: {
|
||||
poDate: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
filterBy: OptionType<string> | undefined;
|
||||
category: OptionType<number>[];
|
||||
status: OptionType<string>[];
|
||||
supplier: OptionType<number> | null;
|
||||
area: OptionType<number> | null;
|
||||
location: OptionType<number> | null;
|
||||
project_flock: OptionType<number> | null;
|
||||
project_flock_kandang: OptionType<number> | null;
|
||||
};
|
||||
onSubmit?: (values: PurchaseFilter) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const PurchaseFilterModal = ({
|
||||
ref,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: PurchaseFilterModalProps) => {
|
||||
const closeModalHandler = () => {
|
||||
const closeModalHandler = useCallback(() => {
|
||||
ref.current?.close();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
// ===== DATE ERROR STATE =====
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
|
||||
// ===== CLEANUP TOAST ON UNMOUNT =====
|
||||
useEffect(() => {
|
||||
@@ -73,32 +103,140 @@ const PurchaseFilterModal = ({
|
||||
'search'
|
||||
);
|
||||
|
||||
const [selectedAreaId, setSelectedAreaId] = useState(
|
||||
initialValues?.area?.value ? String(initialValues.area.value) : ''
|
||||
);
|
||||
const [selectedLocationId, setSelectedLocationId] = useState(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
|
||||
const {
|
||||
setInputValue: setSupplierInputValue,
|
||||
options: supplierOptions,
|
||||
isLoadingOptions: isLoadingSupplierOptions,
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
area_id: selectedAreaId || '',
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
options: projectFlockOptions,
|
||||
rawData: projectFlocksRawData,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationId || '',
|
||||
}
|
||||
);
|
||||
|
||||
const formik = useFormik<{
|
||||
poDate: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
filterBy: OptionType<string> | undefined;
|
||||
category: { label: string; value: number }[];
|
||||
status: { label: string; value: string }[];
|
||||
supplier: OptionType<number> | null;
|
||||
area: OptionType<number> | null;
|
||||
location: OptionType<number> | null;
|
||||
project_flock: OptionType<number> | null;
|
||||
project_flock_kandang: OptionType<number> | null;
|
||||
}>({
|
||||
initialValues: {
|
||||
// enableReinitialize: true,
|
||||
initialValues: initialValues || {
|
||||
poDate: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filterBy: undefined,
|
||||
category: [],
|
||||
status: [],
|
||||
supplier: null,
|
||||
area: null,
|
||||
location: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues = {
|
||||
...values,
|
||||
category: values.category.map((item) => String(item.value)),
|
||||
category_labels: values.category,
|
||||
status: values.status.map((item) => String(item.value)),
|
||||
supplier_id: values.supplier?.value,
|
||||
supplier_label: values.supplier?.label,
|
||||
area_id: values.area?.value,
|
||||
area_label: values.area?.label,
|
||||
location_id: values.location?.value,
|
||||
location_label: values.location?.label,
|
||||
project_flock_id: values.project_flock?.value,
|
||||
project_flock_label: values.project_flock?.label,
|
||||
project_flock_kandang_id: values.project_flock_kandang?.value,
|
||||
project_flock_kandang_label: values.project_flock_kandang?.label,
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: () => {
|
||||
setSelectedAreaId('');
|
||||
setSelectedLocationId('');
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm, submitForm } = formik;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAreaId(
|
||||
initialValues?.area?.value ? String(initialValues.area.value) : ''
|
||||
);
|
||||
setSelectedLocationId(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
}, [initialValues?.area, initialValues?.location]);
|
||||
|
||||
const projectFlockKandangOptions = useMemo(() => {
|
||||
if (
|
||||
!formik.values.project_flock ||
|
||||
!projectFlocksRawData ||
|
||||
!isResponseSuccess(projectFlocksRawData)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedProjectFlock = projectFlocksRawData.data.find(
|
||||
(item) => item.id === formik.values.project_flock?.value
|
||||
);
|
||||
|
||||
return (
|
||||
selectedProjectFlock?.kandangs?.map((item) => ({
|
||||
value: item.project_flock_kandang_id,
|
||||
label: item.name,
|
||||
})) || []
|
||||
);
|
||||
}, [formik.values.project_flock, projectFlocksRawData]);
|
||||
|
||||
const productCategoryChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
@@ -109,6 +247,86 @@ const PurchaseFilterModal = ({
|
||||
formik.setFieldValue('status', val);
|
||||
};
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
resetForm({
|
||||
values: {
|
||||
poDate: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filterBy: undefined,
|
||||
category: [],
|
||||
status: [],
|
||||
supplier: null,
|
||||
area: null,
|
||||
location: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
});
|
||||
setSelectedAreaId('');
|
||||
setSelectedLocationId('');
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset, closeModalHandler, dateErrorShown]);
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value);
|
||||
|
||||
if (value && formik.values.end_date) {
|
||||
if (new Date(formik.values.end_date) < new Date(value)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value);
|
||||
|
||||
if (value && formik.values.start_date) {
|
||||
if (new Date(value) < new Date(formik.values.start_date)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formikSubmitHandler = useCallback(async () => {
|
||||
await submitForm();
|
||||
}, [submitForm]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
@@ -118,7 +336,7 @@ const PurchaseFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -132,7 +350,9 @@ const PurchaseFilterModal = ({
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={closeModalHandler}
|
||||
onClick={() => {
|
||||
closeModalHandler();
|
||||
}}
|
||||
className='p-0 text-base-content/50 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
@@ -142,6 +362,44 @@ const PurchaseFilterModal = ({
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col'>
|
||||
<div>
|
||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||
Tanggal
|
||||
</label>
|
||||
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
||||
<DateInput
|
||||
name='start_date'
|
||||
value={formik.values.start_date}
|
||||
onChange={handleStartDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='end_date'
|
||||
value={formik.values.end_date}
|
||||
onChange={handleEndDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
isError={hasDateError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectInputRadio
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={filterByOptions}
|
||||
value={formik.values.filterBy ?? null}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'filterBy',
|
||||
!Array.isArray(val) ? (val ?? undefined) : undefined
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
label='PO Date'
|
||||
name='poDate'
|
||||
@@ -172,6 +430,108 @@ const PurchaseFilterModal = ({
|
||||
value: item.step_name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Vendor'
|
||||
placeholder='Pilih Vendor'
|
||||
value={formik.values.supplier}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'supplier',
|
||||
!Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null
|
||||
)
|
||||
}
|
||||
options={supplierOptions}
|
||||
isLoading={isLoadingSupplierOptions}
|
||||
onInputChange={setSupplierInputValue}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
value={formik.values.area}
|
||||
onChange={(val) => {
|
||||
const nextValue = !Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null;
|
||||
formik.setFieldValue('area', nextValue);
|
||||
formik.setFieldValue('location', null);
|
||||
formik.setFieldValue('project_flock', null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
setSelectedAreaId(
|
||||
nextValue?.value ? String(nextValue.value) : ''
|
||||
);
|
||||
setSelectedLocationId('');
|
||||
}}
|
||||
options={areaOptions}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
onInputChange={setAreaInputValue}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
value={formik.values.location}
|
||||
onChange={(val) => {
|
||||
const nextValue = !Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null;
|
||||
formik.setFieldValue('location', nextValue);
|
||||
formik.setFieldValue('project_flock', null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
setSelectedLocationId(
|
||||
nextValue?.value ? String(nextValue.value) : ''
|
||||
);
|
||||
}}
|
||||
options={locationOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isClearable
|
||||
isDisabled={!formik.values.area}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
value={formik.values.project_flock}
|
||||
onChange={(val) => {
|
||||
const nextValue = !Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null;
|
||||
formik.setFieldValue('project_flock', nextValue);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
}}
|
||||
options={projectFlockOptions}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isClearable
|
||||
isDisabled={!formik.values.location}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang'
|
||||
value={formik.values.project_flock_kandang}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'project_flock_kandang',
|
||||
!Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null
|
||||
)
|
||||
}
|
||||
options={projectFlockKandangOptions}
|
||||
isClearable
|
||||
isDisabled={!formik.values.project_flock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +547,9 @@ const PurchaseFilterModal = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
type='button'
|
||||
onClick={formikSubmitHandler}
|
||||
disabled={hasDateError}
|
||||
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
|
||||
>
|
||||
Apply Filter
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
Updater,
|
||||
} from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import Button from '@/components/Button';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -28,17 +25,40 @@ import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
|
||||
import Dropdown from '@/components/dropdown/Dropdown';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
|
||||
import { PurchaseApi } from '@/services/api/purchase';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { Color } from '@/types/theme';
|
||||
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
|
||||
|
||||
type PurchaseTableFilters = {
|
||||
search: string;
|
||||
sort_by: string;
|
||||
order_by: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
filter_by: string;
|
||||
po_date: string;
|
||||
approval_status: string;
|
||||
product_category_id: string;
|
||||
product_category_name: string;
|
||||
supplier_id: string;
|
||||
supplier_name: string;
|
||||
area_id: string;
|
||||
area_name: string;
|
||||
location_id: string;
|
||||
location_name: string;
|
||||
project_flock_id: string;
|
||||
project_flock_name: string;
|
||||
project_flock_kandang_id: string;
|
||||
project_flock_kandang_name: string;
|
||||
};
|
||||
|
||||
// ===== STATUS BADGE UTILITIES =====
|
||||
const statusTextMap: Record<string, string> = {
|
||||
@@ -147,42 +167,100 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const PurchaseTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
|
||||
null
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
// ===== TABLE FILTER STATE =====
|
||||
const {
|
||||
state: tableFilterState,
|
||||
setFilters,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<PurchaseTableFilters>({
|
||||
initial: {
|
||||
search: '',
|
||||
sort_by: '',
|
||||
order_by: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filter_by: '',
|
||||
po_date: '',
|
||||
approval_status: '',
|
||||
product_category_id: '',
|
||||
product_category_name: '',
|
||||
supplier_id: '',
|
||||
supplier_name: '',
|
||||
area_id: '',
|
||||
area_name: '',
|
||||
location_id: '',
|
||||
location_name: '',
|
||||
project_flock_id: '',
|
||||
project_flock_name: '',
|
||||
project_flock_kandang_id: '',
|
||||
project_flock_kandang_name: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
sort_by: 'sort_by',
|
||||
order_by: 'sort_order',
|
||||
start_date: 'start_date',
|
||||
end_date: 'end_date',
|
||||
filter_by: 'filter_by',
|
||||
po_date: 'po_date',
|
||||
approval_status: 'approval_status',
|
||||
product_category_id: 'product_category_id',
|
||||
supplier_id: 'supplier_id',
|
||||
area_id: 'area_id',
|
||||
location_id: 'location_id',
|
||||
project_flock_id: 'project_flock_id',
|
||||
project_flock_kandang_id: 'project_flock_kandang_id',
|
||||
},
|
||||
excludeKeysFromUrl: [
|
||||
'product_category_name',
|
||||
'supplier_name',
|
||||
'area_name',
|
||||
'location_name',
|
||||
'project_flock_name',
|
||||
'project_flock_kandang_name',
|
||||
],
|
||||
persist: true,
|
||||
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 =====
|
||||
const filterModal = useModal();
|
||||
const deleteModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
|
||||
// ===== API DATA FETCHING =====
|
||||
const {
|
||||
@@ -194,36 +272,10 @@ const PurchaseTable = () => {
|
||||
PurchaseApi.getAllFetcher
|
||||
);
|
||||
|
||||
const getKey = (
|
||||
pageIndex: number,
|
||||
previousPageData: BaseApiResponse<Expense>[] | null
|
||||
) => {
|
||||
if (pageIndex > 0 && !previousPageData) return null;
|
||||
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
|
||||
};
|
||||
|
||||
const { data: expensesPages } = useSWRInfinite(
|
||||
getKey,
|
||||
ExpenseApi.getAllFetcher
|
||||
);
|
||||
|
||||
const expenseMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!expensesPages) return map;
|
||||
|
||||
expensesPages.forEach((page) => {
|
||||
if (isResponseSuccess(page)) {
|
||||
page.data.forEach((expense: Expense) => {
|
||||
map.set(expense.reference_number, expense.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [expensesPages]);
|
||||
|
||||
// ===== TABLE COLUMNS DEFINITION =====
|
||||
const purchaseColumns: ColumnDef<Purchase>[] = [
|
||||
{
|
||||
accessorKey: 'po_number',
|
||||
header: 'No. PR/PO',
|
||||
cell: (props) => {
|
||||
const { pr_number, po_number } = props.row.original;
|
||||
@@ -239,37 +291,91 @@ const PurchaseTable = () => {
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{poExpedition.map((exp, index) => {
|
||||
const expenseId = expenseMap.get(exp.refrence);
|
||||
if (expenseId) {
|
||||
return (
|
||||
<li key={index}>
|
||||
<Link
|
||||
href={`/expense/detail/?expenseId=${expenseId}`}
|
||||
className='p-0 h-auto text-primary underline'
|
||||
>
|
||||
{exp.refrence}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return <li key={index}>{exp.refrence}</li>;
|
||||
return (
|
||||
<li key={index}>
|
||||
<Link
|
||||
href={`/expense/detail/?expenseId=${exp.id}`}
|
||||
className='p-0 h-auto text-primary underline'
|
||||
>
|
||||
{exp.refrence}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'requester_name',
|
||||
header: 'Nama Pengaju',
|
||||
cell: (props) => props.row.original.requester_name || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'po_date',
|
||||
header: 'Tgl. PO',
|
||||
cell: (props) =>
|
||||
props.row.original.po_date
|
||||
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'received_date',
|
||||
header: 'Tgl. Terima',
|
||||
cell: (props) =>
|
||||
props.row.original.received_date
|
||||
? formatDate(props.row.original.received_date, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'due_date',
|
||||
header: 'Jatuh Tempo',
|
||||
cell: (props) =>
|
||||
props.row.original.due_date
|
||||
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
header: 'Aging',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const purchase = props.row.original;
|
||||
if (!purchase.po_date) return '-';
|
||||
const poDate = new Date(purchase.po_date);
|
||||
const today = new Date();
|
||||
const diffTime = Math.abs(today.getTime() - poDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return `${diffDays} hari`;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'supplier',
|
||||
header: 'Vendor',
|
||||
cell: (props) => props.row.original.supplier.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'requester_name',
|
||||
header: 'Nama Pengaju',
|
||||
cell: (props) => props.row.original.requester_name || '-',
|
||||
accessorKey: 'location',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location?.name || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'products.name',
|
||||
accessorKey: 'warehouse',
|
||||
header: 'Gudang',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item.warehouse?.name ?? '-'}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'products',
|
||||
header: 'Produk',
|
||||
cell: (props) => {
|
||||
const products = props.row.original.products;
|
||||
@@ -284,39 +390,162 @@ const PurchaseTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'location.name',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location?.name || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'po_date',
|
||||
header: 'Tgl. PO',
|
||||
cell: (props) =>
|
||||
props.row.original.po_date
|
||||
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'due_date',
|
||||
header: 'Jatuh Tempo',
|
||||
cell: (props) =>
|
||||
props.row.original.due_date
|
||||
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
header: 'Aging',
|
||||
accessorKey: 'total_qty',
|
||||
header: 'Kuantitas',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const purchase = props.row.original;
|
||||
if (!purchase.po_date) return '-';
|
||||
const poDate = new Date(purchase.po_date);
|
||||
const today = new Date();
|
||||
const diffTime = Math.abs(today.getTime() - poDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return `${diffDays} hari`;
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{formatNumber(item.total_qty ?? 0)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'uom',
|
||||
header: 'Satuan',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item.product?.uom?.name ?? '-'}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Harga',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{formatCurrency(item.price ?? 0)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_price',
|
||||
header: 'Total Harga',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{formatCurrency(item.total_price ?? 0)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'products_total',
|
||||
header: 'Total Harga Produk',
|
||||
cell: (props) => formatCurrency(props.row.original.products_total ?? 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'expedition_vendor',
|
||||
header: 'Vendor Ekspedisi',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item.expedition_vendor?.name ?? '-'}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'expedition_qty',
|
||||
header: 'Qty Ekspedisi',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>
|
||||
{item.expedition_qty != null
|
||||
? formatNumber(item.expedition_qty)
|
||||
: '-'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'transport_per_item',
|
||||
header: 'Harga Ekspedisi',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>
|
||||
{item.transport_per_item != null
|
||||
? formatCurrency(item.transport_per_item)
|
||||
: '-'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'item_expedition_total',
|
||||
header: 'Total Ekspedisi',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const items = props.row.original.items;
|
||||
if (!items || items.length === 0) return '-';
|
||||
return (
|
||||
<ul className='list-disc pl-4'>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>
|
||||
{item.expedition_total != null
|
||||
? formatCurrency(item.expedition_total)
|
||||
: '-'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'expedition_total',
|
||||
header: 'Total Ekspedisi Semua Produk',
|
||||
cell: (props) => formatCurrency(props.row.original.expedition_total ?? 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'grand_total_all',
|
||||
header: 'Grand Total All',
|
||||
cell: (props) => formatCurrency(props.row.original.grand_total_all ?? 0),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status Approval',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.latest_approval;
|
||||
@@ -361,6 +590,19 @@ const PurchaseTable = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'notes',
|
||||
header: 'Notes',
|
||||
cell: (props) => props.row.original.notes || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Tanggal Dibuat',
|
||||
cell: (props) =>
|
||||
props.row.original.created_at
|
||||
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props) => {
|
||||
@@ -392,10 +634,17 @@ const PurchaseTable = () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
try {
|
||||
await PurchaseApi.delete(selectedPurchase?.id as number);
|
||||
refreshPurchaseRequests();
|
||||
deleteModal.closeModal();
|
||||
toast.success('Berhasil menghapus data permintaan pembelian!');
|
||||
const deleteResponse = await PurchaseApi.delete(
|
||||
selectedPurchase?.id as number
|
||||
);
|
||||
|
||||
if (isResponseSuccess(deleteResponse)) {
|
||||
refreshPurchaseRequests();
|
||||
deleteModal.closeModal();
|
||||
toast.success('Berhasil menghapus data permintaan pembelian!');
|
||||
} else {
|
||||
toast.error(deleteResponse?.message ?? 'Gagal menghapus data!');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Gagal menghapus data permintaan pembelian!');
|
||||
}
|
||||
@@ -403,34 +652,214 @@ const PurchaseTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('purchase-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
},
|
||||
[updateFilter, setSearchValue]
|
||||
[updateFilter]
|
||||
);
|
||||
|
||||
const filterSubmitHandler = (values: PurchaseFilter) => {
|
||||
updateFilter('po_date', values.poDate);
|
||||
updateFilter('product_category_id', values.category.join(','));
|
||||
updateFilter('approval_status', values.status.join(','));
|
||||
setFilters({
|
||||
start_date: values.start_date || '',
|
||||
end_date: values.end_date || '',
|
||||
filter_by: values.filterBy?.value || '',
|
||||
po_date: values.poDate,
|
||||
product_category_id: values.category.join(','),
|
||||
product_category_name:
|
||||
values.category_labels?.map((item) => item.label).join(',') || '',
|
||||
approval_status: values.status.join(','),
|
||||
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
|
||||
supplier_name: values.supplier_label || '',
|
||||
area_id: values.area_id ? String(values.area_id) : '',
|
||||
area_name: values.area_label || '',
|
||||
location_id: values.location_id ? String(values.location_id) : '',
|
||||
location_name: values.location_label || '',
|
||||
project_flock_id: values.project_flock_id
|
||||
? String(values.project_flock_id)
|
||||
: '',
|
||||
project_flock_name: values.project_flock_label || '',
|
||||
project_flock_kandang_id: values.project_flock_kandang_id
|
||||
? String(values.project_flock_kandang_id)
|
||||
: '',
|
||||
project_flock_kandang_name: values.project_flock_kandang_label || '',
|
||||
});
|
||||
};
|
||||
|
||||
const filterResetHandler = () => {
|
||||
updateFilter('po_date', '');
|
||||
updateFilter('product_category_id', '');
|
||||
updateFilter('approval_status', '');
|
||||
setFilters({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
filter_by: '',
|
||||
po_date: '',
|
||||
product_category_id: '',
|
||||
product_category_name: '',
|
||||
approval_status: '',
|
||||
supplier_id: '',
|
||||
supplier_name: '',
|
||||
area_id: '',
|
||||
area_name: '',
|
||||
location_id: '',
|
||||
location_name: '',
|
||||
project_flock_id: '',
|
||||
project_flock_name: '',
|
||||
project_flock_kandang_id: '',
|
||||
project_flock_kandang_name: '',
|
||||
});
|
||||
};
|
||||
|
||||
const purchaseFilterInitialValues = useMemo(() => {
|
||||
const filterByLabelMap: Record<string, string> = {
|
||||
po_date: 'Tanggal PO',
|
||||
received_date: 'Tanggal Terima',
|
||||
due_date: 'Tanggal Jatuh Tempo',
|
||||
created_at: 'Tanggal Dibuat',
|
||||
};
|
||||
|
||||
const categoryIds = tableFilterState.product_category_id
|
||||
? tableFilterState.product_category_id
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const categoryLabels = tableFilterState.product_category_name
|
||||
? tableFilterState.product_category_name
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const approvalStatuses = tableFilterState.approval_status
|
||||
? tableFilterState.approval_status
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
poDate: tableFilterState.po_date,
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
filterBy: tableFilterState.filter_by
|
||||
? {
|
||||
value: tableFilterState.filter_by,
|
||||
label:
|
||||
filterByLabelMap[tableFilterState.filter_by] ||
|
||||
tableFilterState.filter_by,
|
||||
}
|
||||
: undefined,
|
||||
category: categoryIds.map((value, index) => ({
|
||||
value: Number(value),
|
||||
label: categoryLabels[index] || value,
|
||||
})),
|
||||
status: approvalStatuses.map((value) => ({
|
||||
value,
|
||||
label:
|
||||
PURCHASE_ORDER_APPROVAL_LINE.find((item) => item.step_name === value)
|
||||
?.step_name || value,
|
||||
})),
|
||||
supplier: tableFilterState.supplier_id
|
||||
? ({
|
||||
value: Number(tableFilterState.supplier_id),
|
||||
label:
|
||||
tableFilterState.supplier_name || tableFilterState.supplier_id,
|
||||
} as OptionType<number>)
|
||||
: null,
|
||||
area: tableFilterState.area_id
|
||||
? ({
|
||||
value: Number(tableFilterState.area_id),
|
||||
label: tableFilterState.area_name || tableFilterState.area_id,
|
||||
} as OptionType<number>)
|
||||
: null,
|
||||
location: tableFilterState.location_id
|
||||
? ({
|
||||
value: Number(tableFilterState.location_id),
|
||||
label:
|
||||
tableFilterState.location_name || tableFilterState.location_id,
|
||||
} as OptionType<number>)
|
||||
: null,
|
||||
project_flock: tableFilterState.project_flock_id
|
||||
? ({
|
||||
value: Number(tableFilterState.project_flock_id),
|
||||
label:
|
||||
tableFilterState.project_flock_name ||
|
||||
tableFilterState.project_flock_id,
|
||||
} as OptionType<number>)
|
||||
: null,
|
||||
project_flock_kandang: tableFilterState.project_flock_kandang_id
|
||||
? ({
|
||||
value: Number(tableFilterState.project_flock_kandang_id),
|
||||
label:
|
||||
tableFilterState.project_flock_kandang_name ||
|
||||
tableFilterState.project_flock_kandang_id,
|
||||
} as OptionType<number>)
|
||||
: null,
|
||||
};
|
||||
}, [tableFilterState]);
|
||||
|
||||
const exportToExcel = useCallback(async () => {
|
||||
setIsLoadingExportingToExcel(true);
|
||||
|
||||
try {
|
||||
await PurchaseApi.exportToExcel(getTableFilterQueryString());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor data pembelian')
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingExportingToExcel(false);
|
||||
}
|
||||
}, [getTableFilterQueryString]);
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback((e) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
}, []);
|
||||
|
||||
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback((e) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
}, []);
|
||||
|
||||
const exportProgressInputToExcelClickHandler = useCallback(() => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
}, [exportProgressInputModal, resetExportProgressForm]);
|
||||
|
||||
const submitExportProgressInputHandler = useCallback(async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await PurchaseApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
}, [
|
||||
exportProgressEndDate,
|
||||
exportProgressInputModal,
|
||||
exportProgressStartDate,
|
||||
resetExportProgressForm,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
@@ -477,11 +906,73 @@ const PurchaseTable = () => {
|
||||
'search',
|
||||
'filter_by',
|
||||
'sort_by',
|
||||
'order_by',
|
||||
'product_category_name',
|
||||
'supplier_name',
|
||||
'area_name',
|
||||
'location_name',
|
||||
'project_flock_name',
|
||||
'project_flock_kandang_name',
|
||||
]}
|
||||
fieldGroups={[['startDate', 'endDate']]}
|
||||
fieldGroups={[['start_date', 'end_date']]}
|
||||
onClick={filterModal.openModal}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportToExcel}
|
||||
isLoading={isLoadingExportingToExcel}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor ke Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -529,7 +1020,8 @@ const PurchaseTable = () => {
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
setSorting={handleSortingChange}
|
||||
manualSorting
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
@@ -543,6 +1035,7 @@ const PurchaseTable = () => {
|
||||
|
||||
<PurchaseFilterModal
|
||||
ref={filterModal.ref}
|
||||
initialValues={purchaseFilterInitialValues}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
/>
|
||||
@@ -562,6 +1055,76 @@ const PurchaseTable = () => {
|
||||
onClick: confirmationModalDeleteClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={exportProgressInputModal.ref}
|
||||
className={{
|
||||
modalBox: 'max-w-lg rounded-lg p-0',
|
||||
}}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,7 +55,6 @@ const PurchaseRequestForm = ({
|
||||
const deleteModal = useModal();
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const [, setLocationSelectInputValue] = useState('');
|
||||
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
|
||||
[]
|
||||
);
|
||||
@@ -163,6 +162,7 @@ const PurchaseRequestForm = ({
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
loadMore: loadMoreLocations,
|
||||
setInputValue: setLocationSelectInputValue,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
|
||||
@@ -26,6 +26,8 @@ import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/or
|
||||
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
|
||||
|
||||
import Card from '@/components/Card';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import TextArea from '@/components/input/TextArea';
|
||||
import {
|
||||
CreateAcceptApprovalRequestPayload,
|
||||
CreateManagerApprovalRequestPayload,
|
||||
@@ -96,6 +98,7 @@ const PurchaseOrderDetail = ({
|
||||
const acceptRejectionModal = useModal();
|
||||
const managerRejectionModal = useModal();
|
||||
const editModal = useModal();
|
||||
const editPoDateModal = useModal();
|
||||
const penerimaanBarangModal = useModal();
|
||||
const deleteModal = useModal();
|
||||
|
||||
@@ -105,6 +108,9 @@ const PurchaseOrderDetail = ({
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(null);
|
||||
const [, setApprovalNotes] = useState('');
|
||||
const [managerApprovalNotes, setManagerApprovalNotes] = useState('');
|
||||
const [managerApprovalPoDate, setManagerApprovalPoDate] = useState('');
|
||||
const [editPoDate, setEditPoDate] = useState('');
|
||||
|
||||
const selectedRowIds = Object.keys(rowSelection).map((item) =>
|
||||
parseInt(item)
|
||||
@@ -212,6 +218,8 @@ const PurchaseOrderDetail = ({
|
||||
break;
|
||||
case 2:
|
||||
setApprovalNotes('');
|
||||
setManagerApprovalNotes('');
|
||||
setManagerApprovalPoDate('');
|
||||
confirmationModalWithNotes.openModal();
|
||||
break;
|
||||
case 3:
|
||||
@@ -414,17 +422,50 @@ const PurchaseOrderDetail = ({
|
||||
deleteModal,
|
||||
]);
|
||||
|
||||
const updatePoDateHandler = useCallback(async () => {
|
||||
const purchaseRequestId = searchParams.get('purchaseId')
|
||||
? parseInt(searchParams.get('purchaseId')!)
|
||||
: initialValues?.id || 1;
|
||||
|
||||
if (!purchaseRequestId) {
|
||||
toast.error('Purchase Request ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await PurchaseApi.updatePoDate(purchaseRequestId, {
|
||||
po_date: editPoDate,
|
||||
});
|
||||
|
||||
if (isResponseError(res)) {
|
||||
toast.error(res.message || 'Gagal mengubah tanggal PO');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Tanggal PO berhasil diubah');
|
||||
setEditPoDate('');
|
||||
editPoDateModal.closeModal();
|
||||
refetchData?.();
|
||||
}, [
|
||||
initialValues?.id,
|
||||
searchParams,
|
||||
editPoDate,
|
||||
editPoDateModal,
|
||||
refetchData,
|
||||
]);
|
||||
|
||||
// ===== APPROVAL/REJECTION HANDLERS =====
|
||||
const managerApprovalHandler = async (notes: string) => {
|
||||
const managerApprovalHandler = async () => {
|
||||
const payload: CreateManagerApprovalRequestPayload = {
|
||||
action: 'APPROVED',
|
||||
notes: notes || null,
|
||||
notes: managerApprovalNotes || null,
|
||||
po_date: managerApprovalPoDate || null,
|
||||
};
|
||||
|
||||
await createManagerApprovalHandler(payload);
|
||||
await refreshApprovals();
|
||||
await refetchData?.();
|
||||
setApprovalNotes('');
|
||||
setManagerApprovalNotes('');
|
||||
setManagerApprovalPoDate('');
|
||||
confirmationModalWithNotes.closeModal();
|
||||
};
|
||||
|
||||
@@ -806,9 +847,25 @@ const PurchaseOrderDetail = ({
|
||||
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
|
||||
Nomor
|
||||
</span>
|
||||
<span className='text-gray-900 ml-3 break-all'>
|
||||
: {purchaseData.pr_number}
|
||||
</span>
|
||||
<div className='flex items-center gap-1'>
|
||||
<span className='text-gray-900 ml-3 break-all'>
|
||||
: {purchaseData.pr_number}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-1 min-h-0 h-auto'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
purchaseData.pr_number || ''
|
||||
);
|
||||
toast.success('Nomor berhasil disalin');
|
||||
}}
|
||||
>
|
||||
<Icon icon='mdi:content-copy' width={14} height={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='group'>
|
||||
@@ -816,11 +873,31 @@ const PurchaseOrderDetail = ({
|
||||
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
|
||||
Nomor PO
|
||||
</span>
|
||||
<div className='ml-3'>
|
||||
<div className='ml-3 flex items-center gap-1'>
|
||||
{canShowPurchaseOrderInvoice &&
|
||||
purchaseData.po_number &&
|
||||
purchaseData.po_number !== 'Belum dibuat' ? (
|
||||
<PurchaseOrderInvoice data={purchaseData} />
|
||||
<>
|
||||
<PurchaseOrderInvoice data={purchaseData} />
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
className='p-1 min-h-0 h-auto'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
purchaseData.po_number || ''
|
||||
);
|
||||
toast.success('Nomor PO berhasil disalin');
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon='mdi:content-copy'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
: <i className='text-gray-400'>Belum dibuat</i>
|
||||
@@ -829,6 +906,41 @@ const PurchaseOrderDetail = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{purchaseData.po_date &&
|
||||
!purchaseData.po_date.startsWith('0001') && (
|
||||
<div className='group'>
|
||||
<div className='flex items-start'>
|
||||
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
|
||||
Tanggal PO
|
||||
</span>
|
||||
<div className='ml-3 flex items-center gap-1'>
|
||||
<span className='text-gray-900'>
|
||||
: {formatDate(purchaseData.po_date, 'DD MMM YYYY')}
|
||||
</span>
|
||||
<RequirePermission permissions='lti.purchase.update'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='warning'
|
||||
className='p-1 min-h-0 h-auto'
|
||||
onClick={() => {
|
||||
setEditPoDate(
|
||||
formatDate(purchaseData.po_date, 'YYYY-MM-DD')
|
||||
);
|
||||
editPoDateModal.openModal();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:edit-outline'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1016,27 +1128,79 @@ const PurchaseOrderDetail = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Confirmation Modal with Notes */}
|
||||
<ConfirmationModalWithNotes
|
||||
{/* Manager Approval Modal */}
|
||||
<Modal
|
||||
ref={confirmationModalWithNotes.ref}
|
||||
type='success'
|
||||
text='Apakah Anda yakin ingin melanjutkan approval ini?'
|
||||
placeholder='(Opsional) Tambahkan catatan untuk approval ini...'
|
||||
rows={4}
|
||||
closeOnBackdrop
|
||||
primaryButton={{
|
||||
text: 'Ya, Lanjutkan',
|
||||
color: 'success',
|
||||
onClick: managerApprovalHandler,
|
||||
className={{
|
||||
modalBox: 'max-w-lg rounded-lg p-0',
|
||||
}}
|
||||
secondaryButton={{
|
||||
text: 'Batal',
|
||||
onClick: () => {
|
||||
setApprovalNotes('');
|
||||
confirmationModalWithNotes.closeModal();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Konfirmasi Approval Manager
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
setManagerApprovalNotes('');
|
||||
setManagerApprovalPoDate('');
|
||||
confirmationModalWithNotes.closeModal();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<p className='text-sm text-base-content/70'>
|
||||
Apakah Anda yakin ingin melanjutkan approval ini?
|
||||
</p>
|
||||
|
||||
<DateInput
|
||||
name='manager_approval_po_date'
|
||||
label='Tanggal PO'
|
||||
value={managerApprovalPoDate}
|
||||
onChange={(e) => setManagerApprovalPoDate(e.target.value)}
|
||||
isNestedModal
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
name='manager_approval_notes'
|
||||
label='Catatan (Opsional)'
|
||||
placeholder='Tambahkan catatan untuk approval ini...'
|
||||
value={managerApprovalNotes}
|
||||
onChange={(e) => setManagerApprovalNotes(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
setManagerApprovalNotes('');
|
||||
setManagerApprovalPoDate('');
|
||||
confirmationModalWithNotes.closeModal();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={managerApprovalHandler}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Ya, Lanjutkan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Staff Approval Modal */}
|
||||
<Modal
|
||||
@@ -1112,6 +1276,66 @@ const PurchaseOrderDetail = ({
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Edit PO Date Modal */}
|
||||
<Modal
|
||||
ref={editPoDateModal.ref}
|
||||
closeOnBackdrop
|
||||
className={{
|
||||
modalBox: 'max-w-sm rounded-lg p-0',
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Edit Tanggal PO
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
setEditPoDate('');
|
||||
editPoDateModal.closeModal();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='edit_po_date'
|
||||
label='Tanggal PO'
|
||||
value={editPoDate}
|
||||
onChange={(e) => setEditPoDate(e.target.value)}
|
||||
isNestedModal
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
setEditPoDate('');
|
||||
editPoDateModal.closeModal();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={updatePoDateHandler}
|
||||
className='px-3 py-2.5'
|
||||
disabled={!editPoDate}
|
||||
>
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Staff Rejection Modal */}
|
||||
<ConfirmationModalWithNotes
|
||||
ref={staffRejectionModal.ref}
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import Tabs from '@/components/Tabs';
|
||||
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import ReportExpenseTab from './tab/ReportExpenseTab';
|
||||
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab';
|
||||
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
|
||||
|
||||
const VALID_TAB_IDS = ['operational-expense', 'depreciation'];
|
||||
|
||||
const ReportExpenseTabs = () => {
|
||||
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get('tab') ?? 'operational-expense';
|
||||
const activeTabId = VALID_TAB_IDS.includes(tabParam)
|
||||
? tabParam
|
||||
: 'operational-expense';
|
||||
const tabActions = useTabActionsStore((state) => state.tabActions);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
router.push(`${pathname}?tab=${tabId}`);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: '1',
|
||||
id: 'operational-expense',
|
||||
label: 'Laporan Biaya Operasional',
|
||||
content: <ReportExpenseTab tabId={'1'} />,
|
||||
content: <ReportExpenseTab tabId={'operational-expense'} />,
|
||||
},
|
||||
{
|
||||
id: 'depreciation',
|
||||
label: 'Laporan Depresiasi',
|
||||
content: <ReportDepreciationTab tabId={'depreciation'} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -24,7 +42,7 @@ const ReportExpenseTabs = () => {
|
||||
tabs={tabs}
|
||||
variant='boxed'
|
||||
activeTabId={activeTabId}
|
||||
onTabChange={setActiveTabId}
|
||||
onTabChange={handleTabChange}
|
||||
className={{
|
||||
tabHeaderWrapper:
|
||||
'justify-between items-center p-3 border-b border-base-content/10',
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import React from 'react';
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
type ReportExpenseColumn =
|
||||
| ColumnDef<ReportExpense>
|
||||
type ReportSkeletonColumn<TData extends object> =
|
||||
| ColumnDef<TData>
|
||||
| {
|
||||
header: string;
|
||||
columns: Array<{
|
||||
header: string;
|
||||
accessorKey?: string;
|
||||
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
|
||||
cell?: (props: { row: { original: TData } }) => React.ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
const ReportExpenseSkeleton = ({
|
||||
const ReportExpenseSkeleton = <TData extends object>({
|
||||
columns,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
columns: ReportExpenseColumn[];
|
||||
columns: ReportSkeletonColumn<TData>[];
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
|
||||
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
|
||||
export type ReportDepreciationFilterValues = {
|
||||
area?: OptionType<string>;
|
||||
location?: OptionType<string>;
|
||||
projectFlock?: OptionType<string>;
|
||||
period: string | null;
|
||||
totalDays: number;
|
||||
};
|
||||
|
||||
export const ReportDepreciationFilterSchema = yup.object({
|
||||
area: yup.mixed<OptionType<string>>().optional(),
|
||||
location: yup.mixed<OptionType<string>>().optional(),
|
||||
projectFlock: yup
|
||||
.mixed<OptionType<string>>()
|
||||
.required('Project Flock wajib dipilih'),
|
||||
period: yup.string().nullable().required('Periode wajib dipilih'),
|
||||
totalDays: yup
|
||||
.number()
|
||||
.min(1, 'Minimal 1 hari')
|
||||
.required('Total Hari wajib diisi'),
|
||||
});
|
||||
|
||||
interface ReportDepreciationFilterModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
initialValues?: Partial<ReportDepreciationFilterValues>;
|
||||
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const defaultInitialValues: (
|
||||
initialValues?: Partial<ReportDepreciationFilterValues>
|
||||
) => ReportDepreciationFilterValues = (initialValues) => ({
|
||||
area: undefined,
|
||||
location: undefined,
|
||||
projectFlock: undefined,
|
||||
period: initialValues?.period ?? null,
|
||||
totalDays: initialValues?.totalDays ?? 10,
|
||||
});
|
||||
|
||||
const ReportDepreciationFilterModal = ({
|
||||
ref,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: ReportDepreciationFilterModalProps) => {
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const formik = useFormik<ReportDepreciationFilterValues>({
|
||||
initialValues: { ...defaultInitialValues(initialValues), ...initialValues },
|
||||
validationSchema: ReportDepreciationFilterSchema,
|
||||
onSubmit: async (values) => {
|
||||
onSubmit?.(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
area_id: String(formik.values.area?.value ?? ''),
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
options: projectFlockOptions,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: String(formik.values.location?.value ?? ''),
|
||||
}
|
||||
);
|
||||
|
||||
const formikResetHandler = () => {
|
||||
onReset?.();
|
||||
formik.resetForm({ values: defaultInitialValues(initialValues) });
|
||||
closeModalHandler();
|
||||
};
|
||||
|
||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const area =
|
||||
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
|
||||
formik.setFieldValue('area', area);
|
||||
formik.setFieldValue('location', undefined);
|
||||
formik.setFieldValue('projectFlock', undefined);
|
||||
};
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const location =
|
||||
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
|
||||
formik.setFieldValue('location', location);
|
||||
formik.setFieldValue('projectFlock', undefined);
|
||||
};
|
||||
|
||||
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const projectFlock =
|
||||
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
|
||||
formik.setFieldValue('projectFlock', projectFlock);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
className={{
|
||||
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='text-sm font-medium'>Filter Data</h3>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={closeModalHandler}
|
||||
className='p-0 text-base-content/50 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={formik.values.area ?? null}
|
||||
onChange={areaChangeHandler}
|
||||
onInputChange={setAreaInputValue}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={formik.values.location ?? null}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
value={formik.values.projectFlock ?? null}
|
||||
onChange={projectFlockChangeHandler}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isError={
|
||||
formik.touched.projectFlock && !!formik.errors.projectFlock
|
||||
}
|
||||
errorMessage={
|
||||
formik.touched.projectFlock
|
||||
? (formik.errors.projectFlock as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
label='Periode'
|
||||
name='period'
|
||||
placeholder='Pilih Periode'
|
||||
value={formik.values.period || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.period && !!formik.errors.period}
|
||||
errorMessage={formik.errors.period}
|
||||
required
|
||||
isNestedModal
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label='Total Hari'
|
||||
name='totalDays'
|
||||
placeholder='Masukkan total hari'
|
||||
value={formik.values.totalDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
formik.setFieldValue(
|
||||
'totalDays',
|
||||
isNaN(val) || val < 1 ? 1 : Math.floor(val)
|
||||
);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
decimalScale={0}
|
||||
allowNegative={false}
|
||||
thousandSeparator=''
|
||||
isError={formik.touched.totalDays && !!formik.errors.totalDays}
|
||||
errorMessage={
|
||||
formik.touched.totalDays
|
||||
? (formik.errors.totalDays as string)
|
||||
: undefined
|
||||
}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='p-4 flex justify-between gap-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={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportDepreciationFilterModal;
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Card from '@/components/Card';
|
||||
import Table from '@/components/Table';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal';
|
||||
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import {
|
||||
DepreciationV2Response,
|
||||
ReportDepreciationV2Item,
|
||||
} from '@/types/api/report/report-expense';
|
||||
import { DepreciationReportV2Api } from '@/services/api/report/expense-report';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import { httpClientFetcher } from '@/services/http/client';
|
||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
|
||||
interface ReportDepreciationTabProps {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
reset: resetFilter,
|
||||
} = useTableFilter<{
|
||||
area?: OptionType<string>;
|
||||
location?: OptionType<string>;
|
||||
projectFlock?: OptionType<string>;
|
||||
period: string;
|
||||
totalDays: number;
|
||||
}>({
|
||||
initial: {
|
||||
area: undefined,
|
||||
location: undefined,
|
||||
projectFlock: undefined,
|
||||
period: formatDate(Date.now(), 'YYYY-MM-DD'),
|
||||
totalDays: 10,
|
||||
},
|
||||
paramMap: {
|
||||
area: 'area_id',
|
||||
location: 'location_id',
|
||||
projectFlock: 'project_flock_id',
|
||||
period: 'period',
|
||||
totalDays: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'report-depreciation-v2-table',
|
||||
});
|
||||
|
||||
const swrKey = tableFilterState.projectFlock
|
||||
? `${DepreciationReportV2Api.basePath}${getTableFilterQueryString()}`
|
||||
: null;
|
||||
|
||||
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
|
||||
useSWR<DepreciationV2Response>(swrKey, httpClientFetcher);
|
||||
|
||||
const depreciationMeta =
|
||||
depreciationsResponse?.status === 'success'
|
||||
? depreciationsResponse.meta
|
||||
: null;
|
||||
const depreciationData =
|
||||
depreciationsResponse?.status === 'success'
|
||||
? depreciationsResponse.data
|
||||
: [];
|
||||
|
||||
const filterModal = useModal();
|
||||
const { ref: filterModalRef } = filterModal;
|
||||
|
||||
const initialOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialOpenRef.current) {
|
||||
initialOpenRef.current = true;
|
||||
if (!tableFilterState.projectFlock) {
|
||||
filterModal.openModal();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
|
||||
|
||||
const depreciationColumns: ColumnDef<ReportDepreciationV2Item>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Tanggal',
|
||||
cell: ({ row }) => formatDate(row.original.date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'day_n',
|
||||
header: 'Hari ke-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'chickin_date',
|
||||
header: 'Tanggal Chick-in',
|
||||
cell: ({ row }) => formatDate(row.original.chickin_date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'depreciation_value',
|
||||
header: 'Nilai Depresiasi',
|
||||
cell: ({ row }) => formatCurrency(row.original.depreciation_value),
|
||||
},
|
||||
{
|
||||
accessorKey: 'pullet_cost_day_n_total',
|
||||
header: 'Total Harga Pullet Hari ke-N',
|
||||
cell: ({ row }) =>
|
||||
formatCurrency(
|
||||
row.original.pullet_cost_day_n_total,
|
||||
'IDR',
|
||||
'id-ID',
|
||||
0,
|
||||
10
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'multiplication_percentage',
|
||||
header: 'Persentase Multiplikasi',
|
||||
cell: ({ row }) =>
|
||||
formatNumber(
|
||||
row.original.multiplication_percentage * 100,
|
||||
'en-US',
|
||||
0,
|
||||
4
|
||||
) + '%',
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_value_pullet_after_depreciation',
|
||||
header: 'Total Nilai Pullet Setelah Depresiasi',
|
||||
cell: ({ row }) =>
|
||||
formatCurrency(
|
||||
row.original.total_value_pullet_after_depreciation,
|
||||
'IDR',
|
||||
'id-ID',
|
||||
0,
|
||||
10
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const tabActionsElement = useMemo(
|
||||
() => (
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize']}
|
||||
onClick={filterModal.openModal}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[tableFilterState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTabActions(tabId, tabActionsElement);
|
||||
}, [setTabActions, tabActionsElement, tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTabActions(tabId);
|
||||
};
|
||||
}, [clearTabActions, tabId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||
{isLoadingDepreciations && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingDepreciations && !tableFilterState.projectFlock && (
|
||||
<ReportExpenseSkeleton
|
||||
columns={depreciationColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:chart-bar'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
title='Pilih Project Flock'
|
||||
subtitle='Silakan pilih Project Flock pada filter untuk melihat data depresiasi.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoadingDepreciations &&
|
||||
tableFilterState.projectFlock &&
|
||||
depreciationData.length === 0 && (
|
||||
<ReportExpenseSkeleton
|
||||
columns={depreciationColumns}
|
||||
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.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoadingDepreciations &&
|
||||
depreciationData.length > 0 &&
|
||||
depreciationMeta && (
|
||||
<Card
|
||||
title={depreciationMeta.farm_name}
|
||||
subtitle={`Periode: ${formatDate(depreciationMeta.period, 'DD MMM YYYY')}`}
|
||||
className={{
|
||||
wrapper: 'w-full rounded-lg border-none',
|
||||
body: 'p-0',
|
||||
title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||
subtitle:
|
||||
'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
|
||||
collapsible: 'rounded-lg',
|
||||
}}
|
||||
variant='bordered'
|
||||
collapsible={true}
|
||||
>
|
||||
<Table
|
||||
data={depreciationData}
|
||||
columns={depreciationColumns}
|
||||
pageSize={depreciationData.length}
|
||||
page={1}
|
||||
totalItems={depreciationData.length}
|
||||
isLoading={isLoadingDepreciations}
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
tableWrapperClassName:
|
||||
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||
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',
|
||||
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',
|
||||
tableFooterClassName:
|
||||
'bg-gray-100 font-semibold border border-gray-200',
|
||||
footerRowClassName: 'border-t-2 border-gray-300',
|
||||
footerColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReportDepreciationFilterModal
|
||||
ref={filterModalRef}
|
||||
initialValues={tableFilterState}
|
||||
onReset={resetFilter}
|
||||
onSubmit={(values) => {
|
||||
updateFilter('area', values.area, true);
|
||||
updateFilter('location', values.location, true);
|
||||
updateFilter('projectFlock', values.projectFlock, true);
|
||||
updateFilter(
|
||||
'period',
|
||||
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '',
|
||||
true
|
||||
);
|
||||
updateFilter('totalDays', values.totalDays ?? 10, true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportDepreciationTab;
|
||||
@@ -23,8 +23,8 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus
|
||||
import Table from '@/components/Table';
|
||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||
import { ReportExpenseApi } from '@/services/api/report';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ReportExpenseApi } from '@/services/api/report/expense-report';
|
||||
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import Pagination from '@/components/Pagination';
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from '@/services/api/master-data';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
@@ -73,6 +73,25 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// ===== SORTING STATE =====
|
||||
const [sortBy, setSortBy] = useState('');
|
||||
const [orderBy, setOrderBy] = useState('');
|
||||
|
||||
const sorting: SortingState = sortBy
|
||||
? [{ id: sortBy, desc: orderBy === 'desc' }]
|
||||
: [];
|
||||
|
||||
const handleSortingChange = (updater: Updater<SortingState>) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
if (next.length > 0) {
|
||||
setSortBy(next[0].id);
|
||||
setOrderBy(next[0].desc ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy('');
|
||||
setOrderBy('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterModalOpenRef = useRef(() => {});
|
||||
|
||||
const filterModal = useModal();
|
||||
@@ -126,8 +145,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
const restoredLocation = filterParams.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.location_id
|
||||
) || {
|
||||
value: filterParams.location_id,
|
||||
label: filterParams.location_id,
|
||||
}
|
||||
: null;
|
||||
const restoredSupplier = filterParams.supplier_id
|
||||
? supplierOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.supplier_id
|
||||
) || {
|
||||
value: filterParams.supplier_id,
|
||||
label: filterParams.supplier_id,
|
||||
}
|
||||
: null;
|
||||
const restoredKandang = filterParams.kandang_id
|
||||
? projectFlockKandangOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.kandang_id
|
||||
) || { value: filterParams.kandang_id, label: filterParams.kandang_id }
|
||||
: null;
|
||||
const restoredNonstock = filterParams.nonstock_id
|
||||
? nonstockOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.nonstock_id
|
||||
) || {
|
||||
value: filterParams.nonstock_id,
|
||||
label: filterParams.nonstock_id,
|
||||
}
|
||||
: null;
|
||||
const restoredCategory = filterParams.category
|
||||
? categoryOptions.find((opt) => opt.value === filterParams.category) ||
|
||||
null
|
||||
: null;
|
||||
|
||||
formik.setValues({
|
||||
location_id: restoredLocation,
|
||||
supplier_id: restoredSupplier,
|
||||
kandang_id: restoredKandang,
|
||||
nonstock_id: restoredNonstock,
|
||||
realization_date: filterParams.realization_date || null,
|
||||
category: restoredCategory,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== OPTIONS =====
|
||||
@@ -189,26 +249,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
[formik.values.category]
|
||||
);
|
||||
|
||||
const buildReportExpenseQueryString = useCallback(
|
||||
(extraParams?: Record<string, string>) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filterParams.location_id) {
|
||||
params.append('location_id', filterParams.location_id);
|
||||
}
|
||||
if (filterParams.supplier_id) {
|
||||
params.append('supplier_id', filterParams.supplier_id);
|
||||
}
|
||||
if (filterParams.kandang_id) {
|
||||
params.append('project_flock_kandang_id', filterParams.kandang_id);
|
||||
}
|
||||
if (filterParams.nonstock_id) {
|
||||
params.append('nonstock_id', filterParams.nonstock_id);
|
||||
}
|
||||
if (filterParams.realization_date) {
|
||||
params.append('realization_date', filterParams.realization_date);
|
||||
}
|
||||
if (filterParams.category) {
|
||||
params.append('category', filterParams.category);
|
||||
}
|
||||
if (sortBy) params.append('sort_by', sortBy);
|
||||
if (orderBy) params.append('sort_order', orderBy);
|
||||
|
||||
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
|
||||
return params.toString();
|
||||
},
|
||||
[filterParams, sortBy, orderBy]
|
||||
);
|
||||
|
||||
// ===== DATA FETCHING =====
|
||||
const { data: reportExpenseResponse, isLoading } = useSWR(
|
||||
() => {
|
||||
const params = new URLSearchParams();
|
||||
if (filterParams.location_id)
|
||||
params.append('location_id', filterParams.location_id);
|
||||
if (filterParams.supplier_id)
|
||||
params.append('supplier_id', filterParams.supplier_id);
|
||||
if (filterParams.kandang_id)
|
||||
params.append('project_flock_kandang_id', filterParams.kandang_id);
|
||||
if (filterParams.nonstock_id)
|
||||
params.append('nonstock_id', filterParams.nonstock_id);
|
||||
if (filterParams.realization_date)
|
||||
params.append('realization_date', filterParams.realization_date);
|
||||
if (filterParams.category)
|
||||
params.append('category', filterParams.category);
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(pageSize));
|
||||
const queryString = buildReportExpenseQueryString({
|
||||
page: String(page),
|
||||
limit: String(pageSize),
|
||||
});
|
||||
|
||||
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
|
||||
return [`${ReportExpenseApi.basePath}?${queryString}`];
|
||||
},
|
||||
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
|
||||
);
|
||||
@@ -233,47 +316,31 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
const reportExpenseExport = useCallback(async (): Promise<
|
||||
ReportExpense[] | null
|
||||
> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filterParams.location_id)
|
||||
params.append('location_id', filterParams.location_id);
|
||||
if (filterParams.supplier_id)
|
||||
params.append('supplier_id', filterParams.supplier_id);
|
||||
if (filterParams.kandang_id)
|
||||
params.append('kandang_id', filterParams.kandang_id);
|
||||
if (filterParams.nonstock_id)
|
||||
params.append('nonstock_id', filterParams.nonstock_id);
|
||||
if (filterParams.realization_date)
|
||||
params.append('realization_date', filterParams.realization_date);
|
||||
if (filterParams.category) params.append('category', filterParams.category);
|
||||
params.append('limit', '100');
|
||||
params.append('page', '1');
|
||||
const queryString = buildReportExpenseQueryString({
|
||||
page: '1',
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
|
||||
`${ReportExpenseApi.basePath}?${params.toString()}`
|
||||
`${ReportExpenseApi.basePath}?${queryString}`
|
||||
);
|
||||
|
||||
return isResponseSuccess(response) ? response.data : null;
|
||||
}, [filterParams]);
|
||||
}, [buildReportExpenseQueryString]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await reportExpenseExport();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
await generateReportExpenseExcel(allDataForExport);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
|
||||
);
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [reportExpenseExport]);
|
||||
}, [buildReportExpenseQueryString]);
|
||||
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
@@ -397,19 +464,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
return [
|
||||
{
|
||||
header: 'No',
|
||||
enableSorting: false,
|
||||
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
|
||||
},
|
||||
{
|
||||
header: 'No. PO',
|
||||
accessorKey: 'po_number',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'No. Referensi',
|
||||
accessorKey: 'reference_number',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'Tanggal Realisasi',
|
||||
accessorKey: 'realization_date',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
|
||||
},
|
||||
@@ -417,6 +488,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
{
|
||||
header: 'Tanggal Transaksi',
|
||||
accessorKey: 'transaction_date',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
|
||||
},
|
||||
@@ -424,21 +496,30 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
{
|
||||
header: 'Kategori',
|
||||
accessorKey: 'category',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'Produk',
|
||||
accessorKey: 'product',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.pengajuan?.nonstock?.name,
|
||||
},
|
||||
{
|
||||
header: 'Supplier',
|
||||
accessorKey: 'supplier',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.supplier?.name,
|
||||
},
|
||||
{
|
||||
header: 'Lokasi',
|
||||
accessorKey: 'location',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.kandang?.location?.name,
|
||||
},
|
||||
{
|
||||
header: 'Kandang',
|
||||
accessorKey: 'kandang',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.kandang?.name,
|
||||
},
|
||||
{
|
||||
@@ -446,23 +527,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
columns: [
|
||||
{
|
||||
header: 'Qty',
|
||||
id: 'qty_pengajuan',
|
||||
accessorFn: (row) => row.pengajuan?.qty,
|
||||
accessorKey: 'qty_pengajuan',
|
||||
cell: ({ row }) =>
|
||||
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
|
||||
},
|
||||
{
|
||||
header: 'Harga',
|
||||
id: 'harga_pengajuan',
|
||||
accessorFn: (row) => row.pengajuan?.price,
|
||||
accessorKey: 'price_pengajuan',
|
||||
cell: ({ row }) =>
|
||||
formatCurrency(row.original.pengajuan?.price || 0),
|
||||
},
|
||||
{
|
||||
header: 'Total',
|
||||
id: 'total_pengajuan',
|
||||
accessorFn: (row) =>
|
||||
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
|
||||
accessorKey: 'total_pengajuan',
|
||||
cell: ({ row }) => {
|
||||
const total =
|
||||
(row.original.pengajuan?.qty || 0) *
|
||||
@@ -477,23 +554,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
columns: [
|
||||
{
|
||||
header: 'Qty',
|
||||
id: 'qty_realisasi',
|
||||
accessorFn: (row) => row.realisasi?.qty,
|
||||
accessorKey: 'qty_realisasi',
|
||||
cell: ({ row }) =>
|
||||
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
|
||||
},
|
||||
{
|
||||
header: 'Harga',
|
||||
id: 'harga_realisasi',
|
||||
accessorFn: (row) => row.realisasi?.price,
|
||||
accessorKey: 'price_realisasi',
|
||||
cell: ({ row }) =>
|
||||
formatCurrency(row.original.realisasi?.price || 0),
|
||||
},
|
||||
{
|
||||
header: 'Total',
|
||||
id: 'total_realisasi',
|
||||
accessorFn: (row) =>
|
||||
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
|
||||
accessorKey: 'total_realisasi',
|
||||
cell: ({ row }) => {
|
||||
const total =
|
||||
(row.original.realisasi?.qty || 0) *
|
||||
@@ -504,6 +577,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'realization_status',
|
||||
header: 'Status Pencairan',
|
||||
cell: (props) => (
|
||||
<RealizationStatusBadge
|
||||
@@ -512,6 +586,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bop_status',
|
||||
header: 'Status BOP',
|
||||
cell: (props) => (
|
||||
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
|
||||
@@ -556,6 +631,9 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
totalItems={meta?.total_results || 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
sorting={sorting}
|
||||
setSorting={handleSortingChange}
|
||||
manualSorting
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
tableWrapperClassName: 'overflow-x-auto',
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import Tabs from '@/components/Tabs';
|
||||
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
|
||||
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
|
||||
import BalanceMonitoringTab from '@/components/pages/report/finance/tab/BalanceMonitoringTab';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
|
||||
const VALID_TAB_IDS = [
|
||||
'debt-supplier',
|
||||
'customer-payment',
|
||||
'balance-monitoring',
|
||||
];
|
||||
|
||||
const FinanceTabs = () => {
|
||||
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const tabParam = searchParams.get('tab') ?? 'debt-supplier';
|
||||
const activeTabId = VALID_TAB_IDS.includes(tabParam)
|
||||
? tabParam
|
||||
: 'debt-supplier';
|
||||
const tabActions = useTabActionsStore((state) => state.tabActions);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
router.push(`${pathname}?tab=${tabId}`);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: '1',
|
||||
id: 'debt-supplier',
|
||||
label: 'Rekapitulasi Hutang Ke Supplier',
|
||||
content: <DebtSupplierTab tabId={'1'} />,
|
||||
content: <DebtSupplierTab tabId={'debt-supplier'} />,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
id: 'customer-payment',
|
||||
label: 'Kontrol Pembayaran Customer',
|
||||
content: <CustomerPaymentTab tabId={'2'} />,
|
||||
content: <CustomerPaymentTab tabId={'customer-payment'} />,
|
||||
},
|
||||
{
|
||||
id: 'balance-monitoring',
|
||||
label: 'Monitoring Saldo',
|
||||
content: <BalanceMonitoringTab tabId={'balance-monitoring'} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -29,7 +51,7 @@ const FinanceTabs = () => {
|
||||
tabs={tabs}
|
||||
variant='boxed'
|
||||
activeTabId={activeTabId}
|
||||
onTabChange={setActiveTabId}
|
||||
onTabChange={handleTabChange}
|
||||
className={{
|
||||
tabHeaderWrapper:
|
||||
'justify-between items-center p-3 border-b border-base-content/10',
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { AxiosError } from 'axios';
|
||||
import { FinanceApi } from '@/services/api/report/finance-report';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
import { useSelect, OptionType } from '@/components/input/SelectInput';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import { formatCurrency, formatNumber } from '@/lib/helper';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
|
||||
import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import Table from '@/components/Table';
|
||||
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
|
||||
|
||||
interface BalanceMonitoringTabProps {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
const filterByOptions: OptionType<string>[] = [
|
||||
{ label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' },
|
||||
{ label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' },
|
||||
];
|
||||
|
||||
const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||
|
||||
const filterModal = useModal();
|
||||
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
reset: resetFilter,
|
||||
} = useTableFilter<{
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
customers: OptionType<number>[];
|
||||
salesPersons: OptionType<number>[];
|
||||
filterBy?: OptionType<string>;
|
||||
sort_by: string;
|
||||
order_by: string;
|
||||
}>({
|
||||
initial: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
customers: [],
|
||||
salesPersons: [],
|
||||
filterBy: undefined,
|
||||
sort_by: '',
|
||||
order_by: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
start_date: 'start_date',
|
||||
end_date: 'end_date',
|
||||
customers: 'customer_ids',
|
||||
salesPersons: 'sales_ids',
|
||||
filterBy: 'filter_by',
|
||||
sort_by: 'sort_by',
|
||||
order_by: 'sort_order',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'balance-monitoring-table',
|
||||
});
|
||||
|
||||
// const sorting: SortingState = tableFilterState.sort_by
|
||||
// ? [
|
||||
// {
|
||||
// id: tableFilterState.sort_by,
|
||||
// desc: tableFilterState.order_by === 'desc',
|
||||
// },
|
||||
// ]
|
||||
// : [];
|
||||
|
||||
// const handleSortingChange = (updater: Updater<SortingState>) => {
|
||||
// const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
// if (next.length > 0) {
|
||||
// updateFilter('sort_by', next[0].id, true);
|
||||
// updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
|
||||
// } else {
|
||||
// updateFilter('sort_by', '', true);
|
||||
// updateFilter('order_by', '', true);
|
||||
// }
|
||||
// };
|
||||
|
||||
const {
|
||||
options: customerOptions,
|
||||
setInputValue: setCustomerInput,
|
||||
isLoadingOptions: isLoadingCustomers,
|
||||
loadMore: loadMoreCustomers,
|
||||
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
options: salesOptions,
|
||||
setInputValue: setSalesInput,
|
||||
isLoadingOptions: isLoadingSales,
|
||||
loadMore: loadMoreSales,
|
||||
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
customers: tableFilterState.customers,
|
||||
salesPersons: tableFilterState.salesPersons,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
},
|
||||
onSubmit: (values) => {
|
||||
updateFilter('start_date', values.start_date, true);
|
||||
updateFilter('end_date', values.end_date, true);
|
||||
updateFilter('customers', values.customers, true);
|
||||
updateFilter('salesPersons', values.salesPersons, true);
|
||||
updateFilter('filterBy', values.filterBy, true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
resetFilter();
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
customers: [],
|
||||
salesPersons: [],
|
||||
filterBy: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value);
|
||||
|
||||
if (value && formik.values.end_date) {
|
||||
if (new Date(formik.values.end_date) < new Date(value)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value);
|
||||
|
||||
if (value && formik.values.start_date) {
|
||||
if (new Date(value) < new Date(formik.values.start_date)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: balanceMonitoringsResponse, isLoading } = useSWR<
|
||||
BaseApiResponse<BalanceMonitoringRow[]>,
|
||||
AxiosError<BaseApiResponse>,
|
||||
SWRHttpKey
|
||||
>(
|
||||
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
|
||||
httpClientFetcher
|
||||
);
|
||||
|
||||
const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess(
|
||||
balanceMonitoringsResponse
|
||||
)
|
||||
? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? [])
|
||||
: [];
|
||||
|
||||
const meta =
|
||||
isResponseSuccess(balanceMonitoringsResponse) &&
|
||||
balanceMonitoringsResponse.meta
|
||||
? balanceMonitoringsResponse.meta
|
||||
: null;
|
||||
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const customer_ids =
|
||||
tableFilterState.customers.length > 0
|
||||
? tableFilterState.customers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
const sales_ids =
|
||||
tableFilterState.salesPersons.length > 0
|
||||
? tableFilterState.salesPersons.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
|
||||
await FinanceApi.exportBalanceMonitoringToExcel(
|
||||
customer_ids,
|
||||
sales_ids,
|
||||
tableFilterState.filterBy?.value,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined
|
||||
);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [tableFilterState]);
|
||||
|
||||
// Inject tab actions directly — no nested component, no remount cycle
|
||||
useEffect(() => {
|
||||
setTabActions(
|
||||
tabId,
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={{
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
customers: tableFilterState.customers,
|
||||
salesPersons: tableFilterState.salesPersons,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
}}
|
||||
fieldGroups={[['start_date', 'end_date']]}
|
||||
onClick={filterModal.openModal}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
isLoading={isExcelExportLoading}
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<span>Export</span>
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcel}
|
||||
isLoading={isExcelExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor ke Excel
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
tabId,
|
||||
setTabActions,
|
||||
tableFilterState,
|
||||
filterModal.openModal,
|
||||
isExcelExportLoading,
|
||||
handleExportExcel,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTabActions(tabId);
|
||||
}, [tabId, clearTabActions]);
|
||||
|
||||
const columns = useMemo(
|
||||
(): ColumnDef<BalanceMonitoringRow>[] => [
|
||||
{
|
||||
header: 'No',
|
||||
enableSorting: false,
|
||||
cell: (props) =>
|
||||
(tableFilterState.page - 1) * tableFilterState.pageSize +
|
||||
props.row.index +
|
||||
1,
|
||||
},
|
||||
{
|
||||
header: 'Customer',
|
||||
accessorKey: 'customer.name',
|
||||
enableSorting: true,
|
||||
id: 'customer_name',
|
||||
cell: ({ row }) => row.original.customer.name,
|
||||
},
|
||||
{
|
||||
header: 'Saldo Awal',
|
||||
accessorKey: 'saldo_awal',
|
||||
id: 'saldo_awal',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.saldo_awal)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Penjualan Ayam',
|
||||
columns: [
|
||||
{
|
||||
header: 'Ekor',
|
||||
accessorKey: 'penjualan_ayam.ekor',
|
||||
id: 'penjualan_ayam_ekor',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatNumber(row.original.penjualan_ayam.ekor)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Kg',
|
||||
accessorKey: 'penjualan_ayam.kg',
|
||||
id: 'penjualan_ayam_kg',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatNumber(row.original.penjualan_ayam.kg)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Nominal',
|
||||
accessorKey: 'penjualan_ayam.nominal',
|
||||
id: 'penjualan_ayam_nominal',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.penjualan_ayam.nominal)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Penjualan Telur',
|
||||
columns: [
|
||||
{
|
||||
header: 'Butir',
|
||||
accessorKey: 'penjualan_telur.butir',
|
||||
id: 'penjualan_telur_butir',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatNumber(row.original.penjualan_telur.butir)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Kg',
|
||||
accessorKey: 'penjualan_telur.kg',
|
||||
id: 'penjualan_telur_kg',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatNumber(row.original.penjualan_telur.kg)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Nominal',
|
||||
accessorKey: 'penjualan_telur.nominal',
|
||||
id: 'penjualan_telur_nominal',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.penjualan_telur.nominal)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
header: 'Penjualan Trading',
|
||||
accessorKey: 'penjualan_trading.nominal',
|
||||
id: 'penjualan_trading',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.penjualan_trading.nominal)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Pembayaran',
|
||||
accessorKey: 'pembayaran',
|
||||
id: 'pembayaran',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.pembayaran)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Aging',
|
||||
accessorKey: 'aging',
|
||||
id: 'aging',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-center'>
|
||||
{formatNumber(row.original.aging)} hari
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Aging Rata-Rata',
|
||||
accessorKey: 'aging_rata_rata',
|
||||
id: 'aging_rata_rata',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div className='text-center'>
|
||||
{formatNumber(row.original.aging_rata_rata)} hari
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Saldo Akhir',
|
||||
accessorKey: 'saldo_akhir',
|
||||
id: 'saldo_akhir',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className={`text-right font-semibold ${row.original.saldo_akhir < 0 ? 'text-error' : ''}`}
|
||||
>
|
||||
{formatCurrency(row.original.saldo_akhir)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[tableFilterState.page, tableFilterState.pageSize]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||
{isLoading && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && balanceMonitorings.length === 0 && (
|
||||
<CustomerSupplierSkeleton
|
||||
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:chart-bar'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
title='Data Not Yet Available'
|
||||
subtitle='Please change your filters to get the data.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && balanceMonitorings.length > 0 && (
|
||||
<>
|
||||
<div className='w-full overflow-x-auto'>
|
||||
<Table
|
||||
data={balanceMonitorings}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize || 10}
|
||||
page={tableFilterState.page || 1}
|
||||
totalItems={meta?.total_results || 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
// sorting={sorting}
|
||||
// setSorting={handleSortingChange}
|
||||
// manualSorting
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
tableWrapperClassName: 'overflow-x-auto',
|
||||
tableClassName: 'w-full table-auto text-sm',
|
||||
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||
headerColumnClassName:
|
||||
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
|
||||
bodyRowClassName:
|
||||
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<Modal
|
||||
ref={filterModal.ref}
|
||||
className={{
|
||||
modal: 'p-0',
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-3'>
|
||||
<div>
|
||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||
Tanggal
|
||||
</label>
|
||||
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
||||
<DateInput
|
||||
name='start_date'
|
||||
value={formik.values.start_date || ''}
|
||||
onChange={handleStartDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='end_date'
|
||||
value={formik.values.end_date || ''}
|
||||
onChange={handleEndDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
isError={hasDateError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectInputCheckbox
|
||||
label='Customer'
|
||||
placeholder='Pilih Customer'
|
||||
options={customerOptions}
|
||||
value={formik.values.customers}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
|
||||
}
|
||||
onInputChange={setCustomerInput}
|
||||
isLoading={isLoadingCustomers}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreCustomers}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInputCheckbox
|
||||
label='Sales'
|
||||
placeholder='Pilih Sales'
|
||||
options={salesOptions}
|
||||
value={formik.values.salesPersons}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'salesPersons',
|
||||
Array.isArray(val) ? val : []
|
||||
)
|
||||
}
|
||||
onInputChange={setSalesInput}
|
||||
isLoading={isLoadingSales}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreSales}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInputRadio
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={filterByOptions}
|
||||
value={formik.values.filterBy ?? null}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'filterBy',
|
||||
!Array.isArray(val) ? (val ?? undefined) : undefined
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={hasDateError}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceMonitoringTab;
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { AxiosError } from 'axios';
|
||||
import Card from '@/components/Card';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { useSelect, OptionType } from '@/components/input/SelectInput';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { FinanceApi } from '@/services/api/report/finance-report';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import Table from '@/components/Table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
@@ -27,55 +30,70 @@ import Dropdown from '@/components/Dropdown';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
import {
|
||||
CustomerPaymentFilterSchema,
|
||||
CustomerPaymentFilterType,
|
||||
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
|
||||
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
|
||||
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
|
||||
import { OptionType } from '@/components/table/TableRowSizeSelector';
|
||||
import { Color } from '@/types/theme';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
|
||||
interface CustomerPaymentTabProps {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface FilterParams {
|
||||
customer_ids?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
filter_by?: string;
|
||||
}
|
||||
const dataTypeOptions: OptionType<string>[] = [
|
||||
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
|
||||
{ value: 'realization_date', label: 'Tanggal Realisasi' },
|
||||
];
|
||||
|
||||
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
|
||||
useState(false);
|
||||
const isAnyExportLoading =
|
||||
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
|
||||
|
||||
// ===== PAGINATION STATE =====
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
// ===== SUBMISSION STATE =====
|
||||
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
|
||||
const handleFilterModalOpenRef = useRef(() => {});
|
||||
|
||||
const filterModal = useModal();
|
||||
|
||||
const dataTypeOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
|
||||
{ value: 'realization_date', label: 'Tanggal Realisasi' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
reset: resetFilter,
|
||||
} = useTableFilter<{
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
customers: OptionType<number>[];
|
||||
filterBy?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
customers: [],
|
||||
filterBy: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
start_date: 'start_date',
|
||||
end_date: 'end_date',
|
||||
customers: 'customer_ids',
|
||||
filterBy: 'filter_by',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'customer-payment-report-table',
|
||||
});
|
||||
|
||||
const {
|
||||
options: customerOptions,
|
||||
@@ -85,223 +103,188 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<CustomerPaymentFilterType>({
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
customer_ids: null,
|
||||
filter_by: null,
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
customers: tableFilterState.customers,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
},
|
||||
validationSchema: CustomerPaymentFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
setFilterParams({
|
||||
start_date: values.start_date || undefined,
|
||||
end_date: values.end_date || undefined,
|
||||
customer_ids: values.customer_ids || undefined,
|
||||
filter_by: values.filter_by || undefined,
|
||||
});
|
||||
filterModal.closeModal();
|
||||
setCurrentPage(1);
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
setFilterParams({});
|
||||
setCurrentPage(1);
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
onSubmit: (values) => {
|
||||
updateFilter('start_date', values.start_date, true);
|
||||
updateFilter('end_date', values.end_date, true);
|
||||
updateFilter('customers', values.customers, true);
|
||||
updateFilter('filterBy', values.filterBy, true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
const formikResetHandler = () => {
|
||||
resetFilter();
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
customers: [],
|
||||
filterBy: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const getPaymentStatusBadgeColor = (notes: string): Color => {
|
||||
const normalizedValue = notes.toLowerCase();
|
||||
|
||||
if (normalizedValue === 'lunas') {
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
if (normalizedValue.includes('belum')) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (normalizedValue === 'lunas') return 'primary';
|
||||
if (normalizedValue.includes('belum')) return 'warning';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
// ===== DATE CHANGE HANDLERS =====
|
||||
const handleStartDateChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value || null);
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value);
|
||||
|
||||
if (value && formik.values.end_date) {
|
||||
const startDate = new Date(value);
|
||||
const endDateObj = new Date(formik.values.end_date);
|
||||
|
||||
if (endDateObj < startDate) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
if (value && formik.values.end_date) {
|
||||
if (new Date(formik.values.end_date) < new Date(value)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
},
|
||||
[formik, dateErrorShown]
|
||||
);
|
||||
|
||||
const handleEndDateChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value || null);
|
||||
|
||||
if (value && formik.values.start_date) {
|
||||
const startDateObj = new Date(formik.values.start_date);
|
||||
const endDate = new Date(value);
|
||||
|
||||
if (endDate < startDateObj) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
return;
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value);
|
||||
|
||||
if (value && formik.values.start_date) {
|
||||
if (new Date(value) < new Date(formik.values.start_date)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[formik, dateErrorShown]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const customerIdsValue = useMemo(() => {
|
||||
if (!formik.values.customer_ids) return [];
|
||||
return customerOptions.filter((opt) =>
|
||||
formik.values.customer_ids?.split(',').includes(String(opt.value))
|
||||
);
|
||||
}, [formik.values.customer_ids, customerOptions]);
|
||||
|
||||
const filterByValue = useMemo(() => {
|
||||
if (!formik.values.filter_by) return null;
|
||||
return (
|
||||
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.filter_by, dataTypeOptions]);
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== DATA FETCHING =====
|
||||
const { data: customerPayment, isLoading } = useSWR(
|
||||
() => {
|
||||
const params = {
|
||||
customer_ids: filterParams.customer_ids,
|
||||
filter_by: filterParams.filter_by as
|
||||
| 'trans_date'
|
||||
| 'realization_date'
|
||||
| undefined,
|
||||
start_date: filterParams.start_date,
|
||||
end_date: filterParams.end_date,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
return ['customer-payment-report', params];
|
||||
},
|
||||
([, params]) =>
|
||||
FinanceApi.getCustomerPaymentReport(
|
||||
params.customer_ids,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date,
|
||||
params.page,
|
||||
params.limit
|
||||
)
|
||||
const { data: customerPayment, isLoading } = useSWR<
|
||||
BaseApiResponse<CustomerPaymentReport>,
|
||||
AxiosError<BaseApiResponse>,
|
||||
SWRHttpKey
|
||||
>(
|
||||
`${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`,
|
||||
httpClientFetcher
|
||||
);
|
||||
|
||||
const data: CustomerPaymentReport[] = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(customerPayment)
|
||||
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
|
||||
: [],
|
||||
[customerPayment]
|
||||
);
|
||||
const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment)
|
||||
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
|
||||
: [];
|
||||
|
||||
const meta =
|
||||
isResponseSuccess(customerPayment) && customerPayment.meta
|
||||
? customerPayment.meta
|
||||
: null;
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const customerPaymentExport = useCallback(async (): Promise<
|
||||
CustomerPaymentReport[] | null
|
||||
> => {
|
||||
const params = {
|
||||
customer_ids: filterParams.customer_ids,
|
||||
filter_by: filterParams.filter_by as
|
||||
| 'trans_date'
|
||||
| 'realization_date'
|
||||
| undefined,
|
||||
start_date: filterParams.start_date,
|
||||
end_date: filterParams.end_date,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
};
|
||||
const customer_ids =
|
||||
tableFilterState.customers.length > 0
|
||||
? tableFilterState.customers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
const filter_by = tableFilterState.filterBy?.value as
|
||||
| 'trans_date'
|
||||
| 'realization_date'
|
||||
| undefined;
|
||||
|
||||
const response = await FinanceApi.getCustomerPaymentReport(
|
||||
params.customer_ids,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date,
|
||||
params.page,
|
||||
params.limit
|
||||
customer_ids,
|
||||
filter_by,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined,
|
||||
1,
|
||||
100
|
||||
);
|
||||
|
||||
return isResponseSuccess(response)
|
||||
? (response.data as unknown as CustomerPaymentReport[])
|
||||
: null;
|
||||
}, [filterParams]);
|
||||
}, [tableFilterState]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcelGeneral = useCallback(async () => {
|
||||
setIsExcelGeneralExportLoading(true);
|
||||
try {
|
||||
const customer_ids =
|
||||
tableFilterState.customers.length > 0
|
||||
? tableFilterState.customers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
await FinanceApi.exportCustomerPaymentToExcelGeneral(
|
||||
customer_ids,
|
||||
tableFilterState.filterBy?.value,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined
|
||||
);
|
||||
toast.success('Excel General berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelGeneralExportLoading(false);
|
||||
}
|
||||
}, [tableFilterState]);
|
||||
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await customerPaymentExport();
|
||||
|
||||
if (
|
||||
!allDataForExport ||
|
||||
!Array.isArray(allDataForExport) ||
|
||||
allDataForExport.length === 0
|
||||
) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
await generateCustomerPaymentExcel({ data: allDataForExport });
|
||||
const customer_ids =
|
||||
tableFilterState.customers.length > 0
|
||||
? tableFilterState.customers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet(
|
||||
customer_ids,
|
||||
tableFilterState.filterBy?.value,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined
|
||||
);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [customerPaymentExport]);
|
||||
}, [tableFilterState]);
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
@@ -317,22 +300,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const customerName = filterParams.customer_ids
|
||||
? customerOptions
|
||||
.filter((opt) =>
|
||||
filterParams.customer_ids?.split(',').includes(String(opt.value))
|
||||
)
|
||||
.map((opt) => opt.label)
|
||||
.join(', ') || 'Semua Customer'
|
||||
: 'Semua Customer';
|
||||
const customerName =
|
||||
tableFilterState.customers.length > 0
|
||||
? tableFilterState.customers.map((o) => o.label).join(', ')
|
||||
: 'Semua Customer';
|
||||
|
||||
await generateCustomerPaymentPDF({
|
||||
data: allDataForExport,
|
||||
params: {
|
||||
customer_name: customerName,
|
||||
start_date: filterParams.start_date,
|
||||
end_date: filterParams.end_date,
|
||||
filter_by: filterParams.filter_by as
|
||||
start_date: tableFilterState.start_date || undefined,
|
||||
end_date: tableFilterState.end_date || undefined,
|
||||
filter_by: tableFilterState.filterBy?.value as
|
||||
| 'trans_date'
|
||||
| 'realization_date'
|
||||
| undefined,
|
||||
@@ -344,106 +323,103 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
} finally {
|
||||
setIsPdfExportLoading(false);
|
||||
}
|
||||
}, [customerPaymentExport, filterParams, customerOptions]);
|
||||
}, [customerPaymentExport, tableFilterState]);
|
||||
|
||||
// ===== TAB ACTIONS COMPONENT =====
|
||||
const TabActions = useMemo(() => {
|
||||
return function TabActionsComponent() {
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore(
|
||||
(state) => state.clearTabActions
|
||||
);
|
||||
// ===== TAB ACTIONS =====
|
||||
useEffect(() => {
|
||||
setTabActions(
|
||||
tabId,
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={{
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
customers: tableFilterState.customers,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
}}
|
||||
fieldGroups={[['start_date', 'end_date']]}
|
||||
onClick={filterModal.openModal}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
useEffect(() => {
|
||||
setTabActions(
|
||||
tabId,
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={filterParams}
|
||||
fieldGroups={[['start_date', 'end_date']]}
|
||||
onClick={() => handleFilterModalOpenRef.current()}
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
isLoading={isAnyExportLoading}
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
color='none'
|
||||
isLoading={isAnyExportLoading}
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcel}
|
||||
isLoading={isExcelExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportPdf}
|
||||
isLoading={isPdfExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:document' width={20} height={20} />
|
||||
Export to PDF
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}, [setTabActions]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTabActions(tabId);
|
||||
};
|
||||
}, [clearTabActions]);
|
||||
|
||||
return null;
|
||||
};
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<span>Export</span>
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcel}
|
||||
isLoading={isExcelExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel - Customer Per Sheet
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcelGeneral}
|
||||
isLoading={isExcelGeneralExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel - General
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportPdf}
|
||||
isLoading={isPdfExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:document' width={20} height={20} />
|
||||
Export to PDF
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
tabId,
|
||||
setTabActions,
|
||||
tableFilterState,
|
||||
filterModal.openModal,
|
||||
isAnyExportLoading,
|
||||
handleExportExcel,
|
||||
handleExportExcelGeneral,
|
||||
handleExportPdf,
|
||||
isExcelExportLoading,
|
||||
isExcelGeneralExportLoading,
|
||||
isPdfExportLoading,
|
||||
filterParams,
|
||||
]);
|
||||
|
||||
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
|
||||
useEffect(() => {
|
||||
return () => clearTabActions(tabId);
|
||||
}, [tabId, clearTabActions]);
|
||||
|
||||
const getTableColumns = (
|
||||
summary: CustomerPaymentSummary
|
||||
@@ -650,11 +626,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const value = props.row.original.status;
|
||||
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (!value) return '-';
|
||||
return (
|
||||
<StatusBadge
|
||||
color={getPaymentStatusBadgeColor(value)}
|
||||
@@ -693,7 +665,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{TabActionsElement}
|
||||
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||
{isLoading && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
@@ -717,6 +688,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='w-full ml-auto'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={tableFilterState.page}
|
||||
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
|
||||
onNextPage={() =>
|
||||
setPage(
|
||||
meta.total_pages && tableFilterState.page < meta.total_pages
|
||||
? tableFilterState.page + 1
|
||||
: tableFilterState.page
|
||||
)
|
||||
}
|
||||
onPageChange={setPage}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
data.length > 0 &&
|
||||
data.map((customerReport) => {
|
||||
@@ -811,6 +803,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='mt-5 px-3'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={tableFilterState.page}
|
||||
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
|
||||
onNextPage={() =>
|
||||
setPage(
|
||||
meta.total_pages && tableFilterState.page < meta.total_pages
|
||||
? tableFilterState.page + 1
|
||||
: tableFilterState.page
|
||||
)
|
||||
}
|
||||
onPageChange={setPage}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
@@ -835,7 +848,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div>
|
||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||
@@ -845,29 +858,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
<DateInput
|
||||
name='start_date'
|
||||
value={formik.values.start_date || ''}
|
||||
errorMessage={formik.errors.start_date}
|
||||
onChange={handleStartDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
isError={
|
||||
formik.touched.start_date &&
|
||||
Boolean(formik.errors.start_date)
|
||||
}
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
|
||||
<DateInput
|
||||
name='end_date'
|
||||
value={formik.values.end_date || ''}
|
||||
errorMessage={formik.errors.end_date}
|
||||
onChange={handleEndDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isNestedModal
|
||||
isError={
|
||||
(formik.touched.end_date &&
|
||||
Boolean(formik.errors.end_date)) ||
|
||||
hasDateError
|
||||
}
|
||||
isError={hasDateError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -876,15 +878,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
label='Customer'
|
||||
placeholder='Pilih Customer'
|
||||
options={customerOptions}
|
||||
value={customerIdsValue}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue(
|
||||
'customer_ids',
|
||||
Array.isArray(val) && val.length > 0
|
||||
? val.map((v: OptionType) => String(v.value)).join(',')
|
||||
: null
|
||||
);
|
||||
}}
|
||||
value={formik.values.customers}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
|
||||
}
|
||||
onInputChange={setCustomerInputValue}
|
||||
isLoading={isLoadingCustomers}
|
||||
isClearable
|
||||
@@ -896,14 +893,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={dataTypeOptions}
|
||||
value={filterByValue}
|
||||
onChange={(val) => {
|
||||
if (!Array.isArray(val)) {
|
||||
formik.setFieldValue('filter_by', val?.value || null);
|
||||
}
|
||||
}}
|
||||
value={formik.values.filterBy ?? null}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'filterBy',
|
||||
!Array.isArray(val) ? (val ?? undefined) : undefined
|
||||
)
|
||||
}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isClearable={true}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -919,7 +917,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={hasDateError || !formik.isValid || formik.isSubmitting}
|
||||
disabled={hasDateError}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
@@ -8,24 +9,15 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import {
|
||||
DebtRow,
|
||||
DebtSupplier,
|
||||
DebtSupplierFilter,
|
||||
} from '@/types/api/report/debt-supplier';
|
||||
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
|
||||
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
|
||||
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
|
||||
import { useFormik } from 'formik';
|
||||
import {
|
||||
DebtSupplierFilterSchema,
|
||||
DebtSupplierFilterType,
|
||||
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import { Color } from '@/types/theme';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
@@ -34,6 +26,10 @@ import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
const dueStatus: Record<string, Color> = {
|
||||
'Sudah Jatuh Tempo': 'error',
|
||||
@@ -51,7 +47,6 @@ const getPillBadge = (
|
||||
statusText: string,
|
||||
type: 'due' | 'payment' = 'payment'
|
||||
) => {
|
||||
// Get color based on type
|
||||
const color =
|
||||
type === 'due'
|
||||
? dueStatus[statusText] || 'neutral'
|
||||
@@ -68,6 +63,11 @@ const getPillBadge = (
|
||||
);
|
||||
};
|
||||
|
||||
const dataTypeOptions: OptionType<string>[] = [
|
||||
{ value: 'received_date', label: 'Tanggal Terima' },
|
||||
{ value: 'po_date', label: 'Tanggal PO' },
|
||||
];
|
||||
|
||||
interface DebtSupplierTabProps {
|
||||
tabId: string;
|
||||
}
|
||||
@@ -76,24 +76,50 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
|
||||
useState(false);
|
||||
const isAnyExportLoading =
|
||||
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
|
||||
|
||||
// ===== SUBMISSION STATE =====
|
||||
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
|
||||
start_date: undefined,
|
||||
end_date: undefined,
|
||||
supplier_ids: undefined,
|
||||
filter_by: undefined,
|
||||
});
|
||||
|
||||
// ===== DATE ERROR STATE =====
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
|
||||
const handleFilterModalOpenRef = useRef(() => {});
|
||||
|
||||
const filterModal = useModal();
|
||||
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
reset: resetFilter,
|
||||
} = useTableFilter<{
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
suppliers: OptionType<number>[];
|
||||
filterBy?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
suppliers: [],
|
||||
filterBy: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
start_date: 'start_date',
|
||||
end_date: 'end_date',
|
||||
suppliers: 'supplier_ids',
|
||||
filterBy: 'filter_by',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'debt-supplier-report-table',
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setSupplierInputValue,
|
||||
options: supplierOptions,
|
||||
@@ -101,140 +127,180 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const dataTypeOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'received_date', label: 'Tanggal Terima' },
|
||||
{ value: 'po_date', label: 'Tanggal PO' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<DebtSupplierFilterType>({
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
supplierIds: null,
|
||||
filterBy: null,
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
suppliers: tableFilterState.suppliers,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
},
|
||||
validationSchema: DebtSupplierFilterSchema,
|
||||
onSubmit: (values) => {
|
||||
setFilterParams({
|
||||
start_date: values.startDate?.toString() || undefined,
|
||||
end_date: values.endDate?.toString() || undefined,
|
||||
supplier_ids:
|
||||
values.supplierIds?.map((v) => String(v.value)).join(',') ||
|
||||
undefined,
|
||||
filter_by: values.filterBy?.value?.toString() || undefined,
|
||||
});
|
||||
filterModal.closeModal();
|
||||
// setIsSubmitted(true);
|
||||
},
|
||||
onReset: () => {
|
||||
setFilterParams({
|
||||
start_date: undefined,
|
||||
end_date: undefined,
|
||||
supplier_ids: undefined,
|
||||
filter_by: undefined,
|
||||
});
|
||||
// setIsSubmitted(false);
|
||||
updateFilter('start_date', values.start_date, true);
|
||||
updateFilter('end_date', values.end_date, true);
|
||||
updateFilter('suppliers', values.suppliers, true);
|
||||
updateFilter('filterBy', values.filterBy, true);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
const formikResetHandler = () => {
|
||||
resetFilter();
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
suppliers: [],
|
||||
filterBy: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== DATE CHANGE HANDLERS =====
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value);
|
||||
|
||||
if (value && formik.values.end_date) {
|
||||
if (new Date(formik.values.end_date) < new Date(value)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value);
|
||||
|
||||
if (value && formik.values.start_date) {
|
||||
if (new Date(value) < new Date(formik.values.start_date)) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ===== DATA FETCHING =====
|
||||
const { data: debtSupplier, isLoading } = useSWR(
|
||||
() => {
|
||||
const params = {
|
||||
supplier_ids: filterParams.supplier_ids,
|
||||
filter_by: filterParams.filter_by,
|
||||
start_date: filterParams.start_date,
|
||||
end_date: filterParams.end_date,
|
||||
};
|
||||
|
||||
return ['debt-supplier-report', params];
|
||||
},
|
||||
([, params]) =>
|
||||
DebtSupplierApi.getDebtSupplierReport(
|
||||
params.supplier_ids,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date
|
||||
)
|
||||
const { data: debtSupplierResponse, isLoading } = useSWR<
|
||||
BaseApiResponse<DebtSupplier[]>,
|
||||
AxiosError<BaseApiResponse>,
|
||||
SWRHttpKey
|
||||
>(
|
||||
`${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`,
|
||||
httpClientFetcher
|
||||
);
|
||||
|
||||
const data: DebtSupplier[] = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(debtSupplier)
|
||||
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
|
||||
: [],
|
||||
[debtSupplier]
|
||||
);
|
||||
const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse)
|
||||
? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? [])
|
||||
: [];
|
||||
|
||||
const meta =
|
||||
isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta
|
||||
? debtSupplierResponse.meta
|
||||
: null;
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const debtSupplierExport = useCallback(async (): Promise<
|
||||
DebtSupplier[] | null
|
||||
> => {
|
||||
const params = {
|
||||
supplier_ids:
|
||||
formik.values.supplierIds && formik.values.supplierIds.length > 0
|
||||
? formik.values.supplierIds.map((v) => String(v.value)).join(',')
|
||||
: undefined,
|
||||
filter_by: formik.values.filterBy?.value?.toString() || undefined,
|
||||
start_date: formik.values.startDate || undefined,
|
||||
end_date: formik.values.endDate || undefined,
|
||||
date_type: formik.values.filterBy
|
||||
? formik.values.filterBy.value
|
||||
: undefined,
|
||||
limit: 100,
|
||||
page: 1,
|
||||
};
|
||||
const supplier_ids =
|
||||
tableFilterState.suppliers.length > 0
|
||||
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
|
||||
const response = await DebtSupplierApi.getDebtSupplierReport(
|
||||
params.supplier_ids,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date
|
||||
supplier_ids,
|
||||
tableFilterState.filterBy?.value,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined,
|
||||
1,
|
||||
100
|
||||
);
|
||||
|
||||
return isResponseSuccess(response)
|
||||
? (response.data as unknown as DebtSupplier[])
|
||||
: null;
|
||||
}, [
|
||||
formik.values.supplierIds,
|
||||
formik.values.startDate,
|
||||
formik.values.endDate,
|
||||
formik.values.filterBy,
|
||||
]);
|
||||
}, [tableFilterState]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await debtSupplierExport();
|
||||
|
||||
if (
|
||||
!allDataForExport ||
|
||||
!Array.isArray(allDataForExport) ||
|
||||
allDataForExport.length === 0
|
||||
) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
generateDebtSupplierExcel({ data: allDataForExport });
|
||||
const supplier_ids =
|
||||
tableFilterState.suppliers.length > 0
|
||||
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
await DebtSupplierApi.exportToExcelSupplierPerSheet(
|
||||
supplier_ids,
|
||||
tableFilterState.filterBy?.value,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined
|
||||
);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [debtSupplierExport]);
|
||||
}, [tableFilterState]);
|
||||
|
||||
const handleExportExcelGeneral = useCallback(async () => {
|
||||
setIsExcelGeneralExportLoading(true);
|
||||
try {
|
||||
const supplier_ids =
|
||||
tableFilterState.suppliers.length > 0
|
||||
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
|
||||
: undefined;
|
||||
|
||||
await DebtSupplierApi.exportToExcelGeneral(
|
||||
supplier_ids,
|
||||
tableFilterState.filterBy?.value,
|
||||
tableFilterState.start_date || undefined,
|
||||
tableFilterState.end_date || undefined
|
||||
);
|
||||
|
||||
toast.success('Excel General berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelGeneralExportLoading(false);
|
||||
}
|
||||
}, [tableFilterState]);
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
@@ -250,15 +316,18 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const supplierName =
|
||||
tableFilterState.suppliers.length > 0
|
||||
? tableFilterState.suppliers.map((o) => o.label).join(', ')
|
||||
: undefined;
|
||||
|
||||
await generateDebtSupplierPDF({
|
||||
data: allDataForExport,
|
||||
params: {
|
||||
supplier_name: formik.values.supplierIds
|
||||
?.map((v) => v.label)
|
||||
.join(', '),
|
||||
filter_by: formik.values.filterBy?.label,
|
||||
start_date: formik.values.startDate || undefined,
|
||||
end_date: formik.values.endDate || undefined,
|
||||
supplier_name: supplierName,
|
||||
filter_by: tableFilterState.filterBy?.label,
|
||||
start_date: tableFilterState.start_date || undefined,
|
||||
end_date: tableFilterState.end_date || undefined,
|
||||
},
|
||||
});
|
||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||
@@ -267,129 +336,103 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
} finally {
|
||||
setIsPdfExportLoading(false);
|
||||
}
|
||||
}, [
|
||||
debtSupplierExport,
|
||||
formik.values.supplierIds,
|
||||
formik.values.filterBy,
|
||||
formik.values.startDate,
|
||||
formik.values.endDate,
|
||||
]);
|
||||
}, [debtSupplierExport, tableFilterState]);
|
||||
|
||||
// ===== TAB ACTIONS COMPONENT =====
|
||||
const TabActions = useMemo(() => {
|
||||
return function TabActionsComponent() {
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore(
|
||||
(state) => state.clearTabActions
|
||||
);
|
||||
// ===== TAB ACTIONS =====
|
||||
useEffect(() => {
|
||||
setTabActions(
|
||||
tabId,
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={{
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
suppliers: tableFilterState.suppliers,
|
||||
filterBy: tableFilterState.filterBy,
|
||||
}}
|
||||
fieldGroups={[['start_date', 'end_date']]}
|
||||
onClick={filterModal.openModal}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
useEffect(() => {
|
||||
setTabActions(
|
||||
tabId,
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={filterParams}
|
||||
fieldGroups={[['start_date', 'end_date']]}
|
||||
onClick={() => handleFilterModalOpenRef.current()}
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
isLoading={isAnyExportLoading}
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
color='none'
|
||||
isLoading={isAnyExportLoading}
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcel}
|
||||
isLoading={isExcelExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportPdf}
|
||||
isLoading={isPdfExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:document' width={20} height={20} />
|
||||
Export to PDF
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}, [setTabActions]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTabActions(tabId);
|
||||
};
|
||||
}, [clearTabActions]);
|
||||
|
||||
return null;
|
||||
};
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
<span>Export</span>
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
<Icon icon='heroicons:chevron-down' width={14} height={14} />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcel}
|
||||
isLoading={isExcelExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel - Supplier Per Sheet
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcelGeneral}
|
||||
isLoading={isExcelGeneralExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel - General
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportPdf}
|
||||
isLoading={isPdfExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:document' width={20} height={20} />
|
||||
Export to PDF
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}, [
|
||||
tabId,
|
||||
filterParams,
|
||||
setTabActions,
|
||||
tableFilterState,
|
||||
filterModal.openModal,
|
||||
isAnyExportLoading,
|
||||
handleExportExcel,
|
||||
handleExportExcelGeneral,
|
||||
handleExportPdf,
|
||||
isExcelExportLoading,
|
||||
isExcelGeneralExportLoading,
|
||||
isPdfExportLoading,
|
||||
]);
|
||||
|
||||
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
}
|
||||
};
|
||||
}, [dateErrorShown]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
}, [filterModal.open, dateErrorShown]);
|
||||
return () => clearTabActions(tabId);
|
||||
}, [tabId, clearTabActions]);
|
||||
|
||||
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
|
||||
{
|
||||
@@ -604,9 +647,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{TabActionsElement}
|
||||
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||
{isLoading && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
@@ -630,6 +673,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='w-full ml-auto'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={tableFilterState.page}
|
||||
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
|
||||
onNextPage={() =>
|
||||
setPage(
|
||||
meta.total_pages && tableFilterState.page < meta.total_pages
|
||||
? tableFilterState.page + 1
|
||||
: tableFilterState.page
|
||||
)
|
||||
}
|
||||
onPageChange={setPage}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
data.length > 0 &&
|
||||
data.map((supplierReport) => {
|
||||
@@ -717,6 +781,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='mt-5 px-3'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={tableFilterState.page}
|
||||
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
|
||||
onNextPage={() =>
|
||||
setPage(
|
||||
meta.total_pages && tableFilterState.page < meta.total_pages
|
||||
? tableFilterState.page + 1
|
||||
: tableFilterState.page
|
||||
)
|
||||
}
|
||||
onPageChange={setPage}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
@@ -727,23 +812,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
type='button'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
{/* Modal Header */}
|
||||
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='font-medium text-sm'>Filter Data</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant='link'
|
||||
type='button'
|
||||
onClick={filterModal.closeModal}
|
||||
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div>
|
||||
@@ -752,153 +837,68 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
</label>
|
||||
<div className='flex flex-row gap-1.5 items-center justify-between'>
|
||||
<DateInput
|
||||
name='startDate'
|
||||
value={formik.values.startDate || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('startDate', value || null);
|
||||
|
||||
if (value && formik.values.endDate) {
|
||||
const startDate = new Date(value);
|
||||
const endDateObj = new Date(formik.values.endDate);
|
||||
|
||||
if (endDateObj < startDate) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setHasDateError(false);
|
||||
}
|
||||
}}
|
||||
name='start_date'
|
||||
value={formik.values.start_date || ''}
|
||||
onChange={handleStartDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isError={
|
||||
formik.touched.startDate && !!formik.errors.startDate
|
||||
}
|
||||
errorMessage={formik.errors.startDate}
|
||||
isNestedModal
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10'></hr>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='endDate'
|
||||
value={formik.values.endDate || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('endDate', value || null);
|
||||
|
||||
if (value && formik.values.startDate) {
|
||||
const startDateObj = new Date(formik.values.startDate);
|
||||
const endDate = new Date(value);
|
||||
|
||||
if (endDate < startDateObj) {
|
||||
setHasDateError(true);
|
||||
if (!dateErrorShown) {
|
||||
toast.error('Tanggal akhir tidak boleh masa lampau', {
|
||||
duration: Infinity,
|
||||
});
|
||||
setDateErrorShown(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasDateError(false);
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
}}
|
||||
name='end_date'
|
||||
value={formik.values.end_date || ''}
|
||||
onChange={handleEndDateChange}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isError={
|
||||
(formik.touched.endDate && !!formik.errors.endDate) ||
|
||||
hasDateError
|
||||
}
|
||||
errorMessage={formik.errors.endDate}
|
||||
isNestedModal
|
||||
isError={hasDateError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SelectInputCheckbox
|
||||
label='Supplier'
|
||||
placeholder='Pilih Supplier'
|
||||
isMulti
|
||||
options={supplierOptions}
|
||||
value={
|
||||
(formik.values.supplierIds as
|
||||
| { value: number; label: string }
|
||||
| { value: number; label: string }[]
|
||||
| null
|
||||
| undefined) || []
|
||||
}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue(
|
||||
'supplierIds',
|
||||
Array.isArray(val) ? val : val ? [val] : null
|
||||
);
|
||||
}}
|
||||
onInputChange={setSupplierInputValue}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isLoading={isLoadingSupplierOptions}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isError={
|
||||
formik.touched.supplierIds && !!formik.errors.supplierIds
|
||||
}
|
||||
errorMessage={formik.errors.supplierIds as string}
|
||||
/>
|
||||
</div>
|
||||
<SelectInputCheckbox
|
||||
label='Supplier'
|
||||
placeholder='Pilih Supplier'
|
||||
options={supplierOptions}
|
||||
value={formik.values.suppliers}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('suppliers', Array.isArray(val) ? val : [])
|
||||
}
|
||||
onInputChange={setSupplierInputValue}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isLoading={isLoadingSupplierOptions}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<SelectInputRadio
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={dataTypeOptions}
|
||||
value={
|
||||
(formik.values.filterBy as
|
||||
| { value: string; label: string }
|
||||
| { value: string; label: string }[]
|
||||
| null
|
||||
| undefined) || null
|
||||
}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue(
|
||||
'filterBy',
|
||||
val ? (val as OptionType) : null
|
||||
);
|
||||
}}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isClearable
|
||||
isError={formik.touched.filterBy && !!formik.errors.filterBy}
|
||||
errorMessage={formik.errors.filterBy as string}
|
||||
/>
|
||||
</div>
|
||||
<SelectInputRadio
|
||||
label='Filter Berdasarkan'
|
||||
placeholder='Pilih Filter Berdasarkan'
|
||||
options={dataTypeOptions}
|
||||
value={formik.values.filterBy ?? null}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'filterBy',
|
||||
!Array.isArray(val) ? (val ?? undefined) : undefined
|
||||
)
|
||||
}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='none'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={hasDateError}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
LogisticPurchasePerSupplierReport,
|
||||
LogisticPurchasePerSupplierSummary,
|
||||
} from '@/types/api/report/logistic-stock';
|
||||
import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX';
|
||||
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
@@ -53,7 +52,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
|
||||
// ===== STATE MANAGEMENT =====
|
||||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] =
|
||||
useState(false);
|
||||
const isAnyExportLoading =
|
||||
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
|
||||
|
||||
// ===== PAGINATION STATE =====
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -156,8 +158,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
start_date: filterParams.start_date || null,
|
||||
end_date: filterParams.end_date || null,
|
||||
area_ids: filterParams.area_id || null,
|
||||
supplier_ids: filterParams.supplier_id || null,
|
||||
product_ids: filterParams.product_id || null,
|
||||
product_category_ids: filterParams.product_category_id || null,
|
||||
filter_by: filterParams.filter_by || null,
|
||||
sort_by: filterParams.sort_by || null,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
@@ -351,25 +362,44 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await logisticPurchasePerSupplierExport();
|
||||
|
||||
if (
|
||||
!allDataForExport ||
|
||||
!Array.isArray(allDataForExport) ||
|
||||
allDataForExport.length === 0
|
||||
) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
await generatePurchasesPerSupplierExcel({ data: allDataForExport });
|
||||
await LogisticApi.exportToExcelSupplierPerSheet(
|
||||
filterParams.area_id,
|
||||
filterParams.supplier_id,
|
||||
filterParams.product_id,
|
||||
filterParams.product_category_id,
|
||||
filterParams.start_date,
|
||||
filterParams.end_date,
|
||||
filterParams.sort_by,
|
||||
filterParams.filter_by
|
||||
);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [logisticPurchasePerSupplierExport]);
|
||||
}, [filterParams]);
|
||||
|
||||
const handleExportExcelGeneral = useCallback(async () => {
|
||||
setIsExcelGeneralExportLoading(true);
|
||||
try {
|
||||
await LogisticApi.exportToExcelGeneral(
|
||||
filterParams.area_id,
|
||||
filterParams.supplier_id,
|
||||
filterParams.product_id,
|
||||
filterParams.product_category_id,
|
||||
filterParams.start_date,
|
||||
filterParams.end_date,
|
||||
filterParams.sort_by,
|
||||
filterParams.filter_by
|
||||
);
|
||||
toast.success('Excel General berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsExcelGeneralExportLoading(false);
|
||||
}
|
||||
}, [filterParams]);
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
@@ -514,7 +544,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
Export to Excel - Supplier Per Sheet
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={handleExportExcelGeneral}
|
||||
isLoading={isExcelGeneralExportLoading}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel - General
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -544,8 +584,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
|
||||
filterParams,
|
||||
isAnyExportLoading,
|
||||
handleExportExcel,
|
||||
handleExportExcelGeneral,
|
||||
handleExportPdf,
|
||||
isExcelExportLoading,
|
||||
isExcelGeneralExportLoading,
|
||||
isPdfExportLoading,
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import Tabs from '@/components/Tabs';
|
||||
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab';
|
||||
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab';
|
||||
import HppPerFarmTab from '@/components/pages/report/marketing/tab/HppPerFarmTab';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
|
||||
const MarketingReportContent = () => {
|
||||
@@ -21,6 +22,11 @@ const MarketingReportContent = () => {
|
||||
label: 'HPP Harian Kandang',
|
||||
content: <HppPerKandangTab tabId={'2'} />,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
label: 'HPP Per Farm',
|
||||
content: <HppPerFarmTab tabId={'3'} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export type DailyMarketingReportFilterType = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search: string | null;
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
@@ -14,6 +16,8 @@ export type DailyMarketingReportFilterType = {
|
||||
};
|
||||
|
||||
export const DailyMarketingReportFilterSchema = yup.object({
|
||||
page: yup.number().nullable(),
|
||||
pageSize: yup.number().nullable(),
|
||||
search: yup.string().nullable(),
|
||||
area_id: yup.string().nullable(),
|
||||
location_id: yup.string().nullable(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export type HppPerKandangFilterType = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
kandang_id: string | null;
|
||||
@@ -12,6 +14,8 @@ export type HppPerKandangFilterType = {
|
||||
};
|
||||
|
||||
export const HppPerKandangFilterSchema = yup.object({
|
||||
page: yup.number().nullable(),
|
||||
pageSize: yup.number().nullable(),
|
||||
area_id: yup.string().nullable(),
|
||||
location_id: yup.string().nullable(),
|
||||
kandang_id: yup.string().nullable(),
|
||||
|
||||
@@ -17,16 +17,10 @@ import {
|
||||
formatVechicleNumber,
|
||||
formatTitleCase,
|
||||
} from '@/lib/helper';
|
||||
import {
|
||||
DailyMarketingRow,
|
||||
DailyMarketingReportResponse,
|
||||
} from '@/types/api/report/marketing';
|
||||
import { DailyMarketingRow } from '@/types/api/report/marketing';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import Button from '@/components/Button';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
|
||||
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -39,8 +33,6 @@ import Modal, { useModal } from '@/components/Modal';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
|
||||
import { useEffect as useEffectHook } from 'react';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import {
|
||||
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
||||
MARKETING_TYPE_OPTIONS,
|
||||
@@ -53,6 +45,8 @@ interface DailyMarketingTabProps {
|
||||
}
|
||||
|
||||
interface FilterParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
area_id?: string;
|
||||
location_id?: string;
|
||||
warehouse_id?: string;
|
||||
@@ -116,6 +110,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<DailyMarketingReportFilterType>({
|
||||
initialValues: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: null,
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
@@ -130,6 +126,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
validationSchema: DailyMarketingReportFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
setFilterParams({
|
||||
page: values.page || undefined,
|
||||
pageSize: values.pageSize || undefined,
|
||||
area_id: values.area_id || undefined,
|
||||
location_id: values.location_id || undefined,
|
||||
warehouse_id: values.warehouse_id || undefined,
|
||||
@@ -150,8 +148,21 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
page: formik.values.page,
|
||||
pageSize: formik.values.pageSize,
|
||||
search: formik.values.search,
|
||||
area_id: filterParams.area_id || null,
|
||||
location_id: filterParams.location_id || null,
|
||||
warehouse_id: filterParams.warehouse_id || null,
|
||||
customer_id: filterParams.customer_id || null,
|
||||
start_date: filterParams.start_date || null,
|
||||
end_date: filterParams.end_date || null,
|
||||
filter_by: filterParams.filter_by || null,
|
||||
marketing_type: filterParams.marketing_type || null,
|
||||
sort_by: filterParams.sort_by || null,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== SEARCH CHANGE HANDLER =====
|
||||
@@ -222,6 +233,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.page) params.set('page', String(filterParams.page));
|
||||
if (filterParams.pageSize)
|
||||
params.set('limit', String(filterParams.pageSize));
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
@@ -262,67 +276,30 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
[dailyMarketings]
|
||||
);
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const dailyMarketingsExport = useCallback(async (): Promise<
|
||||
DailyMarketingRow[] | null
|
||||
> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
params.set('limit', '9999999');
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await httpClient<DailyMarketingReportResponse>(
|
||||
`${MarketingReportApi.basePath}${queryString}`
|
||||
);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data || [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await dailyMarketingsExport();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by)
|
||||
params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
|
||||
const period =
|
||||
filterParams.start_date && filterParams.end_date
|
||||
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
|
||||
: undefined;
|
||||
|
||||
await generateDailyMarketingExcel({
|
||||
data: allDataForExport,
|
||||
summaryTotal: summaryTotal,
|
||||
period: period,
|
||||
});
|
||||
await MarketingReportApi.exportDailyMarketingToExcel(params.toString());
|
||||
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
@@ -330,34 +307,39 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [filterParams, dailyMarketingsExport, summaryTotal]);
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await dailyMarketingsExport();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by)
|
||||
params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
|
||||
const dailyMarketingReportPdfBlob = await pdf(
|
||||
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
|
||||
).toBlob();
|
||||
await MarketingReportApi.exportDailyMarketingToPDF(params.toString());
|
||||
|
||||
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
||||
dailyMarketingReportPdfBlob
|
||||
);
|
||||
window.open(dailyMarketingReportPdfUrl, '_blank');
|
||||
|
||||
toast.success('PDF berhasil dibuat.');
|
||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsPdfExportLoading(false);
|
||||
}
|
||||
}, [dailyMarketingsExport, summaryTotal]);
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
// ===== TAB ACTIONS COMPONENT =====
|
||||
const TabActions = useMemo(() => {
|
||||
@@ -572,7 +554,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'qty',
|
||||
cell: (props) => formatNumber(props.row.original.qty),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_qty
|
||||
? formatNumber(summaryTotal.total_qty)
|
||||
: '-'}
|
||||
@@ -585,7 +567,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'average_weight_kg',
|
||||
cell: (props) => formatNumber(props.row.original.average_weight_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.average_weight_kg
|
||||
? formatNumber(summaryTotal.average_weight_kg)
|
||||
: '-'}
|
||||
@@ -598,7 +580,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'total_weight_kg',
|
||||
cell: (props) => formatNumber(props.row.original.total_weight_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_weight_kg
|
||||
? formatNumber(summaryTotal.total_weight_kg)
|
||||
: '-'}
|
||||
@@ -611,9 +593,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'sales_price_per_kg',
|
||||
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.average_sales_price
|
||||
? formatNumber(summaryTotal.average_sales_price)
|
||||
? formatCurrency(summaryTotal.average_sales_price)
|
||||
: '-'}
|
||||
</div>
|
||||
),
|
||||
@@ -624,7 +606,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'hpp_price_per_kg',
|
||||
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_hpp_price_per_kg
|
||||
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
|
||||
: '-'}
|
||||
@@ -637,7 +619,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'sales_amount',
|
||||
cell: (props) => formatCurrency(props.row.original.sales_amount),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_sales_amount
|
||||
? formatCurrency(summaryTotal.total_sales_amount)
|
||||
: '-'}
|
||||
@@ -688,6 +670,27 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
<Table
|
||||
data={data}
|
||||
columns={getTableColumns()}
|
||||
pageSize={filterParams.pageSize}
|
||||
page={
|
||||
isResponseSuccess(dailyMarketings)
|
||||
? dailyMarketings?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(dailyMarketings)
|
||||
? dailyMarketings?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={(newPage) =>
|
||||
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
|
||||
}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setFilterParams((prevVal) => ({
|
||||
...prevVal,
|
||||
pageSize: newPageSize,
|
||||
}))
|
||||
}
|
||||
isLoading={isLoading}
|
||||
renderFooter={data.length > 0}
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ColumnDef, Row, flexRender } from '@tanstack/react-table';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SaleReportApi } from '@/services/api/report/marketing-sale';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
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 { 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 {
|
||||
HppPerFarmReport,
|
||||
HppPerFarmRow,
|
||||
HppPerFarmFlock,
|
||||
} from '@/types/api/report/hpp-per-farm';
|
||||
import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import Table from '@/components/Table';
|
||||
import HppPerKandangSkeleton from '@/components/pages/report/marketing/skeleton/HppPerKandangSkeleton';
|
||||
|
||||
interface HppPerFarmTabProps {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
const HppPerFarmTab = ({ tabId }: HppPerFarmTabProps) => {
|
||||
const [dateError, setDateError] = useState('');
|
||||
const [expandedLocations, setExpandedLocations] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
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;
|
||||
locations: OptionType<number>[];
|
||||
}>({
|
||||
initial: {
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
locations: [],
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
start_date: 'start_date',
|
||||
end_date: 'end_date',
|
||||
locations: 'location_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'hpp-per-farm-table',
|
||||
});
|
||||
|
||||
const {
|
||||
options: locationOptions,
|
||||
setInputValue: setLocationInput,
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
locations: tableFilterState.locations,
|
||||
},
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('start_date', values.start_date, true);
|
||||
updateFilter('end_date', values.end_date, true);
|
||||
updateFilter('locations', values.locations, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
const DATE_ERROR_TOAST_ID = 'hpp-farm-date-range-error';
|
||||
|
||||
const getDateRangeError = (start: string, end: string): string => {
|
||||
if (!start || !end) return '';
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
if (endDate < startDate)
|
||||
return 'Tanggal akhir tidak boleh lebih kecil dari tanggal mulai';
|
||||
const diffDays =
|
||||
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (diffDays > 31) return 'Rentang tanggal maksimal 31 hari';
|
||||
return '';
|
||||
};
|
||||
|
||||
const applyDateValidation = (start: string, end: string) => {
|
||||
const error = getDateRangeError(start, end);
|
||||
setDateError(error);
|
||||
if (error) {
|
||||
toast.error(error, { duration: Infinity, id: DATE_ERROR_TOAST_ID });
|
||||
} else {
|
||||
toast.dismiss(DATE_ERROR_TOAST_ID);
|
||||
}
|
||||
};
|
||||
|
||||
const formikResetHandler = () => {
|
||||
resetFilter();
|
||||
setDateError('');
|
||||
toast.dismiss(DATE_ERROR_TOAST_ID);
|
||||
formik.resetForm({
|
||||
values: { start_date: '', end_date: '', locations: [] },
|
||||
});
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('start_date', value);
|
||||
applyDateValidation(value, formik.values.end_date);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
formik.setFieldValue('end_date', value);
|
||||
applyDateValidation(formik.values.start_date, value);
|
||||
};
|
||||
|
||||
const isSubmitted = !!tableFilterState.start_date;
|
||||
|
||||
const { data: response, isLoading } = useSWR<
|
||||
BaseApiResponse<HppPerFarmReport>,
|
||||
AxiosError<BaseApiResponse>,
|
||||
SWRHttpKey | null
|
||||
>(
|
||||
isSubmitted
|
||||
? `${SaleReportApi.basePath}/hpp-per-farm${getTableFilterQueryString()}`
|
||||
: null,
|
||||
httpClientFetcher
|
||||
);
|
||||
|
||||
const data = isResponseSuccess(response) ? (response.data?.rows ?? []) : [];
|
||||
const summary = isResponseSuccess(response)
|
||||
? response.data?.summary
|
||||
: undefined;
|
||||
const meta =
|
||||
isResponseSuccess(response) && response.meta ? response.meta : null;
|
||||
|
||||
const toggleLocation = useCallback((locationId: number) => {
|
||||
setExpandedLocations((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(locationId)) {
|
||||
next.delete(locationId);
|
||||
} else {
|
||||
next.add(locationId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reset expansion when page changes
|
||||
useEffect(() => {
|
||||
setExpandedLocations(new Set());
|
||||
}, [tableFilterState.page]);
|
||||
|
||||
// Inject tab actions
|
||||
useEffect(() => {
|
||||
setTabActions(
|
||||
tabId,
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={{
|
||||
start_date: tableFilterState.start_date,
|
||||
end_date: tableFilterState.end_date,
|
||||
locations: tableFilterState.locations,
|
||||
}}
|
||||
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]);
|
||||
|
||||
// Open filter modal on mount when no date set
|
||||
useEffect(() => {
|
||||
if (!tableFilterState.start_date) {
|
||||
filterModal.openModal();
|
||||
}
|
||||
}, [filterModal.openModal]);
|
||||
|
||||
const columns = useMemo(
|
||||
(): ColumnDef<HppPerFarmRow>[] => [
|
||||
{
|
||||
id: 'expand',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
const hasFlocks = (row.original.flocks?.length ?? 0) > 0;
|
||||
if (!hasFlocks) return null;
|
||||
const isExpanded = expandedLocations.has(row.original.location.id);
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggleLocation(row.original.location.id)}
|
||||
className='flex items-center justify-center w-5 h-5 rounded text-base-content/50 hover:text-base-content hover:bg-base-content/10 transition-colors'
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
isExpanded
|
||||
? 'heroicons:chevron-down'
|
||||
: 'heroicons:chevron-right'
|
||||
}
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
footer: () => null,
|
||||
},
|
||||
{
|
||||
id: 'no',
|
||||
header: 'No',
|
||||
cell: (props) => props.row.index + 1,
|
||||
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
|
||||
},
|
||||
{
|
||||
id: 'farm',
|
||||
header: 'Farm',
|
||||
cell: ({ row }) => (
|
||||
<div className='font-semibold'>{row.original.location.name}</div>
|
||||
),
|
||||
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
|
||||
},
|
||||
{
|
||||
id: 'total_cost_rp',
|
||||
header: 'Total Biaya (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.total_cost_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary?.total_cost_rp ?? 0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'feed_cost_rp',
|
||||
header: 'Biaya Pakan (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.feed_cost_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ovk_cost_rp',
|
||||
header: 'Biaya OVK (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.ovk_cost_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bop_cost_rp',
|
||||
header: 'BOP (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.bop_cost_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'depreciation_rp',
|
||||
header: 'Penyusutan (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.depreciation_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'other_cost_rp',
|
||||
header: 'Biaya Lain (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.other_cost_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'egg_weight_recording_kg',
|
||||
header: 'Bobot Telur Recording (KG)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatNumber(row.original.egg_weight_recording_kg)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatNumber(summary?.total_egg_weight_recording_kg ?? 0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'egg_weight_do_kg',
|
||||
header: 'Bobot Telur DO (KG)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatNumber(row.original.egg_weight_do_kg)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatNumber(summary?.total_egg_weight_do_kg ?? 0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'hpp_per_kg_production',
|
||||
header: 'HPP/KG Produksi (RP/KG)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.hpp_per_kg_production)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary?.average_hpp_per_kg_production ?? 0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'hpp_per_kg_sales',
|
||||
header: 'HPP/KG Penjualan (RP/KG)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.hpp_per_kg_sales)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
{formatCurrency(summary?.average_hpp_per_kg_sales ?? 0)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'average_doc_price_rp',
|
||||
header: 'Rata-rata Harga DOC (RP)',
|
||||
cell: ({ row }) => (
|
||||
<div className='text-right'>
|
||||
{formatCurrency(row.original.average_doc_price_rp)}
|
||||
</div>
|
||||
),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>-</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[expandedLocations, toggleLocation, summary]
|
||||
);
|
||||
|
||||
const renderCustomRow = useCallback(
|
||||
(row: Row<HppPerFarmRow>): React.ReactNode => {
|
||||
const isExpanded = expandedLocations.has(row.original.location.id);
|
||||
const flocks = row.original.flocks ?? [];
|
||||
|
||||
const locationRow = (
|
||||
<tr
|
||||
key={row.id}
|
||||
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border-gray-200'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (!isExpanded || flocks.length === 0) {
|
||||
return locationRow;
|
||||
}
|
||||
|
||||
const flockRows = flocks.map((flock: HppPerFarmFlock, i: number) => (
|
||||
<tr
|
||||
key={`flock-${flock.project_flock_id}`}
|
||||
className='bg-gray-50/70 hover:bg-gray-100 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200 [&_td]:px-4 [&_td]:py-2.5 [&_td]:text-xs [&_td]:text-gray-600 [&_td]:whitespace-nowrap'
|
||||
>
|
||||
<td />
|
||||
<td className='text-gray-500'>{i + 1}</td>
|
||||
<td className='pl-6 text-gray-700 italic'>{flock.flock_name}</td>
|
||||
<td className='text-right'>{formatCurrency(flock.total_cost_rp)}</td>
|
||||
<td className='text-right'>{formatCurrency(flock.feed_cost_rp)}</td>
|
||||
<td className='text-right'>{formatCurrency(flock.ovk_cost_rp)}</td>
|
||||
<td className='text-right'>{formatCurrency(flock.bop_cost_rp)}</td>
|
||||
<td className='text-right'>
|
||||
{formatCurrency(flock.depreciation_rp)}
|
||||
</td>
|
||||
<td className='text-right'>{formatCurrency(flock.other_cost_rp)}</td>
|
||||
<td className='text-right'>
|
||||
{formatNumber(flock.egg_weight_recording_kg)}
|
||||
</td>
|
||||
<td className='text-right'>{formatNumber(flock.egg_weight_do_kg)}</td>
|
||||
<td className='text-right'>
|
||||
{formatCurrency(flock.hpp_per_kg_production)}
|
||||
</td>
|
||||
<td className='text-right'>
|
||||
{formatCurrency(flock.hpp_per_kg_sales)}
|
||||
</td>
|
||||
<td className='text-right'>
|
||||
{formatCurrency(flock.average_doc_price_rp)}
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
return [locationRow, ...flockRows];
|
||||
},
|
||||
[expandedLocations]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||
{!isSubmitted ? (
|
||||
<HppPerKandangSkeleton
|
||||
columns={
|
||||
columns as unknown as ColumnDef<HppPerKandangReport['rows'][0]>[]
|
||||
}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:funnel'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
title='No Filters Selected'
|
||||
subtitle='Please choose filters to narrow down your results and make your search easier.'
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<HppPerKandangSkeleton
|
||||
columns={
|
||||
columns as unknown as ColumnDef<HppPerKandangReport['rows'][0]>[]
|
||||
}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:document-report'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
title='Memuat Data HPP Per Farm'
|
||||
subtitle='Silakan tunggu sebentar...'
|
||||
/>
|
||||
) : data.length === 0 ? (
|
||||
<HppPerKandangSkeleton
|
||||
columns={
|
||||
columns as unknown as ColumnDef<HppPerKandangReport['rows'][0]>[]
|
||||
}
|
||||
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.'
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
data={data}
|
||||
columns={columns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={tableFilterState.page}
|
||||
totalItems={meta?.total_results ?? 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
renderFooter={data.length > 0}
|
||||
renderCustomRow={renderCustomRow}
|
||||
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',
|
||||
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',
|
||||
tableFooterClassName:
|
||||
'bg-gray-100 font-semibold border border-gray-200',
|
||||
footerRowClassName: 'border-t-2 border-gray-300',
|
||||
footerColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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'>
|
||||
{/* Date Range Filter */}
|
||||
<div>
|
||||
<label className='block text-xs font-semibold text-base-content py-2'>
|
||||
Periode
|
||||
</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
|
||||
isError={!!dateError}
|
||||
/>
|
||||
<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={!!dateError}
|
||||
/>
|
||||
</div>
|
||||
{dateError && (
|
||||
<div className='text-error text-xs mt-1'>{dateError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location Filter */}
|
||||
<SelectInputCheckbox
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={formik.values.locations}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('locations', Array.isArray(val) ? val : [])
|
||||
}
|
||||
onInputChange={setLocationInput}
|
||||
isLoading={isLoadingLocations}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
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={!!dateError || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HppPerFarmTab;
|
||||
@@ -40,6 +40,8 @@ interface HppPerKandangTabProps {
|
||||
}
|
||||
|
||||
interface FilterParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
area_id?: string;
|
||||
location_id?: string;
|
||||
kandang_id?: string;
|
||||
@@ -108,6 +110,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<HppPerKandangFilterType>({
|
||||
initialValues: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
kandang_id: null,
|
||||
@@ -120,6 +124,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
validationSchema: HppPerKandangFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
setFilterParams({
|
||||
page: values.page || undefined,
|
||||
pageSize: values.pageSize || undefined,
|
||||
area_id: values.area_id || undefined,
|
||||
location_id: values.location_id || undefined,
|
||||
kandang_id: values.kandang_id || undefined,
|
||||
@@ -146,8 +152,19 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
page: formik.values.page,
|
||||
pageSize: formik.values.pageSize,
|
||||
area_id: filterParams.area_id || null,
|
||||
location_id: filterParams.location_id || null,
|
||||
kandang_id: filterParams.kandang_id || null,
|
||||
weight_min: filterParams.weight_min || null,
|
||||
weight_max: filterParams.weight_max || null,
|
||||
period: filterParams.period || null,
|
||||
sort_by: filterParams.sort_by || null,
|
||||
show_unrecorded: filterParams.show_unrecorded ?? false,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== WEIGHT CHANGE HANDLERS =====
|
||||
@@ -257,6 +274,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
period: filterParams.period,
|
||||
sort_by: filterParams.sort_by,
|
||||
show_unrecorded: filterParams.show_unrecorded,
|
||||
page: filterParams.page,
|
||||
pageSize: filterParams.pageSize,
|
||||
};
|
||||
|
||||
return ['hpp-per-kandang-report', params];
|
||||
@@ -271,7 +290,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
params.weight_max,
|
||||
params.period,
|
||||
params.sort_by,
|
||||
params.show_unrecorded
|
||||
params.show_unrecorded,
|
||||
params.page,
|
||||
params.pageSize
|
||||
)
|
||||
);
|
||||
|
||||
@@ -321,7 +342,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
params.weight_max,
|
||||
params.period,
|
||||
params.sort_by,
|
||||
params.show_unrecorded
|
||||
params.show_unrecorded,
|
||||
params.page,
|
||||
params.limit
|
||||
);
|
||||
|
||||
return isResponseSuccess(response) ? response.data : null;
|
||||
@@ -466,6 +489,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={filterParams}
|
||||
excludeFields={['page', 'pageSize']}
|
||||
onClick={() => handleFilterModalOpenRef.current()}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
@@ -845,6 +869,25 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
<Table
|
||||
data={data}
|
||||
columns={getTableColumns()}
|
||||
pageSize={filterParams.pageSize}
|
||||
page={
|
||||
isResponseSuccess(hppPerKandang) ? hppPerKandang?.meta?.page : 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(hppPerKandang)
|
||||
? hppPerKandang?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={(newPage) =>
|
||||
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
|
||||
}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setFilterParams((prevVal) => ({
|
||||
...prevVal,
|
||||
pageSize: newPageSize,
|
||||
}))
|
||||
}
|
||||
isLoading={isLoading}
|
||||
renderFooter={data.length > 0}
|
||||
renderCustomRow={renderCustomRow}
|
||||
className={{
|
||||
|
||||
+36
-1
@@ -263,8 +263,43 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
const restoredAreaId = filterParams.area_id
|
||||
? areaOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.area_id
|
||||
) || { value: filterParams.area_id, label: filterParams.area_id }
|
||||
: null;
|
||||
const restoredLocationId = filterParams.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.location_id
|
||||
) || {
|
||||
value: filterParams.location_id,
|
||||
label: filterParams.location_id,
|
||||
}
|
||||
: null;
|
||||
const restoredProjectFlockId = filterParams.project_flock_id
|
||||
? projectFlockOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.project_flock_id
|
||||
) || {
|
||||
value: filterParams.project_flock_id,
|
||||
label: filterParams.project_flock_id,
|
||||
}
|
||||
: null;
|
||||
const restoredKandangId = filterParams.project_flock_kandang_id
|
||||
? projectFlockKandangOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.project_flock_kandang_id
|
||||
) || {
|
||||
value: filterParams.project_flock_kandang_id,
|
||||
label: filterParams.project_flock_kandang_id,
|
||||
}
|
||||
: null;
|
||||
|
||||
formik.setValues({
|
||||
area_id: restoredAreaId,
|
||||
location_id: restoredLocationId,
|
||||
project_flock_id: restoredProjectFlockId,
|
||||
kandang_id: restoredKandangId,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
|
||||
|
||||
+11
-1
@@ -197,6 +197,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
icon: 'heroicons-outline:folder',
|
||||
permission: [
|
||||
'lti.inventory.product_stock.list',
|
||||
'lti.inventory.stock_log.list',
|
||||
'lti.inventory.product_warehouses.list',
|
||||
'lti.inventory.transfer.list',
|
||||
],
|
||||
@@ -204,7 +205,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
{
|
||||
text: 'Stok Produk',
|
||||
link: '/inventory/product',
|
||||
permission: ['lti.inventory.product_stock.list'],
|
||||
permission: [
|
||||
'lti.inventory.product_stock.list',
|
||||
'lti.inventory.stock_log.list',
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Penyesuaian Stok',
|
||||
@@ -236,6 +240,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
'lti.master.uoms.list',
|
||||
'lti.master.warehouses.list',
|
||||
'lti.master.production_standards.list',
|
||||
'lti.system_settings.update',
|
||||
],
|
||||
submenu: [
|
||||
{
|
||||
@@ -303,6 +308,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
link: '/master-data/production-standard',
|
||||
permission: ['lti.master.production_standards.list'],
|
||||
},
|
||||
{
|
||||
text: 'Konfigurasi Sistem',
|
||||
link: '/master-data/system-config',
|
||||
permission: ['lti.system_settings.update'],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -218,4 +218,6 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
|
||||
'/master-data/production-standard/detail/edit/': [
|
||||
'lti.master.production_standards.update',
|
||||
],
|
||||
|
||||
'/master-data/system-config/': ['lti.system_settings.update'],
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ interface DatePickerProps {
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
formatDisplay?: (date: string) => string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
@@ -28,6 +29,7 @@ export function DatePicker({
|
||||
disabled = false,
|
||||
placeholder = 'Select date',
|
||||
formatDisplay,
|
||||
hasError = false,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
@@ -154,7 +156,7 @@ export function DatePicker({
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={disabled}
|
||||
className='w-full justify-start text-left font-normal border-gray-200 hover:bg-gray-50'
|
||||
className={`w-full justify-start text-left font-normal hover:bg-gray-50 ${hasError ? 'border-red-500 focus:ring-red-500' : 'border-gray-200'}`}
|
||||
>
|
||||
<CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
|
||||
{date ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user