mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Compare commits
377 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82dca3b57e | |||
| 6668c7b1f9 | |||
| ce4f50c92a | |||
| 146192a5b3 | |||
| 27c24e7c82 | |||
| a99a399f09 | |||
| 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 | |||
| b9ef0fa338 | |||
| 6d8cdeffe9 | |||
| 2e36247a1a | |||
| 37cd990b4f | |||
| bdc7ac4d22 | |||
| b6c2f36dd1 | |||
| 10cc4bee72 | |||
| ff6bcf019b | |||
| bb0508d456 | |||
| d6dd5e6709 | |||
| 3c75a7631a | |||
| e3d3e744b0 | |||
| 5767a078d9 | |||
| 67c7e85ba8 | |||
| c5a5582147 | |||
| 46cb8a7d61 | |||
| 0189733dec | |||
| d0c3581f57 | |||
| e7569b7448 | |||
| 69b998a61a | |||
| c50c110005 | |||
| 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 | |||
| 7026619249 | |||
| 3945142966 | |||
| b19099cea2 | |||
| f65593de25 | |||
| a5f1a6ea75 | |||
| 4e58f20ba3 | |||
| dc41d6ce73 | |||
| 8869c9df2c | |||
| f2b3f2b584 | |||
| 31cea258a7 | |||
| 53d7439300 | |||
| 28a1852de8 | |||
| ff92073d19 | |||
| 6ffc2c2806 | |||
| 9e402e373c | |||
| 2e4c19b714 | |||
| 039c926e2d | |||
| e52ba7b394 | |||
| 90dc7c80f2 | |||
| 15c883ca73 | |||
| 47f74b8842 | |||
| ef9009b304 | |||
| ce25758a17 | |||
| 371b236e25 | |||
| a54dd1fa9e | |||
| 31205a44f9 | |||
| 3c9c55e049 | |||
| 7a4f93cf0c | |||
| a738d58c37 | |||
| 46daed8fc4 | |||
| 29347c24f4 | |||
| a0f603b707 | |||
| 631e3959cd | |||
| 2a340a26f9 | |||
| e75246ff8d | |||
| 64aee33452 | |||
| 1851f0e12f | |||
| 8b3f44708d | |||
| a5fd97a175 | |||
| 2ee5d1f7bd | |||
| 6eb257705f | |||
| 9ea1d06972 | |||
| ff8833b5b3 | |||
| 2dd98fd7e3 | |||
| 76fff98d9d | |||
| 18eeabd353 | |||
| 06b5a97de3 | |||
| 5cccc0b3c6 | |||
| 7ab9518a55 | |||
| ac51229398 | |||
| 5a2532a0fa | |||
| f9d2a875e2 | |||
| 6cf8e463c6 | |||
| 4206408db1 | |||
| ff2ed8757f | |||
| 0c5ee08f90 | |||
| bbf9581d3a | |||
| 5830ab4c67 | |||
| a1a0b71814 | |||
| 2b3b6b9549 | |||
| be3034a94e | |||
| a11d05e720 | |||
| cb454e7eb7 | |||
| a6d6c53069 | |||
| c875ebd951 | |||
| a369386922 | |||
| b3198a44e9 | |||
| b2dfb8fec6 | |||
| d4d77bb13a | |||
| 7dfa5233f3 | |||
| 3d910f78db | |||
| 2dfac0be72 | |||
| afe0d2161d | |||
| 68c13c48c7 | |||
| b9a1e94a29 | |||
| d8c6a90c55 | |||
| 4d01ad7d1d | |||
| c487e7f53e | |||
| a316120a78 | |||
| 9af0537587 | |||
| f668bcecb8 | |||
| a12b09eb5f | |||
| cfb96c45c9 | |||
| 747b0f9c2c | |||
| ee2f530d81 | |||
| 617124efe4 | |||
| c0337f4d67 | |||
| e5dcca3408 | |||
| f2b05856bb | |||
| 5d6aaace86 | |||
| 9dcb3d7269 | |||
| e96bb46cfd | |||
| 37edc957d2 | |||
| 60df577cc6 | |||
| e0e2b0c406 | |||
| 244be32b59 | |||
| 1080a26f93 | |||
| 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 | |||
| c5a0cfe118 | |||
| 898bbd57ec | |||
| 5b5113de6e | |||
| 267a6f37cc | |||
| 7d4898c266 | |||
| f49822d03d | |||
| aa4da686c6 | |||
| 5a668c469f | |||
| 2ca733de97 | |||
| 8afc1a6381 | |||
| d47142153e | |||
| 188385b638 | |||
| 08aa79a06b | |||
| 16741aaa46 | |||
| 93083c7d2a | |||
| 8333b5138a | |||
| c0ee2013f3 | |||
| 022656cd80 | |||
| 9d3c22fcf3 | |||
| 11353809f0 | |||
| 6463b7a572 | |||
| 7a5ee2aca1 | |||
| 5e907d7e53 | |||
| 71edc9c68a | |||
| 2a33fdbbbe | |||
| 178c659b58 | |||
| c1d6436583 | |||
| 8dc62453bd | |||
| 244d800874 | |||
| 52dd1613bb | |||
| 57ea81fdf2 | |||
| 90742604cb | |||
| 4b8853b766 | |||
| 7168270527 | |||
| 47b186e195 | |||
| ff39514b78 | |||
| f97b6fc218 | |||
| 62dc8235d4 | |||
| 73d05d6b4b | |||
| 3c44906a20 | |||
| e4b1deecdc | |||
| 12afa88f2c | |||
| 0527155bb9 | |||
| 34a45d084b | |||
| 9de897dfbd | |||
| 907f6664e1 | |||
| 3ad04e5bac | |||
| 4649dfde89 | |||
| b580a01bdc | |||
| 15ec6c3b9c | |||
| 8b970aeb64 | |||
| de6fd2367e | |||
| 3fedbc7ffb | |||
| 9e297cc0a4 | |||
| 6ff3a715e0 | |||
| cd4cef883e | |||
| d853781c17 | |||
| 8faed2e561 | |||
| 8aeef46ee3 | |||
| c9000c1e2c | |||
| bb3541090a | |||
| ad0e617ed0 | |||
| 73ef1c2ece | |||
| 9cf0d15c33 | |||
| c66f7b1cbf | |||
| 17ebc31f00 | |||
| ce0b4d744c | |||
| f6f3290743 | |||
| 31a4dec8a3 | |||
| fbb6f87368 | |||
| d88d71fb16 | |||
| 570024c2e6 | |||
| 70556d04ba | |||
| 00434002a7 | |||
| bcb1e0b5b6 | |||
| 03a6aabf1f | |||
| 47adaa4f92 | |||
| f2cdbd497a | |||
| 4ffea739a9 | |||
| bf5591d61d | |||
| a725ae4891 | |||
| f5d3fb3b9d | |||
| 4a6c443003 | |||
| 09f4af3ece | |||
| 62d250109b | |||
| e50f4dbddb | |||
| c898154b48 | |||
| acb02c9bdc | |||
| 986f429ea9 | |||
| 1dafb0d365 | |||
| 095b1c5850 | |||
| 4297502c55 | |||
| 726065da51 | |||
| 6c03e42006 | |||
| 5c39e900f3 | |||
| 68c1655824 | |||
| 0ef8c06e41 | |||
| 68b25332b1 | |||
| b402a06706 | |||
| 7df2fad959 | |||
| 0b52fff5f5 | |||
| e251ab9eb4 | |||
| 9f0fbcf041 | |||
| 05fbae680f | |||
| 444c475cb4 | |||
| ef1ce2c78c | |||
| 429ff58bfd | |||
| 8961004000 | |||
| 2dc3bcf9f0 | |||
| 9c31705865 | |||
| b89730ab68 | |||
| 6e34eede4b | |||
| ffd5e70947 | |||
| 2f89c6f216 | |||
| ebf966228b | |||
| ebe1d77c72 | |||
| 922a93414f | |||
| 854a1e7c4c | |||
| 129a3fda44 | |||
| 981b48acc0 | |||
| dd5bbf0ac6 | |||
| 8872b283ac | |||
| 860c9dec22 | |||
| 0cd6c9bd2f | |||
| 107d412c10 | |||
| c6d8533190 | |||
| ae90d55f81 | |||
| c155717459 | |||
| d74de4b2d9 | |||
| 529ba21f47 | |||
| 449c2030fe | |||
| 679740972f | |||
| 57b8326fdf | |||
| e26b5127c5 | |||
| 1a4a1e8e56 | |||
| 10d1f05aa5 | |||
| e4b6238771 | |||
| fc89922ed1 | |||
| 25b5165249 | |||
| cae19d905b | |||
| c040c0e9bb | |||
| b3bd7563fa | |||
| edf21fbfc4 | |||
| 65f31f8340 | |||
| 772087bacd | |||
| f302bcdb4b | |||
| b68bcc77f5 | |||
| 3d7a2073b0 | |||
| 8b1546a305 | |||
| fa3d0a1179 | |||
| ffcf422cb5 | |||
| 8d92da75cf | |||
| 6b29406307 | |||
| 4f3e304b2b | |||
| 18aff48dc2 | |||
| 7bee13124d | |||
| f5d9dcbdf6 | |||
| 04d22e55db | |||
| 8bf5a88edb | |||
| aa52211b0a | |||
| 668abeca23 |
@@ -45,3 +45,9 @@ next-env.d.ts
|
||||
|
||||
# claude
|
||||
.claude
|
||||
|
||||
# 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
|
||||
npx tsc --noEmit
|
||||
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,414 @@
|
||||
# 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**
|
||||
- Clear each filter with `updateFilter(fieldName, defaultValue, true)`
|
||||
- Call `formik.resetForm({ values: { ...defaults } })`
|
||||
- Close the modal at the end
|
||||
- Attach to both button `onClick` and form `onReset` handler
|
||||
|
||||
**Optimization: Avoid useCallback for simple handlers**
|
||||
|
||||
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
|
||||
- Simple pass-through handlers don't need it:
|
||||
|
||||
```tsx
|
||||
// ✅ Good: Simple handler without useCallback
|
||||
const handleFilterChange = (val) => setFieldValue('location', val);
|
||||
|
||||
// ❌ Avoid: Unnecessary useCallback overhead
|
||||
const handleFilterChange = useCallback(
|
||||
(val) => setFieldValue('location', val),
|
||||
[setFieldValue]
|
||||
);
|
||||
```
|
||||
|
||||
**Best practice: Store OptionType objects directly, not IDs**
|
||||
|
||||
For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
|
||||
|
||||
```tsx
|
||||
// Type the useTableFilter with the filter state structure
|
||||
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
|
||||
search: string;
|
||||
locationFilter?: OptionType<string>;
|
||||
picFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
locationFilter: undefined,
|
||||
picFilter: undefined
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
locationFilter: 'location_id',
|
||||
picFilter: 'pic_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'kandangs-table',
|
||||
});
|
||||
|
||||
// Initialize formik with tableFilterState values (now typed OptionType objects)
|
||||
const formik = useFormik<KandangFilterType>({
|
||||
initialValues: {
|
||||
location: tableFilterState.locationFilter,
|
||||
pic: tableFilterState.picFilter,
|
||||
},
|
||||
...
|
||||
});
|
||||
|
||||
// Handlers store the complete OptionType, not just the ID
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val) => setFieldValue('location', val),
|
||||
[setFieldValue]
|
||||
);
|
||||
|
||||
// Use formik values directly in select inputs (no computed helpers needed)
|
||||
<SelectInput
|
||||
value={formik.values.location}
|
||||
onChange={handleFilterLocationChange}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
**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/`
|
||||
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
|
||||
|
||||
## 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 -->
|
||||
+2
-1
@@ -7,9 +7,10 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"format": "prettier --write .",
|
||||
"pre-commit": "npm run format && npm run lint && npx tsc --noEmit && npm run build"
|
||||
"pre-commit": "npm run format && npm run lint && npm run typecheck && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.1",
|
||||
|
||||
@@ -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;
|
||||
@@ -11,10 +11,13 @@ const RecordingEdit = () => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const recordingId = searchParams.get('recordingId');
|
||||
const recordingDetailKey = recordingId
|
||||
? ['recording-detail', recordingId]
|
||||
: null;
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId,
|
||||
(id: string) => RecordingApi.getSingle(parseInt(id))
|
||||
recordingDetailKey,
|
||||
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
|
||||
@@ -11,10 +11,13 @@ const RecordingDetail = () => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const recordingId = searchParams.get('recordingId');
|
||||
const recordingDetailKey = recordingId
|
||||
? ['recording-detail', recordingId]
|
||||
: null;
|
||||
|
||||
const { data: recording, isLoading: isLoadingRecording } = useSWR(
|
||||
recordingId,
|
||||
(id: string) => RecordingApi.getSingle(parseInt(id))
|
||||
recordingDetailKey,
|
||||
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
|
||||
);
|
||||
|
||||
if (!recordingId) {
|
||||
|
||||
@@ -51,7 +51,7 @@ const Button = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!href && (
|
||||
{(!href || (href && disabled)) && (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
@@ -68,9 +68,9 @@ const Button = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{href && (
|
||||
{href && !disabled && (
|
||||
<Link
|
||||
href={disabled ? '#' : href}
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
aria-disabled={disabled}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -35,7 +35,9 @@ const NumberInput = ({
|
||||
| undefined;
|
||||
|
||||
if (newChangeEvent) {
|
||||
newChangeEvent.target.value = numberFormatValues.value;
|
||||
newChangeEvent.target.value = parseFloat(
|
||||
numberFormatValues.value
|
||||
) as unknown as string;
|
||||
|
||||
onChange?.(newChangeEvent);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -566,23 +566,31 @@ const useSelect = <T,>(
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
|
||||
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
|
||||
|
||||
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
|
||||
|
||||
if (isResponseSuccess(pages?.[latestPagesIndex])) {
|
||||
formattedSuccessRawData = {
|
||||
...pages?.[latestPagesIndex],
|
||||
data:
|
||||
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
|
||||
[],
|
||||
};
|
||||
}
|
||||
const { formattedSuccessRawData, formattedErrorRawData } = useMemo(() => {
|
||||
let successData: SuccessApiResponse<T[]> | undefined = undefined;
|
||||
let errorData: ErrorApiResponse | undefined = undefined;
|
||||
|
||||
if (isResponseError(pages?.[latestPagesIndex])) {
|
||||
formattedErrorRawData = pages?.[latestPagesIndex];
|
||||
}
|
||||
if (isResponseSuccess(pages?.[latestPagesIndex])) {
|
||||
successData = {
|
||||
...pages![latestPagesIndex],
|
||||
data:
|
||||
pages?.flatMap((page) =>
|
||||
isResponseSuccess(page) ? page.data : []
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isResponseError(pages?.[latestPagesIndex])) {
|
||||
errorData = pages![latestPagesIndex];
|
||||
}
|
||||
|
||||
return {
|
||||
formattedSuccessRawData: successData,
|
||||
formattedErrorRawData: errorData,
|
||||
};
|
||||
}, [pages, latestPagesIndex]);
|
||||
|
||||
return {
|
||||
inputValue,
|
||||
|
||||
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
|
||||
secondaryButton={
|
||||
secondaryButton
|
||||
? {
|
||||
...secondaryButton,
|
||||
text: secondaryButton?.text ?? 'Tidak',
|
||||
onClick: (e) => {
|
||||
if (secondaryButton && secondaryButton?.onClick) {
|
||||
|
||||
@@ -112,12 +112,11 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
|
||||
kandangData={kandangData}
|
||||
/>
|
||||
|
||||
{!kandangData && (
|
||||
<ClosingKandangList
|
||||
initialValue={initialValue}
|
||||
projectData={projectData}
|
||||
/>
|
||||
)}
|
||||
<ClosingKandangList
|
||||
initialValue={initialValue}
|
||||
projectData={projectData}
|
||||
selectedKandangId={kandangData?.id}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
activeTabId={activeTabId}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
const ClosingKandangList = ({
|
||||
initialValue,
|
||||
projectData,
|
||||
selectedKandangId,
|
||||
}: {
|
||||
initialValue?: ClosingGeneralInformation;
|
||||
projectData?: ProjectFlock;
|
||||
selectedKandangId?: number;
|
||||
}) => {
|
||||
return (
|
||||
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
|
||||
@@ -22,6 +24,9 @@ const ClosingKandangList = ({
|
||||
variant='outline'
|
||||
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
|
||||
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
|
||||
disabled={
|
||||
selectedKandangId === kandang.project_flock_kandang_id
|
||||
}
|
||||
>
|
||||
{kandang.name}
|
||||
</Button>
|
||||
|
||||
@@ -276,7 +276,7 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
|
||||
{
|
||||
id: 'kandang',
|
||||
accessorKey: 'kandang',
|
||||
header: 'Kandang',
|
||||
header: 'Kandang Atribusi',
|
||||
cell: (props) => {
|
||||
const kandang = props.getValue() as Kandang;
|
||||
return kandang?.name || '-';
|
||||
|
||||
@@ -127,11 +127,11 @@ const ClosingOutgoingSapronaksTable = ({
|
||||
},
|
||||
{
|
||||
accessorKey: 'source_warehouse',
|
||||
header: 'Gudang Asal',
|
||||
header: 'Gudang Asal (Fisik)',
|
||||
},
|
||||
{
|
||||
accessorKey: 'destination_warehouse',
|
||||
header: 'Gudang Tujuan',
|
||||
header: 'Gudang Tujuan (Fisik)',
|
||||
},
|
||||
{
|
||||
accessorKey: 'quantity',
|
||||
|
||||
@@ -9,8 +9,11 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { DashboardApi } from '@/services/api/dashboard';
|
||||
import { useFormik } from 'formik';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import {
|
||||
ProjectFlockApi,
|
||||
ProjectFlockKandangApi,
|
||||
} from '@/services/api/production';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
|
||||
import {
|
||||
DashboardFilterType,
|
||||
@@ -22,10 +25,7 @@ import DashboardExportCharts, {
|
||||
DashboardExportChartsRef,
|
||||
} from '@/components/pages/dashboard/export/DashboardExportCharts';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
|
||||
import {
|
||||
DashboardFilter,
|
||||
DashboardMeta,
|
||||
} from '@/types/api/dashboard/dashboard';
|
||||
import { DashboardMeta } from '@/types/api/dashboard/dashboard';
|
||||
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import AlertErrorList from '@/components/helper/form/FormErrors';
|
||||
@@ -42,6 +42,8 @@ import { cn } from '@/lib/helper';
|
||||
import DashboardExportStats, {
|
||||
DashboardExportStatsRef,
|
||||
} from '@/components/pages/dashboard/export/DashboardExportStats';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
|
||||
|
||||
// Helper function to normalize values to array
|
||||
const normalizeToArray = (
|
||||
@@ -68,7 +70,6 @@ const DashboardProduction = () => {
|
||||
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
|
||||
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
|
||||
);
|
||||
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
|
||||
normalizeToArray(filterValues.location)
|
||||
);
|
||||
@@ -80,9 +81,29 @@ const DashboardProduction = () => {
|
||||
const {
|
||||
data: dashboardProductionResponse,
|
||||
isLoading: isLoadingDashboardProductionData,
|
||||
mutate: refreshDashboardProductionData,
|
||||
} = useSWR(endpointUrl, () =>
|
||||
DashboardApi.getDashboardProductionFetcher(endpointUrl)
|
||||
} = useSWR(
|
||||
[
|
||||
'dashboard-production',
|
||||
filterValues.startDate ?? '',
|
||||
filterValues.endDate ?? '',
|
||||
filterValues.analysisMode ?? 'OVERVIEW',
|
||||
normalizeToArray(filterValues.location).toString(),
|
||||
normalizeToArray(filterValues.flock).toString(),
|
||||
normalizeToArray(filterValues.kandang).toString(),
|
||||
filterValues.comparisonType ?? '',
|
||||
],
|
||||
() =>
|
||||
DashboardApi.getDashboardProductionFetcher({
|
||||
start_date: filterValues.startDate || '',
|
||||
end_date: filterValues.endDate || '',
|
||||
analysis_mode:
|
||||
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') ||
|
||||
'OVERVIEW',
|
||||
location_ids: normalizeToArray(filterValues.location),
|
||||
flock_ids: normalizeToArray(filterValues.flock),
|
||||
kandang_ids: normalizeToArray(filterValues.kandang),
|
||||
comparison_type: filterValues.comparisonType || '',
|
||||
})
|
||||
);
|
||||
|
||||
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
|
||||
@@ -95,23 +116,23 @@ const DashboardProduction = () => {
|
||||
options: flockOptions,
|
||||
isLoadingOptions: isLoadingFlockOptions,
|
||||
loadMore: loadMoreFlock,
|
||||
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
setInputValue: setInputValueLocation,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name');
|
||||
const {
|
||||
setInputValue: setInputValueKandang,
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
|
||||
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
|
||||
});
|
||||
|
||||
const comparisonTypeOptions = [
|
||||
{ value: 'FARM', label: 'Farm' },
|
||||
{ value: 'FLOCK', label: 'Flock' },
|
||||
@@ -135,68 +156,43 @@ const DashboardProduction = () => {
|
||||
enableReinitialize: true,
|
||||
validationSchema: getDashboardFilterSchema(analysisMode),
|
||||
onSubmit: (values) => {
|
||||
// Save filter values to store
|
||||
setFilterValues(values);
|
||||
|
||||
handleApplyFilter({
|
||||
start_date: values.startDate || '',
|
||||
end_date: values.endDate || '',
|
||||
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
|
||||
location_ids: normalizeToArray(values.location),
|
||||
flock_ids: normalizeToArray(values.flock),
|
||||
kandang_ids: normalizeToArray(values.kandang),
|
||||
comparison_type: values.comparisonType,
|
||||
});
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm } = formik;
|
||||
|
||||
const selectedLocationValues = normalizeToArray(formik.values.location);
|
||||
const selectedFlockValues = normalizeToArray(formik.values.flock);
|
||||
|
||||
const {
|
||||
setInputValue: setInputValueKandang,
|
||||
options: kandangOptions,
|
||||
isLoadingOptions: isLoadingKandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect<ProjectFlockKandang>(
|
||||
ProjectFlockKandangApi.basePath,
|
||||
'kandang_id',
|
||||
'kandang.name',
|
||||
'search',
|
||||
{
|
||||
location_id:
|
||||
selectedLocationValues.length > 0
|
||||
? selectedLocationValues.toString()
|
||||
: '',
|
||||
project_flock_id:
|
||||
selectedFlockValues.length > 0 ? selectedFlockValues.toString() : '',
|
||||
}
|
||||
);
|
||||
|
||||
const handleResetFilter = useCallback(() => {
|
||||
resetForm();
|
||||
resetFilterValues(); // Clear stored filter values
|
||||
setAnalysisMode('OVERVIEW');
|
||||
setEndpointUrl('/dashboards');
|
||||
setSelectedLocationIds([]);
|
||||
}, [resetForm, resetFilterValues]);
|
||||
|
||||
const handleApplyFilter = useCallback(
|
||||
(values: DashboardFilter) => {
|
||||
// Build query params object, only include non-empty values
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (values.start_date) params.start_date = values.start_date;
|
||||
if (values.end_date) params.end_date = values.end_date;
|
||||
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
|
||||
if (values.location_ids.length > 0)
|
||||
params.location_ids = values.location_ids.toString();
|
||||
if (values.flock_ids.length > 0)
|
||||
params.flock_ids = values.flock_ids.toString();
|
||||
if (values.kandang_ids.length > 0)
|
||||
params.kandang_ids = values.kandang_ids.toString();
|
||||
if (values.comparison_type)
|
||||
params.comparison_type = values.comparison_type;
|
||||
|
||||
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
|
||||
filterModal.closeModal();
|
||||
refreshDashboardProductionData();
|
||||
},
|
||||
[filterModal, refreshDashboardProductionData]
|
||||
);
|
||||
|
||||
// ===== Load filter from store on mount =====
|
||||
useEffect(() => {
|
||||
if (!filterValues) return;
|
||||
handleApplyFilter({
|
||||
start_date: filterValues.startDate,
|
||||
end_date: filterValues.endDate,
|
||||
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
|
||||
location_ids: normalizeToArray(filterValues.location),
|
||||
flock_ids: normalizeToArray(filterValues.flock),
|
||||
kandang_ids: normalizeToArray(filterValues.kandang),
|
||||
comparison_type: filterValues.comparisonType,
|
||||
});
|
||||
}, [filterValues, handleApplyFilter]);
|
||||
filterModal.closeModal();
|
||||
}, [filterModal, resetForm, resetFilterValues]);
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
|
||||
@@ -268,14 +264,6 @@ const DashboardProduction = () => {
|
||||
};
|
||||
}, [clearNavbarActions]);
|
||||
|
||||
if (isLoadingDashboardProductionData) {
|
||||
return (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
<span className='loading loading-spinner loading-xl'></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full p-3 space-y-3'>
|
||||
@@ -327,9 +315,15 @@ const DashboardProduction = () => {
|
||||
</div>
|
||||
{/* Dashboard Stats */}
|
||||
<div>
|
||||
<DashboardStats
|
||||
data={dashboardProductionData?.statistics_data ?? []}
|
||||
/>
|
||||
{isLoadingDashboardProductionData ? (
|
||||
<div className='w-full min-h-screen flex items-center justify-center'>
|
||||
<span className='loading loading-spinner loading-xl'></span>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardStats
|
||||
data={dashboardProductionData?.statistics_data ?? []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use DashboardLineChart component or skeleton */}
|
||||
@@ -537,6 +531,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -573,6 +568,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
) : (
|
||||
<SelectInputRadio
|
||||
@@ -604,6 +600,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -643,6 +640,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
) : (
|
||||
<SelectInputRadio
|
||||
@@ -669,6 +667,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -707,6 +706,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
) : (
|
||||
<SelectInputRadio
|
||||
@@ -733,6 +733,7 @@ const DashboardProduction = () => {
|
||||
className={{
|
||||
select: 'rounded-lg text-sm border-base-content/10',
|
||||
}}
|
||||
isClearable={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -279,8 +323,6 @@ const ExpenseRequestContent = ({
|
||||
)}
|
||||
|
||||
<div className='w-full mt-4 flex flex-col gap-4'>
|
||||
{/* TODO: apply RBAC */}
|
||||
|
||||
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
|
||||
{isCurrentApprovalOnHeadArea && (
|
||||
<RequirePermission permissions='lti.expense.approve.head_area'>
|
||||
@@ -367,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
|
||||
@@ -380,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} />
|
||||
@@ -521,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>
|
||||
@@ -536,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}
|
||||
@@ -746,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>({
|
||||
@@ -207,7 +210,7 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
// add new realizations for each kandang
|
||||
kandangs.forEach((kandangItem) => {
|
||||
if (!kandangItem.id) return;
|
||||
if (isNaN(Number(kandangItem.id))) return;
|
||||
|
||||
const existingRealization = formik.values.realizations?.find(
|
||||
(realizationItem) => realizationItem.kandang_id === kandangItem.id
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -35,6 +35,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
|
||||
setInputValue: setNonstockInputValue,
|
||||
options: nonstockOptions,
|
||||
isLoadingOptions: isLoadingNonstockOptions,
|
||||
loadMore: loadMoreNonstocks,
|
||||
} = useSelect<Nonstock>(
|
||||
NonstockApi.basePath,
|
||||
'id',
|
||||
@@ -164,6 +165,7 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
|
||||
options={nonstockOptions}
|
||||
isLoading={isLoadingNonstockOptions}
|
||||
onInputChange={setNonstockInputValue}
|
||||
onMenuScrollToBottom={loadMoreNonstocks}
|
||||
className={{ wrapper: 'min-w-48' }}
|
||||
isDisabled
|
||||
/>
|
||||
|
||||
@@ -178,12 +178,14 @@ const ExpenseRequestForm = ({
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setVendorInputValue,
|
||||
options: supplierOptions,
|
||||
isLoadingOptions: isLoadingVendorOptions,
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
@@ -408,6 +410,7 @@ const ExpenseRequestForm = ({
|
||||
options={locationOptions}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isError={
|
||||
formik.touched.location_id && Boolean(formik.errors.location_id)
|
||||
}
|
||||
@@ -452,6 +455,7 @@ const ExpenseRequestForm = ({
|
||||
options={supplierOptions}
|
||||
onInputChange={setVendorInputValue}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isError={
|
||||
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
|
||||
}
|
||||
|
||||
@@ -287,8 +287,8 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
|
||||
PT LUMBUNG TELUR INDONESIA
|
||||
</Text>
|
||||
<Text style={ExpensePDFStyle.companyAddress}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
|
||||
<View style={ExpensePDFStyle.doubleDivider} />
|
||||
|
||||
@@ -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';
|
||||
@@ -39,7 +38,7 @@ 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 {
|
||||
FinanceTableFilterSchema,
|
||||
FinanceTableFilterValues,
|
||||
@@ -176,9 +175,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const FinanceTable = () => {
|
||||
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
|
||||
const previousPathRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -187,14 +183,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 +203,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 =====
|
||||
@@ -235,7 +239,7 @@ const FinanceTable = () => {
|
||||
// ===== Formik for Filter =====
|
||||
const filterFormik = useFormik<FinanceTableFilterValues>({
|
||||
initialValues: {
|
||||
search: searchValue,
|
||||
search: tableFilterState.search || '',
|
||||
transaction_types: '',
|
||||
bank_ids: '',
|
||||
customer_ids: '',
|
||||
@@ -245,29 +249,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 +343,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 +402,26 @@ const FinanceTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const sorting: SortingState = tableFilterState.sort_by
|
||||
? [
|
||||
{
|
||||
id: tableFilterState.sort_by,
|
||||
desc: tableFilterState.orderBy === 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const handleSortingChange = (updater: Updater<SortingState>) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
if (next.length > 0) {
|
||||
updateFilter('sort_by', next[0].id, true);
|
||||
updateFilter('orderBy', next[0].desc ? 'desc' : 'asc', true);
|
||||
} else {
|
||||
updateFilter('sort_by', '', true);
|
||||
updateFilter('orderBy', '', true);
|
||||
}
|
||||
};
|
||||
|
||||
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const endDate = filterFormik.values.end_date;
|
||||
@@ -469,28 +482,74 @@ 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 confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -509,10 +568,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 +582,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 +592,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 +602,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 +626,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 +684,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 +745,20 @@ 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,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -741,6 +794,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 +930,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>;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -199,6 +199,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
'yyyy-MM-DD'
|
||||
),
|
||||
vehicle_number: product.vehicle_number,
|
||||
weight_per_convertion: parseFloat(
|
||||
String(product.weight_per_convertion ?? 0)
|
||||
),
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -368,7 +371,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
const currentProducts = deliveryOrderValues?.find(
|
||||
(product) => product.id == id
|
||||
);
|
||||
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
|
||||
|
||||
setSelectedDeliveryProduct(currentProducts ?? values ?? null);
|
||||
|
||||
if (id) {
|
||||
setStep(2);
|
||||
}
|
||||
@@ -430,6 +435,9 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
'yyyy-MM-DD'
|
||||
),
|
||||
vehicle_number: product.vehicle_number,
|
||||
weight_per_convertion: parseFloat(
|
||||
String(product.weight_per_convertion ?? 0)
|
||||
),
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -841,7 +849,11 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
|
||||
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
|
||||
disabled={deliveryRejected}
|
||||
>
|
||||
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 } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal from '@/components/Modal';
|
||||
@@ -10,22 +10,38 @@ import SelectInput, {
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import {
|
||||
MarketingFilterFormValues,
|
||||
MarketingFilterSchema,
|
||||
} from '@/components/pages/marketing/filter/MarketingFilter';
|
||||
import { MarketingFilter } from '@/types/api/marketing/marketing';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import { MarketingApi } from '@/services/api/marketing/marketing';
|
||||
import { CustomerApi, ProductApi } from '@/services/api/master-data';
|
||||
import { 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';
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
const MarketingFilterModal = ({
|
||||
ref,
|
||||
onSubmit,
|
||||
onReset,
|
||||
initialValues,
|
||||
}: MarketingFilterModal) => {
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
@@ -33,51 +49,35 @@ const MarketingFilterModal = ({
|
||||
|
||||
// ===== OPTIONS =====
|
||||
const {
|
||||
rawData: productsRawData,
|
||||
options: productsOptions,
|
||||
isLoadingOptions: isLoadingProductsOptions,
|
||||
setInputValue: setProductsInputValue,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', {
|
||||
limit: 'limit',
|
||||
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
|
||||
include_all: 'true',
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
const {
|
||||
options: customersOptions,
|
||||
isLoadingOptions: isLoadingCustomersOptions,
|
||||
setInputValue: setCustomersInputValue,
|
||||
loadMore: loadMoreCustomers,
|
||||
} = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', {
|
||||
limit: 'limit',
|
||||
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search', {
|
||||
has_marketing: 'true',
|
||||
});
|
||||
|
||||
const uniqueCustomersOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
return customersOptions.filter((customer) => {
|
||||
if (seen.has(customer.value)) return false;
|
||||
seen.add(customer.value);
|
||||
return true;
|
||||
});
|
||||
}, [customersOptions]);
|
||||
const {
|
||||
options: projectFlockOptions,
|
||||
rawData: projectFlocksRawData,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search'
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
...MARKETING_APPROVAL_LINE.map((item) => ({
|
||||
@@ -87,23 +87,30 @@ const MarketingFilterModal = ({
|
||||
{ value: 'DITOLAK', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const formik = useFormik<{
|
||||
product_ids: OptionType[];
|
||||
status: OptionType | null;
|
||||
customer_id: OptionType | null;
|
||||
}>({
|
||||
initialValues: {
|
||||
const formik = useFormik<MarketingFilterFormValues>({
|
||||
initialValues: initialValues || {
|
||||
product_ids: [],
|
||||
status: null,
|
||||
customer_id: null,
|
||||
customer: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
validationSchema: MarketingFilterSchema,
|
||||
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues = {
|
||||
...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() || '',
|
||||
customer_id: Number(values.customer_id?.value),
|
||||
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,
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues);
|
||||
@@ -116,18 +123,58 @@ const MarketingFilterModal = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm } = formik;
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
resetForm({
|
||||
values: {
|
||||
product_ids: [],
|
||||
status: null,
|
||||
customer: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
});
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset, closeModalHandler]);
|
||||
|
||||
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('product_ids', val as OptionType[]);
|
||||
};
|
||||
|
||||
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('customer_id', val as OptionType);
|
||||
formik.setFieldValue(
|
||||
'customer',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
);
|
||||
};
|
||||
|
||||
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
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}
|
||||
@@ -137,7 +184,7 @@ const MarketingFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -187,13 +234,44 @@ const MarketingFilterModal = ({
|
||||
label='Customer'
|
||||
isClearable
|
||||
placeholder='Pilih customer'
|
||||
options={uniqueCustomersOptions}
|
||||
options={customersOptions}
|
||||
isLoading={isLoadingCustomersOptions}
|
||||
value={formik.values.customer_id}
|
||||
value={formik.values.customer}
|
||||
onChange={customerChangeHandler}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
@@ -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,17 @@ 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: '',
|
||||
sort_by: '',
|
||||
order_by: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -181,9 +212,43 @@ 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',
|
||||
sort_by: 'sort_by',
|
||||
order_by: 'sort_order',
|
||||
},
|
||||
excludeKeysFromUrl: [
|
||||
'product_names',
|
||||
'status_name',
|
||||
'customer_name',
|
||||
'project_flock_name',
|
||||
'project_flock_kandang_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 +263,64 @@ 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
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const approveClickHandler = () => {
|
||||
setApproveAction('APPROVED');
|
||||
|
||||
if (selectedApprovalStep === 2) {
|
||||
bulkDeliveryModal.openModal();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmationModal.openModal();
|
||||
};
|
||||
|
||||
@@ -226,10 +329,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 +357,226 @@ 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,
|
||||
};
|
||||
|
||||
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 +586,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 +640,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 +697,7 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'so_do_number',
|
||||
accessorKey: 'so_number',
|
||||
header: 'No. Order',
|
||||
cell: (props) => {
|
||||
return props.row.original.do_number
|
||||
@@ -394,7 +713,7 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'approval.step_name',
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.latest_approval;
|
||||
@@ -429,10 +748,12 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'customer.name',
|
||||
accessorKey: 'customer',
|
||||
header: 'Customer',
|
||||
cell: (props) => props.row.original.customer.name,
|
||||
},
|
||||
{
|
||||
accessorKey: 'grand_total',
|
||||
accessorFn: (row) =>
|
||||
row.sales_order
|
||||
?.map((product) => product.total_price)
|
||||
@@ -449,6 +770,7 @@ const MarketingTable = () => {
|
||||
{
|
||||
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 +792,14 @@ const MarketingTable = () => {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Tanggal Dibuat',
|
||||
cell: (props) =>
|
||||
props.row.original.created_at
|
||||
? formatDate(props.row.original.created_at, 'DD MMM yyyy')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
maxSize: 80,
|
||||
@@ -504,7 +834,13 @@ const MarketingTable = () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
}, [
|
||||
deleteModal,
|
||||
deliveryModal,
|
||||
getRowCanSelect,
|
||||
productsClickHandler,
|
||||
selectedApprovalStep,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -527,7 +863,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 +877,7 @@ const MarketingTable = () => {
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Reject
|
||||
Reject ({idsToProcess.length} Item)
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permissions='lti.marketing.sales_order.approve'>
|
||||
@@ -557,7 +893,7 @@ const MarketingTable = () => {
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Approve
|
||||
Approve ({idsToProcess.length} Item)
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</>
|
||||
@@ -566,7 +902,18 @@ 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',
|
||||
'sort_by',
|
||||
'order_by',
|
||||
]}
|
||||
onClick={() => {
|
||||
filterModal.openModal();
|
||||
}}
|
||||
@@ -612,7 +959,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 +1003,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 +1037,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 +1070,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={{
|
||||
@@ -746,7 +1263,7 @@ const MarketingTable = () => {
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
header: 'Kandang',
|
||||
header: 'Gudang Fisik',
|
||||
accessorFn(row) {
|
||||
return row.product_warehouse.warehouse.name;
|
||||
},
|
||||
@@ -777,6 +1294,7 @@ const MarketingTable = () => {
|
||||
ref={filterModal.ref}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
initialValues={marketingFilterInitialValues}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -195,7 +195,9 @@ const SalesOrderFormModal = ({
|
||||
product.marketing_type?.value?.toLowerCase() === 'telur'
|
||||
? convertionUnitValue === 'PETI'
|
||||
? 'PETI'
|
||||
: 'KG' // termasuk "QTY" dan "KG"
|
||||
: convertionUnitValue === 'QTY'
|
||||
? 'QTY'
|
||||
: 'KG'
|
||||
: undefined;
|
||||
|
||||
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
|
||||
@@ -207,7 +209,6 @@ const SalesOrderFormModal = ({
|
||||
|
||||
return {
|
||||
vehicle_number: product.vehicle_number as string,
|
||||
kandang_id: product.kandang_id as number,
|
||||
product_warehouse_id: product.product_warehouse_id as number,
|
||||
unit_price: parseFloat(String(product.unit_price || 0)),
|
||||
total_weight: parseFloat(String(product.total_weight || 0)),
|
||||
@@ -245,6 +246,7 @@ const SalesOrderFormModal = ({
|
||||
})
|
||||
.filter((item) => Boolean(item)),
|
||||
} as UpdateDeliveryOrderPayload);
|
||||
|
||||
switch (modalAction) {
|
||||
case 'add':
|
||||
await createMarketingHandler(payload as CreateSalesOrderPayload);
|
||||
@@ -260,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) => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { array, mixed, object } 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(),
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Marketing,
|
||||
} from '@/types/api/marketing/marketing';
|
||||
import { formatDate, formatTitleCase } from '@/lib/helper';
|
||||
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
|
||||
|
||||
type MarketingSchemaType = {
|
||||
customer_id: number | undefined;
|
||||
@@ -70,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 });
|
||||
});
|
||||
@@ -97,17 +98,21 @@ export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
|
||||
export const SalesProductToFieldValues = (
|
||||
product: BaseSalesOrder
|
||||
): SalesOrderProductFormValues => {
|
||||
const warehouseOption = {
|
||||
value: product.product_warehouse.warehouse.id,
|
||||
label: product.product_warehouse.warehouse.name,
|
||||
};
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
vehicle_number: product.vehicle_number,
|
||||
warehouse_id: product.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
kandang_id: product.product_warehouse.warehouse.id,
|
||||
kandang: {
|
||||
value: product.product_warehouse.warehouse.id,
|
||||
label: product.product_warehouse.warehouse.name,
|
||||
},
|
||||
kandang: warehouseOption,
|
||||
product_warehouse: {
|
||||
value: product.product_warehouse.id,
|
||||
label: product.product_warehouse.product.name,
|
||||
label: getProductWarehouseOptionLabel(product.product_warehouse),
|
||||
},
|
||||
product_warehouse_data: product.product_warehouse,
|
||||
product_warehouse_id: product.product_warehouse.id,
|
||||
@@ -118,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
|
||||
@@ -139,11 +153,36 @@ export const DeliveryProductToFieldValues = (
|
||||
delivery: BaseDeliveryOrder
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const data = delivery.deliveries.map((item) => {
|
||||
const soId = salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
)?.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,
|
||||
};
|
||||
|
||||
const initialSisaBerat =
|
||||
item?.total_weight &&
|
||||
salesOrder?.weight_per_convertion &&
|
||||
salesOrder?.total_peti
|
||||
? Number(item.total_weight) -
|
||||
Number(salesOrder.weight_per_convertion) *
|
||||
Number(salesOrder.total_peti)
|
||||
: 0;
|
||||
|
||||
const initialPricePerConvertion =
|
||||
item?.total_price &&
|
||||
salesOrder?.total_peti &&
|
||||
Number(salesOrder.total_peti) !== 0
|
||||
? (Number(item.total_price) -
|
||||
initialSisaBerat * Number(item.unit_price || 0)) /
|
||||
Number(salesOrder.total_peti)
|
||||
: Number(item?.unit_price || 0);
|
||||
|
||||
return {
|
||||
id: soId,
|
||||
id: salesOrder?.id,
|
||||
unit_price: item.unit_price,
|
||||
total_weight: item.total_weight,
|
||||
qty: item.qty,
|
||||
@@ -152,19 +191,40 @@ 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: soId,
|
||||
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
|
||||
marketing_type: 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
|
||||
? {
|
||||
value: salesOrder?.convertion_unit.toLowerCase(),
|
||||
label: formatTitleCase(salesOrder?.convertion_unit),
|
||||
}
|
||||
: null,
|
||||
marketing_product: {
|
||||
id: soId,
|
||||
id: item.marketing_product_id ?? salesOrder?.id,
|
||||
vehicle_number: item.vehicle_number,
|
||||
warehouse_id: item.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
kandang_id: item.product_warehouse.warehouse.id,
|
||||
kandang: {
|
||||
value: item.product_warehouse.warehouse.id,
|
||||
label: item.product_warehouse.warehouse.name,
|
||||
},
|
||||
kandang: warehouseOption,
|
||||
product_warehouse: {
|
||||
value: item.product_warehouse.id,
|
||||
label: item.product_warehouse.product.name,
|
||||
label: getProductWarehouseOptionLabel(item.product_warehouse),
|
||||
},
|
||||
product_warehouse_data: item.product_warehouse,
|
||||
product_warehouse_id: item.product_warehouse.id,
|
||||
unit_price: item.unit_price,
|
||||
total_weight: item.total_weight,
|
||||
@@ -172,8 +232,13 @@ export const DeliveryProductToFieldValues = (
|
||||
avg_weight: item.avg_weight,
|
||||
total_price: item.total_price,
|
||||
},
|
||||
total_peti: salesOrder?.total_peti,
|
||||
weight_per_convertion:
|
||||
item?.weight_per_convertion ?? salesOrder?.weight_per_convertion ?? 0,
|
||||
price_per_convertion: initialPricePerConvertion,
|
||||
} as DeliveryOrderProductFormValues;
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
export const mergeSOwithDO = (
|
||||
@@ -181,10 +246,25 @@ export const mergeSOwithDO = (
|
||||
deliveryOrders: DeliveryOrderProductFormValues[],
|
||||
autofill?: boolean
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const hasDeliveryOrders = deliveryOrders.length > 0;
|
||||
|
||||
return salesOrders.map((so) => {
|
||||
const delivery = deliveryOrders.find(
|
||||
(d) => d?.marketing_product_id === so.id
|
||||
);
|
||||
const isTelurQty =
|
||||
so.marketing_type?.value?.toLowerCase() === 'telur' &&
|
||||
so.convertion_unit?.value?.toLowerCase() === 'qty';
|
||||
const salesOrderUnitPrice =
|
||||
isTelurQty && Number(so.total_price || 0) > 0 && Number(so.qty || 0) > 0
|
||||
? Number(so.total_price) / Number(so.qty)
|
||||
: so.unit_price;
|
||||
const salesOrderPricePerQty =
|
||||
isTelurQty &&
|
||||
Number(so.total_price || 0) > 0 &&
|
||||
Number(so.total_weight || 0) > 0
|
||||
? Number(so.total_price) / Number(so.total_weight)
|
||||
: so.price_per_qty;
|
||||
|
||||
return {
|
||||
...so, // nilai dasar dari sales order
|
||||
@@ -192,30 +272,50 @@ export const mergeSOwithDO = (
|
||||
delivery_date: delivery?.delivery_date || undefined,
|
||||
do_number: delivery?.do_number || undefined,
|
||||
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
|
||||
unit_price: autofill ? so.unit_price : delivery?.unit_price,
|
||||
total_weight: autofill ? so.total_weight : delivery?.total_weight,
|
||||
qty: autofill ? so.qty : delivery?.qty,
|
||||
avg_weight: autofill ? so.avg_weight : delivery?.avg_weight,
|
||||
total_price: autofill ? so.total_price : delivery?.total_price,
|
||||
unit_price:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.unit_price
|
||||
: salesOrderUnitPrice,
|
||||
total_weight:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.total_weight
|
||||
: so.total_weight,
|
||||
qty: autofill && hasDeliveryOrders ? delivery?.qty : so.qty,
|
||||
avg_weight:
|
||||
autofill && hasDeliveryOrders ? delivery?.avg_weight : so.avg_weight,
|
||||
total_price:
|
||||
autofill && hasDeliveryOrders ? delivery?.total_price : so.total_price,
|
||||
marketing_product: so, // jika ada, override
|
||||
uom: autofill ? so.uom : delivery?.uom,
|
||||
weight_per_convertion: autofill
|
||||
? so.weight_per_convertion
|
||||
: delivery?.weight_per_convertion,
|
||||
price_per_convertion: autofill
|
||||
? so.price_per_convertion
|
||||
: delivery?.price_per_convertion,
|
||||
convertion_unit: autofill
|
||||
? so.convertion_unit
|
||||
: delivery?.convertion_unit,
|
||||
marketing_type: autofill ? so.marketing_type : delivery?.marketing_type,
|
||||
total_peti: autofill ? so.total_peti : delivery?.total_peti,
|
||||
price_per_qty: autofill ? so.price_per_qty : delivery?.price_per_qty,
|
||||
sisa_berat: autofill ? so.sisa_berat : delivery?.sisa_berat,
|
||||
price_sisa_berat: autofill
|
||||
? so.price_sisa_berat
|
||||
: delivery?.price_sisa_berat,
|
||||
week: autofill ? so.week : delivery?.week,
|
||||
uom: autofill && hasDeliveryOrders ? delivery?.uom : so.uom,
|
||||
weight_per_convertion:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.weight_per_convertion
|
||||
: so.weight_per_convertion,
|
||||
price_per_convertion:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.price_per_convertion
|
||||
: so.price_per_convertion,
|
||||
convertion_unit:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.convertion_unit
|
||||
: so.convertion_unit,
|
||||
marketing_type:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.marketing_type
|
||||
: so.marketing_type,
|
||||
total_peti:
|
||||
autofill && hasDeliveryOrders ? delivery?.total_peti : so.total_peti,
|
||||
price_per_qty:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.price_per_qty
|
||||
: salesOrderPricePerQty,
|
||||
sisa_berat:
|
||||
autofill && hasDeliveryOrders ? delivery?.sisa_berat : so.sisa_berat,
|
||||
price_sisa_berat:
|
||||
autofill && hasDeliveryOrders
|
||||
? delivery?.price_sisa_berat
|
||||
: so.price_sisa_berat,
|
||||
week: autofill && hasDeliveryOrders ? delivery?.week : so.week,
|
||||
} as DeliveryOrderProductFormValues;
|
||||
});
|
||||
};
|
||||
|
||||
+145
-56
@@ -32,6 +32,63 @@ import Dropdown from '@/components/Dropdown';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
|
||||
|
||||
type PricingOption =
|
||||
| string
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type PricingSource =
|
||||
| {
|
||||
marketing_type?: PricingOption;
|
||||
convertion_unit?: PricingOption;
|
||||
total_price?: string | number | null;
|
||||
qty?: string | number | null;
|
||||
total_weight?: string | number | null;
|
||||
unit_price?: string | number | null;
|
||||
price_per_qty?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const getOptionValue = (value?: PricingOption) => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'string') return value.toLowerCase();
|
||||
|
||||
return value.value?.toLowerCase();
|
||||
};
|
||||
|
||||
const isTelurQtyProduct = (value?: PricingSource) =>
|
||||
getOptionValue(value?.marketing_type) === 'telur' &&
|
||||
getOptionValue(value?.convertion_unit) === 'qty';
|
||||
|
||||
const getDisplayedUnitPrice = (value?: PricingSource) => {
|
||||
if (
|
||||
isTelurQtyProduct(value) &&
|
||||
Number(value?.total_price || 0) > 0 &&
|
||||
Number(value?.qty || 0) > 0
|
||||
) {
|
||||
return Number(value?.total_price) / Number(value?.qty);
|
||||
}
|
||||
|
||||
return value?.unit_price ?? undefined;
|
||||
};
|
||||
|
||||
const getDisplayedPricePerQty = (value?: PricingSource) => {
|
||||
if (
|
||||
isTelurQtyProduct(value) &&
|
||||
Number(value?.total_price || 0) > 0 &&
|
||||
Number(value?.total_weight || 0) > 0
|
||||
) {
|
||||
return Number(value?.total_price) / Number(value?.total_weight);
|
||||
}
|
||||
|
||||
return value?.price_per_qty ?? null;
|
||||
};
|
||||
|
||||
const DeliveryOrderProductForm = ({
|
||||
formState,
|
||||
salesOrders,
|
||||
@@ -76,7 +133,7 @@ const DeliveryOrderProductForm = ({
|
||||
? (Number(initialValues.total_price) -
|
||||
initialSisaBerat * Number(initialValues.unit_price || 0)) /
|
||||
Number(initialValues.total_peti)
|
||||
: 0;
|
||||
: Number(initialValues?.unit_price || 0);
|
||||
|
||||
const initialPriceSisaBerat =
|
||||
initialValues?.total_price && initialValues?.total_peti
|
||||
@@ -89,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(() => {
|
||||
@@ -112,7 +160,7 @@ const DeliveryOrderProductForm = ({
|
||||
if (!Boolean(item.qty)) {
|
||||
return {
|
||||
value: item.id,
|
||||
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
|
||||
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.warehouse?.label ?? item.marketing_product?.kandang?.label}`,
|
||||
} as OptionType;
|
||||
} else {
|
||||
return null;
|
||||
@@ -133,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,
|
||||
@@ -154,6 +209,27 @@ const DeliveryOrderProductForm = ({
|
||||
(item) => item.id === initialValues?.marketing_product_id
|
||||
);
|
||||
|
||||
const defaultPricingSource: PricingSource = {
|
||||
marketing_type:
|
||||
initialValues?.marketing_type ?? salesOrder?.marketing_type ?? null,
|
||||
convertion_unit:
|
||||
initialValues?.convertion_unit ?? salesOrder?.convertion_unit ?? null,
|
||||
total_price:
|
||||
deliveryOrder?.total_price ??
|
||||
initialValues?.total_price ??
|
||||
salesOrder?.total_price,
|
||||
qty: deliveryOrder?.qty ?? initialValues?.qty ?? salesOrder?.qty,
|
||||
total_weight:
|
||||
deliveryOrder?.total_weight ??
|
||||
initialValues?.total_weight ??
|
||||
salesOrder?.total_weight,
|
||||
unit_price:
|
||||
deliveryOrder?.unit_price ??
|
||||
initialValues?.unit_price ??
|
||||
salesOrder?.unit_price,
|
||||
price_per_qty: initialValues?.price_per_qty ?? null,
|
||||
};
|
||||
|
||||
const formik = useFormik<DeliveryOrderProductFormValues>({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
@@ -167,8 +243,7 @@ const DeliveryOrderProductForm = ({
|
||||
undefined,
|
||||
marketing_product_id:
|
||||
salesOrder?.id || initialValues?.marketing_product_id || undefined,
|
||||
unit_price:
|
||||
deliveryOrder?.unit_price ?? initialValues?.unit_price ?? undefined,
|
||||
unit_price: getDisplayedUnitPrice(defaultPricingSource),
|
||||
total_weight:
|
||||
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
|
||||
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
|
||||
@@ -186,7 +261,7 @@ const DeliveryOrderProductForm = ({
|
||||
convertion_unit: initialValues?.convertion_unit || null,
|
||||
marketing_type: initialValues?.marketing_type || null,
|
||||
total_peti: initialValues?.total_peti ?? null,
|
||||
price_per_qty: initialValues?.price_per_qty ?? null,
|
||||
price_per_qty: getDisplayedPricePerQty(defaultPricingSource),
|
||||
sisa_berat: initialSisaBerat,
|
||||
price_sisa_berat: initialPriceSisaBerat,
|
||||
week: initialValues?.week ?? null,
|
||||
@@ -326,14 +401,21 @@ const DeliveryOrderProductForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
if (!Boolean(initialValues.qty)) {
|
||||
if (
|
||||
!Boolean(initialValues.qty) &&
|
||||
!Boolean(initialValues.marketing_product_id)
|
||||
) {
|
||||
handleResetForm();
|
||||
} else {
|
||||
setFormikValues(initialValues);
|
||||
setFormikValues({
|
||||
...initialValues,
|
||||
unit_price: getDisplayedUnitPrice(initialValues),
|
||||
price_per_qty: getDisplayedPricePerQty(initialValues),
|
||||
});
|
||||
if (initialValues?.marketing_product_id) {
|
||||
setSelectedProduct({
|
||||
value: initialValues?.id,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
|
||||
value: initialValues?.marketing_product_id,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
|
||||
} as OptionType);
|
||||
}
|
||||
}
|
||||
@@ -349,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 ?? ''
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -458,10 +541,11 @@ const DeliveryOrderProductForm = ({
|
||||
marketing_product_id: selected.value as number,
|
||||
marketing_product: soFieldValues,
|
||||
qty: so.qty,
|
||||
unit_price: so.unit_price,
|
||||
unit_price: getDisplayedUnitPrice(so),
|
||||
total_price: so.total_price,
|
||||
avg_weight: so.avg_weight,
|
||||
total_weight: so.total_weight,
|
||||
price_per_qty: getDisplayedPricePerQty(so),
|
||||
vehicle_number: so.vehicle_number,
|
||||
week: soFieldValues.week ?? null,
|
||||
});
|
||||
@@ -472,7 +556,11 @@ const DeliveryOrderProductForm = ({
|
||||
text={
|
||||
exisitingValues?.find(
|
||||
(item) => item.id === selectedProduct?.value
|
||||
)?.marketing_product?.kandang?.label ?? ''
|
||||
)?.marketing_product?.warehouse?.label ??
|
||||
exisitingValues?.find(
|
||||
(item) => item.id === selectedProduct?.value
|
||||
)?.marketing_product?.kandang?.label ??
|
||||
''
|
||||
}
|
||||
color='success'
|
||||
className={{
|
||||
@@ -638,7 +726,7 @@ const DeliveryOrderProductForm = ({
|
||||
placeholder='Masukan Total Peti'
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-base-content/50'>Kg</span>
|
||||
<span className='text-sm text-base-content/50'>Peti</span>
|
||||
</div>
|
||||
}
|
||||
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
|
||||
@@ -688,6 +776,9 @@ const DeliveryOrderProductForm = ({
|
||||
}
|
||||
errorMessage={formik.errors.total_weight}
|
||||
placeholder='Masukan Total Bobot'
|
||||
disabled={
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -714,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>
|
||||
}
|
||||
@@ -727,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 ?? '')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
@@ -757,12 +846,32 @@ const DeliveryOrderProductForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per butir untuk TELUR + QTY */}
|
||||
{/* Harga Satuan */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
|
||||
<NumberInput
|
||||
required
|
||||
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
|
||||
name='unit_price'
|
||||
value={formik.values.unit_price}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('unit_price', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
isError={Boolean(formik.errors.unit_price)}
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per kg untuk TELUR + QTY */}
|
||||
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga / Butir (Rp)'
|
||||
label='Harga / Kg (Rp)'
|
||||
name='price_per_qty'
|
||||
value={formik.values.price_per_qty ?? undefined}
|
||||
onChange={(e) => {
|
||||
@@ -776,27 +885,7 @@ const DeliveryOrderProductForm = ({
|
||||
Boolean(formik.errors.price_per_qty)
|
||||
}
|
||||
errorMessage={formik.errors.price_per_qty}
|
||||
placeholder='Masukan Harga per Butir'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga Satuan */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
|
||||
<NumberInput
|
||||
required
|
||||
label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
|
||||
name='unit_price'
|
||||
value={formik.values.unit_price}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('unit_price', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
isError={Boolean(formik.errors.unit_price)}
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
placeholder='Masukan Harga per Kg'
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+19
-7
@@ -3,6 +3,11 @@ import * as Yup from 'yup';
|
||||
|
||||
type SalesOrderProductSchemaType = {
|
||||
id?: number | undefined;
|
||||
warehouse_id?: number;
|
||||
warehouse?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
kandang_id?: number;
|
||||
kandang?: {
|
||||
value: number;
|
||||
@@ -44,15 +49,22 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
|
||||
Yup.object({
|
||||
id: Yup.number(),
|
||||
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
|
||||
kandang: Yup.object({
|
||||
warehouse: Yup.object({
|
||||
value: Yup.number()
|
||||
.min(1, 'Kandang wajib diisi!')
|
||||
.required('Kandang wajib diisi!'),
|
||||
label: Yup.string().required('Kandang wajib diisi!'),
|
||||
.min(1, 'Gudang fisik wajib diisi!')
|
||||
.required('Gudang fisik wajib diisi!'),
|
||||
label: Yup.string().required('Gudang fisik wajib diisi!'),
|
||||
}).nullable(),
|
||||
kandang_id: Yup.number()
|
||||
.min(1, 'Kandang wajib diisi!')
|
||||
.required('Kandang wajib diisi!'),
|
||||
warehouse_id: Yup.number()
|
||||
.min(1, 'Gudang fisik wajib diisi!')
|
||||
.required('Gudang fisik wajib diisi!'),
|
||||
kandang: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
kandang_id: Yup.number().optional(),
|
||||
product_warehouse: Yup.object({
|
||||
value: Yup.number()
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
|
||||
+146
-66
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
|
||||
import { RefObject, useEffect, useMemo, useState } from 'react';
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { WarehouseApi } from '@/services/api/master-data';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { Icon } from '@iconify/react';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
|
||||
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
|
||||
|
||||
const SalesOrderProductForm = ({
|
||||
initialValues,
|
||||
@@ -67,7 +68,25 @@ const SalesOrderProductForm = ({
|
||||
? (Number(initialValues.total_price) -
|
||||
initialSisaBerat * Number(initialValues.unit_price || 0)) /
|
||||
Number(initialValues.total_peti)
|
||||
: 0;
|
||||
: Number(initialValues?.unit_price || 0);
|
||||
|
||||
const isInitialTelurQty =
|
||||
initialValues?.marketing_type?.value?.toLowerCase() === 'telur' &&
|
||||
initialValues?.convertion_unit?.value?.toLowerCase() === 'qty';
|
||||
|
||||
const initialUnitPrice =
|
||||
isInitialTelurQty &&
|
||||
Number(initialValues?.total_price || 0) > 0 &&
|
||||
Number(initialValues?.qty || 0) > 0
|
||||
? Number(initialValues?.total_price) / Number(initialValues?.qty)
|
||||
: initialValues?.unit_price || '';
|
||||
|
||||
const initialPricePerQty =
|
||||
isInitialTelurQty &&
|
||||
Number(initialValues?.total_price || 0) > 0 &&
|
||||
Number(initialValues?.total_weight || 0) > 0
|
||||
? Number(initialValues?.total_price) / Number(initialValues?.total_weight)
|
||||
: (initialValues?.price_per_qty ?? null);
|
||||
|
||||
const initialPriceSisaBerat =
|
||||
initialValues?.total_price && initialValues?.total_peti
|
||||
@@ -84,11 +103,15 @@ const SalesOrderProductForm = ({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
vehicle_number: initialValues?.vehicle_number || '',
|
||||
warehouse_id:
|
||||
initialValues?.warehouse_id ?? initialValues?.kandang_id ?? undefined,
|
||||
warehouse: initialValues?.warehouse ?? initialValues?.kandang ?? null,
|
||||
kandang_id: initialValues?.kandang_id || undefined,
|
||||
kandang: initialValues?.kandang || null,
|
||||
product_warehouse: initialValues?.product_warehouse || null,
|
||||
product_warehouse_data: initialValues?.product_warehouse_data || null,
|
||||
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
|
||||
unit_price: initialValues?.unit_price || '',
|
||||
unit_price: initialUnitPrice,
|
||||
total_weight: initialValues?.total_weight || '',
|
||||
qty: initialValues?.qty || '',
|
||||
avg_weight: initialValues?.avg_weight || '',
|
||||
@@ -102,7 +125,7 @@ const SalesOrderProductForm = ({
|
||||
convertion_unit: initialValues?.convertion_unit || null,
|
||||
marketing_type: initialValues?.marketing_type || null,
|
||||
total_peti: initialValues?.total_peti ?? null,
|
||||
price_per_qty: initialValues?.price_per_qty ?? null,
|
||||
price_per_qty: initialPricePerQty,
|
||||
sisa_berat: initialSisaBerat,
|
||||
price_sisa_berat: initialPriceSisaBerat,
|
||||
week: initialValues?.week ?? null,
|
||||
@@ -132,11 +155,11 @@ const SalesOrderProductForm = ({
|
||||
|
||||
// ===== Options =====
|
||||
const {
|
||||
options: kandangSourceOptions,
|
||||
isLoadingOptions: isLoadingKandangSourceOptions,
|
||||
setInputValue: setKandangInputValue,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
|
||||
options: warehouseOptions,
|
||||
isLoadingOptions: isLoadingWarehouseOptions,
|
||||
setInputValue: setWarehouseSearchValue,
|
||||
loadMore: loadMoreWarehouses,
|
||||
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
|
||||
|
||||
// Options Week dari minggu 1 - 22
|
||||
// const optionsWeek = useMemo(() => {
|
||||
@@ -147,7 +170,6 @@ const SalesOrderProductForm = ({
|
||||
// }, []);
|
||||
|
||||
const {
|
||||
options: warehouseSourceOptions,
|
||||
rawData: warehouseSourceRawData,
|
||||
isLoadingOptions: isLoadingWarehouseSourceOptions,
|
||||
setInputValue: setWarehouseInputValue,
|
||||
@@ -156,32 +178,69 @@ const SalesOrderProductForm = ({
|
||||
ProductWarehouseApi.basePath,
|
||||
'id',
|
||||
'product.name',
|
||||
'',
|
||||
'search',
|
||||
{
|
||||
warehouse_id: formik.values.kandang_id?.toString() ?? '',
|
||||
limit: '100',
|
||||
available_only: 'true',
|
||||
warehouse_id: formik.values.warehouse_id?.toString() ?? '',
|
||||
type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '',
|
||||
}
|
||||
);
|
||||
|
||||
const productOptionsFiltered = useMemo(() => {
|
||||
return warehouseSourceOptions.filter(
|
||||
(product) =>
|
||||
!exisitingValues
|
||||
?.map((item) => item.product_warehouse_id)
|
||||
.includes(product.value)
|
||||
if (!isResponseSuccess(warehouseSourceRawData)) {
|
||||
return initialValues?.product_warehouse
|
||||
? [initialValues.product_warehouse]
|
||||
: [];
|
||||
}
|
||||
|
||||
const selectedProductIds = new Set(
|
||||
exisitingValues
|
||||
?.filter((item) => item.id !== initialValues?.id)
|
||||
.map((item) => Number(item.product_warehouse_id))
|
||||
.filter((item) => item > 0) ?? []
|
||||
);
|
||||
}, [warehouseSourceOptions, exisitingValues]);
|
||||
|
||||
const options = warehouseSourceRawData.data
|
||||
.filter((item: ProductWarehouse) => !selectedProductIds.has(item.id))
|
||||
.map((item: ProductWarehouse) => ({
|
||||
value: item.id,
|
||||
label: getProductWarehouseOptionLabel(item),
|
||||
}));
|
||||
|
||||
if (
|
||||
initialValues?.product_warehouse &&
|
||||
initialValues?.product_warehouse_id
|
||||
) {
|
||||
const exists = options.find(
|
||||
(option) =>
|
||||
Number(option.value) === Number(initialValues.product_warehouse_id)
|
||||
);
|
||||
if (!exists) {
|
||||
options.push(initialValues.product_warehouse);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [warehouseSourceRawData, exisitingValues, initialValues]);
|
||||
|
||||
// ===== Handler =====
|
||||
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('kandang', val as OptionType);
|
||||
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
|
||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const warehouse = (val as OptionType | null) ?? null;
|
||||
|
||||
formik.setFieldValue('warehouse', warehouse);
|
||||
formik.setFieldValue('warehouse_id', warehouse?.value);
|
||||
formik.setFieldValue('kandang', warehouse);
|
||||
formik.setFieldValue('kandang_id', warehouse?.value);
|
||||
formik.setFieldValue('product_warehouse_id', null);
|
||||
formik.setFieldValue('product_warehouse', null);
|
||||
formik.setFieldValue('product_warehouse_data', null);
|
||||
formik.setFieldValue('qty', '');
|
||||
};
|
||||
|
||||
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const productWarehouseChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('product_warehouse', val as OptionType);
|
||||
const newId = (val as OptionType)?.value;
|
||||
formik.setFieldValue('product_warehouse_id', newId);
|
||||
@@ -191,7 +250,13 @@ const SalesOrderProductForm = ({
|
||||
(item: ProductWarehouse) => item.id === newId
|
||||
);
|
||||
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 &&
|
||||
@@ -204,6 +269,8 @@ const SalesOrderProductForm = ({
|
||||
}
|
||||
handleBlurField('qty');
|
||||
} else {
|
||||
setSelectedProductWarehouse(null);
|
||||
formik.setFieldValue('product_warehouse_data', null);
|
||||
formik.setFieldValue('qty', '');
|
||||
formik.setFieldValue('uom', '');
|
||||
formik.setFieldValue('week', null);
|
||||
@@ -217,9 +284,12 @@ const SalesOrderProductForm = ({
|
||||
formik.resetForm({
|
||||
values: {
|
||||
vehicle_number: '',
|
||||
warehouse_id: undefined,
|
||||
warehouse: null,
|
||||
kandang_id: undefined,
|
||||
kandang: null,
|
||||
product_warehouse: null,
|
||||
product_warehouse_data: null,
|
||||
product_warehouse_id: undefined,
|
||||
unit_price: '',
|
||||
total_weight: '',
|
||||
@@ -310,6 +380,10 @@ const SalesOrderProductForm = ({
|
||||
handleBlurField('week');
|
||||
}, [formik.values.week]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
|
||||
}, [initialValues?.product_warehouse_data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
@@ -348,22 +422,22 @@ const SalesOrderProductForm = ({
|
||||
errorMessage={formik.errors.vehicle_number}
|
||||
/>
|
||||
|
||||
{/* Gudang */}
|
||||
{/* Gudang Fisik */}
|
||||
<SelectInputRadio
|
||||
required
|
||||
label='Gudang'
|
||||
options={kandangSourceOptions}
|
||||
isLoading={isLoadingKandangSourceOptions}
|
||||
value={formik.values.kandang}
|
||||
onChange={kandangChangeHandler}
|
||||
label='Gudang Fisik'
|
||||
options={warehouseOptions}
|
||||
isLoading={isLoadingWarehouseOptions}
|
||||
value={formik.values.warehouse}
|
||||
onChange={warehouseChangeHandler}
|
||||
isClearable
|
||||
onInputChange={setKandangInputValue}
|
||||
onMenuScrollToBottom={loadMoreKandang}
|
||||
onInputChange={setWarehouseSearchValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouses}
|
||||
isError={
|
||||
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
|
||||
formik.touched.warehouse_id && Boolean(formik.errors.warehouse_id)
|
||||
}
|
||||
errorMessage={formik.errors.kandang_id}
|
||||
placeholder='Pilih Gudang'
|
||||
errorMessage={formik.errors.warehouse_id}
|
||||
placeholder='Pilih Gudang Fisik'
|
||||
/>
|
||||
|
||||
{/* Kategori */}
|
||||
@@ -374,8 +448,9 @@ const SalesOrderProductForm = ({
|
||||
value={formik.values.marketing_type}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('marketing_type', val);
|
||||
warehouseChangeHandler(null);
|
||||
productWarehouseChangeHandler(null);
|
||||
formik.setFieldValue('product_warehouse', null);
|
||||
formik.setFieldValue('product_warehouse_data', null);
|
||||
formik.setFieldValue('product_warehouse_id', null);
|
||||
formik.setFieldValue('convertion_unit', null);
|
||||
formik.setFieldValue('weight_per_convertion', null);
|
||||
@@ -392,18 +467,18 @@ const SalesOrderProductForm = ({
|
||||
options={productOptionsFiltered}
|
||||
isLoading={isLoadingWarehouseSourceOptions}
|
||||
value={formik.values.product_warehouse}
|
||||
onChange={warehouseChangeHandler}
|
||||
onChange={productWarehouseChangeHandler}
|
||||
onInputChange={setWarehouseInputValue}
|
||||
onMenuScrollToBottom={loadMoreWarehouse}
|
||||
isClearable
|
||||
placeholder={
|
||||
formik.values.kandang_id
|
||||
formik.values.warehouse_id
|
||||
? productOptionsFiltered.length == 0
|
||||
? 'Tidak ada produk yang tersedia'
|
||||
: 'Pilih produk'
|
||||
: 'Pilih Kandang Terlebih Dahulu'
|
||||
: 'Pilih Gudang Fisik Terlebih Dahulu'
|
||||
}
|
||||
isDisabled={!formik.values.kandang_id}
|
||||
isDisabled={!formik.values.warehouse_id}
|
||||
isError={
|
||||
formik.touched.product_warehouse_id &&
|
||||
Boolean(formik.errors.product_warehouse_id)
|
||||
@@ -471,7 +546,7 @@ const SalesOrderProductForm = ({
|
||||
<input
|
||||
type='radio'
|
||||
checked={
|
||||
formik.values.convertion_unit?.value ===
|
||||
formik.values.convertion_unit?.value.toLowerCase() ===
|
||||
option.value
|
||||
}
|
||||
onChange={() => null}
|
||||
@@ -494,7 +569,9 @@ const SalesOrderProductForm = ({
|
||||
} per ${formik.values.convertion_unit?.value}`}
|
||||
value={formik.values.weight_per_convertion ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
const value = Number(e.target.value)
|
||||
? Number(e.target.value)
|
||||
: '';
|
||||
handleFieldChange('weight_per_convertion', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
@@ -548,7 +625,7 @@ const SalesOrderProductForm = ({
|
||||
placeholder='Masukan Total Peti'
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-base-content/50'>Kg</span>
|
||||
<span className='text-sm text-base-content/50'>Peti</span>
|
||||
</div>
|
||||
}
|
||||
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
|
||||
@@ -598,6 +675,9 @@ const SalesOrderProductForm = ({
|
||||
}
|
||||
errorMessage={formik.errors.total_weight}
|
||||
placeholder='Masukan Total Bobot'
|
||||
disabled={
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -665,12 +745,34 @@ const SalesOrderProductForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per butir untuk TELUR + QTY */}
|
||||
{/* Harga Satuan per Uom Produk Warehouse */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
|
||||
<NumberInput
|
||||
required
|
||||
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
|
||||
name='unit_price'
|
||||
value={formik.values.unit_price}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('unit_price', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
isError={
|
||||
formik.touched.unit_price && Boolean(formik.errors.unit_price)
|
||||
}
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan...'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga per kg untuk TELUR + QTY */}
|
||||
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
|
||||
<NumberInput
|
||||
required
|
||||
label='Harga / Butir (Rp)'
|
||||
label='Harga / Kg (Rp)'
|
||||
name='price_per_qty'
|
||||
value={formik.values.price_per_qty ?? undefined}
|
||||
onChange={(e) => {
|
||||
@@ -684,29 +786,7 @@ const SalesOrderProductForm = ({
|
||||
Boolean(formik.errors.price_per_qty)
|
||||
}
|
||||
errorMessage={formik.errors.price_per_qty}
|
||||
placeholder='Masukan Harga per Butir'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Harga Satuan per Uom Produk Warehouse */}
|
||||
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
|
||||
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
|
||||
<NumberInput
|
||||
required
|
||||
label={`Harga / ${formik.values.convertion_unit?.label !== 'qty' ? 'Kg' : (selectedProductWarehouse?.product?.uom?.name ?? 'Produk')} (Rp)`}
|
||||
name='unit_price'
|
||||
value={formik.values.unit_price}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
handleFieldChange('unit_price', value, () =>
|
||||
setCurrentInput(e.target.name)
|
||||
);
|
||||
}}
|
||||
isError={
|
||||
formik.touched.unit_price && Boolean(formik.errors.unit_price)
|
||||
}
|
||||
errorMessage={formik.errors.unit_price}
|
||||
placeholder='Masukan Harga Satuan'
|
||||
placeholder='Masukan Harga per Kg'
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Icon } from '@iconify/react';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
|
||||
import { Marketing, BaseDelivery } from '@/types/api/marketing/marketing';
|
||||
import { Marketing } from '@/types/api/marketing/marketing';
|
||||
import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
|
||||
|
||||
type DeliveryOrderProductTableProps = {
|
||||
data: DeliveryOrderProductFormValues[];
|
||||
@@ -55,14 +56,17 @@ const DeliveryOrderProductTable = ({
|
||||
|
||||
const deliveryItems = useMemo(() => {
|
||||
if (!hasDeliveryOrder) return [];
|
||||
|
||||
return (
|
||||
marketing?.delivery_order?.flatMap((doItem) =>
|
||||
doItem.deliveries.map((delivery) => ({
|
||||
...delivery,
|
||||
do_number: doItem.do_number,
|
||||
delivery_date: doItem.delivery_date,
|
||||
warehouse: doItem.warehouse,
|
||||
}))
|
||||
DeliveryProductToFieldValues(marketing?.sales_order, doItem).map(
|
||||
(delivery) => ({
|
||||
...delivery,
|
||||
do_number: doItem.do_number,
|
||||
delivery_date: doItem.delivery_date,
|
||||
warehouse: doItem.warehouse,
|
||||
})
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
}, [marketing?.delivery_order, hasDeliveryOrder]);
|
||||
@@ -81,7 +85,7 @@ const DeliveryOrderProductTable = ({
|
||||
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||
<div>Value</div>
|
||||
{formType !== 'success' &&
|
||||
{/* {formType !== 'success' &&
|
||||
(formType === 'add_delivery' ||
|
||||
formType === 'edit_delivery' ||
|
||||
formType === 'detail') && (
|
||||
@@ -98,15 +102,16 @@ const DeliveryOrderProductTable = ({
|
||||
<Icon icon='heroicons:pencil' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Gudang</td>
|
||||
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{doItem?.warehouse?.name ||
|
||||
item.marketing_product?.warehouse?.label ||
|
||||
item.marketing_product?.product_warehouse_data?.warehouse?.name}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -119,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>
|
||||
@@ -136,12 +141,15 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Bobot</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatNumber(Number(item.total_weight))}
|
||||
{formatNumber(Number(item.total_weight))} Kg
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
Total Harga Satuan
|
||||
{item.convertion_unit?.label.toLowerCase() === 'peti' && ' (Kg)'}
|
||||
</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(parseFloat(item.unit_price as string))}
|
||||
</td>
|
||||
@@ -211,7 +219,7 @@ const DeliveryOrderProductTable = ({
|
||||
};
|
||||
|
||||
const renderDeliveryOrderContent = (
|
||||
item: BaseDelivery & {
|
||||
item: DeliveryOrderProductFormValues & {
|
||||
do_number: string;
|
||||
delivery_date: string;
|
||||
warehouse: Warehouse;
|
||||
@@ -230,25 +238,43 @@ const DeliveryOrderProductTable = ({
|
||||
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
|
||||
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
|
||||
<div>Value</div>
|
||||
{formType !== 'success' &&
|
||||
(formType === 'add_delivery' ||
|
||||
formType === 'edit_delivery' ||
|
||||
formType === 'detail') && (
|
||||
<div className='flex flex-row gap-1.5 items-center'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
onEditRef.current(item.id as number, item);
|
||||
}}
|
||||
className='p-0 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:pencil' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Gudang</td>
|
||||
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
|
||||
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Produk</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.product_warehouse?.product?.name}
|
||||
{item.marketing_product?.product_warehouse_data?.product.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
? `${formatNumber(item.qty)} ${item.product_warehouse?.product?.uom?.name ?? ''}`
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -271,13 +297,13 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(item.unit_price)}
|
||||
{formatCurrency(Number(item.unit_price))}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Total Penjualan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(item.total_price)}
|
||||
{formatCurrency(Number(item.total_price))}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
@@ -333,7 +359,9 @@ const DeliveryOrderProductTable = ({
|
||||
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
|
||||
{hasDeliveryOrder
|
||||
? deliveryItems.map((item, index) => (
|
||||
<div key={`do-table-${item.product_warehouse?.id}-${index}`}>
|
||||
<div
|
||||
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
|
||||
>
|
||||
{formType === 'success' ? (
|
||||
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
|
||||
<table
|
||||
@@ -349,8 +377,11 @@ const DeliveryOrderProductTable = ({
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
key={`do-table-${item.product_warehouse?.id}-${index}`}
|
||||
title={item.product_warehouse?.product?.name || 'Produk'}
|
||||
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
|
||||
title={
|
||||
item.marketing_product?.product_warehouse_data?.product
|
||||
.name || 'Produk'
|
||||
}
|
||||
collapsible={true}
|
||||
defaultCollapsed={false}
|
||||
variant='bordered'
|
||||
|
||||
@@ -73,8 +73,10 @@ const SalesOrderProductTable = ({
|
||||
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Gudang</td>
|
||||
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
|
||||
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.warehouse?.label ?? item.kandang?.label}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Kategori</td>
|
||||
@@ -135,8 +137,22 @@ const SalesOrderProductTable = ({
|
||||
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
|
||||
</td>
|
||||
</tr>
|
||||
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Harga Satuan Per Peti</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(
|
||||
parseFloat(item.unit_price as string) *
|
||||
parseFloat(String(item.weight_per_convertion))
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Harga Satuan</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
Harga Satuan
|
||||
{item.convertion_unit?.value.toLowerCase() === 'peti' && ' (Kg)'}
|
||||
</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{formatCurrency(parseFloat(item.unit_price as string))}
|
||||
</td>
|
||||
|
||||
@@ -101,8 +101,8 @@ const PDFDocument = ({
|
||||
<View style={pdfStyles.header}>
|
||||
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
|
||||
<Text style={pdfStyles.address}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
<View style={pdfStyles.divider} />
|
||||
</View>
|
||||
|
||||
@@ -87,8 +87,8 @@ const PDFDocument = ({ data }: { data: Marketing }) => {
|
||||
<View style={pdfStyles.header}>
|
||||
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
|
||||
<Text style={pdfStyles.address}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
<View style={pdfStyles.divider} />
|
||||
</View>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -154,17 +154,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
|
||||
sku: values.sku,
|
||||
uom_id: values.uom_id,
|
||||
product_category_id: values.product_category_id,
|
||||
product_price: parseInt(values.product_price.toString()) || 0,
|
||||
product_price: parseFloat(values.product_price.toString()) || 0,
|
||||
selling_price: values.selling_price
|
||||
? parseInt(values.selling_price.toString()) || 0
|
||||
? parseFloat(values.selling_price.toString()) || 0
|
||||
: undefined,
|
||||
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
|
||||
tax: values.tax ? parseFloat(values.tax.toString()) || 0 : undefined,
|
||||
expiry_period: values.expiry_period
|
||||
? parseInt(values.expiry_period.toString()) || 0
|
||||
? parseFloat(values.expiry_period.toString()) || 0
|
||||
: undefined,
|
||||
suppliers: values.suppliers.map((s) => ({
|
||||
supplier_id: s.supplier?.value as number,
|
||||
price: parseInt(s.price.toString()) || 0,
|
||||
price: parseFloat(s.price.toString()) || 0,
|
||||
})),
|
||||
flag: values.flag,
|
||||
sub_flags: values.sub_flags,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
@@ -59,8 +58,7 @@ const RowOptionsMenu = ({
|
||||
detailClickHandler: (id: number) => void;
|
||||
deleteClickHandler: () => void;
|
||||
}) => {
|
||||
// TODO: change this to real condition
|
||||
const showEditButton = true;
|
||||
const showEditButton = props.row.original.approval?.step_number !== 2;
|
||||
|
||||
const showDeleteButton = showEditButton;
|
||||
|
||||
@@ -149,7 +147,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
|
||||
@@ -175,6 +172,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
kandang_id: '',
|
||||
category: '',
|
||||
period: '',
|
||||
area_name: '',
|
||||
location_name: '',
|
||||
kandang_name: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -186,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 =====
|
||||
@@ -207,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,
|
||||
@@ -258,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);
|
||||
},
|
||||
@@ -267,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();
|
||||
@@ -308,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;
|
||||
@@ -351,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;
|
||||
@@ -426,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'
|
||||
@@ -555,6 +574,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
price: budget.price,
|
||||
total_price: budget.qty * budget.price,
|
||||
})) || [],
|
||||
periode: createdProjectFlock.period ?? '-',
|
||||
} as ProjectFlockFormValues;
|
||||
}, [createdProjectFlock]);
|
||||
|
||||
@@ -777,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();
|
||||
@@ -973,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>
|
||||
|
||||
@@ -1350,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,15 +34,14 @@ 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';
|
||||
|
||||
// ===== STATUS BADGE UTILITIES =====
|
||||
const statusTextMap: Record<string, string> = {
|
||||
@@ -74,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,
|
||||
@@ -265,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) =>
|
||||
@@ -352,9 +397,16 @@ const RecordingTable = () => {
|
||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||
const [, setApprovalNotes] = useState('');
|
||||
|
||||
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,
|
||||
@@ -366,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,
|
||||
@@ -383,7 +428,9 @@ const RecordingTable = () => {
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
locationParams
|
||||
{
|
||||
area_id: String(formik.values.area_id?.value),
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -398,13 +445,6 @@ const RecordingTable = () => {
|
||||
'search'
|
||||
);
|
||||
|
||||
const projectFlockParams = useMemo(() => {
|
||||
if (filterProjectFlockLocationId) {
|
||||
return { location_id: filterProjectFlockLocationId };
|
||||
}
|
||||
return undefined;
|
||||
}, [filterProjectFlockLocationId]);
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
options: projectFlockOptions,
|
||||
@@ -416,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,
|
||||
@@ -465,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 = () => {
|
||||
@@ -584,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);
|
||||
@@ -686,6 +644,68 @@ const RecordingTable = () => {
|
||||
});
|
||||
}, [selectedRowIds, recordings, isRecordingApproved]);
|
||||
|
||||
const exportToExcelHandler = async () => {
|
||||
setIsLoadingExportingToExcel(true);
|
||||
|
||||
await RecordingApi.exportToExcel(getTableFilterQueryString());
|
||||
|
||||
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> = {};
|
||||
@@ -846,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>
|
||||
</>
|
||||
);
|
||||
@@ -1092,7 +1113,7 @@ const RecordingTable = () => {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
? `${value.toFixed(2)} butir`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
@@ -1108,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>
|
||||
);
|
||||
@@ -1313,6 +1334,60 @@ const RecordingTable = () => {
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className={cn(
|
||||
'px-3 py-2.5 rounded-lg font-semibold text-sm gap-1.5',
|
||||
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
width={20}
|
||||
height={20}
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
/>
|
||||
Export
|
||||
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
|
||||
<Icon
|
||||
width={14}
|
||||
height={14}
|
||||
icon='heroicons:chevron-down'
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1390,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}
|
||||
@@ -1409,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' }}
|
||||
/>
|
||||
|
||||
@@ -1423,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' }}
|
||||
/>
|
||||
|
||||
@@ -1437,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>
|
||||
@@ -1449,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>
|
||||
@@ -1495,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,63 +31,96 @@ 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;
|
||||
}[];
|
||||
};
|
||||
|
||||
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!')
|
||||
.min(1, 'Jumlah penggunaan tidak boleh 0!')
|
||||
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
|
||||
.typeError('Jumlah penggunaan harus berupa angka!'),
|
||||
});
|
||||
|
||||
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!'),
|
||||
qty: Yup.number()
|
||||
.optional()
|
||||
.typeError('Jumlah depletions harus berupa angka!'),
|
||||
});
|
||||
|
||||
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!'),
|
||||
});
|
||||
@@ -243,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: '',
|
||||
},
|
||||
],
|
||||
@@ -258,12 +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: '',
|
||||
},
|
||||
],
|
||||
@@ -275,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: '',
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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<
|
||||
@@ -455,13 +457,13 @@ const TransferToLayingFormModal = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(flockSourceRawData)) {
|
||||
const selectedFlockSourceRawData = flockSourceRawData.data.find(
|
||||
const currentSelectedFlockSourceRawData = flockSourceRawData.data.find(
|
||||
(item) => item.id === formik.values.flockSource?.value
|
||||
);
|
||||
|
||||
setSelectedFlockSourceRawData(selectedFlockSourceRawData);
|
||||
setSelectedFlockSourceRawData(currentSelectedFlockSourceRawData);
|
||||
}
|
||||
}, [flockSourceRawData]);
|
||||
}, [flockSourceRawData, formikFlockSource]);
|
||||
|
||||
useEffect(() => {
|
||||
formik.setFieldValue('totalQuantity', totalTransferedChicken);
|
||||
@@ -625,6 +627,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 +640,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 +822,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 +928,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}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Uniformity } from '@/types/api/production/uniformity';
|
||||
|
||||
type UniformityFormSchemaType = {
|
||||
date: string;
|
||||
week: number;
|
||||
location?: {
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -45,10 +44,6 @@ const FileSchema = Yup.mixed<File>()
|
||||
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
|
||||
Yup.object({
|
||||
date: Yup.string().required('Tanggal wajib diisi!'),
|
||||
week: Yup.number()
|
||||
.min(1, 'Minggu ke wajib diisi!')
|
||||
.required('Minggu ke wajib diisi!')
|
||||
.typeError('Minggu ke wajib diisi!'),
|
||||
location: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
@@ -81,7 +76,6 @@ export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
|
||||
|
||||
export type UniformityFormData = {
|
||||
date: string;
|
||||
week: number;
|
||||
project_flock_kandang_id: number;
|
||||
document: File | null;
|
||||
document_name: string;
|
||||
@@ -91,8 +85,7 @@ export const getUniformityFormInitialValues = (
|
||||
initialValues?: Partial<Uniformity>
|
||||
): UniformityFormValues => {
|
||||
return {
|
||||
date: initialValues?.week ? '' : '',
|
||||
week: initialValues?.week ?? 0,
|
||||
date: '',
|
||||
location: null,
|
||||
location_id: 0,
|
||||
project_flock: null,
|
||||
|
||||
@@ -27,7 +27,6 @@ import { LocationApi } from '@/services/api/master-data';
|
||||
import {
|
||||
ProjectFlockApi,
|
||||
ProjectFlockKandangApi,
|
||||
RecordingApi,
|
||||
} from '@/services/api/production';
|
||||
import { UniformityApi } from '@/services/api/uniformity';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
ProjectFlockKandangLookup,
|
||||
ProjectFlock,
|
||||
} from '@/types/api/production/project-flock';
|
||||
import { Recording } from '@/types/api/production/recording';
|
||||
import { Kandang } from '@/types/api/master-data/kandang';
|
||||
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
|
||||
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
|
||||
@@ -204,23 +202,6 @@ const UniformityForm = ({
|
||||
? projectFlockKandangLookupData.data
|
||||
: undefined;
|
||||
|
||||
// ===== RECORDINGS DATA (FOR WEEK CALCULATION) =====
|
||||
const recordingsUrl = useMemo(() => {
|
||||
if (!projectFlockKandangLookup?.project_flock_kandang_id) return null;
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '100',
|
||||
project_flock_kandang_id:
|
||||
projectFlockKandangLookup.project_flock_kandang_id.toString(),
|
||||
});
|
||||
return `${RecordingApi.basePath}?${params.toString()}`;
|
||||
}, [projectFlockKandangLookup?.project_flock_kandang_id]);
|
||||
|
||||
const { data: recordingsData } = useSWR(
|
||||
recordingsUrl,
|
||||
recordingsUrl ? RecordingApi.getAllFetcher : null
|
||||
);
|
||||
|
||||
// ===== FORM CONFIGURATION =====
|
||||
const formikInitialValues = useMemo<UniformityFormValues>(
|
||||
() => getUniformityFormInitialValues(initialValues),
|
||||
@@ -246,7 +227,6 @@ const UniformityForm = ({
|
||||
|
||||
setUniformityFormData({
|
||||
date: values.date,
|
||||
week: values.week,
|
||||
project_flock_kandang_id: projectFlockKandangId,
|
||||
document: values.document as File,
|
||||
document_name: (values.document as File).name,
|
||||
@@ -475,59 +455,6 @@ const UniformityForm = ({
|
||||
generateUniformityTemplate(population, projectFlockKandangLookup);
|
||||
}, [projectFlockKandangLookup]);
|
||||
|
||||
// ===== SIDE EFFECTS =====
|
||||
useEffect(() => {
|
||||
if (
|
||||
projectFlockKandangLookup?.chick_in_date &&
|
||||
projectFlockKandangLookup?.project_flock_kandang_id
|
||||
) {
|
||||
const chickInDate = new Date(projectFlockKandangLookup.chick_in_date);
|
||||
chickInDate.setHours(0, 0, 0, 0);
|
||||
|
||||
let initialWeek = 18;
|
||||
|
||||
if (
|
||||
isResponseSuccess(recordingsData) &&
|
||||
recordingsData.data &&
|
||||
recordingsData.data.length > 0
|
||||
) {
|
||||
const sortedRecordings = [...recordingsData.data].sort(
|
||||
(a: Recording, b: Recording) =>
|
||||
new Date(a.record_datetime).getTime() -
|
||||
new Date(b.record_datetime).getTime()
|
||||
);
|
||||
|
||||
const earliestRecording = sortedRecordings[0];
|
||||
if (earliestRecording?.project_flock?.production_standart?.week) {
|
||||
initialWeek =
|
||||
earliestRecording.project_flock.production_standart.week;
|
||||
}
|
||||
}
|
||||
|
||||
if (formik.values.date) {
|
||||
const selectedDate = new Date(formik.values.date);
|
||||
selectedDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const daysDiff = Math.floor(
|
||||
(selectedDate.getTime() - chickInDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
const weeksDiff = Math.floor(daysDiff / 7);
|
||||
|
||||
setFieldValue('week', initialWeek + weeksDiff);
|
||||
} else {
|
||||
setFieldValue('week', initialWeek);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
projectFlockKandangLookup?.chick_in_date,
|
||||
projectFlockKandangLookup?.project_flock_kandang_id,
|
||||
recordingsData,
|
||||
formik.values.date,
|
||||
setFieldValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribeValidate(() => {
|
||||
setIsValid(true);
|
||||
@@ -597,6 +524,7 @@ const UniformityForm = ({
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.date && Boolean(formik.errors.date)}
|
||||
errorMessage={formik.errors.date as string}
|
||||
disabled={isNextStep}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
@@ -615,6 +543,7 @@ const UniformityForm = ({
|
||||
errorMessage={formik.errors.location_id as string}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isDisabled={isNextStep}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
@@ -627,7 +556,7 @@ const UniformityForm = ({
|
||||
onInputChange={setProjectFlockSearchValue}
|
||||
isLoading={isLoadingProjectFlocks}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isDisabled={!formik.values.location_id}
|
||||
isDisabled={!formik.values.location_id || isNextStep}
|
||||
isError={
|
||||
formik.touched.project_flock_id &&
|
||||
Boolean(formik.errors.project_flock_id)
|
||||
@@ -644,7 +573,7 @@ const UniformityForm = ({
|
||||
value={formik.values.kandang}
|
||||
onChange={handleKandangChange}
|
||||
options={kandangOptions}
|
||||
isDisabled={!formik.values.project_flock_id}
|
||||
isDisabled={!formik.values.project_flock_id || isNextStep}
|
||||
isError={
|
||||
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ const UniformityResultForm = () => {
|
||||
try {
|
||||
const payload = {
|
||||
date: uniformityFormData.date,
|
||||
week: uniformityFormData.week,
|
||||
project_flock_kandang_id: uniformityFormData.project_flock_kandang_id,
|
||||
document: uniformityFormData.document,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
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 { 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';
|
||||
|
||||
interface PurchaseFilterModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
initialValues?: {
|
||||
poDate: string;
|
||||
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 = useCallback(() => {
|
||||
ref.current?.close();
|
||||
}, [ref]);
|
||||
|
||||
// ===== DATE ERROR STATE =====
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
|
||||
// ===== CLEANUP TOAST ON UNMOUNT =====
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
}
|
||||
};
|
||||
}, [dateErrorShown]);
|
||||
|
||||
// ===== CLEANUP TOAST WHEN MODAL CLOSES =====
|
||||
useEffect(() => {
|
||||
const dialogElement = ref.current;
|
||||
const handleModalClose = () => {
|
||||
if (dateErrorShown) {
|
||||
toast.dismiss();
|
||||
setDateErrorShown(false);
|
||||
}
|
||||
};
|
||||
|
||||
dialogElement?.addEventListener('close', handleModalClose);
|
||||
|
||||
return () => {
|
||||
dialogElement?.removeEventListener('close', handleModalClose);
|
||||
};
|
||||
}, [ref, dateErrorShown]);
|
||||
|
||||
const {
|
||||
setInputValue: setProductCategoryInputValue,
|
||||
options: productCategoryOptions,
|
||||
isLoadingOptions: isLoadingProductCategoryOptions,
|
||||
loadMore: loadMoreProductCategory,
|
||||
} = useSelect<ProductCategory>(
|
||||
ProductCategoryApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'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;
|
||||
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;
|
||||
}>({
|
||||
// enableReinitialize: true,
|
||||
initialValues: initialValues || {
|
||||
poDate: '',
|
||||
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
|
||||
) => {
|
||||
formik.setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('status', val);
|
||||
};
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
resetForm({
|
||||
values: {
|
||||
poDate: '',
|
||||
category: [],
|
||||
status: [],
|
||||
supplier: null,
|
||||
area: null,
|
||||
location: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
});
|
||||
setSelectedAreaId('');
|
||||
setSelectedLocationId('');
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset, closeModalHandler]);
|
||||
|
||||
const formikSubmitHandler = useCallback(async () => {
|
||||
await submitForm();
|
||||
}, [submitForm]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
className={{
|
||||
modalBox: 'p-0 rounded-xl',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<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 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>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col'>
|
||||
<DateInput
|
||||
label='PO Date'
|
||||
name='poDate'
|
||||
placeholder='Pilih Tanggal'
|
||||
value={formik.values.poDate}
|
||||
onChange={formik.handleChange}
|
||||
isNestedModal
|
||||
/>
|
||||
|
||||
<SelectInputCheckbox
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
value={formik.values.category}
|
||||
onChange={productCategoryChangeHandler}
|
||||
options={productCategoryOptions}
|
||||
isLoading={isLoadingProductCategoryOptions}
|
||||
onInputChange={setProductCategoryInputValue}
|
||||
onMenuScrollToBottom={loadMoreProductCategory}
|
||||
/>
|
||||
|
||||
<SelectInputCheckbox
|
||||
label='Status'
|
||||
placeholder='Status'
|
||||
value={formik.values.status}
|
||||
onChange={statusChangeHandler}
|
||||
options={PURCHASE_ORDER_APPROVAL_LINE.map((item) => ({
|
||||
label: item.step_name,
|
||||
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>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<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='button'
|
||||
onClick={formikSubmitHandler}
|
||||
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 PurchaseFilterModal;
|
||||
@@ -1,42 +1,61 @@
|
||||
'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';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
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 { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { Purchase } from '@/types/api/purchase/purchase';
|
||||
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 Link from 'next/link';
|
||||
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
|
||||
|
||||
type PurchaseTableFilters = {
|
||||
search: string;
|
||||
sort_by: string;
|
||||
order_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> = {
|
||||
@@ -145,35 +164,94 @@ 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: '',
|
||||
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',
|
||||
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 {
|
||||
@@ -185,36 +263,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;
|
||||
@@ -230,20 +282,16 @@ 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>
|
||||
);
|
||||
@@ -260,7 +308,7 @@ const PurchaseTable = () => {
|
||||
cell: (props) => props.row.original.requester_name || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'products.name',
|
||||
accessorKey: 'products',
|
||||
header: 'Produk',
|
||||
cell: (props) => {
|
||||
const products = props.row.original.products;
|
||||
@@ -275,7 +323,7 @@ const PurchaseTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'location.name',
|
||||
accessorKey: 'location',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location?.name || '-',
|
||||
},
|
||||
@@ -287,6 +335,14 @@ const PurchaseTable = () => {
|
||||
? 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',
|
||||
@@ -297,6 +353,7 @@ const PurchaseTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Aging',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const purchase = props.row.original;
|
||||
if (!purchase.po_date) return '-';
|
||||
@@ -308,6 +365,7 @@ const PurchaseTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status Approval',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.latest_approval;
|
||||
@@ -352,6 +410,14 @@ const PurchaseTable = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Tanggal Dibuat',
|
||||
cell: (props) =>
|
||||
props.row.original.created_at
|
||||
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props) => {
|
||||
@@ -383,10 +449,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!');
|
||||
}
|
||||
@@ -394,29 +467,190 @@ 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 pageSizeChangeHandler = useCallback(
|
||||
// (val: OptionType | OptionType[] | null) => {
|
||||
// const newVal = val as OptionType;
|
||||
// setPageSize(newVal.value as number);
|
||||
// },
|
||||
// [setPageSize]
|
||||
// );
|
||||
const filterSubmitHandler = (values: PurchaseFilter) => {
|
||||
setFilters({
|
||||
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 = () => {
|
||||
setFilters({
|
||||
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 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,
|
||||
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 (
|
||||
<>
|
||||
@@ -455,6 +689,82 @@ const PurchaseTable = () => {
|
||||
'placeholder:font-semibold placeholder:text-base-content/50',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'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']]}
|
||||
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>
|
||||
|
||||
@@ -502,7 +812,8 @@ const PurchaseTable = () => {
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
setSorting={handleSortingChange}
|
||||
manualSorting
|
||||
className={{
|
||||
containerClassName: cn('p-3 mb-0'),
|
||||
headerColumnClassName: 'text-nowrap',
|
||||
@@ -513,6 +824,14 @@ const PurchaseTable = () => {
|
||||
</div>
|
||||
|
||||
{/* ===== MODAL COMPONENTS ===== */}
|
||||
|
||||
<PurchaseFilterModal
|
||||
ref={filterModal.ref}
|
||||
initialValues={purchaseFilterInitialValues}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={deleteModal.ref}
|
||||
type='error'
|
||||
@@ -528,6 +847,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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -294,7 +294,6 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
item.expedition_vendor_id || item.expedition_vendor?.id || null;
|
||||
|
||||
return {
|
||||
purchase_item: null,
|
||||
purchase_item_id: item.id,
|
||||
received_date: item.received_date
|
||||
? new Date(item.received_date).toISOString().split('T')[0]
|
||||
@@ -308,7 +307,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
}
|
||||
: null,
|
||||
expedition_vendor_id: expeditionVendorId,
|
||||
received_qty: item.total_qty || '',
|
||||
received_qty: item.sub_qty || '',
|
||||
transport_per_item: item.transport_per_item || '',
|
||||
};
|
||||
});
|
||||
@@ -367,6 +366,9 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
);
|
||||
} else {
|
||||
formik.setFieldValue(`items.${idx}.expedition_vendor_id`, null);
|
||||
|
||||
formik.setFieldValue(`items.${idx}.transport_per_item`, null);
|
||||
formik.setFieldValue(`items.${idx}.vehicle_number`, null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -553,6 +555,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
)
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={!Boolean(formItem?.expedition_vendor)}
|
||||
isError={
|
||||
isRepeaterInputError(idx, 'vehicle_number').isError
|
||||
}
|
||||
@@ -569,7 +572,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
<td>
|
||||
<SelectInput
|
||||
isClearable={true}
|
||||
value={formItem?.expedition_vendor}
|
||||
value={formItem?.expedition_vendor ?? null}
|
||||
key={`expedition-vendor-${idx}`}
|
||||
onChange={(val) =>
|
||||
expeditionVendorChangeHandler(idx, val)
|
||||
@@ -657,6 +660,7 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
inputPrefix={'Rp'}
|
||||
disabled={!Boolean(formItem?.expedition_vendor)}
|
||||
isError={
|
||||
isRepeaterInputError(idx, 'transport_per_item')
|
||||
.isError
|
||||
|
||||
@@ -31,10 +31,6 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
|
||||
action: 'APPROVED' | 'REJECTED';
|
||||
notes: string | null;
|
||||
items: {
|
||||
purchase_item?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
@@ -68,10 +64,6 @@ export type PurchaseStaffApprovalItemSchema = {
|
||||
};
|
||||
|
||||
export type PurchaseAcceptApprovalItemSchema = {
|
||||
purchase_item?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
purchase_item_id: number;
|
||||
received_date: string;
|
||||
travel_number: string;
|
||||
@@ -160,12 +152,6 @@ const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManag
|
||||
|
||||
const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApprovalItemSchema> =
|
||||
Yup.object({
|
||||
purchase_item: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchase_item_id: Yup.number()
|
||||
.min(1, 'Purchase item is required!')
|
||||
.required('Purchase item is required!')
|
||||
@@ -185,12 +171,17 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
|
||||
.typeError('No. Surat jalan wajib diisi!'),
|
||||
vehicle_number: Yup.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.when('expedition_vendor_id', {
|
||||
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
|
||||
then: (schema) => schema.required('Nomor kendaraan wajib diisi!'),
|
||||
otherwise: (schema) => schema.optional(),
|
||||
})
|
||||
.typeError('Nomor kendaraan harus berupa plat nomor!'),
|
||||
expedition_vendor: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.default(undefined)
|
||||
.nullable()
|
||||
.optional(),
|
||||
expedition_vendor_id: Yup.number()
|
||||
@@ -213,7 +204,12 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
|
||||
.typeError('Jumlah diterima harus berupa angka!'),
|
||||
transport_per_item: Yup.mixed<string | number>()
|
||||
.nullable()
|
||||
.optional()
|
||||
.when('expedition_vendor_id', {
|
||||
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
|
||||
then: (schema) =>
|
||||
schema.required('Biaya transport per item wajib diisi!'),
|
||||
otherwise: (schema) => schema.optional(),
|
||||
})
|
||||
.test(
|
||||
'is-valid-transport-per-item',
|
||||
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -829,6 +870,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 +1092,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 +1240,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}
|
||||
|
||||
@@ -34,7 +34,7 @@ const pdfStyles = StyleSheet.create({
|
||||
marginBottom: 20,
|
||||
},
|
||||
logo: {
|
||||
width: 120,
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginBottom: 8,
|
||||
},
|
||||
@@ -265,7 +265,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
|
||||
<View style={pdfStyles.header}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image
|
||||
src={'https://placehold.co/120x30/png'}
|
||||
src='/assets/img/lti-logo.png'
|
||||
style={pdfStyles.logo}
|
||||
id={'mbu-logo'}
|
||||
/>
|
||||
@@ -273,8 +273,8 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
|
||||
PT LUMBUNG TELUR INDONESIA
|
||||
</Text>
|
||||
<Text style={pdfStyles.address}>
|
||||
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
|
||||
Cipedes, Kec. Sukajadi, Kota Bandung 40162
|
||||
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
|
||||
Bandung Barat, Jawa Barat 40514
|
||||
</Text>
|
||||
<View style={pdfStyles.divider} />
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
||||
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 ReportExpenseTabs = () => {
|
||||
const [activeTabId, setActiveTabId] = useState<string>('1');
|
||||
@@ -16,6 +17,11 @@ const ReportExpenseTabs = () => {
|
||||
label: 'Laporan Biaya Operasional',
|
||||
content: <ReportExpenseTab tabId={'1'} />,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: 'Laporan Depresiasi',
|
||||
content: <ReportDepreciationTab tabId={'2'} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@ export const generateReportExpensePDF = async (
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(102, 102, 102);
|
||||
doc.text(
|
||||
'SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Cipedes, Kec. Sukajadi, Kota Bandung 40162',
|
||||
'Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten Bandung Barat, Jawa Barat 40514',
|
||||
marginX,
|
||||
25
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user